@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,456 @@
1
+ import {
2
+ ConfigSyncProtocolType,
3
+ type PrimitiveEntry,
4
+ SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
5
+ SOLANA_PRIMITIVE_PROGRAM_LOG,
6
+ SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
7
+ } from "@effectstream/config";
8
+ import { BaseDataFetcher } from "../base/fetcher.ts";
9
+ import type { DataFetched } from "../base/fetcher.ts";
10
+ import type {
11
+ LastPage,
12
+ OutputAndCleanup,
13
+ RootConversion,
14
+ } from "../base/state.ts";
15
+ import type { RootOutput, RootPage } from "../types.ts";
16
+ import type {
17
+ ConfigType,
18
+ Input,
19
+ Output,
20
+ Page,
21
+ PrimitiveType,
22
+ } from "./types.ts";
23
+ import {
24
+ resolveAccountKeys,
25
+ SolanaClient,
26
+ type SolanaTokenBalance,
27
+ } from "./SolanaClient.ts";
28
+ import { requestTimeoutOf } from "../common/http.ts";
29
+ import { extractProgramLogs } from "./program-logs.ts";
30
+ import { call, sleep, type Operation } from "effection";
31
+ import { bound } from "@effectstream/utils";
32
+
33
+ /**
34
+ * Attempts per slot before `readData` reports partial progress and lets the
35
+ * fetch loop retry the range. Small on purpose — the outer loop is the real
36
+ * retry mechanism; see `getBlockWithRetry`.
37
+ */
38
+ const BLOCK_FETCH_ATTEMPTS = 3;
39
+ /** Linear backoff between those attempts (250ms, then 500ms). */
40
+ const BLOCK_FETCH_RETRY_DELAY_MS = 250;
41
+
42
+ export class SolanaFetcher extends BaseDataFetcher<
43
+ Input,
44
+ Output,
45
+ RootOutput,
46
+ Page,
47
+ RootPage
48
+ > {
49
+ readonly client: SolanaClient;
50
+
51
+ /**
52
+ * Last non-null `blockTime` seen, used to fill in a block whose timestamp the
53
+ * RPC couldn't compute. Carried across `readData` calls so only a null on the
54
+ * very first block ever fetched has no fallback.
55
+ */
56
+ private lastKnownBlockTime: number | undefined;
57
+
58
+ /**
59
+ * Messages already logged by `warnOnce`. `readPrimitives` runs per transaction,
60
+ * so a misconfigured primitive would otherwise emit an identical line thousands
61
+ * of times during catch-up and bury everything else.
62
+ */
63
+ private readonly warnedOnce = new Set<string>();
64
+
65
+ private warnOnce(message: string): void {
66
+ if (this.warnedOnce.has(message)) return;
67
+ this.warnedOnce.add(message);
68
+ console.warn(message);
69
+ }
70
+
71
+ constructor(
72
+ readonly config: ConfigType,
73
+ ) {
74
+ super(config.syncProtocol.name);
75
+ this.client = new SolanaClient(
76
+ config.network.rpcUrl,
77
+ requestTimeoutOf(config.syncProtocol),
78
+ );
79
+ }
80
+
81
+ @bound
82
+ override *readData(
83
+ data: Input,
84
+ rootConversion: RootConversion<Output, RootOutput, RootPage>,
85
+ lastPage: LastPage<Page, RootPage> | undefined,
86
+ ): Operation<DataFetched<Output, Page, RootPage>> {
87
+ const outputs: OutputAndCleanup<Output>[] = [];
88
+
89
+ console.log(
90
+ `[Solana] Fetching slots from ${data.from} to ${data.to}.${
91
+ data.isPresync ? " [presync]" : ""
92
+ }`,
93
+ );
94
+
95
+ // Highest slot we have positively resolved — either read a block for, or had
96
+ // the RPC confirm was skipped. The page may never advance past this, or we
97
+ // would silently step over a slot we never actually read.
98
+ let scannedThrough: number | undefined;
99
+ let fetchFailure: { slot: number; error: unknown } | undefined;
100
+
101
+ for (let slot = Number(data.from); slot <= Number(data.to); slot++) {
102
+ let block;
103
+ try {
104
+ block = yield* this.getBlockWithRetry(slot);
105
+ } catch (error) {
106
+ // Keep the blocks already gathered rather than discarding the chunk:
107
+ // everything below `slot` is resolved, so we report progress up to
108
+ // there and the next poll resumes exactly at the failed slot.
109
+ fetchFailure = { slot, error };
110
+ break;
111
+ }
112
+ scannedThrough = slot;
113
+
114
+ // Skipped slots (no block produced) are skipped gracefully
115
+ if (!block) {
116
+ continue;
117
+ }
118
+
119
+ // `blockTime` is the merge key for this chain, so it can't be null.
120
+ // Mapping null to 0 (the previous behaviour) sorted the block to the unix
121
+ // epoch and broke the monotonicity the merge relies on. Solana guarantees
122
+ // blockTime is non-decreasing, so reusing the last known value keeps the
123
+ // block in the stream, in order, in the same bucket as its predecessor.
124
+ const blockTime = block.blockTime ?? this.lastKnownBlockTime;
125
+ if (blockTime == null) {
126
+ console.warn(
127
+ `[Solana] slot ${slot} has no blockTime and no earlier block to derive one from — skipping.`,
128
+ );
129
+ continue;
130
+ }
131
+ this.lastKnownBlockTime = blockTime;
132
+
133
+ const primitives = yield* this.readPrimitives(
134
+ slot,
135
+ block,
136
+ this.config.primitives,
137
+ );
138
+
139
+ outputs.push({
140
+ output: {
141
+ slot,
142
+ blockhash: block.blockhash,
143
+ blockTime,
144
+ blockHeight: block.blockHeight,
145
+ parentSlot: block.parentSlot,
146
+ // `meta` is null for transactions the RPC couldn't decode; keep the
147
+ // entry (it still occupies a tx index) but don't dereference it.
148
+ transactions: (block.transactions ?? []).map((tx) => ({
149
+ err: tx.meta?.err ?? null,
150
+ logMessages: tx.meta?.logMessages ?? null,
151
+ preBalances: tx.meta?.preBalances ?? [],
152
+ postBalances: tx.meta?.postBalances ?? [],
153
+ })),
154
+ primitives,
155
+ },
156
+ cleanup: () => {},
157
+ });
158
+ }
159
+
160
+ if (fetchFailure != null) {
161
+ console.warn(
162
+ `[Solana] slot ${fetchFailure.slot} could not be fetched after ` +
163
+ `${BLOCK_FETCH_ATTEMPTS} attempts: ${String(fetchFailure.error)}. ` +
164
+ `Keeping ${outputs.length} block(s) up to slot ${scannedThrough ?? "-"}; ` +
165
+ `the next poll resumes at ${fetchFailure.slot}.`,
166
+ );
167
+ if (scannedThrough == null) {
168
+ // Nothing at all was resolved, so there is no progress to report and
169
+ // nothing to advance to. Throw: the fetch loop counts it in
170
+ // `consecutiveErrors`, leaves `lastSuccessfulFetchMs` alone and retries
171
+ // this exact range after `pollingInterval`. That is what keeps a
172
+ // genuinely unreachable RPC visible to /health instead of looking idle.
173
+ throw fetchFailure.error;
174
+ }
175
+ }
176
+
177
+ if (outputs.length === 0) {
178
+ if (!lastPage) {
179
+ throw new Error(
180
+ `[Solana] Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
181
+ );
182
+ }
183
+ // Advance the page even though nothing was found. `stateToInput` derives
184
+ // `from` from `lastPage.own`, so a page that never moves re-requests the
185
+ // same window forever — a range of entirely skipped slots (a Solana
186
+ // outage, or any leaderless stretch) would stall the fetcher permanently
187
+ // with no recovery once blocks resume. This is the "Page != Data"
188
+ // invariant in the package CLAUDE.md: pages track scan progress, not
189
+ // content.
190
+ //
191
+ // The root timestamp is carried over unchanged: no block means no new
192
+ // time to report, and the merge requires root to be non-decreasing.
193
+ //
194
+ // NOTE: near/fetcher.ts:94 has the identical stall — it's the pattern this
195
+ // was copied from. Not fixed here to keep this PR scoped to Solana.
196
+ //
197
+ // `scannedThrough`, not `data.to`: if a fetch failed part-way we must not
198
+ // advance over the slot we never read.
199
+ return {
200
+ output: [],
201
+ lastPage: {
202
+ ...lastPage,
203
+ own: scannedThrough as Page,
204
+ ownBlockNumber: scannedThrough as Page,
205
+ },
206
+ };
207
+ }
208
+
209
+ const lastOutput = outputs[outputs.length - 1].output;
210
+ // Page to the highest RESOLVED slot rather than the last one that carried
211
+ // data: trailing skipped slots were positively confirmed empty, so
212
+ // re-scanning them next poll is wasted work. On a clean full scan this is
213
+ // `data.to`, matching bitcoin/evm. The root timestamp still comes from the
214
+ // newest block we actually have, which is what the merge gates on.
215
+ const page = (scannedThrough ?? lastOutput.slot) as Page;
216
+ return {
217
+ output: outputs,
218
+ lastPage: {
219
+ ownBlockNumber: page,
220
+ own: page,
221
+ root: rootConversion.toRootPage(lastOutput),
222
+ },
223
+ };
224
+ }
225
+
226
+ /**
227
+ * `getBlock` with a few immediate retries, so a transient RPC blip does not
228
+ * cost the whole chunk.
229
+ *
230
+ * Deliberately BOUNDED. Retrying here forever would look like "we always get
231
+ * the block eventually", but it recreates the exact failure the request
232
+ * timeout was added to kill (sync CLAUDE.md finding #4): `readData` would
233
+ * never return, so the fetch loop never reaches its `catch`,
234
+ * `consecutiveErrors` stays 0, `lastSuccessfulFetchMs` freezes, and /health
235
+ * reports a happy node that has silently stopped producing blocks.
236
+ *
237
+ * Unbounded retry still happens — one level up, where it is observable. The
238
+ * fetch loop re-requests the same range every `pollingInterval` indefinitely,
239
+ * and page ranges are idempotent, so no slot is ever skipped. This helper only
240
+ * decides how much we retry *before* handing that decision back.
241
+ */
242
+ @bound
243
+ *getBlockWithRetry(slot: number) {
244
+ let lastError: unknown;
245
+ for (let attempt = 1; attempt <= BLOCK_FETCH_ATTEMPTS; attempt++) {
246
+ try {
247
+ return yield* call(() => this.client.getBlock(slot));
248
+ } catch (error) {
249
+ lastError = error;
250
+ if (attempt < BLOCK_FETCH_ATTEMPTS) {
251
+ yield* sleep(BLOCK_FETCH_RETRY_DELAY_MS * attempt);
252
+ }
253
+ }
254
+ }
255
+ throw lastError;
256
+ }
257
+
258
+ @bound
259
+ *readPrimitives(
260
+ slot: number,
261
+ block: {
262
+ transactions: {
263
+ transaction: {
264
+ message: { accountKeys: string[] };
265
+ signatures: string[];
266
+ };
267
+ meta: {
268
+ err: unknown | null;
269
+ logMessages: string[] | null;
270
+ postBalances: number[];
271
+ loadedAddresses?: {
272
+ writable: string[];
273
+ readonly: string[];
274
+ } | null;
275
+ postTokenBalances?: SolanaTokenBalance[] | null;
276
+ } | null;
277
+ }[];
278
+ },
279
+ primitiveEntries: Extract<
280
+ PrimitiveEntry,
281
+ { syncProtocol: ConfigSyncProtocolType.SOLANA_RPC_PARALLEL }
282
+ >[],
283
+ ): Operation<PrimitiveType[]> {
284
+ if (primitiveEntries.length === 0) return [];
285
+
286
+ const allPrimitives: PrimitiveType[] = [];
287
+
288
+ for (
289
+ let txIndex = 0;
290
+ txIndex < block.transactions.length;
291
+ txIndex++
292
+ ) {
293
+ const tx = block.transactions[txIndex];
294
+ // A reverted transaction has no on-chain effect: its logs describe work
295
+ // that was rolled back and its postBalances are the pre-state. Emitting
296
+ // primitives for it would drive state transitions off events that never
297
+ // happened. `meta == null` means the RPC couldn't decode the tx — equally
298
+ // unusable, so skip both.
299
+ if (!tx.meta || tx.meta.err) continue;
300
+ // Balances are indexed over static keys PLUS lookup-table addresses, so a
301
+ // watched address pulled in via an ALT is only findable in the resolved
302
+ // list. Legacy transactions resolve to the static keys unchanged.
303
+ const accountKeys = resolveAccountKeys(
304
+ tx.transaction.message.accountKeys,
305
+ tx.meta.loadedAddresses,
306
+ );
307
+ const logs = tx.meta.logMessages ?? [];
308
+ const postBalances = tx.meta.postBalances ?? [];
309
+ const txHash = tx.transaction.signatures[0] ?? "";
310
+
311
+ for (const entry of primitiveEntries) {
312
+ const prim = entry.primitive;
313
+
314
+ // Dispatch on `prim.type`, NOT on which optional fields happen to be set.
315
+ // `SolanaPrimitive` is a flat bag of optionals, so field-truthy dispatch
316
+ // fails two ways once a third primitive exists: a TokenAccount entry
317
+ // matches no field branch and silently emits nothing, and any field it
318
+ // shares with an earlier branch (`address` being the natural name for a
319
+ // token account) routes it into that branch instead. Neither is a type
320
+ // error. `type` is always populated at runtime because
321
+ // `Primitive.getConfig()` sets it and runtime/src/main.ts replaces the
322
+ // config entry with its output.
323
+ switch (prim.type) {
324
+ // ── SOLANA:AccountBalance — watch an address's lamport balance ──
325
+ case SOLANA_PRIMITIVE_ACCOUNT_BALANCE: {
326
+ if (!prim.address) continue;
327
+ const idx = accountKeys.indexOf(prim.address);
328
+ if (idx === -1) continue;
329
+ allPrimitives.push({
330
+ syncProtocol: {
331
+ name: entry.syncProtocol,
332
+ blockNumber: slot,
333
+ transactionHash: txHash,
334
+ contractAddress: prim.address,
335
+ logIndex: txIndex,
336
+ },
337
+ primitive: prim.name,
338
+ output: {
339
+ payloadType: "solana:balance",
340
+ payload: {
341
+ address: prim.address,
342
+ lamports: postBalances[idx] ?? 0,
343
+ slot,
344
+ },
345
+ },
346
+ });
347
+ continue;
348
+ }
349
+
350
+ // ── SOLANA:ProgramLog — logs the watched programId actually emitted ──
351
+ case SOLANA_PRIMITIVE_PROGRAM_LOG: {
352
+ if (!prim.programId) continue;
353
+ // Source of truth is the log stream's invoke/success framing, NOT
354
+ // accountKeys: naming a program as an account doesn't invoke it, and
355
+ // a program reached through a lookup table isn't in accountKeys at
356
+ // all. See program-logs.ts.
357
+ const programLogs = extractProgramLogs(logs, prim.programId);
358
+ if (programLogs == null) continue;
359
+ // Filter by eventType if specified — against this program's own lines
360
+ // only, so another program can't trigger it by echoing the string.
361
+ if (prim.eventType) {
362
+ const hasMatchingLog = programLogs.some((log) =>
363
+ log.includes(prim.eventType!)
364
+ );
365
+ if (!hasMatchingLog) continue;
366
+ }
367
+ allPrimitives.push({
368
+ syncProtocol: {
369
+ name: entry.syncProtocol,
370
+ blockNumber: slot,
371
+ transactionHash: txHash,
372
+ contractAddress: prim.programId,
373
+ logIndex: txIndex,
374
+ },
375
+ primitive: prim.name,
376
+ output: {
377
+ payloadType: "solana:transaction",
378
+ payload: {
379
+ programId: prim.programId,
380
+ slot,
381
+ logMessages: programLogs,
382
+ },
383
+ },
384
+ });
385
+ continue;
386
+ }
387
+
388
+ // ── SOLANA:TokenAccount — SPL balance of a watched token account ──
389
+ case SOLANA_PRIMITIVE_TOKEN_ACCOUNT: {
390
+ // An entry with no filter would match every token balance on chain.
391
+ // The primitive constructor rejects that, so reaching here means a
392
+ // hand-built config bypassed it.
393
+ if (!prim.mint && !prim.owner && !prim.tokenAccount) {
394
+ this.warnOnce(
395
+ `[Solana] primitive "${prim.name}" is ${SOLANA_PRIMITIVE_TOKEN_ACCOUNT} with no ` +
396
+ `mint, owner or tokenAccount — it would match every token balance, so it is skipped.`,
397
+ );
398
+ continue;
399
+ }
400
+ for (const bal of tx.meta.postTokenBalances ?? []) {
401
+ if (prim.mint && bal.mint !== prim.mint) continue;
402
+ if (prim.owner && bal.owner !== prim.owner) continue;
403
+ if (prim.tokenProgramId && bal.programId !== prim.tokenProgramId) {
404
+ continue;
405
+ }
406
+ // `accountIndex` indexes the RESOLVED list, same as postBalances, so
407
+ // a token account pulled in via a lookup table is only findable here
408
+ // (security fix B3 applied to token balances).
409
+ const tokenAccount = accountKeys[bal.accountIndex];
410
+ if (tokenAccount == null) continue;
411
+ if (prim.tokenAccount && tokenAccount !== prim.tokenAccount) {
412
+ continue;
413
+ }
414
+ allPrimitives.push({
415
+ syncProtocol: {
416
+ name: entry.syncProtocol,
417
+ blockNumber: slot,
418
+ transactionHash: txHash,
419
+ contractAddress: bal.mint,
420
+ logIndex: txIndex,
421
+ },
422
+ primitive: prim.name,
423
+ output: {
424
+ payloadType: "solana:token-balance",
425
+ payload: {
426
+ tokenAccount,
427
+ mint: bal.mint,
428
+ owner: bal.owner ?? "",
429
+ amount: bal.uiTokenAmount.amount,
430
+ decimals: bal.uiTokenAmount.decimals,
431
+ slot,
432
+ },
433
+ },
434
+ });
435
+ }
436
+ continue;
437
+ }
438
+
439
+ default:
440
+ // A Solana sync protocol carrying a primitive type this fetcher does
441
+ // not implement produced nothing and said nothing before. Warn once
442
+ // per type rather than per transaction, which would be thousands of
443
+ // identical lines during catch-up.
444
+ this.warnOnce(
445
+ `[Solana] primitive "${prim.name}" has unsupported type "${prim.type}" — ignored. ` +
446
+ `Supported: ${SOLANA_PRIMITIVE_ACCOUNT_BALANCE}, ${SOLANA_PRIMITIVE_PROGRAM_LOG}, ` +
447
+ `${SOLANA_PRIMITIVE_TOKEN_ACCOUNT}.`,
448
+ );
449
+ continue;
450
+ }
451
+ }
452
+ }
453
+
454
+ return allPrimitives;
455
+ }
456
+ }
@@ -0,0 +1,113 @@
1
+ import { expect, test } from "bun:test";
2
+ import { extractProgramLogs } from "./program-logs.ts";
3
+
4
+ const WATCHED = "Watch1111111111111111111111111111111111111";
5
+ const OTHER = "0ther1111111111111111111111111111111111111";
6
+ const SYSTEM = "11111111111111111111111111111111";
7
+
8
+ test("returns null when the program never appears at all", () => {
9
+ expect(extractProgramLogs([
10
+ `Program ${OTHER} invoke [1]`,
11
+ "Program log: unrelated",
12
+ `Program ${OTHER} success`,
13
+ ], WATCHED)).toBeNull();
14
+ });
15
+
16
+ test("SPOOF: a program merely named in another program's log line is not invoked", () => {
17
+ // The attack the old `accountKeys.includes()` + substring match allowed: the
18
+ // watched program is referenced as a bare account key (so it shows up in
19
+ // accountKeys) while someone else's program emits the expected text.
20
+ const logs = [
21
+ `Program ${OTHER} invoke [1]`,
22
+ `Program log: ${WATCHED} EFFECTSTREAM_COUNTER|attacker|9999|1`,
23
+ `Program ${OTHER} success`,
24
+ ];
25
+ expect(extractProgramLogs(logs, WATCHED)).toBeNull();
26
+ });
27
+
28
+ test("collects only the watched program's own lines at top level", () => {
29
+ expect(extractProgramLogs([
30
+ `Program ${WATCHED} invoke [1]`,
31
+ "Program log: mine",
32
+ `Program ${WATCHED} consumed 1234 of 200000 compute units`,
33
+ `Program ${WATCHED} success`,
34
+ ], WATCHED)).toEqual(["Program log: mine"]);
35
+ });
36
+
37
+ test("CPI: an inner program's lines are not attributed to the outer one", () => {
38
+ const logs = [
39
+ `Program ${WATCHED} invoke [1]`,
40
+ "Program log: outer before",
41
+ `Program ${OTHER} invoke [2]`,
42
+ "Program log: inner",
43
+ `Program ${OTHER} consumed 10 of 100 compute units`,
44
+ `Program ${OTHER} success`,
45
+ "Program log: outer after",
46
+ `Program ${WATCHED} consumed 50 of 200 compute units`,
47
+ `Program ${WATCHED} success`,
48
+ ];
49
+ expect(extractProgramLogs(logs, WATCHED))
50
+ .toEqual(["Program log: outer before", "Program log: outer after"]);
51
+ expect(extractProgramLogs(logs, OTHER)).toEqual(["Program log: inner"]);
52
+ });
53
+
54
+ test("CPI: a watched program called by another still reports its own lines", () => {
55
+ expect(extractProgramLogs([
56
+ `Program ${OTHER} invoke [1]`,
57
+ "Program log: caller",
58
+ `Program ${WATCHED} invoke [2]`,
59
+ "Program log: callee",
60
+ `Program ${WATCHED} success`,
61
+ `Program ${OTHER} success`,
62
+ ], WATCHED)).toEqual(["Program log: callee"]);
63
+ });
64
+
65
+ test("invoked-but-silent yields [] — distinct from null", () => {
66
+ // The System Program logs only its framing; the primitive should still fire.
67
+ const result = extractProgramLogs([
68
+ `Program ${SYSTEM} invoke [1]`,
69
+ `Program ${SYSTEM} success`,
70
+ ], SYSTEM);
71
+ expect(result).not.toBeNull();
72
+ expect(result).toEqual([]);
73
+ });
74
+
75
+ test("multiple invocations of the same program accumulate", () => {
76
+ expect(extractProgramLogs([
77
+ `Program ${WATCHED} invoke [1]`,
78
+ "Program log: first",
79
+ `Program ${WATCHED} success`,
80
+ `Program ${WATCHED} invoke [1]`,
81
+ "Program log: second",
82
+ `Program ${WATCHED} success`,
83
+ ], WATCHED)).toEqual(["Program log: first", "Program log: second"]);
84
+ });
85
+
86
+ test("a failed inner frame still pops", () => {
87
+ expect(extractProgramLogs([
88
+ `Program ${WATCHED} invoke [1]`,
89
+ `Program ${OTHER} invoke [2]`,
90
+ "Program log: inner",
91
+ `Program ${OTHER} failed: custom program error: 0x1`,
92
+ "Program log: outer recovers",
93
+ `Program ${WATCHED} success`,
94
+ ], WATCHED)).toEqual(["Program log: outer recovers"]);
95
+ });
96
+
97
+ test("truncated logs leaving an unbalanced stack don't throw", () => {
98
+ expect(() => extractProgramLogs([
99
+ `Program ${WATCHED} success`,
100
+ `Program ${WATCHED} invoke [1]`,
101
+ "Program log: mine",
102
+ "Log truncated",
103
+ ], WATCHED)).not.toThrow();
104
+ });
105
+
106
+ test("Program data and return lines are kept as content", () => {
107
+ expect(extractProgramLogs([
108
+ `Program ${WATCHED} invoke [1]`,
109
+ "Program data: SGVsbG8=",
110
+ `Program return: ${WATCHED} AQID`,
111
+ `Program ${WATCHED} success`,
112
+ ], WATCHED)).toEqual(["Program data: SGVsbG8=", `Program return: ${WATCHED} AQID`]);
113
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Attributing Solana log lines to the program that actually emitted them.
3
+ *
4
+ * A transaction's `meta.logMessages` is a flat, structured stream covering every
5
+ * program the transaction touched:
6
+ *
7
+ * ```
8
+ * Program A invoke [1]
9
+ * Program log: from A
10
+ * Program B invoke [2] ← A calls B (CPI)
11
+ * Program log: from B
12
+ * Program B consumed 1234 of 200000 compute units
13
+ * Program B success
14
+ * Program log: from A again
15
+ * Program A success
16
+ * ```
17
+ *
18
+ * Two things follow, and the naive `accountKeys.includes(programId)` +
19
+ * substring-match approach gets both wrong:
20
+ *
21
+ * 1. **Presence in `accountKeys` does not mean invocation.** Any transaction
22
+ * may list an arbitrary account key without calling it. Only an
23
+ * `invoke [N]` line proves the program ran — which is why this module is
24
+ * the authorization boundary for the `SOLANA:ProgramLog` primitive. Without
25
+ * it, an attacker names the watched program as a bare account key, has
26
+ * their *own* program emit the expected text, and the primitive reports it
27
+ * as the watched program's event with an attacker-chosen payload.
28
+ * 2. **Log lines belong to the innermost frame.** Forwarding every line in the
29
+ * transaction hands the state machine other programs' output under the
30
+ * watched program's name.
31
+ *
32
+ * Reading the stream also fixes an ALT blind spot for free: a program invoked
33
+ * via an address lookup table is absent from `message.accountKeys` (which holds
34
+ * static keys only) but always appears in its `invoke` line.
35
+ */
36
+
37
+ /** `Program <id> invoke [<depth>]` — pushes a frame. */
38
+ const INVOKE_RE = /^Program (\S+) invoke \[\d+\]$/;
39
+ /** `Program <id> success` — pops a frame. */
40
+ const SUCCESS_RE = /^Program (\S+) success$/;
41
+ /** `Program <id> failed: <reason>` — pops a frame. */
42
+ const FAILED_RE = /^Program (\S+) failed: /;
43
+ /** `Program <id> consumed <n> of <m> compute units` — metering noise, not output. */
44
+ const CONSUMED_RE = /^Program (\S+) consumed \d+ of \d+ compute units$/;
45
+
46
+ /**
47
+ * The log lines `programId` itself emitted in this transaction, or `null` if it
48
+ * was never invoked.
49
+ *
50
+ * An empty array is meaningful and distinct from `null`: the program ran but
51
+ * emitted no output of its own (the System Program, for instance, logs only its
52
+ * `invoke`/`success` framing). Callers that care about *invocation* should check
53
+ * for `null`; callers that need *content* should also check `.length`.
54
+ */
55
+ export function extractProgramLogs(
56
+ logMessages: readonly string[],
57
+ programId: string,
58
+ ): string[] | null {
59
+ // Frames currently open, innermost last. Truncated logs can leave this
60
+ // unbalanced, so pops are tolerant of an empty stack.
61
+ const frames: string[] = [];
62
+ const collected: string[] = [];
63
+ let invoked = false;
64
+
65
+ for (const line of logMessages) {
66
+ const invoke = INVOKE_RE.exec(line);
67
+ if (invoke) {
68
+ frames.push(invoke[1]);
69
+ if (invoke[1] === programId) invoked = true;
70
+ continue;
71
+ }
72
+ if (SUCCESS_RE.test(line) || FAILED_RE.test(line)) {
73
+ frames.pop();
74
+ continue;
75
+ }
76
+ if (CONSUMED_RE.test(line)) continue;
77
+
78
+ if (frames[frames.length - 1] === programId) {
79
+ collected.push(line);
80
+ }
81
+ }
82
+
83
+ return invoked ? collected : null;
84
+ }