@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.
|
|
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.
|
|
17
|
-
"@effectstream/db": "0.100.
|
|
18
|
-
"@effectstream/log": "0.100.
|
|
19
|
-
"@effectstream/utils": "0.100.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
self.
|
|
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
|
+
});
|