@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.
- package/CLAUDE.md +82 -18
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +63 -4
- package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
- package/package.json +8 -8
- package/src/sync-protocols/avail/AvailClient.ts +34 -6
- package/src/sync-protocols/avail/fetcher.ts +2 -0
- package/src/sync-protocols/avail/state.ts +2 -0
- package/src/sync-protocols/base/state.ts +51 -0
- package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
- package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
- package/src/sync-protocols/celestia/fetcher.ts +5 -1
- package/src/sync-protocols/celestia/state.ts +5 -1
- package/src/sync-protocols/common/http.ts +77 -0
- package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
- package/src/sync-protocols/midnight/fetcher.ts +18 -11
- package/src/sync-protocols/midnight/types.ts +1 -1
- package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
- package/src/sync-protocols/near/NearClient.ts +16 -6
- package/src/sync-protocols/near/fetcher.ts +5 -1
- package/src/sync-protocols/near/state.ts +5 -1
- package/src/sync-protocols/orchestration/sync.ts +101 -13
- package/src/sync-protocols/solana/SolanaClient.ts +188 -0
- package/src/sync-protocols/solana/fetcher.ts +456 -0
- package/src/sync-protocols/solana/program-logs.test.ts +113 -0
- package/src/sync-protocols/solana/program-logs.ts +84 -0
- package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
- package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
- package/src/sync-protocols/solana/root-page.test.ts +66 -0
- package/src/sync-protocols/solana/state.ts +167 -0
- package/src/sync-protocols/solana/types.ts +58 -0
- package/src/sync-protocols/types.ts +2 -0
- package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
- package/src/sync-protocols/utxorpc/state.ts +3 -0
- package/src/syncProtocolFactory.ts +15 -0
- package/test/poll-loop-spin.test.ts +112 -0
- package/test/rpc-timeout.test.ts +150 -0
- package/test/spin-probe.ts +102 -0
- package/test/start-async-supervision.test.ts +139 -0
- 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
|
+
});
|