Skip to content

Commit 008a7c3

Browse files
committed
test(xdr): source the real-traffic corpus from both Horizon and RPC
1 parent 5888209 commit 008a7c3

10 files changed

Lines changed: 1620 additions & 174 deletions

File tree

scripts/corpus-fixtures.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Shared plumbing for the two corpus refreshers, `refresh-horizon-corpus.ts`
2+
// and `refresh-rpc-corpus.ts`.
3+
//
4+
// The validation helpers exist because of a real incident: Horizon quietly
5+
// stopped serving `result_meta_xdr`, the refresher wrote `undefined` into the
6+
// fixture, and it surfaced much later as a wall of unreadable test failures.
7+
// Anything a corpus depends on goes through these, so a field disappearing
8+
// upstream fails at fetch time and names the field.
9+
import { writeFileSync, mkdirSync } from "node:fs";
10+
import { resolve } from "node:path";
11+
12+
export interface CorpusFile<T> {
13+
source: string;
14+
fetchedAt: string;
15+
records: T[];
16+
}
17+
18+
export function requireString(
19+
value: unknown,
20+
field: string,
21+
where: string,
22+
): string {
23+
// Trimmed, so a whitespace-only value fails here rather than surviving into
24+
// the fixture and failing later at base64 decode. Internal whitespace is
25+
// left alone: `Buffer.from(x, "base64")` tolerates it, so wrapped base64 is
26+
// still legitimate.
27+
if (typeof value !== "string" || value.trim().length === 0) {
28+
throw new Error(
29+
`${where}: response has no \`${field}\`. The corpus needs it — check ` +
30+
`whether this endpoint still returns the field before refreshing.`,
31+
);
32+
}
33+
return value;
34+
}
35+
36+
// `JSON.stringify` renders NaN and Infinity as `null`, which is the opposite of
37+
// helpful in a diagnostic, so numbers are stringified directly.
38+
function describe(value: unknown): string {
39+
return typeof value === "number" ? String(value) : JSON.stringify(value);
40+
}
41+
42+
// Only used for ledger sequences, which are always positive integers — `0`,
43+
// negatives and fractions all mean the response was not what we expected.
44+
export function requireNumber(
45+
value: unknown,
46+
field: string,
47+
where: string,
48+
): number {
49+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
50+
throw new Error(
51+
`${where}: response has no positive integer \`${field}\` ` +
52+
`(got ${describe(value)}).`,
53+
);
54+
}
55+
return value;
56+
}
57+
58+
// `COUNT` comes from the environment, so every malformed value has to fail
59+
// here. It used to flow straight into `Number()`: `COUNT=abc` became `NaN`, the
60+
// `records.length < COUNT` fetch loops never ran, an empty corpus was written,
61+
// and the test file skipped every corpus describe on a zero exit code.
62+
export function readCount(raw: string | undefined, fallback: number): number {
63+
const count = Number(raw ?? fallback);
64+
if (!Number.isInteger(count) || count < 1) {
65+
throw new Error(
66+
`COUNT must be a positive integer, got ${JSON.stringify(raw)}.`,
67+
);
68+
}
69+
return count;
70+
}
71+
72+
export function writeCorpus<T>(
73+
outDir: string,
74+
filename: string,
75+
payload: CorpusFile<T>,
76+
): void {
77+
// An empty corpus is never a legitimate refresh result, and writing one is
78+
// silently destructive: `corpus_round_trip.test.ts` skips a section whose
79+
// corpus has no records, so replacing a populated fixture with an empty one
80+
// converts real coverage into a green run. Refuse rather than overwrite.
81+
if (payload.records.length === 0) {
82+
throw new Error(
83+
`${filename}: refusing to write an empty corpus — the fetch returned no ` +
84+
`records, and an empty fixture would make the tests skip silently.`,
85+
);
86+
}
87+
88+
mkdirSync(outDir, { recursive: true });
89+
writeFileSync(
90+
resolve(outDir, filename),
91+
JSON.stringify(payload, null, 2) + "\n",
92+
);
93+
}
94+
95+
export function reportCount(got: number, want: number, kind: string): void {
96+
console.log(`Wrote ${got} ${kind} records.`);
97+
if (got < want) {
98+
console.warn(` ! wanted ${want}; only ${got} were available.`);
99+
}
100+
}
101+
102+
// The corpus is only as good as its envelope-type spread, and that is the one
103+
// thing a fetch cannot guarantee — print it so a lopsided sample is visible
104+
// before it gets committed.
105+
export function reportEnvelopeSpread(envelopesBase64: string[]): void {
106+
// EnvelopeType discriminants, per the XDR union.
107+
const names = new Map([
108+
[0, "envelopeTypeTxV0"],
109+
[2, "envelopeTypeTx"],
110+
[5, "envelopeTypeTxFeeBump"],
111+
]);
112+
const counts = new Map<string, number>();
113+
for (const b64 of envelopesBase64) {
114+
const buf = Buffer.from(b64, "base64");
115+
// A truncated or non-base64 value carries no discriminant. Count it rather
116+
// than letting `readUInt32BE` throw a bare RangeError — this runs after the
117+
// fixture is already on disk, so aborting here would leave a written file
118+
// behind a failed run.
119+
if (buf.length < 4) {
120+
counts.set("unparseable", (counts.get("unparseable") ?? 0) + 1);
121+
continue;
122+
}
123+
// The discriminant is the first 4 bytes of the XDR union.
124+
const kind = buf.readUInt32BE(0);
125+
const name = names.get(kind) ?? `unknown(${kind})`;
126+
counts.set(name, (counts.get(name) ?? 0) + 1);
127+
}
128+
const spread = [...counts]
129+
.sort((a, b) => b[1] - a[1])
130+
.map(([name, n]) => `${name}=${n}`)
131+
.join(", ");
132+
console.log(` envelope spread: ${spread || "(none)"}`);
133+
}

scripts/refresh-horizon-corpus.ts

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
// One-shot fetcher that populates `test/fixtures/horizon-corpus/` with
2-
// real envelope/operation/ledger XDR captured from horizon mainnet.
1+
// One-shot fetcher that populates `test/fixtures/horizon-corpus/` with real
2+
// envelope/result/fee-meta/ledger XDR captured from Horizon mainnet.
33
//
44
// Usage:
55
// pnpm tsx scripts/refresh-horizon-corpus.ts
6+
// HORIZON=... COUNT=100 pnpm tsx scripts/refresh-horizon-corpus.ts
67
//
78
// Refresh quarterly or when a new Stellar protocol version ships. The
89
// resulting JSON files are checked into the repo and consumed by
9-
// `test/unit/base/xdr/corpus_round_trip.test.ts` — that test asserts every
10+
// `test/unit/xdr/corpus_round_trip.test.ts` — that test asserts every
1011
// fixture round-trips losslessly through both the new and legacy XDR
1112
// runtimes, so any encoding regression that affects shapes the network
1213
// actually produces will fail loudly.
1314
//
14-
// We pull from a few endpoints to hit a broad surface:
15-
// - /transactions → TransactionEnvelope (covers ops, memos, signers)
16-
// - /ledgers/{seq} → LedgerHeader (covers nested ledger entry types)
15+
// We pull from two endpoints to hit a broad surface:
16+
// - /transactions → TransactionEnvelope (covers ops, memos,
17+
// signers), TransactionResult, and OperationMeta
18+
// via `fee_meta_xdr`
19+
// - /ledgers → LedgerHeader (covers nested ledger entry types)
20+
//
21+
// Note on transaction meta: SDF removed `result_meta_xdr` from the hosted
22+
// Horizon API and points callers at Stellar RPC instead, so there is no
23+
// `TransactionMeta` here. That coverage lives in the RPC corpus — see
24+
// `scripts/refresh-rpc-corpus.ts`. Don't try to reintroduce it from Horizon.
1725
//
1826
// We don't decode the bytes here — just snapshot the on-wire form. The
1927
// test does the decode/re-encode validation.
@@ -23,80 +31,82 @@
2331
// either sample a larger range, or hand-pick a mix of `envelopeTypeTx`,
2432
// `envelopeTypeTxV0`, and `envelopeTypeTxFeeBump` records by querying
2533
// historical ranges that included Soroban contract calls, classic
26-
// payments, and fee-bumped batches.
27-
import { writeFileSync, mkdirSync } from "node:fs";
34+
// payments, and fee-bumped batches. The run prints the spread it got.
2835
import { resolve } from "node:path";
2936

37+
import {
38+
readCount,
39+
requireNumber,
40+
requireString,
41+
reportCount,
42+
reportEnvelopeSpread,
43+
writeCorpus,
44+
} from "./corpus-fixtures.js";
45+
3046
const HORIZON = process.env.HORIZON ?? "https://horizon.stellar.org";
3147
const OUT_DIR = resolve(import.meta.dirname, "../test/fixtures/horizon-corpus");
32-
const COUNT = Number(process.env.COUNT ?? 50);
48+
const COUNT = readCount(process.env.COUNT, 50);
3349

34-
interface CorpusFile<T> {
35-
source: string;
36-
fetchedAt: string;
37-
records: T[];
50+
// Horizon caps a single page at 200 records.
51+
const PAGE_LIMIT = Math.min(COUNT, 200);
52+
53+
interface HorizonPage {
54+
_embedded?: { records?: Array<Record<string, unknown>> };
3855
}
3956

40-
async function getJson(url: string): Promise<unknown> {
57+
async function getJson(url: string): Promise<HorizonPage> {
4158
const r = await fetch(url);
4259
if (!r.ok) throw new Error(`${url}${r.status} ${r.statusText}`);
43-
return r.json();
60+
return (await r.json()) as HorizonPage;
4461
}
4562

4663
async function snapshotTransactions(): Promise<void> {
47-
const url = `${HORIZON}/transactions?limit=${Math.min(COUNT, 200)}&order=desc&include_failed=false`;
48-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49-
const json = (await getJson(url)) as any;
50-
const records = json._embedded.records.map(
51-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
52-
(r: any) => ({
53-
hash: r.hash,
54-
envelope_xdr: r.envelope_xdr,
55-
result_xdr: r.result_xdr,
56-
result_meta_xdr: r.result_meta_xdr,
57-
}),
58-
);
59-
writeCorpus("transactions.json", {
64+
const url = `${HORIZON}/transactions?limit=${PAGE_LIMIT}&order=desc&include_failed=false`;
65+
const records = ((await getJson(url))._embedded?.records ?? []).map((r) => {
66+
const where = `transaction ${String(r.hash ?? "<no hash>")}`;
67+
return {
68+
hash: requireString(r.hash, "hash", where),
69+
envelope_xdr: requireString(r.envelope_xdr, "envelope_xdr", where),
70+
result_xdr: requireString(r.result_xdr, "result_xdr", where),
71+
fee_meta_xdr: requireString(r.fee_meta_xdr, "fee_meta_xdr", where),
72+
};
73+
});
74+
75+
writeCorpus(OUT_DIR, "transactions.json", {
6076
source: url,
6177
fetchedAt: new Date().toISOString(),
6278
records,
6379
});
64-
console.log(`Wrote ${records.length} transaction records.`);
80+
reportCount(records.length, COUNT, "transaction");
81+
reportEnvelopeSpread(records.map((r) => r.envelope_xdr));
6582
}
6683

6784
async function snapshotLedgers(): Promise<void> {
68-
const url = `${HORIZON}/ledgers?limit=${Math.min(COUNT, 200)}&order=desc`;
69-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70-
const json = (await getJson(url)) as any;
71-
const records = json._embedded.records.map(
72-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
73-
(r: any) => ({
74-
sequence: r.sequence,
75-
header_xdr: r.header_xdr,
76-
}),
77-
);
78-
writeCorpus("ledgers.json", {
85+
const url = `${HORIZON}/ledgers?limit=${PAGE_LIMIT}&order=desc`;
86+
const records = ((await getJson(url))._embedded?.records ?? []).map((r) => {
87+
const where = `ledger ${String(r.sequence ?? "<no sequence>")}`;
88+
return {
89+
sequence: requireNumber(r.sequence, "sequence", where),
90+
header_xdr: requireString(r.header_xdr, "header_xdr", where),
91+
};
92+
});
93+
94+
writeCorpus(OUT_DIR, "ledgers.json", {
7995
source: url,
8096
fetchedAt: new Date().toISOString(),
8197
records,
8298
});
83-
console.log(`Wrote ${records.length} ledger records.`);
84-
}
85-
86-
function writeCorpus<T>(filename: string, payload: CorpusFile<T>): void {
87-
mkdirSync(OUT_DIR, { recursive: true });
88-
writeFileSync(
89-
resolve(OUT_DIR, filename),
90-
JSON.stringify(payload, null, 2) + "\n",
91-
);
99+
reportCount(records.length, COUNT, "ledger");
92100
}
93101

94102
async function main(): Promise<void> {
95103
console.log(`Fetching from ${HORIZON}, COUNT=${COUNT}`);
96104
await snapshotTransactions();
97105
await snapshotLedgers();
98106
console.log(`\nCorpus written to ${OUT_DIR}`);
99-
console.log(`Run \`pnpm exec vitest run test/unit/base/xdr/corpus_round_trip.test.ts\` to validate.`);
107+
console.log(
108+
`Run \`pnpm exec vitest run test/unit/xdr/corpus_round_trip.test.ts\` to validate.`,
109+
);
100110
}
101111

102112
main().catch((err) => {

0 commit comments

Comments
 (0)