diff --git a/README.md b/README.md index 1d66b66..9e88439 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,29 @@ Pull requests and pushes to `main` run these checks on Node.js `24.21.0` through --- +## Optional: Read Wallet Balances (library, not run by `npm start`) + +`src/wallet-balances.ts` is an opt-in, read-only helper for `GET /v1/data/{chain}/addresses/{address}/balances`. `collectBalancePages(readPage, expectedChain, maxPages = 20)` follows `next_cursor` until it is omitted and rejects on malformed envelopes, chain mismatch, HTTP/read failures, repeated cursors, the page cap, duplicate tokens, or a changed known `meta.refreshed_at`. Balances stay exact decimal-integer strings; `symbol`/`decimals` stay `null` when unknown (`decimals: 0` is a real value). `readPage` must reject HTTP errors before returning JSON. Run it server-side only; the API key must not reach browsers or logs. + +```ts +import { collectBalancePages } from "./src/wallet-balances.js"; + +const chain = "robinhood_mainnet"; // check chain capability/coverage first +const address = "0x..."; // wallet to read + +const result = await collectBalancePages(async (cursor) => { + const url = new URL(`https://api.blockvectra.com/v1/data/${chain}/addresses/${address}/balances`); + if (cursor !== undefined) url.searchParams.set("cursor", cursor); // opaque, unchanged + const res = await fetch(url, { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +}, chain); +``` + +Before real use, confirm the selected chain's capability and coverage for this endpoint; this repository makes no claim that Robinhood balances are currently available and has not run this against a live API. Balances come from a snapshot refreshed every 6 hours (plus up to 30 minutes of delay and the refresh run), so rows may be stale. `meta.as_of_block` is the indexed head, not the snapshot height; read `meta.refreshed_at` (`null` means unknown, treat as stale). Pages are not a transactionally consistent portfolio; `pages` keeps each page's original `meta`. The aggregate `refreshedAt` is the shared known time only when every page reported the same one; if any page has `refreshed_at: null` it is `null` (unknown), regardless of page order, and differing known times are still rejected even with a `null` page between them. + +--- + ## Example Output Illustrative output (addresses, keys, block heights and dataset counts vary; this revision was validated offline): diff --git a/README.zh.md b/README.zh.md index 856bbd5..c80ae2a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -105,6 +105,29 @@ PR 和 `main` 更新会通过 [Offline checks](.github/workflows/offline-checks. --- +## 可选:读取钱包余额(库函数,`npm start` 不会运行) + +`src/wallet-balances.ts` 是按需引入的只读辅助模块,对应 `GET /v1/data/{chain}/addresses/{address}/balances`。`collectBalancePages(readPage, expectedChain, maxPages = 20)` 会沿 `next_cursor` 翻页直到该字段缺省;遇到响应结构错误、链不匹配、HTTP/读取失败、游标重复、超过页数上限、跨页重复 token,或已知的 `meta.refreshed_at` 在页间变化时整体拒绝。余额保持精确的十进制整数字符串;未知的 `symbol`/`decimals` 保持 `null`(`decimals: 0` 是有效的真实值)。`readPage` 必须在返回 JSON 之前拒绝 HTTP 错误。只能在服务端运行,API Key 不得出现在浏览器或日志中。 + +```ts +import { collectBalancePages } from "./src/wallet-balances.js"; + +const chain = "robinhood_mainnet"; // 先确认该链的能力/覆盖范围 +const address = "0x..."; // 要读取的钱包地址 + +const result = await collectBalancePages(async (cursor) => { + const url = new URL(`https://api.blockvectra.com/v1/data/${chain}/addresses/${address}/balances`); + if (cursor !== undefined) url.searchParams.set("cursor", cursor); // 不透明游标,原样传回 + const res = await fetch(url, { headers: { "x-api-key": process.env.BLOCKVECTRA_API_KEY! } }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +}, chain); +``` + +实际使用前须确认所选链在该端点上的能力与覆盖范围;本仓库不声明 Robinhood 余额当前可用,也未对线上 API 运行过此模块。余额来自每 6 小时刷新一次的快照(另有最多 30 分钟随机延迟及刷新耗时),数据可能过期。`meta.as_of_block` 是索引头部高度,不是快照高度;请读取 `meta.refreshed_at`(`null` 表示未知,按过期处理)。分页结果不是事务一致的资产组合;`pages` 保留每页原始 `meta`。聚合的 `refreshedAt` 仅在所有页都报告同一个已知时间时才是该时间;只要任一页的 `refreshed_at` 为 `null`,不论页序,聚合值即为 `null`(未知);即使中间夹着 `null` 页,不同的已知时间仍会被拒绝。 + +--- + ## 运行结果示例 以下为输出示例(地址、Key、区块高度及数据集数量会变化;本次修订仅做离线验证): diff --git a/src/wallet-balances.ts b/src/wallet-balances.ts new file mode 100644 index 0000000..9d2576e --- /dev/null +++ b/src/wallet-balances.ts @@ -0,0 +1,128 @@ +// Read-only consumer for GET /{chain}/addresses/{address}/balances envelopes. +// Contract: data.yaml sha256 c5b949e955b5efd70e221b0dc91d5cd2502332534ee907aca2c48dd2457ca36d +// (AddressBalance, AddressBalanceListEnvelope, Meta, UInt256String). Only the fields +// below are checked; other Meta properties are passed through untouched. + +export interface AddressBalance { + token: string; + /** Raw decimal-integer string; never converted to Number. */ + balance: string; + symbol: string | null; + /** Integer 0..255 or null (unknown). 0 is a real value, distinct from null. */ + decimals: number | null; +} + +export interface BalanceMeta { + chain: string; + /** Snapshot time, or null when unknown (stale). Not tied to as_of_block. */ + refreshed_at: string | null; + [key: string]: unknown; +} + +export interface BalancePage { + data: AddressBalance[]; + meta: BalanceMeta; + next_cursor?: string; +} + +export interface CollectedBalances { + balances: AddressBalance[]; + /** Original meta of every page, in fetch order. */ + pages: BalanceMeta[]; + /** The shared refreshed_at only if every page reported the same known value; null if any page was unknown. */ + refreshedAt: string | null; +} + +export type ReadPage = (cursor?: string) => Promise; + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const UINT = /^[0-9]+$/; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function decodeRow(row: unknown, index: number): AddressBalance { + if (!isRecord(row)) throw new Error(`malformed balance row ${index}: not an object`); + const { token, balance, symbol, decimals } = row; + if (typeof token !== "string" || !ADDRESS.test(token)) { + throw new Error(`malformed balance row ${index}: invalid token address`); + } + if (typeof balance !== "string" || !UINT.test(balance)) { + throw new Error(`malformed balance row ${index}: balance must be a decimal integer string`); + } + if (!Object.hasOwn(row, "symbol") || (symbol !== null && typeof symbol !== "string")) { + throw new Error(`malformed balance row ${index}: symbol must be string or null`); + } + if ( + !Object.hasOwn(row, "decimals") || + (decimals !== null && + !(typeof decimals === "number" && Number.isInteger(decimals) && decimals >= 0 && decimals <= 255)) + ) { + throw new Error(`malformed balance row ${index}: decimals must be integer 0..255 or null`); + } + return { token, balance, symbol, decimals }; +} + +export function decodeBalancePage(raw: unknown, expectedChain: string): BalancePage { + if (!isRecord(raw)) throw new Error("malformed balances response: not an object"); + const { data, meta, next_cursor } = raw; + if (!Array.isArray(data)) throw new Error("malformed balances response: data must be an array"); + if (!isRecord(meta)) throw new Error("malformed balances response: meta must be an object"); + if (meta.chain !== expectedChain) { + throw new Error(`balances chain mismatch: expected ${expectedChain}, got ${String(meta.chain)}`); + } + if (!Object.hasOwn(meta, "refreshed_at") || (meta.refreshed_at !== null && typeof meta.refreshed_at !== "string")) { + throw new Error("malformed balances response: meta.refreshed_at must be string or null"); + } + if (Object.hasOwn(raw, "next_cursor") && typeof next_cursor !== "string") { + throw new Error("malformed balances response: next_cursor must be a string when present"); + } + const page: BalancePage = { data: data.map(decodeRow), meta: meta as BalanceMeta }; + if (typeof next_cursor === "string") page.next_cursor = next_cursor; + return page; +} + +export async function collectBalancePages( + readPage: ReadPage, + expectedChain: string, + maxPages = 20, +): Promise { + if (!Number.isSafeInteger(maxPages) || maxPages <= 0) { + throw new Error("maxPages must be a positive safe integer"); + } + const balances: AddressBalance[] = []; + const pages: BalanceMeta[] = []; + const tokens = new Set(); + const cursors = new Set(); + let knownStamp: string | null = null; + let hasUnknown = false; + let cursor: string | undefined; + + for (let n = 1; ; n++) { + const page = decodeBalancePage(await readPage(cursor), expectedChain); + const stamp = page.meta.refreshed_at; + if (stamp === null) { + hasUnknown = true; + } else { + if (knownStamp !== null && knownStamp !== stamp) { + throw new Error(`balances snapshot changed between pages: ${knownStamp} -> ${stamp}`); + } + knownStamp = stamp; + } + for (const row of page.data) { + const key = row.token.toLowerCase(); + if (tokens.has(key)) throw new Error(`duplicate token across balance pages: ${row.token}`); + tokens.add(key); + balances.push(row); + } + pages.push(page.meta); + if (page.next_cursor === undefined) { + return { balances, pages, refreshedAt: hasUnknown ? null : knownStamp }; + } + if (cursors.has(page.next_cursor)) throw new Error("balances pagination cursor repeated"); + if (n >= maxPages) throw new Error(`balances pagination exceeded maxPages (${maxPages})`); + cursors.add(page.next_cursor); + cursor = page.next_cursor; + } +} diff --git a/tests/wallet-balances.test.ts b/tests/wallet-balances.test.ts new file mode 100644 index 0000000..55c28e2 --- /dev/null +++ b/tests/wallet-balances.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { collectBalancePages, decodeBalancePage } from "../src/wallet-balances.js"; + +const CHAIN = "robinhood_mainnet"; +const T1 = "0x" + "1".repeat(40); +const T2 = "0x" + "2".repeat(40); +const T3 = "0x" + "3".repeat(40); +const row = (token: string, extra: Record = {}) => + ({ token, balance: "5", symbol: "AAA", decimals: 18, ...extra }); +const meta = (refreshed_at: string | null = "2026-10-20T00:00:00Z", extra = {}) => + ({ chain: CHAIN, chain_slug: "ROBINHOOD_MAINNET", as_of_block: 7, coverage: "full", refreshed_at, ...extra }); +const page = (data: unknown[], next?: unknown, m: unknown = meta()) => + ({ data, meta: m, ...(next === undefined ? {} : { next_cursor: next }) }); + +function reader(pages: unknown[]) { + const seen: (string | undefined)[] = []; + return { + seen, + read: async (cursor?: string) => { + seen.push(cursor); + const p = pages[seen.length - 1]; + if (p instanceof Error) throw p; + return p; + }, + }; +} + +test("valid empty and non-empty pages", () => { + assert.deepEqual(decodeBalancePage(page([]), CHAIN).data, []); + const m = meta("2026-10-20T00:00:00Z", { future_field: { a: 1 } }); + const decoded = decodeBalancePage(page([row(T1)], undefined, m), CHAIN); + assert.equal(decoded.data.length, 1); + assert.equal(decoded.meta.future_field !== undefined, true); + assert.equal(decoded.next_cursor, undefined); +}); + +test("balance above 2^53 stays an exact string", () => { + const big = "340282366920938463463374607431768211455"; + const [r] = decodeBalancePage(page([row(T1, { balance: big })]), CHAIN).data; + assert.equal(r.balance, big); +}); + +test("decimals 0 differs from null; null symbol preserved", () => { + const [a, b] = decodeBalancePage( + page([row(T1, { decimals: 0, symbol: null }), row(T2, { decimals: null })]), CHAIN).data; + assert.equal(a.decimals, 0); + assert.equal(a.symbol, null); + assert.equal(b.decimals, null); +}); + +test("missing metadata, bad rows and no coercion are rejected", () => { + const bad: Record[] = [ + { token: T1, balance: "5", decimals: 1 }, + { token: T1, balance: "5", symbol: "A" }, + row(T1, { balance: 5 }), + row(T1, { balance: "1.5" }), + row(T1, { balance: "-1" }), + row(T1, { balance: "0x10" }), + row(T1, { decimals: "18" }), + row(T1, { decimals: 256 }), + row(T1, { decimals: -1 }), + row(T1, { decimals: 1.5 }), + row(T1, { symbol: 1 }), + row("0x123"), + row(T1, { token: undefined }), + ]; + for (const r of bad) assert.throws(() => decodeBalancePage(page([r]), CHAIN), /malformed/); + assert.throws(() => decodeBalancePage(page([null]), CHAIN), /malformed/); +}); + +test("bare array, HTML-like object and bad envelope are malformed", () => { + for (const raw of [[row(T1)], { html: "" }, null, "x", { data: {}, meta: meta() }, + { data: [] }, { data: [], meta: [] }]) { + assert.throws(() => decodeBalancePage(raw, CHAIN), /malformed/); + } +}); + +test("chain mismatch is rejected", () => { + assert.throws(() => decodeBalancePage(page([], undefined, meta()), "other"), /chain mismatch/); + assert.throws(() => decodeBalancePage(page([], undefined, { ...meta(), chain: "ROBINHOOD_MAINNET" }), CHAIN), /chain mismatch/); +}); + +test("refreshed_at must be present; null is valid", () => { + const { refreshed_at: _omit, ...noStamp } = meta(); + assert.throws(() => decodeBalancePage(page([], undefined, noStamp), CHAIN), /refreshed_at/); + assert.throws(() => decodeBalancePage(page([], undefined, { ...noStamp, refreshed_at: undefined }), CHAIN), /refreshed_at/); + assert.throws(() => decodeBalancePage(page([], undefined, meta(1 as never)), CHAIN), /refreshed_at/); + assert.equal(decodeBalancePage(page([], undefined, meta(null)), CHAIN).meta.refreshed_at, null); +}); + +test("opaque cursors round-trip exactly; last page omits next_cursor", async () => { + const c1 = "a/b?c=d&e=%20+=#é/../"; + const c2 = " "; + const r = reader([page([row(T1)], c1), page([row(T2)], c2), page([row(T3)])]); + const out = await collectBalancePages(r.read, CHAIN); + assert.deepEqual(r.seen, [undefined, c1, c2]); + assert.deepEqual(out.balances.map((b) => b.token), [T1, T2, T3]); + assert.equal(out.pages.length, 3); +}); + +test("next_cursor null is malformed, not a last page", async () => { + const r = reader([page([row(T1)], null)]); + await assert.rejects(collectBalancePages(r.read, CHAIN), /next_cursor/); +}); + +test("later read failure rejects the whole collection", async () => { + const r = reader([page([row(T1)], "c1"), new Error("HTTP 503")]); + await assert.rejects(collectBalancePages(r.read, CHAIN), /HTTP 503/); +}); + +test("later decode failure rejects the whole collection", async () => { + const r = reader([page([row(T1)], "c1"), [row(T2)]]); + await assert.rejects(collectBalancePages(r.read, CHAIN), /malformed/); +}); + +test("repeated and cyclic cursors are rejected", async () => { + await assert.rejects(collectBalancePages(reader([page([row(T1)], "c1"), page([row(T2)], "c1")]).read, CHAIN), /repeated/); + await assert.rejects( + collectBalancePages(reader([page([row(T1)], "a"), page([row(T2)], "b"), page([row(T3)], "a")]).read, CHAIN), + /repeated/); +}); + +test("maxPages is validated and enforced while more pages remain", async () => { + for (const bad of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + await assert.rejects(collectBalancePages(reader([]).read, CHAIN, bad), /maxPages/); + } + const r = reader([page([row(T1)], "c1"), page([row(T2)], "c2"), page([row(T3)])]); + await assert.rejects(collectBalancePages(r.read, CHAIN, 2), /exceeded maxPages/); + assert.equal(r.seen.length, 2); + const ok = reader([page([row(T1)], "c1"), page([row(T2)])]); + assert.equal((await collectBalancePages(ok.read, CHAIN, 2)).balances.length, 2); +}); + +test("known refreshed_at change between pages aborts", async () => { + const r = reader([page([row(T1)], "c1", meta("2026-10-20T00:00:00Z")), + page([row(T2)], undefined, meta("2026-10-20T06:00:00Z"))]); + await assert.rejects(collectBalancePages(r.read, CHAIN), /snapshot changed/); +}); + +test("null freshness stays unknown and is not fabricated", async () => { + const allNull = await collectBalancePages( + reader([page([row(T1)], "c1", meta(null)), page([row(T2)], undefined, meta(null))]).read, CHAIN); + assert.equal(allNull.refreshedAt, null); + const A = "2026-10-20T00:00:00Z"; + const run = (stamps: (string | null)[]) => + collectBalancePages(reader(stamps.map((s, i) => + page([row("0x" + String(i + 1).repeat(40))], i < stamps.length - 1 ? `c${i}` : undefined, meta(s)))).read, CHAIN); + const nt = await run([null, A]); + assert.deepEqual(nt.pages.map((m) => m.refreshed_at), [null, A]); + assert.equal(nt.refreshedAt, null); + assert.equal((await run([A, null])).refreshedAt, null); + assert.equal((await run([A, A])).refreshedAt, A); + assert.equal((await run([null, null])).refreshedAt, null); + await assert.rejects(run([A, null, "2026-10-20T06:00:00Z"]), /snapshot changed/); +}); + +test("duplicate tokens are rejected across pages (case-insensitive) and within a page", async () => { + const upper = "0x" + "AB".repeat(20); + const lower = "0x" + "ab".repeat(20); + await assert.rejects(collectBalancePages(reader([page([row(upper)], "c1"), page([row(lower)])]).read, CHAIN), /duplicate/); + await assert.rejects(collectBalancePages(reader([page([row(T1), row(T1)])]).read, CHAIN), /duplicate/); +});