@effectstream/sync 0.101.0 → 0.102.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 (38) 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/fetcher.ts +2 -0
  17. package/src/sync-protocols/midnight/types.ts +1 -1
  18. package/src/sync-protocols/near/NearClient.ts +16 -6
  19. package/src/sync-protocols/near/fetcher.ts +5 -1
  20. package/src/sync-protocols/near/state.ts +5 -1
  21. package/src/sync-protocols/orchestration/sync.ts +101 -13
  22. package/src/sync-protocols/solana/SolanaClient.ts +158 -0
  23. package/src/sync-protocols/solana/fetcher.ts +358 -0
  24. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  25. package/src/sync-protocols/solana/program-logs.ts +84 -0
  26. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  27. package/src/sync-protocols/solana/read-primitives.test.ts +188 -0
  28. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  29. package/src/sync-protocols/solana/state.ts +167 -0
  30. package/src/sync-protocols/solana/types.ts +58 -0
  31. package/src/sync-protocols/types.ts +2 -0
  32. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  33. package/src/sync-protocols/utxorpc/state.ts +3 -0
  34. package/src/syncProtocolFactory.ts +15 -0
  35. package/test/poll-loop-spin.test.ts +112 -0
  36. package/test/rpc-timeout.test.ts +150 -0
  37. package/test/spin-probe.ts +102 -0
  38. package/test/start-async-supervision.test.ts +139 -0
@@ -0,0 +1,358 @@
1
+ import {
2
+ ConfigSyncProtocolType,
3
+ type PrimitiveEntry,
4
+ } from "@effectstream/config";
5
+ import { BaseDataFetcher } from "../base/fetcher.ts";
6
+ import type { DataFetched } from "../base/fetcher.ts";
7
+ import type {
8
+ LastPage,
9
+ OutputAndCleanup,
10
+ RootConversion,
11
+ } from "../base/state.ts";
12
+ import type { RootOutput, RootPage } from "../types.ts";
13
+ import type {
14
+ ConfigType,
15
+ Input,
16
+ Output,
17
+ Page,
18
+ PrimitiveType,
19
+ } from "./types.ts";
20
+ import { resolveAccountKeys, SolanaClient } from "./SolanaClient.ts";
21
+ import { requestTimeoutOf } from "../common/http.ts";
22
+ import { extractProgramLogs } from "./program-logs.ts";
23
+ import { call, sleep, type Operation } from "effection";
24
+ import { bound } from "@effectstream/utils";
25
+
26
+ /**
27
+ * Attempts per slot before `readData` reports partial progress and lets the
28
+ * fetch loop retry the range. Small on purpose — the outer loop is the real
29
+ * retry mechanism; see `getBlockWithRetry`.
30
+ */
31
+ const BLOCK_FETCH_ATTEMPTS = 3;
32
+ /** Linear backoff between those attempts (250ms, then 500ms). */
33
+ const BLOCK_FETCH_RETRY_DELAY_MS = 250;
34
+
35
+ export class SolanaFetcher extends BaseDataFetcher<
36
+ Input,
37
+ Output,
38
+ RootOutput,
39
+ Page,
40
+ RootPage
41
+ > {
42
+ readonly client: SolanaClient;
43
+
44
+ /**
45
+ * Last non-null `blockTime` seen, used to fill in a block whose timestamp the
46
+ * RPC couldn't compute. Carried across `readData` calls so only a null on the
47
+ * very first block ever fetched has no fallback.
48
+ */
49
+ private lastKnownBlockTime: number | undefined;
50
+
51
+ constructor(
52
+ readonly config: ConfigType,
53
+ ) {
54
+ super(config.syncProtocol.name);
55
+ this.client = new SolanaClient(
56
+ config.network.rpcUrl,
57
+ requestTimeoutOf(config.syncProtocol),
58
+ );
59
+ }
60
+
61
+ @bound
62
+ override *readData(
63
+ data: Input,
64
+ rootConversion: RootConversion<Output, RootOutput, RootPage>,
65
+ lastPage: LastPage<Page, RootPage> | undefined,
66
+ ): Operation<DataFetched<Output, Page, RootPage>> {
67
+ const outputs: OutputAndCleanup<Output>[] = [];
68
+
69
+ console.log(
70
+ `[Solana] Fetching slots from ${data.from} to ${data.to}.${
71
+ data.isPresync ? " [presync]" : ""
72
+ }`,
73
+ );
74
+
75
+ // Highest slot we have positively resolved — either read a block for, or had
76
+ // the RPC confirm was skipped. The page may never advance past this, or we
77
+ // would silently step over a slot we never actually read.
78
+ let scannedThrough: number | undefined;
79
+ let fetchFailure: { slot: number; error: unknown } | undefined;
80
+
81
+ for (let slot = Number(data.from); slot <= Number(data.to); slot++) {
82
+ let block;
83
+ try {
84
+ block = yield* this.getBlockWithRetry(slot);
85
+ } catch (error) {
86
+ // Keep the blocks already gathered rather than discarding the chunk:
87
+ // everything below `slot` is resolved, so we report progress up to
88
+ // there and the next poll resumes exactly at the failed slot.
89
+ fetchFailure = { slot, error };
90
+ break;
91
+ }
92
+ scannedThrough = slot;
93
+
94
+ // Skipped slots (no block produced) are skipped gracefully
95
+ if (!block) {
96
+ continue;
97
+ }
98
+
99
+ // `blockTime` is the merge key for this chain, so it can't be null.
100
+ // Mapping null to 0 (the previous behaviour) sorted the block to the unix
101
+ // epoch and broke the monotonicity the merge relies on. Solana guarantees
102
+ // blockTime is non-decreasing, so reusing the last known value keeps the
103
+ // block in the stream, in order, in the same bucket as its predecessor.
104
+ const blockTime = block.blockTime ?? this.lastKnownBlockTime;
105
+ if (blockTime == null) {
106
+ console.warn(
107
+ `[Solana] slot ${slot} has no blockTime and no earlier block to derive one from — skipping.`,
108
+ );
109
+ continue;
110
+ }
111
+ this.lastKnownBlockTime = blockTime;
112
+
113
+ const primitives = yield* this.readPrimitives(
114
+ slot,
115
+ block,
116
+ this.config.primitives,
117
+ );
118
+
119
+ outputs.push({
120
+ output: {
121
+ slot,
122
+ blockhash: block.blockhash,
123
+ blockTime,
124
+ blockHeight: block.blockHeight,
125
+ parentSlot: block.parentSlot,
126
+ // `meta` is null for transactions the RPC couldn't decode; keep the
127
+ // entry (it still occupies a tx index) but don't dereference it.
128
+ transactions: (block.transactions ?? []).map((tx) => ({
129
+ err: tx.meta?.err ?? null,
130
+ logMessages: tx.meta?.logMessages ?? null,
131
+ preBalances: tx.meta?.preBalances ?? [],
132
+ postBalances: tx.meta?.postBalances ?? [],
133
+ })),
134
+ primitives,
135
+ },
136
+ cleanup: () => {},
137
+ });
138
+ }
139
+
140
+ if (fetchFailure != null) {
141
+ console.warn(
142
+ `[Solana] slot ${fetchFailure.slot} could not be fetched after ` +
143
+ `${BLOCK_FETCH_ATTEMPTS} attempts: ${String(fetchFailure.error)}. ` +
144
+ `Keeping ${outputs.length} block(s) up to slot ${scannedThrough ?? "-"}; ` +
145
+ `the next poll resumes at ${fetchFailure.slot}.`,
146
+ );
147
+ if (scannedThrough == null) {
148
+ // Nothing at all was resolved, so there is no progress to report and
149
+ // nothing to advance to. Throw: the fetch loop counts it in
150
+ // `consecutiveErrors`, leaves `lastSuccessfulFetchMs` alone and retries
151
+ // this exact range after `pollingInterval`. That is what keeps a
152
+ // genuinely unreachable RPC visible to /health instead of looking idle.
153
+ throw fetchFailure.error;
154
+ }
155
+ }
156
+
157
+ if (outputs.length === 0) {
158
+ if (!lastPage) {
159
+ throw new Error(
160
+ `[Solana] Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
161
+ );
162
+ }
163
+ // Advance the page even though nothing was found. `stateToInput` derives
164
+ // `from` from `lastPage.own`, so a page that never moves re-requests the
165
+ // same window forever — a range of entirely skipped slots (a Solana
166
+ // outage, or any leaderless stretch) would stall the fetcher permanently
167
+ // with no recovery once blocks resume. This is the "Page != Data"
168
+ // invariant in the package CLAUDE.md: pages track scan progress, not
169
+ // content.
170
+ //
171
+ // The root timestamp is carried over unchanged: no block means no new
172
+ // time to report, and the merge requires root to be non-decreasing.
173
+ //
174
+ // NOTE: near/fetcher.ts:94 has the identical stall — it's the pattern this
175
+ // was copied from. Not fixed here to keep this PR scoped to Solana.
176
+ //
177
+ // `scannedThrough`, not `data.to`: if a fetch failed part-way we must not
178
+ // advance over the slot we never read.
179
+ return {
180
+ output: [],
181
+ lastPage: {
182
+ ...lastPage,
183
+ own: scannedThrough as Page,
184
+ ownBlockNumber: scannedThrough as Page,
185
+ },
186
+ };
187
+ }
188
+
189
+ const lastOutput = outputs[outputs.length - 1].output;
190
+ // Page to the highest RESOLVED slot rather than the last one that carried
191
+ // data: trailing skipped slots were positively confirmed empty, so
192
+ // re-scanning them next poll is wasted work. On a clean full scan this is
193
+ // `data.to`, matching bitcoin/evm. The root timestamp still comes from the
194
+ // newest block we actually have, which is what the merge gates on.
195
+ const page = (scannedThrough ?? lastOutput.slot) as Page;
196
+ return {
197
+ output: outputs,
198
+ lastPage: {
199
+ ownBlockNumber: page,
200
+ own: page,
201
+ root: rootConversion.toRootPage(lastOutput),
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * `getBlock` with a few immediate retries, so a transient RPC blip does not
208
+ * cost the whole chunk.
209
+ *
210
+ * Deliberately BOUNDED. Retrying here forever would look like "we always get
211
+ * the block eventually", but it recreates the exact failure the request
212
+ * timeout was added to kill (sync CLAUDE.md finding #4): `readData` would
213
+ * never return, so the fetch loop never reaches its `catch`,
214
+ * `consecutiveErrors` stays 0, `lastSuccessfulFetchMs` freezes, and /health
215
+ * reports a happy node that has silently stopped producing blocks.
216
+ *
217
+ * Unbounded retry still happens — one level up, where it is observable. The
218
+ * fetch loop re-requests the same range every `pollingInterval` indefinitely,
219
+ * and page ranges are idempotent, so no slot is ever skipped. This helper only
220
+ * decides how much we retry *before* handing that decision back.
221
+ */
222
+ @bound
223
+ *getBlockWithRetry(slot: number) {
224
+ let lastError: unknown;
225
+ for (let attempt = 1; attempt <= BLOCK_FETCH_ATTEMPTS; attempt++) {
226
+ try {
227
+ return yield* call(() => this.client.getBlock(slot));
228
+ } catch (error) {
229
+ lastError = error;
230
+ if (attempt < BLOCK_FETCH_ATTEMPTS) {
231
+ yield* sleep(BLOCK_FETCH_RETRY_DELAY_MS * attempt);
232
+ }
233
+ }
234
+ }
235
+ throw lastError;
236
+ }
237
+
238
+ @bound
239
+ *readPrimitives(
240
+ slot: number,
241
+ block: {
242
+ transactions: {
243
+ transaction: {
244
+ message: { accountKeys: string[] };
245
+ signatures: string[];
246
+ };
247
+ meta: {
248
+ err: unknown | null;
249
+ logMessages: string[] | null;
250
+ postBalances: number[];
251
+ loadedAddresses?: {
252
+ writable: string[];
253
+ readonly: string[];
254
+ } | null;
255
+ } | null;
256
+ }[];
257
+ },
258
+ primitiveEntries: Extract<
259
+ PrimitiveEntry,
260
+ { syncProtocol: ConfigSyncProtocolType.SOLANA_RPC_PARALLEL }
261
+ >[],
262
+ ): Operation<PrimitiveType[]> {
263
+ if (primitiveEntries.length === 0) return [];
264
+
265
+ const allPrimitives: PrimitiveType[] = [];
266
+
267
+ for (
268
+ let txIndex = 0;
269
+ txIndex < block.transactions.length;
270
+ txIndex++
271
+ ) {
272
+ const tx = block.transactions[txIndex];
273
+ // A reverted transaction has no on-chain effect: its logs describe work
274
+ // that was rolled back and its postBalances are the pre-state. Emitting
275
+ // primitives for it would drive state transitions off events that never
276
+ // happened. `meta == null` means the RPC couldn't decode the tx — equally
277
+ // unusable, so skip both.
278
+ if (!tx.meta || tx.meta.err) continue;
279
+ // Balances are indexed over static keys PLUS lookup-table addresses, so a
280
+ // watched address pulled in via an ALT is only findable in the resolved
281
+ // list. Legacy transactions resolve to the static keys unchanged.
282
+ const accountKeys = resolveAccountKeys(
283
+ tx.transaction.message.accountKeys,
284
+ tx.meta.loadedAddresses,
285
+ );
286
+ const logs = tx.meta.logMessages ?? [];
287
+ const postBalances = tx.meta.postBalances ?? [];
288
+ const txHash = tx.transaction.signatures[0] ?? "";
289
+
290
+ for (const entry of primitiveEntries) {
291
+ const prim = entry.primitive;
292
+
293
+ // ── SOLANA:AccountBalance — watch an address's lamport balance ──
294
+ if (prim.address) {
295
+ const idx = accountKeys.indexOf(prim.address);
296
+ if (idx === -1) continue;
297
+ allPrimitives.push({
298
+ syncProtocol: {
299
+ name: entry.syncProtocol,
300
+ blockNumber: slot,
301
+ transactionHash: txHash,
302
+ contractAddress: prim.address,
303
+ logIndex: txIndex,
304
+ },
305
+ primitive: prim.name,
306
+ output: {
307
+ payloadType: "solana:balance",
308
+ payload: {
309
+ address: prim.address,
310
+ lamports: postBalances[idx] ?? 0,
311
+ slot,
312
+ },
313
+ },
314
+ });
315
+ continue;
316
+ }
317
+
318
+ // ── SOLANA:ProgramLog — logs the watched programId actually emitted ──
319
+ if (prim.programId) {
320
+ // Source of truth is the log stream's invoke/success framing, NOT
321
+ // accountKeys: naming a program as an account doesn't invoke it, and
322
+ // a program reached through a lookup table isn't in accountKeys at
323
+ // all. See program-logs.ts.
324
+ const programLogs = extractProgramLogs(logs, prim.programId);
325
+ if (programLogs == null) continue;
326
+ // Filter by eventType if specified — against this program's own lines
327
+ // only, so another program can't trigger it by echoing the string.
328
+ if (prim.eventType) {
329
+ const hasMatchingLog = programLogs.some((log) =>
330
+ log.includes(prim.eventType!)
331
+ );
332
+ if (!hasMatchingLog) continue;
333
+ }
334
+ allPrimitives.push({
335
+ syncProtocol: {
336
+ name: entry.syncProtocol,
337
+ blockNumber: slot,
338
+ transactionHash: txHash,
339
+ contractAddress: prim.programId,
340
+ logIndex: txIndex,
341
+ },
342
+ primitive: prim.name,
343
+ output: {
344
+ payloadType: "solana:transaction",
345
+ payload: {
346
+ programId: prim.programId,
347
+ slot,
348
+ logMessages: programLogs,
349
+ },
350
+ },
351
+ });
352
+ }
353
+ }
354
+ }
355
+
356
+ return allPrimitives;
357
+ }
358
+ }
@@ -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
+ }