@effectstream/sync 0.100.21 → 0.100.23

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 CHANGED
@@ -171,6 +171,15 @@ Consequences during catch-up:
171
171
  `bufferedData` and pauses the fetch loop (each chain's `stateToInput` returns `undefined`)
172
172
  until the merge drains. See README "Backpressure (`maxBufferedPages`)".
173
173
 
174
+ > **Merge→apply queue cap (Fix C′) — ✅ SHIPPED.** The Deque cap above bounds *fetch*, but the
175
+ > merge then sends finalized blocks into an effection channel whose subscriber queue buffers
176
+ > unbounded — so the merge (no I/O) could still race ahead of the serial apply loop and grow that
177
+ > queue toward the whole backlog, especially with empty-block coalescing (Fix E) removing the
178
+ > per-block apply brake. The runtime now gates the merge's `send` (`runtime/src/main.ts`): it blocks
179
+ > once `EFFECTSTREAM_FINALIZED_STREAM_CAP` (default 2048) produced blocks are unconsumed, and the
180
+ > apply loop wakes it as it drains. `inFlight = produced − consumed` is surfaced on
181
+ > `/debug/metrics`. Repro/guard: `runtime/test/reproduction/coalesce-memory.test.ts`.
182
+
174
183
  > **Merge-demand exemption (safeguard).** A naive cap can deadlock the merge: it drains a
175
184
  > parallel chain's buffer only *after* its page passes the root timestamp `τ`, so if the cap
176
185
  > pauses the fetcher while its page is still `≤ τ` (a far skip-ahead, or a chain finer-grained
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.21",
3
+ "version": "0.100.23",
4
4
  "license": "MIT",
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.100.21",
17
- "@effectstream/db": "0.100.21",
18
- "@effectstream/log": "0.100.21",
19
- "@effectstream/utils": "0.100.21",
16
+ "@effectstream/config": "0.100.23",
17
+ "@effectstream/db": "0.100.23",
18
+ "@effectstream/log": "0.100.23",
19
+ "@effectstream/utils": "0.100.23",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.4",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
@@ -32,6 +32,22 @@ export interface MidnightGqlBlockState {
32
32
  outputIndex: number;
33
33
  owner: string;
34
34
  }[];
35
+ unshieldedCreatedOutputs?: {
36
+ intentHash: string;
37
+ outputIndex: number;
38
+ owner: string;
39
+ }[];
40
+ // Present only on RegularTransaction (selected via inline fragment): the
41
+ // coin-commitment Merkle tree root after this transaction.
42
+ zswapMerkleTreeRoot?: string;
43
+ // Selected only for MidnightTokenMintPrimitive: the serialized
44
+ // transaction bytes (Transaction interface) and the apply result
45
+ // (RegularTransaction only — absent on system transactions).
46
+ raw?: string;
47
+ transactionResult?: {
48
+ status: "SUCCESS" | "PARTIAL_SUCCESS" | "FAILURE";
49
+ segments?: { id: number; success: boolean }[] | null;
50
+ };
35
51
  }[];
36
52
  };
37
53
  };
@@ -43,6 +59,12 @@ export interface BlockFetchOptions {
43
59
  zswapLedgerEvents?: boolean;
44
60
  /** Include unshieldedSpentOutputs (needed for MidnightUnshieldedSpendPrimitive). Default: false */
45
61
  unshieldedSpentOutputs?: boolean;
62
+ /** Include unshieldedCreatedOutputs (needed for MidnightUnshieldedCreatePrimitive). Default: false */
63
+ unshieldedCreatedOutputs?: boolean;
64
+ /** Include `... on RegularTransaction { zswapMerkleTreeRoot }` (needed for MidnightZswapRootPrimitive). Default: false */
65
+ zswapRoots?: boolean;
66
+ /** Include tx `raw` + `... on RegularTransaction { transactionResult }` (needed for MidnightTokenMintPrimitive). Default: false */
67
+ tokenMints?: boolean;
46
68
  }
47
69
 
48
70
  type PublicDataProvider = ReturnType<typeof indexerPublicDataProvider>;
@@ -168,6 +190,9 @@ export class MidnightClient {
168
190
  contractActions = true,
169
191
  zswapLedgerEvents = true,
170
192
  unshieldedSpentOutputs = false,
193
+ unshieldedCreatedOutputs = false,
194
+ zswapRoots = false,
195
+ tokenMints = false,
171
196
  } = options;
172
197
  const contractActionsField = contractActions
173
198
  ? `contractActions { address state }`
@@ -178,6 +203,22 @@ export class MidnightClient {
178
203
  const unshieldedSpentField = unshieldedSpentOutputs
179
204
  ? `unshieldedSpentOutputs { intentHash outputIndex owner }`
180
205
  : "";
206
+ const unshieldedCreatedField = unshieldedCreatedOutputs
207
+ ? `unshieldedCreatedOutputs { intentHash outputIndex owner }`
208
+ : "";
209
+ // zswapMerkleTreeRoot lives on RegularTransaction, not the Transaction
210
+ // interface, so it must be selected through an inline fragment.
211
+ const zswapRootFragment = zswapRoots
212
+ ? `... on RegularTransaction { zswapMerkleTreeRoot }`
213
+ : "";
214
+ // `raw` is on the Transaction interface; `transactionResult` is
215
+ // RegularTransaction-only (system transactions have neither result nor
216
+ // contract calls, so they are skipped downstream). Duplicate inline
217
+ // fragments merge legally in GraphQL.
218
+ const tokenMintFields = tokenMints
219
+ ? `raw
220
+ ... on RegularTransaction { transactionResult { status segments { id success } } }`
221
+ : "";
181
222
  const query = `query {
182
223
  block(offset: { height: ${blockHeight} }) {
183
224
  hash
@@ -192,6 +233,9 @@ export class MidnightClient {
192
233
  ${contractActionsField}
193
234
  ${zswapField}
194
235
  ${unshieldedSpentField}
236
+ ${unshieldedCreatedField}
237
+ ${zswapRootFragment}
238
+ ${tokenMintFields}
195
239
  }
196
240
  }
197
241
  }`;
@@ -19,6 +19,7 @@ import { bound } from "@effectstream/utils";
19
19
  import { MidnightClient, type BlockFetchOptions, type MidnightGqlBlockState } from "./MidnightClient.ts";
20
20
  import { ContractState, StateValue } from "@midnight-ntwrk/onchain-runtime";
21
21
  import { decodeZswapInputEvent } from "./zswap-decoder.ts";
22
+ import { decodeTokenMints } from "./mint-decoder.ts";
22
23
 
23
24
  export class MidnightFetcher extends BaseDataFetcher<
24
25
  Input,
@@ -84,7 +85,10 @@ export class MidnightFetcher extends BaseDataFetcher<
84
85
  contractActions: this.config.primitives.some(
85
86
  (p) =>
86
87
  p.primitive.type !== "Midnight:Nullifier" &&
87
- p.primitive.type !== "Midnight:UnshieldedSpend",
88
+ p.primitive.type !== "Midnight:UnshieldedSpend" &&
89
+ p.primitive.type !== "Midnight:UnshieldedCreate" &&
90
+ p.primitive.type !== "Midnight:ZswapRoot" &&
91
+ p.primitive.type !== "Midnight:TokenMint",
88
92
  ),
89
93
  zswapLedgerEvents: this.config.primitives.some(
90
94
  (p) => p.primitive.type === "Midnight:Nullifier",
@@ -92,6 +96,15 @@ export class MidnightFetcher extends BaseDataFetcher<
92
96
  unshieldedSpentOutputs: this.config.primitives.some(
93
97
  (p) => p.primitive.type === "Midnight:UnshieldedSpend",
94
98
  ),
99
+ unshieldedCreatedOutputs: this.config.primitives.some(
100
+ (p) => p.primitive.type === "Midnight:UnshieldedCreate",
101
+ ),
102
+ zswapRoots: this.config.primitives.some(
103
+ (p) => p.primitive.type === "Midnight:ZswapRoot",
104
+ ),
105
+ tokenMints: this.config.primitives.some(
106
+ (p) => p.primitive.type === "Midnight:TokenMint",
107
+ ),
95
108
  };
96
109
  const self = this;
97
110
  const heights = Array.from(
@@ -105,14 +118,28 @@ export class MidnightFetcher extends BaseDataFetcher<
105
118
  const fetched = yield* all(
106
119
  heights.map(function* (height) {
107
120
  const signal = yield* useAbortSignal();
108
- const result: MidnightGqlBlockState = yield* call(() =>
109
- self.client.fetchBlock(height, blockFetchOptions, signal)
110
- );
111
- const primitives = yield* self.readPrimitives(
112
- height,
113
- result,
114
- self.config.primitives,
115
- );
121
+ let result: MidnightGqlBlockState;
122
+ let primitives;
123
+ try {
124
+ result = yield* call(() =>
125
+ self.client.fetchBlock(height, blockFetchOptions, signal)
126
+ );
127
+ primitives = yield* self.readPrimitives(
128
+ height,
129
+ result,
130
+ self.config.primitives,
131
+ );
132
+ } catch (e) {
133
+ // Surface per-height failures: the outer fetch loop swallows errors
134
+ // and retries silently, which previously hid a permanent failure
135
+ // (e.g. a wasm-instance mismatch in a contract ledger() call) as an
136
+ // ever-stuck page with no log output.
137
+ console.error(
138
+ `[Midnight] readData failed at height ${height}:`,
139
+ (e as Error)?.stack ?? e,
140
+ );
141
+ throw e;
142
+ }
116
143
  return {
117
144
  output: {
118
145
  raw: result.block as unknown as Block,
@@ -166,6 +193,12 @@ export class MidnightFetcher extends BaseDataFetcher<
166
193
  syncResults.push(...this.fetchNullifiers(height, primitiveEntry, block));
167
194
  } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedSpend") {
168
195
  syncResults.push(...this.fetchUnshieldedSpends(height, primitiveEntry, block));
196
+ } else if (primitiveEntry.primitive.type === "Midnight:UnshieldedCreate") {
197
+ syncResults.push(...this.fetchUnshieldedCreates(height, primitiveEntry, block));
198
+ } else if (primitiveEntry.primitive.type === "Midnight:ZswapRoot") {
199
+ syncResults.push(...this.fetchZswapRoots(height, primitiveEntry, block));
200
+ } else if (primitiveEntry.primitive.type === "Midnight:TokenMint") {
201
+ syncResults.push(...this.fetchTokenMints(height, primitiveEntry, block));
169
202
  } else {
170
203
  asyncOps.push(
171
204
  this.fetchContractState(height, client, primitiveEntry, block),
@@ -243,6 +276,112 @@ export class MidnightFetcher extends BaseDataFetcher<
243
276
  return results;
244
277
  }
245
278
 
279
+ // Mirror of fetchUnshieldedSpends over `unshieldedCreatedOutputs` — every
280
+ // unshielded UTXO created in the block (regular AND system transactions).
281
+ // One state-machine input per created output.
282
+ @bound
283
+ fetchUnshieldedCreates(
284
+ height: number,
285
+ primitiveEntry: PrimitiveEntryType,
286
+ block: MidnightGqlBlockState,
287
+ ): PrimitiveType[] {
288
+ const results: PrimitiveType[] = [];
289
+ for (const tx of block.block.transactions) {
290
+ for (const created of tx.unshieldedCreatedOutputs ?? []) {
291
+ results.push({
292
+ syncProtocol: {
293
+ name: primitiveEntry.syncProtocol,
294
+ blockNumber: height,
295
+ transactionHash: tx.hash,
296
+ contractAddress: "",
297
+ },
298
+ primitive: primitiveEntry.primitive.name,
299
+ output: {
300
+ payloadType: "midnight-unshielded-create",
301
+ payload: {
302
+ owner: created.owner,
303
+ intentHash: created.intentHash,
304
+ outputIndex: created.outputIndex,
305
+ txHash: tx.hash,
306
+ },
307
+ },
308
+ });
309
+ }
310
+ }
311
+ return results;
312
+ }
313
+
314
+ // Emit the latest zswap coin-commitment Merkle tree root for the block: the
315
+ // `zswapMerkleTreeRoot` of the last RegularTransaction that carries one.
316
+ // Blocks with no regular transactions emit nothing (the root is unchanged).
317
+ @bound
318
+ fetchZswapRoots(
319
+ height: number,
320
+ primitiveEntry: PrimitiveEntryType,
321
+ block: MidnightGqlBlockState,
322
+ ): PrimitiveType[] {
323
+ let root: string | undefined;
324
+ let txHash = "";
325
+ for (const tx of block.block.transactions) {
326
+ if (tx.zswapMerkleTreeRoot) {
327
+ root = tx.zswapMerkleTreeRoot;
328
+ txHash = tx.hash;
329
+ }
330
+ }
331
+ if (!root) return [];
332
+ return [
333
+ {
334
+ syncProtocol: {
335
+ name: primitiveEntry.syncProtocol,
336
+ blockNumber: height,
337
+ transactionHash: txHash,
338
+ contractAddress: "",
339
+ },
340
+ primitive: primitiveEntry.primitive.name,
341
+ output: {
342
+ payloadType: "midnight-zswap-root",
343
+ payload: { root, txHash },
344
+ },
345
+ },
346
+ ];
347
+ }
348
+
349
+ // Decode custom token mints out of each regular transaction's raw bytes
350
+ // (ledger-v8 deserialize → contract call transcript effects). One input per
351
+ // (tx, call, domainSep, kind) that actually applied on chain — see
352
+ // mint-decoder.ts for the segment semantics. System transactions carry no
353
+ // transactionResult and are skipped.
354
+ @bound
355
+ fetchTokenMints(
356
+ height: number,
357
+ primitiveEntry: PrimitiveEntryType,
358
+ block: MidnightGqlBlockState,
359
+ ): PrimitiveType[] {
360
+ const results: PrimitiveType[] = [];
361
+ for (const tx of block.block.transactions) {
362
+ if (!tx.raw || !tx.transactionResult) continue;
363
+ for (const mint of decodeTokenMints(tx.raw, tx.transactionResult)) {
364
+ results.push({
365
+ syncProtocol: {
366
+ name: primitiveEntry.syncProtocol,
367
+ blockNumber: height,
368
+ transactionHash: tx.hash,
369
+ contractAddress: mint.contractAddress,
370
+ },
371
+ primitive: primitiveEntry.primitive.name,
372
+ output: {
373
+ payloadType: "midnight-token-mint",
374
+ payload: {
375
+ ...mint,
376
+ txHash: tx.hash,
377
+ },
378
+ },
379
+ });
380
+ }
381
+ }
382
+ return results;
383
+ }
384
+
246
385
  @bound
247
386
  *fetchContractState(
248
387
  height: number,
@@ -0,0 +1,151 @@
1
+ import { Buffer } from "node:buffer";
2
+ import * as ledger from "@midnight-ntwrk/ledger-v8";
3
+
4
+ // Decodes custom token mints out of a raw Midnight transaction. A contract
5
+ // call's transcript effects record mints as `domain_sep → amount` maps
6
+ // (`shieldedMints` / `unshieldedMints`); the resulting token type ("color")
7
+ // is `rawTokenType(domain_sep, contract_address)`. Neither the indexer's
8
+ // GraphQL nor the wallet expose this mapping, so the only route is
9
+ // deserializing the transaction bytes with ledger-v8 — same approach as
10
+ // zswap-decoder.ts for nullifier events.
11
+ //
12
+ // Mirrors zswap-decoder.ts tolerance: never throws — an undecodable
13
+ // transaction logs a warning and contributes nothing.
14
+
15
+ export interface TokenMintRecord {
16
+ /** Address of the minting contract, hex */
17
+ contractAddress: string;
18
+ /** 32-byte domain separator pre-image, hex */
19
+ domainSep: string;
20
+ /** Derived token type ("color") = rawTokenType(domainSep, contractAddress), hex */
21
+ rawTokenType: string;
22
+ kind: "shielded" | "unshielded";
23
+ /** Total minted for this (call, domainSep, kind), as a decimal string (u64 exceeds MAX_SAFE_INTEGER) */
24
+ amount: string;
25
+ /** Contract entry point that minted, when printable */
26
+ entryPoint?: string;
27
+ }
28
+
29
+ export interface TxResultLike {
30
+ status: "SUCCESS" | "PARTIAL_SUCCESS" | "FAILURE";
31
+ segments?: { id: number; success: boolean }[] | null;
32
+ }
33
+
34
+ function hexToBytes(hex: string): Uint8Array {
35
+ const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
36
+ return Uint8Array.from(Buffer.from(clean, "hex"));
37
+ }
38
+
39
+ function bytesToHex(bytes: Uint8Array): string {
40
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
41
+ }
42
+
43
+ function normalizeHex(value: string): string {
44
+ return value.replace(/^0x/, "").toLowerCase();
45
+ }
46
+
47
+ /**
48
+ * Extract every applied token mint from a raw serialized transaction.
49
+ *
50
+ * Segment semantics (failed segments roll back on chain):
51
+ * - no `result` (system transactions) or status FAILURE → nothing applied;
52
+ * - SUCCESS → guaranteed + all fallible transcripts applied;
53
+ * - PARTIAL_SUCCESS → guaranteed transcripts applied, fallible only where
54
+ * the intent's segment (the `intents` map key) succeeded.
55
+ */
56
+ export function decodeTokenMints(
57
+ rawHex: string,
58
+ result?: TxResultLike | null,
59
+ ): TokenMintRecord[] {
60
+ if (!result || result.status === "FAILURE") return [];
61
+
62
+ let tx: any;
63
+ try {
64
+ tx = ledger.Transaction.deserialize(
65
+ "signature",
66
+ "proof",
67
+ "binding",
68
+ hexToBytes(rawHex),
69
+ );
70
+ } catch (e) {
71
+ console.warn(
72
+ `[mint-decoder] undecodable transaction, skipping: ${
73
+ (e as Error)?.message ?? e
74
+ }`,
75
+ );
76
+ return [];
77
+ }
78
+
79
+ const records: TokenMintRecord[] = [];
80
+ for (const [segmentId, intent] of tx.intents ?? new Map()) {
81
+ const fallibleApplied = result.status === "SUCCESS" ||
82
+ (result.segments?.some((s) => s.id === Number(segmentId) && s.success) ??
83
+ false);
84
+ for (const action of intent.actions ?? []) {
85
+ // Only contract calls carry transcripts; deploys and maintenance
86
+ // updates cannot mint.
87
+ if (!(action instanceof ledger.ContractCall)) continue;
88
+ try {
89
+ records.push(...mintsOfCall(action, fallibleApplied));
90
+ } catch (e) {
91
+ console.warn(
92
+ `[mint-decoder] failed to read mints of call to ${
93
+ (action as any)?.address ?? "?"
94
+ }: ${(e as Error)?.message ?? e}`,
95
+ );
96
+ }
97
+ }
98
+ }
99
+ return records;
100
+ }
101
+
102
+ function mintsOfCall(
103
+ call: any,
104
+ fallibleApplied: boolean,
105
+ ): TokenMintRecord[] {
106
+ // Accumulate domainSep|kind → amount across the applied transcripts. The
107
+ // guaranteed transcript applied whenever the transaction wasn't a FAILURE
108
+ // (the caller filters those); the fallible transcript only if its segment
109
+ // succeeded.
110
+ const totals = new Map<string, bigint>();
111
+ const transcripts = [
112
+ call.guaranteedTranscript,
113
+ ...(fallibleApplied ? [call.fallibleTranscript] : []),
114
+ ];
115
+ for (const transcript of transcripts) {
116
+ const effects = transcript?.effects;
117
+ if (!effects) continue;
118
+ for (
119
+ const [kind, mints] of [
120
+ ["shielded", effects.shieldedMints],
121
+ ["unshielded", effects.unshieldedMints],
122
+ ] as const
123
+ ) {
124
+ for (const [sepHex, amount] of mints ?? new Map()) {
125
+ const key = `${normalizeHex(String(sepHex))}|${kind}`;
126
+ totals.set(key, (totals.get(key) ?? 0n) + BigInt(amount));
127
+ }
128
+ }
129
+ }
130
+
131
+ const records: TokenMintRecord[] = [];
132
+ for (const [key, amount] of totals) {
133
+ const [domainSep, kind] = key.split("|") as [
134
+ string,
135
+ "shielded" | "unshielded",
136
+ ];
137
+ records.push({
138
+ contractAddress: normalizeHex(String(call.address)),
139
+ domainSep,
140
+ rawTokenType: normalizeHex(
141
+ String(ledger.rawTokenType(hexToBytes(domainSep), call.address)),
142
+ ),
143
+ kind,
144
+ amount: amount.toString(),
145
+ entryPoint: typeof call.entryPoint === "string"
146
+ ? call.entryPoint
147
+ : bytesToHex(call.entryPoint),
148
+ });
149
+ }
150
+ return records;
151
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { decodeTokenMints } from "../src/sync-protocols/midnight/mint-decoder.ts";
4
+
5
+ // Tolerance contract: the decoder must never throw — an undecodable or
6
+ // inapplicable transaction contributes nothing (the fetcher's per-height
7
+ // error path would otherwise wedge the sync loop).
8
+ describe("decodeTokenMints — tolerance", () => {
9
+ test("no transactionResult (system tx) → []", () => {
10
+ expect(decodeTokenMints("deadbeef", undefined)).toEqual([]);
11
+ expect(decodeTokenMints("deadbeef", null)).toEqual([]);
12
+ });
13
+
14
+ test("status FAILURE → [] (everything rolled back, incl. guaranteed)", () => {
15
+ expect(
16
+ decodeTokenMints("deadbeef", { status: "FAILURE", segments: [] }),
17
+ ).toEqual([]);
18
+ });
19
+
20
+ test("garbage bytes with SUCCESS status → [] without throwing", () => {
21
+ expect(
22
+ decodeTokenMints("not-even-hex", { status: "SUCCESS" }),
23
+ ).toEqual([]);
24
+ expect(
25
+ decodeTokenMints("00".repeat(64), { status: "SUCCESS" }),
26
+ ).toEqual([]);
27
+ });
28
+ });