@effectstream/sync 0.101.1 → 0.103.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CLAUDE.md +82 -18
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +63 -4
  5. package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
  6. package/package.json +8 -8
  7. package/src/sync-protocols/avail/AvailClient.ts +34 -6
  8. package/src/sync-protocols/avail/fetcher.ts +2 -0
  9. package/src/sync-protocols/avail/state.ts +2 -0
  10. package/src/sync-protocols/base/state.ts +51 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
  12. package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
  13. package/src/sync-protocols/celestia/fetcher.ts +5 -1
  14. package/src/sync-protocols/celestia/state.ts +5 -1
  15. package/src/sync-protocols/common/http.ts +77 -0
  16. package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
  17. package/src/sync-protocols/midnight/fetcher.ts +18 -11
  18. package/src/sync-protocols/midnight/types.ts +1 -1
  19. package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
  20. package/src/sync-protocols/near/NearClient.ts +16 -6
  21. package/src/sync-protocols/near/fetcher.ts +5 -1
  22. package/src/sync-protocols/near/state.ts +5 -1
  23. package/src/sync-protocols/orchestration/sync.ts +101 -13
  24. package/src/sync-protocols/solana/SolanaClient.ts +188 -0
  25. package/src/sync-protocols/solana/fetcher.ts +456 -0
  26. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  27. package/src/sync-protocols/solana/program-logs.ts +84 -0
  28. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  29. package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
  30. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  31. package/src/sync-protocols/solana/state.ts +167 -0
  32. package/src/sync-protocols/solana/types.ts +58 -0
  33. package/src/sync-protocols/types.ts +2 -0
  34. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  35. package/src/sync-protocols/utxorpc/state.ts +3 -0
  36. package/src/syncProtocolFactory.ts +15 -0
  37. package/test/poll-loop-spin.test.ts +112 -0
  38. package/test/rpc-timeout.test.ts +150 -0
  39. package/test/spin-probe.ts +102 -0
  40. package/test/start-async-supervision.test.ts +139 -0
  41. package/test/zswap-decoder.test.ts +74 -0
@@ -0,0 +1,153 @@
1
+ import { expect, test } from "bun:test";
2
+ import { run } from "effection";
3
+ import { SolanaFetcher } from "./fetcher.ts";
4
+ import type { ConfigType, Output, Page } from "./types.ts";
5
+ import type { LastPage } from "../base/state.ts";
6
+ import type { RootPage } from "../types.ts";
7
+
8
+ /**
9
+ * How `readData` behaves when the RPC misbehaves. The contract it must keep:
10
+ *
11
+ * - a transient failure costs nothing — the block is retried immediately
12
+ * - a persistent failure keeps whatever was already fetched, and the page
13
+ * advances only over slots that were positively resolved, so no slot is ever
14
+ * stepped over unread
15
+ * - a failure with nothing resolved throws, so the fetch loop counts it and
16
+ * /health can see it, rather than looking idle
17
+ */
18
+
19
+ const BLOCKHASH = "hash";
20
+
21
+ function makeFetcher(getBlock: (slot: number) => Promise<any>): SolanaFetcher {
22
+ const fetcher = new SolanaFetcher({
23
+ network: { rpcUrl: "http://127.0.0.1:8899" },
24
+ syncProtocol: { name: "parallelSolanaRPC" },
25
+ primitives: [],
26
+ } as unknown as ConfigType);
27
+ (fetcher.client as any).getBlock = getBlock;
28
+ return fetcher;
29
+ }
30
+
31
+ const block = (slot: number) => ({
32
+ blockhash: `${BLOCKHASH}-${slot}`,
33
+ blockTime: 1_700_000_000 + slot,
34
+ blockHeight: slot,
35
+ parentSlot: slot - 1,
36
+ transactions: [],
37
+ });
38
+
39
+ const rootConversion = {
40
+ toRootPage: (o: Output) => (o.blockTime * 1000) as RootPage,
41
+ } as any;
42
+
43
+ const priorPage: LastPage<Page, RootPage> = {
44
+ own: 0 as Page,
45
+ ownBlockNumber: 0 as Page,
46
+ root: 0 as RootPage,
47
+ };
48
+
49
+ function readData(fetcher: SolanaFetcher, from: number, to: number) {
50
+ return run(() =>
51
+ fetcher.readData(
52
+ { from, to, isPresync: false } as any,
53
+ rootConversion,
54
+ priorPage,
55
+ )
56
+ );
57
+ }
58
+
59
+ test("a transient failure is retried and costs no blocks", async () => {
60
+ const attempts = new Map<number, number>();
61
+ const fetcher = makeFetcher(async (slot) => {
62
+ const n = (attempts.get(slot) ?? 0) + 1;
63
+ attempts.set(slot, n);
64
+ if (slot === 2 && n === 1) throw new Error("transient timeout");
65
+ return block(slot);
66
+ });
67
+
68
+ const result = await readData(fetcher, 1, 3);
69
+
70
+ expect(result.output.length).toBe(3);
71
+ expect(attempts.get(2)).toBe(2); // failed once, succeeded on retry
72
+ expect(Number(result.lastPage.own)).toBe(3);
73
+ });
74
+
75
+ test("a persistent failure keeps earlier blocks and stops at the bad slot", async () => {
76
+ const fetcher = makeFetcher(async (slot) => {
77
+ if (slot >= 3) throw new Error("still down");
78
+ return block(slot);
79
+ });
80
+
81
+ const result = await readData(fetcher, 1, 5);
82
+
83
+ // Slots 1-2 survive rather than the whole chunk being discarded...
84
+ expect(result.output.map((o) => o.output.slot)).toEqual([1, 2]);
85
+ // ...and the page stops at 2, so the next poll resumes AT 3 — the failed slot
86
+ // is never stepped over.
87
+ expect(Number(result.lastPage.own)).toBe(2);
88
+ });
89
+
90
+ test("a failure with nothing resolved throws, so the loop can count it", async () => {
91
+ const fetcher = makeFetcher(async () => {
92
+ throw new Error("RPC unreachable");
93
+ });
94
+
95
+ let thrown: unknown;
96
+ try {
97
+ await readData(fetcher, 10, 15);
98
+ } catch (e) {
99
+ thrown = e;
100
+ }
101
+ expect(String(thrown)).toContain("RPC unreachable");
102
+ });
103
+
104
+ test("retries are bounded — it gives up rather than hanging forever", async () => {
105
+ let calls = 0;
106
+ const fetcher = makeFetcher(async () => {
107
+ calls++;
108
+ throw new Error("down");
109
+ });
110
+
111
+ let thrown: unknown;
112
+ try {
113
+ await readData(fetcher, 1, 1);
114
+ } catch (e) {
115
+ thrown = e;
116
+ }
117
+ expect(String(thrown)).toContain("down");
118
+ // 3 attempts for the single slot, then hand back to the fetch loop. An
119
+ // unbounded inner retry would never return and would freeze the health signal.
120
+ expect(calls).toBe(3);
121
+ });
122
+
123
+ test("a fully skipped range advances the page over CONFIRMED-skipped slots only", async () => {
124
+ const fetcher = makeFetcher(async () => null); // every slot skipped
125
+
126
+ const result = await readData(fetcher, 1, 4);
127
+
128
+ expect(result.output.length).toBe(0);
129
+ expect(Number(result.lastPage.own)).toBe(4);
130
+ });
131
+
132
+ test("skipped-then-failed advances only to the last confirmed slot", async () => {
133
+ const fetcher = makeFetcher(async (slot) => {
134
+ if (slot <= 2) return null; // confirmed skipped
135
+ throw new Error("down"); // never resolved
136
+ });
137
+
138
+ const result = await readData(fetcher, 1, 5);
139
+
140
+ expect(result.output.length).toBe(0);
141
+ // 2, not 5: slots 3-5 were never read, so the page must not pass them.
142
+ expect(Number(result.lastPage.own)).toBe(2);
143
+ });
144
+
145
+ test("trailing skipped slots still advance the page past the last block", async () => {
146
+ const fetcher = makeFetcher(async (slot) => (slot === 1 ? block(1) : null));
147
+
148
+ const result = await readData(fetcher, 1, 4);
149
+
150
+ expect(result.output.map((o) => o.output.slot)).toEqual([1]);
151
+ // Paging to 4 (not 1) avoids re-scanning slots already confirmed empty.
152
+ expect(Number(result.lastPage.own)).toBe(4);
153
+ });
@@ -0,0 +1,435 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
4
+ SOLANA_PRIMITIVE_PROGRAM_LOG,
5
+ SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
6
+ } from "@effectstream/config";
7
+ import { SolanaFetcher } from "./fetcher.ts";
8
+ import type { ConfigType } from "./types.ts";
9
+
10
+ /**
11
+ * Direct unit coverage for `SolanaFetcher.readPrimitives` — the function that
12
+ * decides what the state machine is told happened on chain. It had none, which
13
+ * meant the reverted-transaction skip, the lookup-table resolution and the
14
+ * two-primitive dispatch were all only exercised (if at all) by a full e2e run
15
+ * against a live validator.
16
+ *
17
+ * `readPrimitives` is a generator that never yields, so it can be driven to
18
+ * completion with a single `.next()`.
19
+ */
20
+
21
+ const WATCHED_PROGRAM = "Watch1111111111111111111111111111111111111";
22
+ const OTHER_PROGRAM = "0ther1111111111111111111111111111111111111";
23
+ const WATCHED_ADDRESS = "Addr11111111111111111111111111111111111111";
24
+
25
+ function makeFetcher(): SolanaFetcher {
26
+ return new SolanaFetcher({
27
+ network: { rpcUrl: "http://127.0.0.1:8899" },
28
+ syncProtocol: { name: "parallelSolanaRPC" },
29
+ primitives: [],
30
+ } as unknown as ConfigType);
31
+ }
32
+
33
+ /** Run the generator to completion and return the primitives it produced. */
34
+ function run(fetcher: SolanaFetcher, slot: number, block: any, entries: any[]) {
35
+ const it = fetcher.readPrimitives(slot, block, entries as any) as any;
36
+ const step = it.next();
37
+ expect(step.done).toBe(true);
38
+ return step.value as any[];
39
+ }
40
+
41
+ // `type` is what readPrimitives dispatches on. These fixtures used to omit it,
42
+ // which the old field-truthy dispatch tolerated — real configs always carry it,
43
+ // because `Primitive.getConfig()` sets it and runtime/src/main.ts substitutes its
44
+ // output for the raw config entry.
45
+ const programLogEntry = {
46
+ syncProtocol: "parallelSolanaRPC",
47
+ primitive: {
48
+ name: "WatchedLog",
49
+ type: SOLANA_PRIMITIVE_PROGRAM_LOG,
50
+ programId: WATCHED_PROGRAM,
51
+ },
52
+ };
53
+ const balanceEntry = {
54
+ syncProtocol: "parallelSolanaRPC",
55
+ primitive: {
56
+ name: "WatchedBalance",
57
+ type: SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
58
+ address: WATCHED_ADDRESS,
59
+ },
60
+ };
61
+
62
+ /** A transaction that genuinely invoked the watched program. */
63
+ function invokingTx(overrides: Record<string, unknown> = {}) {
64
+ return {
65
+ transaction: {
66
+ message: { accountKeys: [WATCHED_ADDRESS, WATCHED_PROGRAM] },
67
+ signatures: ["sig-1"],
68
+ },
69
+ meta: {
70
+ err: null,
71
+ logMessages: [
72
+ `Program ${WATCHED_PROGRAM} invoke [1]`,
73
+ "Program log: hello",
74
+ `Program ${WATCHED_PROGRAM} success`,
75
+ ],
76
+ postBalances: [500, 0],
77
+ ...overrides,
78
+ },
79
+ };
80
+ }
81
+
82
+ test("emits a ProgramLog primitive for a genuine invocation", () => {
83
+ const out = run(makeFetcher(), 42, { transactions: [invokingTx()] }, [
84
+ programLogEntry,
85
+ ]);
86
+ expect(out.length).toBe(1);
87
+ expect(out[0].primitive).toBe("WatchedLog");
88
+ expect(out[0].output.payload.programId).toBe(WATCHED_PROGRAM);
89
+ expect(out[0].output.payload.logMessages).toEqual(["Program log: hello"]);
90
+ expect(out[0].syncProtocol.transactionHash).toBe("sig-1");
91
+ });
92
+
93
+ test("SKIPS a reverted transaction (security fix B2)", () => {
94
+ // A failed tx's logs describe work that was rolled back and its postBalances
95
+ // are the pre-state. Emitting either would drive state transitions off events
96
+ // that never happened.
97
+ const reverted = invokingTx({ err: { InstructionError: [0, "Custom"] } });
98
+ const out = run(makeFetcher(), 42, { transactions: [reverted] }, [
99
+ programLogEntry,
100
+ balanceEntry,
101
+ ]);
102
+ expect(out).toEqual([]);
103
+ });
104
+
105
+ test("SKIPS a transaction the RPC could not decode (meta === null)", () => {
106
+ const undecodable = {
107
+ transaction: {
108
+ message: { accountKeys: [WATCHED_PROGRAM] },
109
+ signatures: ["sig-x"],
110
+ },
111
+ meta: null,
112
+ };
113
+ const out = run(makeFetcher(), 42, { transactions: [undecodable] }, [
114
+ programLogEntry,
115
+ ]);
116
+ expect(out).toEqual([]);
117
+ });
118
+
119
+ test("does NOT attribute another program's logs to the watched program", () => {
120
+ // The spoof: the watched program is present as a bare account key while a
121
+ // different program emits the text.
122
+ const spoof = {
123
+ transaction: {
124
+ message: { accountKeys: [WATCHED_PROGRAM, OTHER_PROGRAM] },
125
+ signatures: ["sig-2"],
126
+ },
127
+ meta: {
128
+ err: null,
129
+ logMessages: [
130
+ `Program ${OTHER_PROGRAM} invoke [1]`,
131
+ `Program log: ${WATCHED_PROGRAM} forged event`,
132
+ `Program ${OTHER_PROGRAM} success`,
133
+ ],
134
+ postBalances: [0, 0],
135
+ },
136
+ };
137
+ const out = run(makeFetcher(), 42, { transactions: [spoof] }, [
138
+ programLogEntry,
139
+ ]);
140
+ expect(out).toEqual([]);
141
+ });
142
+
143
+ test("eventType filters against the program's OWN lines only", () => {
144
+ const tx = invokingTx({
145
+ logMessages: [
146
+ `Program ${OTHER_PROGRAM} invoke [1]`,
147
+ "Program log: MARKER from someone else",
148
+ `Program ${OTHER_PROGRAM} success`,
149
+ `Program ${WATCHED_PROGRAM} invoke [1]`,
150
+ "Program log: nothing relevant",
151
+ `Program ${WATCHED_PROGRAM} success`,
152
+ ],
153
+ });
154
+ const entry = {
155
+ syncProtocol: "parallelSolanaRPC",
156
+ primitive: {
157
+ name: "WatchedLog",
158
+ type: SOLANA_PRIMITIVE_PROGRAM_LOG,
159
+ programId: WATCHED_PROGRAM,
160
+ eventType: "MARKER",
161
+ },
162
+ };
163
+ expect(run(makeFetcher(), 42, { transactions: [tx] }, [entry])).toEqual([]);
164
+ });
165
+
166
+ test("AccountBalance reads postBalances by resolved account index", () => {
167
+ const out = run(makeFetcher(), 7, { transactions: [invokingTx()] }, [
168
+ balanceEntry,
169
+ ]);
170
+ expect(out.length).toBe(1);
171
+ expect(out[0].output.payload).toMatchObject({
172
+ address: WATCHED_ADDRESS,
173
+ lamports: 500,
174
+ slot: 7,
175
+ });
176
+ });
177
+
178
+ test("AccountBalance resolves lookup-table addresses (security fix B3)", () => {
179
+ // The watched address arrives via an ALT, so it is absent from
180
+ // message.accountKeys — but postBalances is indexed over the full list:
181
+ // static keys, then ALT writable, then ALT readonly.
182
+ const tx = {
183
+ transaction: {
184
+ message: { accountKeys: [OTHER_PROGRAM] },
185
+ signatures: ["sig-3"],
186
+ },
187
+ meta: {
188
+ err: null,
189
+ logMessages: [],
190
+ postBalances: [1, 999, 2],
191
+ loadedAddresses: { writable: [WATCHED_ADDRESS], readonly: ["Ro11111111111111111111111111111111111111111"] },
192
+ },
193
+ };
194
+ const out = run(makeFetcher(), 9, { transactions: [tx] }, [balanceEntry]);
195
+ expect(out.length).toBe(1);
196
+ expect(out[0].output.payload.lamports).toBe(999);
197
+ });
198
+
199
+ test("dispatches both primitive kinds from one transaction", () => {
200
+ const out = run(makeFetcher(), 42, { transactions: [invokingTx()] }, [
201
+ balanceEntry,
202
+ programLogEntry,
203
+ ]);
204
+ expect(out.map((p) => p.primitive).sort())
205
+ .toEqual(["WatchedBalance", "WatchedLog"]);
206
+ });
207
+
208
+ test("returns nothing when no primitives are configured", () => {
209
+ expect(run(makeFetcher(), 42, { transactions: [invokingTx()] }, [])).toEqual([]);
210
+ });
211
+
212
+ // ───────────────────────── SOLANA:TokenAccount ─────────────────────────
213
+
214
+ const MINT = "Mint111111111111111111111111111111111111111";
215
+ const OTHER_MINT = "0therMint11111111111111111111111111111111";
216
+ const TOKEN_ACCOUNT = "Tok111111111111111111111111111111111111111";
217
+ const OWNER = "0wner11111111111111111111111111111111111111";
218
+ const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
219
+ const TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
220
+
221
+ const tokenEntry = {
222
+ syncProtocol: "parallelSolanaRPC",
223
+ primitive: {
224
+ name: "WatchedToken",
225
+ type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
226
+ mint: MINT,
227
+ },
228
+ };
229
+
230
+ /** `meta.postTokenBalances` record shape, per the JSON-RPC `getBlock` response. */
231
+ function tokenBalance(overrides: Record<string, unknown> = {}) {
232
+ return {
233
+ accountIndex: 0,
234
+ mint: MINT,
235
+ owner: OWNER,
236
+ programId: TOKEN_PROGRAM,
237
+ uiTokenAmount: {
238
+ amount: "1500000000",
239
+ decimals: 9,
240
+ uiAmount: 1.5,
241
+ uiAmountString: "1.5",
242
+ },
243
+ ...overrides,
244
+ };
245
+ }
246
+
247
+ /** A successful transaction carrying token balances. */
248
+ function tokenTx(
249
+ balances: Record<string, unknown>[],
250
+ overrides: Record<string, unknown> = {},
251
+ ) {
252
+ return {
253
+ transaction: {
254
+ message: { accountKeys: [TOKEN_ACCOUNT, WATCHED_PROGRAM] },
255
+ signatures: ["sig-tok"],
256
+ },
257
+ meta: {
258
+ err: null,
259
+ logMessages: [],
260
+ postBalances: [0, 0],
261
+ postTokenBalances: balances,
262
+ ...overrides,
263
+ },
264
+ };
265
+ }
266
+
267
+ test("TokenAccount emits the post balance for a watched mint", () => {
268
+ const out = run(makeFetcher(), 11, { transactions: [tokenTx([tokenBalance()])] }, [
269
+ tokenEntry,
270
+ ]);
271
+ expect(out.length).toBe(1);
272
+ expect(out[0].primitive).toBe("WatchedToken");
273
+ expect(out[0].output.payloadType).toBe("solana:token-balance");
274
+ expect(out[0].output.payload).toEqual({
275
+ tokenAccount: TOKEN_ACCOUNT,
276
+ mint: MINT,
277
+ owner: OWNER,
278
+ // Raw u64 as a string: 1.5 tokens at 9 decimals does not survive a JS number
279
+ // once amounts get large, so the grammar carries the base units.
280
+ amount: "1500000000",
281
+ decimals: 9,
282
+ slot: 11,
283
+ });
284
+ });
285
+
286
+ test("TokenAccount resolves lookup-table token accounts (security fix B3)", () => {
287
+ // `accountIndex` indexes the resolved list — static keys, then ALT writable, then
288
+ // ALT readonly — so a token account reached through an ALT is at an index past
289
+ // the end of message.accountKeys. Without resolution this reads undefined and
290
+ // drops the balance silently.
291
+ // Two static keys (indices 0-1), then ALT writable (2), then ALT readonly (3),
292
+ // so the watched token account sits at index 3 — past the end of accountKeys.
293
+ const tx = tokenTx([tokenBalance({ accountIndex: 3 })], {
294
+ loadedAddresses: {
295
+ writable: ["W1111111111111111111111111111111111111111111"],
296
+ readonly: [TOKEN_ACCOUNT],
297
+ },
298
+ });
299
+ tx.transaction.message.accountKeys = [OTHER_PROGRAM, WATCHED_PROGRAM];
300
+ const out = run(makeFetcher(), 12, { transactions: [tx] }, [tokenEntry]);
301
+ expect(out.length).toBe(1);
302
+ expect(out[0].output.payload.tokenAccount).toBe(TOKEN_ACCOUNT);
303
+ });
304
+
305
+ test("TokenAccount SKIPS a reverted transaction (security fix B2)", () => {
306
+ const tx = tokenTx([tokenBalance()], { err: { InstructionError: [0, "Custom"] } });
307
+ expect(run(makeFetcher(), 13, { transactions: [tx] }, [tokenEntry])).toEqual([]);
308
+ });
309
+
310
+ test("TokenAccount ignores balances for a different mint", () => {
311
+ const tx = tokenTx([tokenBalance({ mint: OTHER_MINT })]);
312
+ expect(run(makeFetcher(), 14, { transactions: [tx] }, [tokenEntry])).toEqual([]);
313
+ });
314
+
315
+ test("TokenAccount narrows by owner", () => {
316
+ const entry = {
317
+ syncProtocol: "parallelSolanaRPC",
318
+ primitive: {
319
+ name: "WatchedToken",
320
+ type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
321
+ mint: MINT,
322
+ owner: OWNER,
323
+ },
324
+ };
325
+ expect(
326
+ run(makeFetcher(), 15, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
327
+ ).toHaveLength(1);
328
+ expect(
329
+ run(
330
+ makeFetcher(),
331
+ 15,
332
+ { transactions: [tokenTx([tokenBalance({ owner: "someone-else" })])] },
333
+ [entry],
334
+ ),
335
+ ).toEqual([]);
336
+ });
337
+
338
+ test("TokenAccount narrows by tokenProgramId, separating Token-2022", () => {
339
+ const entry = {
340
+ syncProtocol: "parallelSolanaRPC",
341
+ primitive: {
342
+ name: "WatchedToken",
343
+ type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
344
+ mint: MINT,
345
+ tokenProgramId: TOKEN_2022_PROGRAM,
346
+ },
347
+ };
348
+ // Same mint, classic SPL Token program — must not match a Token-2022 filter.
349
+ expect(
350
+ run(makeFetcher(), 16, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
351
+ ).toEqual([]);
352
+ expect(
353
+ run(
354
+ makeFetcher(),
355
+ 16,
356
+ { transactions: [tokenTx([tokenBalance({ programId: TOKEN_2022_PROGRAM })])] },
357
+ [entry],
358
+ ),
359
+ ).toHaveLength(1);
360
+ });
361
+
362
+ test("TokenAccount narrows by tokenAccount", () => {
363
+ const entry = {
364
+ syncProtocol: "parallelSolanaRPC",
365
+ primitive: {
366
+ name: "WatchedToken",
367
+ type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
368
+ tokenAccount: TOKEN_ACCOUNT,
369
+ },
370
+ };
371
+ expect(
372
+ run(makeFetcher(), 17, { transactions: [tokenTx([tokenBalance()])] }, [entry]),
373
+ ).toHaveLength(1);
374
+ // accountIndex 1 resolves to WATCHED_PROGRAM, not the watched token account.
375
+ expect(
376
+ run(
377
+ makeFetcher(),
378
+ 17,
379
+ { transactions: [tokenTx([tokenBalance({ accountIndex: 1 })])] },
380
+ [entry],
381
+ ),
382
+ ).toEqual([]);
383
+ });
384
+
385
+ test("TokenAccount emits one primitive per matching balance record", () => {
386
+ const tx = tokenTx([
387
+ tokenBalance(),
388
+ tokenBalance({ accountIndex: 1, owner: "second-owner" }),
389
+ tokenBalance({ mint: OTHER_MINT, accountIndex: 1 }),
390
+ ]);
391
+ const out = run(makeFetcher(), 18, { transactions: [tx] }, [tokenEntry]);
392
+ expect(out.length).toBe(2);
393
+ expect(out.map((p) => p.output.payload.owner)).toEqual([OWNER, "second-owner"]);
394
+ });
395
+
396
+ test("TokenAccount tolerates a transaction with no token balances", () => {
397
+ expect(run(makeFetcher(), 19, { transactions: [invokingTx()] }, [tokenEntry]))
398
+ .toEqual([]);
399
+ });
400
+
401
+ test("a TokenAccount entry is never treated as an AccountBalance", () => {
402
+ // The regression the type dispatch exists for. `SolanaPrimitive` is a flat bag of
403
+ // optionals, so a config can set `address` on a TokenAccount entry — under the old
404
+ // field-truthy dispatch (`if (prim.address)` first) that emitted a lamport-balance
405
+ // event under the token primitive's name.
406
+ const entry = {
407
+ syncProtocol: "parallelSolanaRPC",
408
+ primitive: {
409
+ name: "WatchedToken",
410
+ type: SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
411
+ mint: MINT,
412
+ address: TOKEN_ACCOUNT,
413
+ },
414
+ };
415
+ const out = run(makeFetcher(), 20, { transactions: [tokenTx([tokenBalance()])] }, [
416
+ entry,
417
+ ]);
418
+ expect(out.length).toBe(1);
419
+ expect(out[0].output.payloadType).toBe("solana:token-balance");
420
+ expect(out[0].output.payload).not.toHaveProperty("lamports");
421
+ });
422
+
423
+ test("an unsupported primitive type is ignored rather than misrouted", () => {
424
+ const entry = {
425
+ syncProtocol: "parallelSolanaRPC",
426
+ primitive: {
427
+ name: "FromTheFuture",
428
+ type: "SOLANA:NotImplementedYet",
429
+ address: WATCHED_ADDRESS,
430
+ programId: WATCHED_PROGRAM,
431
+ },
432
+ };
433
+ expect(run(makeFetcher(), 21, { transactions: [invokingTx()] }, [entry]))
434
+ .toEqual([]);
435
+ });
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "bun:test";
2
+ import { toMsTimestamp } from "./types.ts";
3
+ import { applyDelay } from "../common/utils.ts";
4
+
5
+ /**
6
+ * The merge pulls buffered parallel (Solana) outputs in slot order and gates
7
+ * them with `rootPage <= mainChainTime`, so the root timestamp must be
8
+ * monotonically non-decreasing across slots. Solana guarantees that for
9
+ * `blockTime`, and duplicate blockTimes must map to EQUAL roots — the merge
10
+ * disambiguates by buffer/slot order, not by this value.
11
+ *
12
+ * Regression guard for the original `(slot % 1000) * 0.001` tiebreaker, which
13
+ * wrapped every 1000 slots so slot 1000 could sort before slot 999.
14
+ */
15
+
16
+ /** What SolanaSyncState.toRootPage does, minus the config plumbing. */
17
+ const rootPage = (blockTime: number, delayMs: number) =>
18
+ Number(applyDelay(toMsTimestamp(blockTime), delayMs));
19
+
20
+ const DELAY = 2400;
21
+
22
+ test("root timestamp is non-decreasing across a realistic slot stream", () => {
23
+ // Repeated blockTimes are normal: slots are ~400ms but blockTime has 1-second
24
+ // resolution, so several consecutive slots share a timestamp.
25
+ const stream = [
26
+ { slot: 100, blockTime: 1_700_000_000 },
27
+ { slot: 101, blockTime: 1_700_000_000 },
28
+ { slot: 102, blockTime: 1_700_000_001 },
29
+ { slot: 999, blockTime: 1_700_000_001 },
30
+ { slot: 1000, blockTime: 1_700_000_002 }, // the old %1000 wrap point
31
+ { slot: 1001, blockTime: 1_700_000_002 },
32
+ ];
33
+ const roots = stream.map((s) => rootPage(s.blockTime, DELAY));
34
+ for (let i = 1; i < roots.length; i++) {
35
+ expect(roots[i]).toBeGreaterThanOrEqual(roots[i - 1]);
36
+ }
37
+ });
38
+
39
+ test("the root depends only on blockTime, never on slot", () => {
40
+ // The previous version of this test compared f(x) to f(x), which holds for any
41
+ // implementation. Assert the actual property instead: a 1-second blockTime
42
+ // step moves the root by exactly 1000ms and nothing else contributes.
43
+ const a = rootPage(1_700_000_001, DELAY);
44
+ const b = rootPage(1_700_000_002, DELAY);
45
+ expect(b - a).toBe(1000);
46
+ });
47
+
48
+ test("ordering holds across the slot-1000 boundary that broke the old tiebreaker", () => {
49
+ // Under `(slot % 1000) * 0.001` the offset reset at slot 1000, so a later slot
50
+ // could sort before an earlier one. Verify the real ordering across it.
51
+ expect(rootPage(1_700_000_002, DELAY))
52
+ .toBeGreaterThan(rootPage(1_700_000_001, DELAY));
53
+ });
54
+
55
+ test("blockTime seconds convert to milliseconds", () => {
56
+ expect(Number(toMsTimestamp(1_700_000_000))).toBe(1_700_000_000_000);
57
+ });
58
+
59
+ test("delayMs shifts the root forward, preserving order", () => {
60
+ // applyDelay ADDS the confirmation delay so older blocks merge into more
61
+ // recent root slots (sync CLAUDE.md, design idea #3).
62
+ const undelayed = rootPage(1_700_000_000, 0);
63
+ const delayed = rootPage(1_700_000_000, DELAY);
64
+ expect(delayed - undelayed).toBe(DELAY);
65
+ expect(rootPage(1_700_000_001, DELAY)).toBeGreaterThan(delayed);
66
+ });