@effectstream/sync 0.102.0 → 0.103.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.102.0",
3
+ "version": "0.103.1",
4
4
  "license": "(MIT OR Apache-2.0)",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,10 +13,10 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.102.0",
17
- "@effectstream/db": "0.102.0",
18
- "@effectstream/log": "0.102.0",
19
- "@effectstream/utils": "0.102.0",
16
+ "@effectstream/config": "0.103.1",
17
+ "@effectstream/db": "0.103.1",
18
+ "@effectstream/log": "0.103.1",
19
+ "@effectstream/utils": "0.103.1",
20
20
  "@midnight-ntwrk/ledger-v8": "8.1.0",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.1.1",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
@@ -30,11 +30,19 @@ export interface MidnightGqlBlockState {
30
30
  intentHash: string;
31
31
  outputIndex: number;
32
32
  owner: string;
33
+ /** u128 value as a decimal string */
34
+ value: string;
35
+ /** hex-encoded serialized token type */
36
+ tokenType: string;
33
37
  }[];
34
38
  unshieldedCreatedOutputs?: {
35
39
  intentHash: string;
36
40
  outputIndex: number;
37
41
  owner: string;
42
+ /** u128 value as a decimal string */
43
+ value: string;
44
+ /** hex-encoded serialized token type */
45
+ tokenType: string;
38
46
  }[];
39
47
  // Present only on RegularTransaction (selected via inline fragment): the
40
48
  // coin-commitment Merkle tree root after this transaction.
@@ -54,7 +62,7 @@ export interface MidnightGqlBlockState {
54
62
  export interface BlockFetchOptions {
55
63
  /** Include contractActions in the transaction fields (needed for MidnightGenericPrimitive). Default: true */
56
64
  contractActions?: boolean;
57
- /** Include zswapLedgerEvents in the transaction fields (needed for MidnightNullifierPrimitive). Default: true */
65
+ /** Include zswapLedgerEvents in the transaction fields (needed for MidnightNullifierAndCommitmentPrimitive). Default: true */
58
66
  zswapLedgerEvents?: boolean;
59
67
  /** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
60
68
  unshieldedSpentOutputs?: boolean;
@@ -150,10 +158,10 @@ export class MidnightClient {
150
158
  ? `zswapLedgerEvents { id raw maxId }`
151
159
  : "";
152
160
  const unshieldedSpentField = unshieldedSpentOutputs
153
- ? `unshieldedSpentOutputs { intentHash outputIndex owner }`
161
+ ? `unshieldedSpentOutputs { intentHash outputIndex owner value tokenType }`
154
162
  : "";
155
163
  const unshieldedCreatedField = unshieldedCreatedOutputs
156
- ? `unshieldedCreatedOutputs { intentHash outputIndex owner }`
164
+ ? `unshieldedCreatedOutputs { intentHash outputIndex owner value tokenType }`
157
165
  : "";
158
166
  // zswapMerkleTreeRoot lives on RegularTransaction, not the Transaction
159
167
  // interface, so it must be selected through an inline fragment.
@@ -19,7 +19,7 @@ import type { RootOutput, RootPage } from "../types.ts";
19
19
  import { bound } from "@effectstream/utils";
20
20
  import { MidnightClient, type BlockFetchOptions, type MidnightGqlBlockState } from "./MidnightClient.ts";
21
21
  import { ContractState, StateValue } from "@midnight-ntwrk/onchain-runtime";
22
- import { decodeZswapInputEvent } from "./zswap-decoder.ts";
22
+ import { decodeZswapEvent } from "./zswap-decoder.ts";
23
23
  import { decodeTokenMints } from "./mint-decoder.ts";
24
24
 
25
25
  export class MidnightFetcher extends BaseDataFetcher<
@@ -78,14 +78,14 @@ export class MidnightFetcher extends BaseDataFetcher<
78
78
  const blockFetchOptions: BlockFetchOptions = {
79
79
  contractActions: this.config.primitives.some(
80
80
  (p) =>
81
- p.primitive.type !== "Midnight:Nullifier" &&
81
+ p.primitive.type !== "Midnight:NullifierAndCommitment" &&
82
82
  p.primitive.type !== "Midnight:UnshieldedSpend" &&
83
83
  p.primitive.type !== "Midnight:UnshieldedCreate" &&
84
84
  p.primitive.type !== "Midnight:ZswapRoot" &&
85
85
  p.primitive.type !== "Midnight:TokenMint",
86
86
  ),
87
87
  zswapLedgerEvents: this.config.primitives.some(
88
- (p) => p.primitive.type === "Midnight:Nullifier",
88
+ (p) => p.primitive.type === "Midnight:NullifierAndCommitment",
89
89
  ),
90
90
  unshieldedSpentOutputs: this.config.primitives.some(
91
91
  (p) => p.primitive.type === "Midnight:UnshieldedSpend",
@@ -183,8 +183,8 @@ export class MidnightFetcher extends BaseDataFetcher<
183
183
  const syncResults: PrimitiveType[] = [];
184
184
 
185
185
  for (const primitiveEntry of primitiveEntries) {
186
- if (primitiveEntry.primitive.type === "Midnight:Nullifier") {
187
- syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
186
+ if (primitiveEntry.primitive.type === "Midnight:NullifierAndCommitment") {
187
+ syncResults.push(...this.fetchZswapEvents(height, primitiveEntry, block));
188
188
  } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
189
189
  syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
190
190
  } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedCreate") {
@@ -205,16 +205,19 @@ export class MidnightFetcher extends BaseDataFetcher<
205
205
  }
206
206
 
207
207
  @bound
208
- fetchNullifiers(
208
+ fetchZswapEvents(
209
209
  height: number,
210
210
  primitiveEntry: PrimitiveEntryType,
211
211
  block: MidnightGqlBlockState,
212
212
  ): PrimitiveType[] {
213
+ const capture = primitiveEntry.primitive.capture ?? "both";
213
214
  const results: PrimitiveType[] = [];
214
215
  for (const tx of block.block.transactions) {
215
216
  for (const event of tx.zswapLedgerEvents ?? []) {
216
- const decoded = decodeZswapInputEvent(event.raw);
217
+ const decoded = decodeZswapEvent(event.raw);
217
218
  if (!decoded) continue;
219
+ if (decoded.kind === "nullifier" && capture === "commitments") continue;
220
+ if (decoded.kind === "commitment" && capture === "nullifiers") continue;
218
221
  results.push({
219
222
  syncProtocol: {
220
223
  name: primitiveEntry.syncProtocol,
@@ -224,12 +227,10 @@ export class MidnightFetcher extends BaseDataFetcher<
224
227
  },
225
228
  primitive: primitiveEntry.primitive.name,
226
229
  output: {
227
- payloadType: "midnight-nullifier",
230
+ payloadType: `midnight-${decoded.kind}`,
228
231
  payload: {
229
- nullifier: decoded.nullifier,
230
- txHash: decoded.txHash,
232
+ ...decoded,
231
233
  eventId: event.id,
232
- logicalSegment: decoded.logicalSegment,
233
234
  },
234
235
  },
235
236
  });
@@ -261,6 +262,8 @@ export class MidnightFetcher extends BaseDataFetcher<
261
262
  owner: spend.owner,
262
263
  intentHash: spend.intentHash,
263
264
  outputIndex: spend.outputIndex,
265
+ value: spend.value,
266
+ tokenType: spend.tokenType,
264
267
  txHash: tx.hash,
265
268
  },
266
269
  },
@@ -296,6 +299,8 @@ export class MidnightFetcher extends BaseDataFetcher<
296
299
  owner: created.owner,
297
300
  intentHash: created.intentHash,
298
301
  outputIndex: created.outputIndex,
302
+ value: created.value,
303
+ tokenType: created.tokenType,
299
304
  txHash: tx.hash,
300
305
  },
301
306
  },
@@ -1,58 +1,26 @@
1
- import { Buffer } from "node:buffer";
2
1
  import * as ledger from '@midnight-ntwrk/ledger-v8';
3
2
 
4
- // TODO This alternative implementation should be the native way to decode the event.
5
- function new_decodeZswapInputEvent(rawHex: string): ledger.Event | null {
6
- try {
7
- const bytes = Uint8Array.from(
8
- Buffer.from(rawHex.replace(/^0x/, ""), "hex"),
9
- );
10
- const event = ledger.Event.deserialize(bytes);
11
- console.log("event", event);
12
- // if (event.type === "ZswapInput") {
13
- // event.nullifier — Uint8Array (32 bytes)
14
- // event.source.transactionHash
15
- // event.source.logicalSegment
16
- // } else if (event.type === "ZswapOutput") {
17
- // event.commitment, etc.
18
- // }
19
- } catch {
20
- return null;
21
- }
22
- return null;
23
- }
24
-
25
- // ─── SCALE compact integer decoder ───────────────────────────────────────────
3
+ // Wire format of a raw zswap ledger event, for reference (midnight-ledger 8.x,
4
+ // `midnight-ledger/ledger/src/events.rs` + `storage-core/src/arena.rs`):
26
5
  //
27
- // Bottom 2 bits of the first byte are the mode:
28
- // 00 = 1-byte (value in bits [7:2])
29
- // 01 = 2-byte
30
- // 10 = 4-byte
31
- // 11 = n-byte length prefix
6
+ // 19 bytes outer tag "midnight:event[v9]:"
7
+ // then a TopoSortedNodes envelope (NOT the struct directly):
8
+ // compact_u32 node count, and per node: compact_u32 child count,
9
+ // compact_u64 child indices, compact_u32 data length, data bytes.
10
+ // The root node's data is:
11
+ // 32 bytes EventSource.transaction_hash
12
+ // 2 bytes logical_segment (LE u16)
13
+ // 2 bytes physical_segment (LE u16)
14
+ // 1 byte EventDetails variant: 0=ZswapInput, 1=ZswapOutput, 2+=other
15
+ // ZswapInput: 32-byte nullifier + Option<Sp<ContractAddress>> byte
16
+ // ZswapOutput: 32-byte commitment + variable-length preimage evidence
17
+ // + Option<Sp<ContractAddress>> byte + compact_u64 mt_index
18
+ // A Some(..) contract address is a SEPARATE node in the envelope, shifting
19
+ // all offsets — so positional parsing is not viable; always deserialize via
20
+ // the ledger bindings.
32
21
 
33
- function readScaleU32(b: Uint8Array, pos: number): [number, number] {
34
- const first = b[pos];
35
- const mode = first & 0b11;
36
- if (mode === 0b00) {
37
- return [(first & 0xFC) >> 2, pos + 1];
38
- } else if (mode === 0b01) {
39
- const second = b[pos + 1];
40
- const v0 = ((first & 0xFC) >> 2) | ((second & 0x03) << 6);
41
- const v1 = (second & 0xFC) >> 2;
42
- return [(v0 | (v1 << 8)) >>> 0, pos + 2];
43
- } else if (mode === 0b10) {
44
- const s = b[pos + 1], t = b[pos + 2], u = b[pos + 3];
45
- const v0 = ((first & 0xFC) >> 2) | ((s & 0x03) << 6);
46
- const v1 = ((s & 0xFC) >> 2) | ((t & 0x03) << 6);
47
- const v2 = ((t & 0xFC) >> 2) | ((u & 0x03) << 6);
48
- const v3 = (u & 0xFC) >> 2;
49
- return [(v0 | (v1 << 8) | (v2 << 16) | (v3 << 24)) >>> 0, pos + 4];
50
- } else {
51
- const n = ((first & 0xFC) >> 2) + 4;
52
- let val = 0;
53
- for (let i = 0; i < n; i++) val |= b[pos + 1 + i] << (i * 8);
54
- return [val >>> 0, pos + 1 + n];
55
- }
22
+ function normalizeHex(hex: string): string {
23
+ return (hex.startsWith("0x") ? hex.slice(2) : hex).toLowerCase();
56
24
  }
57
25
 
58
26
  function hexToBytes(hex: string): Uint8Array {
@@ -64,60 +32,90 @@ function hexToBytes(hex: string): Uint8Array {
64
32
  return arr;
65
33
  }
66
34
 
67
- function bytesToHex(bytes: Uint8Array): string {
68
- return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
69
- }
70
-
71
- // ─── Binary layout for "midnight:event[v9]:" ─────────────────────────────────
72
- // 19 bytes outer tag "midnight:event[v9]:"
73
- // 32 bytes EventSource.transaction_hash
74
- // 2 bytes EventSource.logical_segment (LE u16)
75
- // 2 bytes EventSource.physical_segment (LE u16, skipped)
76
- // SCALE u32 EventDetails variant: 0=ZswapInput, 1=ZswapOutput, 2+=other
77
- //
78
- // ZswapInput: 4-byte contract_id + 32-byte CoinNullifier
79
-
80
- const OUTER_TAG = "midnight:event[v9]:";
81
-
82
- export interface DecodedZswapInput {
83
- /** 32-byte transaction hash, hex */
84
- txHash: string;
85
- /** Logical segment index */
86
- logicalSegment: number;
87
- /** 32-byte nullifier, hex */
88
- nullifier: string;
89
- }
35
+ /** A zswap ledger event decoded via the native ledger-v8 bindings. */
36
+ export type DecodedZswapEvent =
37
+ | {
38
+ kind: "nullifier";
39
+ /** 32-byte nullifier, hex */
40
+ nullifier: string;
41
+ /** 32-byte transaction hash, hex */
42
+ txHash: string;
43
+ /** Logical segment index */
44
+ logicalSegment: number;
45
+ /** Contract address that spent the coin, hex — absent for user coins */
46
+ contract?: string;
47
+ }
48
+ | {
49
+ kind: "commitment";
50
+ /** 32-byte coin commitment, hex */
51
+ commitment: string;
52
+ /** Index of the commitment in the zswap Merkle tree, u64 as decimal string */
53
+ mtIndex: string;
54
+ /** 32-byte transaction hash, hex */
55
+ txHash: string;
56
+ /** Logical segment index */
57
+ logicalSegment: number;
58
+ /** Contract address that received the coin, hex — absent for user coins */
59
+ contract?: string;
60
+ };
90
61
 
91
62
  /**
92
- * Decode a raw SCALE-encoded zswap ledger event.
93
- * Returns the decoded data if the event is a ZswapInput (nullifier spend),
94
- * or `null` for ZswapOutput / other variants.
63
+ * Decode a raw zswap ledger event (ZswapInput or ZswapOutput) using the
64
+ * native ledger-v8 WASM deserializer. The indexer's `zswapLedgerEvents`
65
+ * field only ever carries these two variants, so a `null` return means the
66
+ * event could not be decoded (e.g. a ledger event-format version bump) and
67
+ * is logged.
95
68
  */
96
- export function decodeZswapInputEvent(
97
- rawHex: string,
98
- ): DecodedZswapInput | null {
69
+ export function decodeZswapEvent(rawHex: string): DecodedZswapEvent | null {
70
+ let event: ledger.Event;
99
71
  try {
100
- const b = hexToBytes(rawHex);
101
- let pos = OUTER_TAG.length;
102
-
103
- const txHash = bytesToHex(b.slice(pos, pos + 32));
104
- pos += 32;
105
- const logicalSegment = b[pos] | (b[pos + 1] << 8);
106
- pos += 2;
107
- pos += 2; // skip physical segment
108
-
109
- const [variant, p1] = readScaleU32(b, pos);
110
- pos = p1;
111
-
112
- if (variant !== 0) {
113
- return null; // ZswapOutput or other — not a nullifier event
114
- }
115
-
116
- pos += 4; // skip 4-byte contract_id
117
-
118
- const nullifier = bytesToHex(b.slice(pos, pos + 32));
119
- return { txHash, logicalSegment, nullifier };
120
- } catch {
72
+ event = ledger.Event.deserialize(hexToBytes(rawHex));
73
+ } catch (err) {
74
+ console.warn(
75
+ "[midnight] failed to deserialize zswap ledger event — ledger event format may have changed",
76
+ { raw: rawHex, error: String(err) },
77
+ );
121
78
  return null;
122
79
  }
80
+ // EventDetails is an open union ({ tag: string } catch-all), so narrow manually.
81
+ const content = event.content as {
82
+ tag: string;
83
+ nullifier?: string;
84
+ commitment?: string;
85
+ contract?: string;
86
+ mtIndex?: bigint;
87
+ };
88
+ const source = {
89
+ txHash: normalizeHex(event.source.transactionHash),
90
+ logicalSegment: event.source.logicalSegment,
91
+ };
92
+ const contract = content.contract != null
93
+ ? { contract: normalizeHex(content.contract) }
94
+ : {};
95
+ if (content.tag === "zswapInput" && content.nullifier != null) {
96
+ return {
97
+ kind: "nullifier",
98
+ nullifier: normalizeHex(content.nullifier),
99
+ ...contract,
100
+ ...source,
101
+ };
102
+ }
103
+ if (
104
+ content.tag === "zswapOutput" &&
105
+ content.commitment != null &&
106
+ content.mtIndex != null
107
+ ) {
108
+ return {
109
+ kind: "commitment",
110
+ commitment: normalizeHex(content.commitment),
111
+ mtIndex: content.mtIndex.toString(),
112
+ ...contract,
113
+ ...source,
114
+ };
115
+ }
116
+ console.warn(
117
+ "[midnight] unexpected zswap ledger event variant",
118
+ { raw: rawHex, tag: content.tag },
119
+ );
120
+ return null;
123
121
  }
@@ -43,9 +43,39 @@ export type SolanaTransaction = {
43
43
  writable: string[];
44
44
  readonly: string[];
45
45
  } | null;
46
+ /**
47
+ * SPL token balances after the transaction, one record per (token account,
48
+ * mint) pair it touched. Returned by `getBlock` with
49
+ * `transactionDetails: "full"`; absent on transactions that touched no token
50
+ * account, and on validators old enough not to report them.
51
+ *
52
+ * `accountIndex` indexes the SAME resolved list as `pre`/`postBalances` — see
53
+ * {@link resolveAccountKeys} — so a token account reached through a lookup
54
+ * table is only findable after resolution.
55
+ */
56
+ postTokenBalances?: SolanaTokenBalance[] | null;
57
+ /** Pre-state counterpart of {@link postTokenBalances}. */
58
+ preTokenBalances?: SolanaTokenBalance[] | null;
46
59
  } | null;
47
60
  };
48
61
 
62
+ export type SolanaTokenBalance = {
63
+ /** Index into the resolved account list, NOT into `message.accountKeys` alone. */
64
+ accountIndex: number;
65
+ mint: string;
66
+ /** Optional: older validators omit it. */
67
+ owner?: string;
68
+ /** The owning token program, i.e. SPL Token or Token-2022. Optional for the same reason. */
69
+ programId?: string;
70
+ uiTokenAmount: {
71
+ /** Raw u64 in base units, as a string. */
72
+ amount: string;
73
+ decimals: number;
74
+ uiAmount: number | null;
75
+ uiAmountString?: string;
76
+ };
77
+ };
78
+
49
79
  /**
50
80
  * The account list `pre`/`postBalances` are indexed against: static message
51
81
  * keys first, then lookup-table writable addresses, then lookup-table readonly
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  ConfigSyncProtocolType,
3
3
  type PrimitiveEntry,
4
+ SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
5
+ SOLANA_PRIMITIVE_PROGRAM_LOG,
6
+ SOLANA_PRIMITIVE_TOKEN_ACCOUNT,
4
7
  } from "@effectstream/config";
5
8
  import { BaseDataFetcher } from "../base/fetcher.ts";
6
9
  import type { DataFetched } from "../base/fetcher.ts";
@@ -17,7 +20,11 @@ import type {
17
20
  Page,
18
21
  PrimitiveType,
19
22
  } from "./types.ts";
20
- import { resolveAccountKeys, SolanaClient } from "./SolanaClient.ts";
23
+ import {
24
+ resolveAccountKeys,
25
+ SolanaClient,
26
+ type SolanaTokenBalance,
27
+ } from "./SolanaClient.ts";
21
28
  import { requestTimeoutOf } from "../common/http.ts";
22
29
  import { extractProgramLogs } from "./program-logs.ts";
23
30
  import { call, sleep, type Operation } from "effection";
@@ -48,6 +55,19 @@ export class SolanaFetcher extends BaseDataFetcher<
48
55
  */
49
56
  private lastKnownBlockTime: number | undefined;
50
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
+
51
71
  constructor(
52
72
  readonly config: ConfigType,
53
73
  ) {
@@ -252,6 +272,7 @@ export class SolanaFetcher extends BaseDataFetcher<
252
272
  writable: string[];
253
273
  readonly: string[];
254
274
  } | null;
275
+ postTokenBalances?: SolanaTokenBalance[] | null;
255
276
  } | null;
256
277
  }[];
257
278
  },
@@ -290,65 +311,142 @@ export class SolanaFetcher extends BaseDataFetcher<
290
311
  for (const entry of primitiveEntries) {
291
312
  const prim = entry.primitive;
292
313
 
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,
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,
312
336
  },
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;
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;
333
348
  }
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
+ // ── 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,
349
374
  },
350
- },
351
- });
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;
352
450
  }
353
451
  }
354
452
  }
@@ -1,4 +1,9 @@
1
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";
2
7
  import { SolanaFetcher } from "./fetcher.ts";
3
8
  import type { ConfigType } from "./types.ts";
4
9
 
@@ -33,13 +38,25 @@ function run(fetcher: SolanaFetcher, slot: number, block: any, entries: any[]) {
33
38
  return step.value as any[];
34
39
  }
35
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.
36
45
  const programLogEntry = {
37
46
  syncProtocol: "parallelSolanaRPC",
38
- primitive: { name: "WatchedLog", programId: WATCHED_PROGRAM },
47
+ primitive: {
48
+ name: "WatchedLog",
49
+ type: SOLANA_PRIMITIVE_PROGRAM_LOG,
50
+ programId: WATCHED_PROGRAM,
51
+ },
39
52
  };
40
53
  const balanceEntry = {
41
54
  syncProtocol: "parallelSolanaRPC",
42
- primitive: { name: "WatchedBalance", address: WATCHED_ADDRESS },
55
+ primitive: {
56
+ name: "WatchedBalance",
57
+ type: SOLANA_PRIMITIVE_ACCOUNT_BALANCE,
58
+ address: WATCHED_ADDRESS,
59
+ },
43
60
  };
44
61
 
45
62
  /** A transaction that genuinely invoked the watched program. */
@@ -136,7 +153,12 @@ test("eventType filters against the program's OWN lines only", () => {
136
153
  });
137
154
  const entry = {
138
155
  syncProtocol: "parallelSolanaRPC",
139
- primitive: { name: "WatchedLog", programId: WATCHED_PROGRAM, eventType: "MARKER" },
156
+ primitive: {
157
+ name: "WatchedLog",
158
+ type: SOLANA_PRIMITIVE_PROGRAM_LOG,
159
+ programId: WATCHED_PROGRAM,
160
+ eventType: "MARKER",
161
+ },
140
162
  };
141
163
  expect(run(makeFetcher(), 42, { transactions: [tx] }, [entry])).toEqual([]);
142
164
  });
@@ -186,3 +208,228 @@ test("dispatches both primitive kinds from one transaction", () => {
186
208
  test("returns nothing when no primitives are configured", () => {
187
209
  expect(run(makeFetcher(), 42, { transactions: [invokingTx()] }, [])).toEqual([]);
188
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,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Buffer } from "node:buffer";
3
+ import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
4
+
5
+ // Synthetic events built per the real midnight-ledger 8.x wire format:
6
+ // "midnight:event[v9]:" tag + TopoSortedNodes envelope wrapping the event
7
+ // struct. Both blobs round-trip through the native ledger-v8 deserializer.
8
+
9
+ function compactU32(n: number): Buffer {
10
+ if (n < 64) return Buffer.from([n << 2]);
11
+ if (n < 16384) {
12
+ const v = (n << 2) | 0b01;
13
+ return Buffer.from([v & 0xff, (v >> 8) & 0xff]);
14
+ }
15
+ throw new Error("value too large for this helper");
16
+ }
17
+
18
+ const TX_HASH = Buffer.alloc(32, 0xaa);
19
+ const BODY32 = Buffer.alloc(32, 0xbb);
20
+ // logical=3, physical=1
21
+ const SEGMENTS = Buffer.from([0x03, 0x00, 0x01, 0x00]);
22
+ const CONTRACT_NONE = Buffer.from([0x01]);
23
+
24
+ function wrapEnvelope(data: Buffer): string {
25
+ return Buffer.concat([
26
+ Buffer.from("midnight:event[v9]:", "ascii"),
27
+ compactU32(1), // node count
28
+ compactU32(0), // child count of the single node
29
+ compactU32(data.length),
30
+ data,
31
+ ]).toString("hex");
32
+ }
33
+
34
+ describe("decodeZswapEvent", () => {
35
+ test("decodes a ZswapInput (nullifier) event", () => {
36
+ const raw = wrapEnvelope(Buffer.concat([
37
+ TX_HASH,
38
+ SEGMENTS,
39
+ Buffer.from([0x00]), // variant: ZswapInput
40
+ BODY32, // nullifier
41
+ CONTRACT_NONE,
42
+ ]));
43
+ expect(decodeZswapEvent(raw)).toEqual({
44
+ kind: "nullifier",
45
+ nullifier: "bb".repeat(32),
46
+ txHash: "aa".repeat(32),
47
+ logicalSegment: 3,
48
+ });
49
+ });
50
+
51
+ test("decodes a ZswapOutput (commitment) event", () => {
52
+ const raw = wrapEnvelope(Buffer.concat([
53
+ TX_HASH,
54
+ SEGMENTS,
55
+ Buffer.from([0x01]), // variant: ZswapOutput
56
+ BODY32, // commitment
57
+ Buffer.from([0x02]), // ZswapPreimageEvidence::None
58
+ CONTRACT_NONE,
59
+ compactU32(7), // mt_index
60
+ ]));
61
+ expect(decodeZswapEvent(raw)).toEqual({
62
+ kind: "commitment",
63
+ commitment: "bb".repeat(32),
64
+ mtIndex: "7",
65
+ txHash: "aa".repeat(32),
66
+ logicalSegment: 3,
67
+ });
68
+ });
69
+
70
+ test("returns null for undecodable input", () => {
71
+ expect(decodeZswapEvent("deadbeef")).toBeNull();
72
+ expect(decodeZswapEvent("")).toBeNull();
73
+ });
74
+ });