Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
23 changes: 23 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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、区块高度及数据集数量会变化;本次修订仅做离线验证):
Expand Down
128 changes: 128 additions & 0 deletions src/wallet-balances.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;

const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
const UINT = /^[0-9]+$/;

function isRecord(v: unknown): v is Record<string, unknown> {
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<CollectedBalances> {
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<string>();
const cursors = new Set<string>();
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;
}
}
163 changes: 163 additions & 0 deletions tests/wallet-balances.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
({ 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<string, unknown>[] = [
{ 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: "<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/);
});
Loading