@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,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
|
+
}
|