@effectstream/sync 0.104.1 → 0.200.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.
@@ -0,0 +1,187 @@
1
+ import { MidnightClient } from "../src/sync-protocols/midnight/MidnightClient.ts";
2
+ import { decodeTokenMints } from "../src/sync-protocols/midnight/mint-decoder.ts";
3
+ import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
4
+
5
+ const PROTOCOL_VERSION = 2_000_000;
6
+ const endpoint = process.env.MIDNIGHT_INDEXER_URL;
7
+ const statePath = process.env.M4_STATE_FILE;
8
+ const requireResume = process.env.M4_REQUIRE_RESUME === "1";
9
+
10
+ if (!endpoint) throw new Error("MIDNIGHT_INDEXER_URL is required");
11
+ if (!statePath) throw new Error("M4_STATE_FILE is required");
12
+
13
+ const targets = new Map([
14
+ ["490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc", "fund-genesis-to-alice"],
15
+ ["2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311", "fund-alice-to-bob-with-shielded-input"],
16
+ ["1dac099bfd8f42f26fd911a036bab2993af9a6242f5aaf7411434db92c4a8f9e", "counter-deploy"],
17
+ ["3c5cf6006b85bbd6fb88c9f900abb25a9e409d642f01cdcb5dac93dafd56c3af", "counter-increment"],
18
+ ["16005a2328950dd914bd2ef2c10838cd033f9ff802ed4047cf6ffedcd4a40818", "counter-mint-shielded"],
19
+ ["c570f6400d6799fe4934ac70b931430e68f255d4220a922954e7b4c4cff12d70", "counter-mint-unshielded"],
20
+ ]);
21
+
22
+ type FollowState = {
23
+ schemaVersion: 1;
24
+ endpoint: string;
25
+ runs: number;
26
+ lastHeight: number;
27
+ lastHash: string;
28
+ seenTransactionHashes: string[];
29
+ seenTargets: Record<string, number>;
30
+ totals: Counters;
31
+ };
32
+
33
+ type Counters = {
34
+ blocks: number;
35
+ transactions: number;
36
+ contractActions: number;
37
+ zswapEvents: number;
38
+ nullifiers: number;
39
+ commitments: number;
40
+ unshieldedSpends: number;
41
+ unshieldedCreates: number;
42
+ zswapRoots: number;
43
+ tokenMints: number;
44
+ };
45
+
46
+ const client = new MidnightClient(endpoint, "undeployed", 30_000);
47
+ const prior = await readState(statePath);
48
+ if (requireResume && !prior) throw new Error("restart run requires an existing checkpoint");
49
+ if (!requireResume && prior) throw new Error("genesis run requires an empty checkpoint path");
50
+ if (prior && prior.endpoint !== endpoint) throw new Error("checkpoint endpoint changed");
51
+
52
+ if (prior) {
53
+ const anchor = await client.fetchBlock(prior.lastHeight, {
54
+ contractActions: false,
55
+ zswapLedgerEvents: false,
56
+ });
57
+ assertProtocol(anchor.block.protocolVersion, `checkpoint block ${prior.lastHeight}`);
58
+ if (anchor.block.hash !== prior.lastHash) {
59
+ throw new Error(`checkpoint anchor mismatch at ${prior.lastHeight}: ${prior.lastHash} != ${anchor.block.hash}`);
60
+ }
61
+ }
62
+
63
+ let latest = await client.fetchLatestBlock();
64
+ if (prior && latest.block.height <= prior.lastHeight) {
65
+ for (let attempt = 0; attempt < 30 && latest.block.height <= prior.lastHeight; attempt++) {
66
+ await Bun.sleep(1_000);
67
+ latest = await client.fetchLatestBlock();
68
+ }
69
+ if (latest.block.height <= prior.lastHeight) {
70
+ throw new Error(`indexer did not advance beyond restart checkpoint ${prior.lastHeight}`);
71
+ }
72
+ }
73
+
74
+ const startHeight = prior ? prior.lastHeight + 1 : 0;
75
+ const endHeight = latest.block.height;
76
+ const seenHashes = new Set(prior?.seenTransactionHashes ?? []);
77
+ const seenTargets = { ...(prior?.seenTargets ?? {}) };
78
+ const delta = zeroCounters();
79
+ let lastHash = prior?.lastHash ?? "";
80
+
81
+ for (let height = startHeight; height <= endHeight; height++) {
82
+ const response = await client.fetchBlock(height, {
83
+ contractActions: true,
84
+ zswapLedgerEvents: true,
85
+ unshieldedSpentOutputs: true,
86
+ unshieldedCreatedOutputs: true,
87
+ zswapRoots: true,
88
+ tokenMints: true,
89
+ });
90
+ const block = response.block;
91
+ assertProtocol(block.protocolVersion, `block ${height}`);
92
+ if (block.height !== height) throw new Error(`requested height ${height}, got ${block.height}`);
93
+ delta.blocks++;
94
+ lastHash = block.hash;
95
+
96
+ for (const tx of block.transactions) {
97
+ assertProtocol(tx.protocolVersion, `transaction ${tx.hash}`);
98
+ if (seenHashes.has(tx.hash)) throw new Error(`duplicate transaction after restart: ${tx.hash}`);
99
+ seenHashes.add(tx.hash);
100
+ delta.transactions++;
101
+ delta.contractActions += tx.contractActions.length;
102
+ delta.unshieldedSpends += tx.unshieldedSpentOutputs?.length ?? 0;
103
+ delta.unshieldedCreates += tx.unshieldedCreatedOutputs?.length ?? 0;
104
+ if (tx.zswapMerkleTreeRoot) delta.zswapRoots++;
105
+
106
+ for (const event of tx.zswapLedgerEvents ?? []) {
107
+ assertProtocol(event.protocolVersion, `zswap event ${event.id}`);
108
+ const decoded = decodeZswapEvent(event.raw);
109
+ delta.zswapEvents++;
110
+ if (decoded.kind === "nullifier") delta.nullifiers++;
111
+ else delta.commitments++;
112
+ }
113
+
114
+ if (tx.raw && tx.transactionResult) {
115
+ delta.tokenMints += decodeTokenMints(tx.raw, tx.transactionResult).length;
116
+ }
117
+ const target = targets.get(tx.hash);
118
+ if (target) seenTargets[target] = height;
119
+ }
120
+ }
121
+
122
+ if (!prior) {
123
+ for (const target of targets.values()) {
124
+ if (seenTargets[target] == null) throw new Error(`genesis follow missed target ${target}`);
125
+ }
126
+ if (delta.nullifiers < 2) throw new Error(`expected at least two nullifiers, got ${delta.nullifiers}`);
127
+ if (delta.tokenMints < 2) throw new Error(`expected at least two token mints, got ${delta.tokenMints}`);
128
+ }
129
+
130
+ const state: FollowState = {
131
+ schemaVersion: 1,
132
+ endpoint,
133
+ runs: (prior?.runs ?? 0) + 1,
134
+ lastHeight: endHeight,
135
+ lastHash,
136
+ seenTransactionHashes: [...seenHashes],
137
+ seenTargets,
138
+ totals: addCounters(prior?.totals ?? zeroCounters(), delta),
139
+ };
140
+ await Bun.write(statePath, `${JSON.stringify(state, null, 2)}\n`);
141
+
142
+ console.log(JSON.stringify({
143
+ mode: prior ? "restart-resume" : "genesis-follow",
144
+ startHeight,
145
+ endHeight,
146
+ anchorHeight: prior?.lastHeight ?? null,
147
+ anchorHash: prior?.lastHash ?? null,
148
+ delta,
149
+ cumulative: state.totals,
150
+ targets: state.seenTargets,
151
+ checkpoint: { runs: state.runs, lastHeight: state.lastHeight, lastHash: state.lastHash },
152
+ }, null, 2));
153
+
154
+ async function readState(path: string): Promise<FollowState | null> {
155
+ const file = Bun.file(path);
156
+ if (!(await file.exists())) return null;
157
+ const parsed = await file.json() as FollowState;
158
+ if (parsed.schemaVersion !== 1) throw new Error(`unsupported checkpoint schema ${parsed.schemaVersion}`);
159
+ return parsed;
160
+ }
161
+
162
+ function assertProtocol(actual: number, context: string): void {
163
+ if (actual !== PROTOCOL_VERSION) {
164
+ throw new Error(`${context}: expected protocolVersion ${PROTOCOL_VERSION}, got ${actual}`);
165
+ }
166
+ }
167
+
168
+ function zeroCounters(): Counters {
169
+ return {
170
+ blocks: 0,
171
+ transactions: 0,
172
+ contractActions: 0,
173
+ zswapEvents: 0,
174
+ nullifiers: 0,
175
+ commitments: 0,
176
+ unshieldedSpends: 0,
177
+ unshieldedCreates: 0,
178
+ zswapRoots: 0,
179
+ tokenMints: 0,
180
+ };
181
+ }
182
+
183
+ function addCounters(left: Counters, right: Counters): Counters {
184
+ return Object.fromEntries(
185
+ Object.keys(left).map((key) => [key, left[key as keyof Counters] + right[key as keyof Counters]]),
186
+ ) as Counters;
187
+ }
@@ -0,0 +1,239 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { MidnightFetcher } from "../src/sync-protocols/midnight/fetcher.ts";
4
+ import { decodeTokenMints } from "../src/sync-protocols/midnight/mint-decoder.ts";
5
+ import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
6
+
7
+ const FIXTURE_NAMES = [
8
+ "fund-genesis-to-alice",
9
+ "fund-alice-to-bob-with-shielded-input",
10
+ "counter-deploy",
11
+ "counter-increment",
12
+ "counter-mint-shielded",
13
+ "counter-mint-unshielded",
14
+ ] as const;
15
+
16
+ type Fixture = {
17
+ name: string;
18
+ purpose: string;
19
+ block: {
20
+ hash: string;
21
+ height: number;
22
+ protocolVersion: number;
23
+ timestamp: number;
24
+ parent: { hash: string };
25
+ };
26
+ transaction: {
27
+ hash: string;
28
+ protocolVersion: number;
29
+ raw: string;
30
+ contractActions: { address: string }[];
31
+ unshieldedSpentOutputs: UnshieldedOutput[];
32
+ unshieldedCreatedOutputs: UnshieldedOutput[];
33
+ zswapLedgerEvents: {
34
+ id: number;
35
+ raw: string;
36
+ maxId: number;
37
+ protocolVersion: number;
38
+ expected: ReturnType<typeof decodeZswapEvent>;
39
+ }[];
40
+ zswapMerkleTreeRoot: string;
41
+ transactionResult: {
42
+ status: "SUCCESS" | "PARTIAL_SUCCESS" | "FAILURE";
43
+ segments?: { id: number; success: boolean }[] | null;
44
+ };
45
+ };
46
+ expectedTokenMints: ReturnType<typeof decodeTokenMints>;
47
+ };
48
+
49
+ type UnshieldedOutput = {
50
+ intentHash: string;
51
+ outputIndex: number;
52
+ owner: string;
53
+ value: string;
54
+ tokenType: string;
55
+ };
56
+
57
+ const fixtures = await Promise.all(
58
+ FIXTURE_NAMES.map((name) =>
59
+ Bun.file(`${import.meta.dir}/fixtures/midnight-rc4/${name}.json`).json() as Promise<Fixture>
60
+ ),
61
+ );
62
+
63
+ const fetcher = Object.create(MidnightFetcher.prototype) as MidnightFetcher;
64
+
65
+ function blockOf(fixture: Fixture): any {
66
+ return {
67
+ block: {
68
+ ...fixture.block,
69
+ transactions: [fixture.transaction],
70
+ },
71
+ };
72
+ }
73
+
74
+ function entry(type: string, name: string, extra: object = {}): any {
75
+ return {
76
+ syncProtocol: "midnight-rc4-fixture",
77
+ primitive: { type, name, ...extra },
78
+ };
79
+ }
80
+
81
+ describe("Midnight rc.4 protocolVersion 2000000 regression corpus", () => {
82
+ test("retains finalized funding and deploy/call provenance", () => {
83
+ expect(fixtures.map((fixture) => [
84
+ fixture.name,
85
+ fixture.block.height,
86
+ fixture.block.hash,
87
+ fixture.transaction.hash,
88
+ ])).toEqual([
89
+ ["fund-genesis-to-alice", 45, "8987d1fa694d0caabe68a2c34ec8135fd1e8adb250752cefcbc96a69dd4aae70", "490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc"],
90
+ ["fund-alice-to-bob-with-shielded-input", 72, "19f0c4f5d7d3347814754e44f8bae37348590cf5eb364b074d05593881e16c52", "2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311"],
91
+ ["counter-deploy", 234, "d8aba57120dc61c9606cd5f6e9c1b8f4f2c100a47db85fe44a78a31432cc8fcd", "1dac099bfd8f42f26fd911a036bab2993af9a6242f5aaf7411434db92c4a8f9e"],
92
+ ["counter-increment", 239, "663f17a0b33794252f8d3158680f222f98cd109765ad6dc773c91fdcac009080", "3c5cf6006b85bbd6fb88c9f900abb25a9e409d642f01cdcb5dac93dafd56c3af"],
93
+ ["counter-mint-shielded", 247, "4e9157d8cfc0daffca5ad7b27ca83a9fc8ced6f9c609c5af1c8fb5803c52b2c9", "16005a2328950dd914bd2ef2c10838cd033f9ff802ed4047cf6ffedcd4a40818"],
94
+ ["counter-mint-unshielded", 254, "62eb5ec099a26ae0e9084b77b22d832eae4dd10c18f314b8154095496656cffb", "c570f6400d6799fe4934ac70b931430e68f255d4220a922954e7b4c4cff12d70"],
95
+ ]);
96
+ for (const fixture of fixtures) {
97
+ expect(fixture.block.protocolVersion).toBe(2_000_000);
98
+ expect(fixture.transaction.protocolVersion).toBe(2_000_000);
99
+ expect(fixture.transaction.raw.length).toBeGreaterThan(10_000);
100
+ for (const event of fixture.transaction.zswapLedgerEvents) {
101
+ expect(event.protocolVersion).toBe(2_000_000);
102
+ expect(event.raw.startsWith("6d69646e696768743a6576656e745b7631345d3a")).toBeTrue();
103
+ }
104
+ }
105
+ });
106
+
107
+ test("ledger-v9 decodes every raw zswap event and both funding nullifiers", () => {
108
+ const events = fixtures.flatMap((fixture) => fixture.transaction.zswapLedgerEvents);
109
+ expect(events).toHaveLength(7);
110
+ for (const event of events) {
111
+ expect(decodeZswapEvent(event.raw)).toEqual(event.expected);
112
+ }
113
+ expect(
114
+ events.map((event) => event.expected).filter((event) => event.kind === "nullifier"),
115
+ ).toEqual([
116
+ {
117
+ kind: "nullifier",
118
+ nullifier: "f10812363f45b4e69972d2f01fca22af4f15fa272e8bc6923d98a598f7046944",
119
+ txHash: "490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc",
120
+ logicalSegment: 0,
121
+ },
122
+ {
123
+ kind: "nullifier",
124
+ nullifier: "279a3ca0245affac570af313472a0a6efa4874760378f58375559aafa821ba00",
125
+ txHash: "2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311",
126
+ logicalSegment: 0,
127
+ },
128
+ ]);
129
+ });
130
+
131
+ test("fetcher emits all decoded nullifier and commitment primitives", () => {
132
+ const outputs = fixtures.flatMap((fixture) =>
133
+ fetcher.fetchZswapEvents(
134
+ fixture.block.height,
135
+ entry("Midnight:NullifierAndCommitment", "zswap", { capture: "both" }),
136
+ blockOf(fixture),
137
+ )
138
+ );
139
+ expect(outputs).toHaveLength(7);
140
+ expect(outputs.map((output: any) => output.output.payload)).toEqual(
141
+ fixtures.flatMap((fixture) =>
142
+ fixture.transaction.zswapLedgerEvents.map((event) => ({
143
+ ...event.expected,
144
+ eventId: event.id,
145
+ }))
146
+ ),
147
+ );
148
+ });
149
+
150
+ test("fetcher preserves unshielded spends and creates", () => {
151
+ const spends = fixtures.flatMap((fixture) =>
152
+ fetcher.fetchUnshieldedSpends(
153
+ fixture.block.height,
154
+ entry("Midnight:UnshieldedSpend", "spends"),
155
+ blockOf(fixture),
156
+ )
157
+ );
158
+ const creates = fixtures.flatMap((fixture) =>
159
+ fetcher.fetchUnshieldedCreates(
160
+ fixture.block.height,
161
+ entry("Midnight:UnshieldedCreate", "creates"),
162
+ blockOf(fixture),
163
+ )
164
+ );
165
+
166
+ expect(spends.map((output: any) => [
167
+ output.syncProtocol.transactionHash,
168
+ output.output.payload.value,
169
+ ])).toEqual([
170
+ ["490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc", "50000000000000"],
171
+ ["2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311", "10000000000000"],
172
+ ]);
173
+ expect(creates.map((output: any) => [
174
+ output.syncProtocol.transactionHash,
175
+ output.output.payload.value,
176
+ output.output.payload.tokenType,
177
+ ])).toEqual([
178
+ ["490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc", "10000000000000", "0".repeat(64)],
179
+ ["490def5ca76143ef2c67af5665de25fa8eaeb76e98c6e2ab35b90441f0fe6fbc", "40000000000000", "0".repeat(64)],
180
+ ["2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311", "1000000", "0".repeat(64)],
181
+ ["2f29a5919f8b805251165889eb25f0ae4d356344e9468f08cff7316c382cb311", "9999999000000", "0".repeat(64)],
182
+ ["c570f6400d6799fe4934ac70b931430e68f255d4220a922954e7b4c4cff12d70", "1000000", "ca03317e975b4148b7f6f4513d051f40d9d589f123e9dee3cc3e22bf69410357"],
183
+ ]);
184
+ });
185
+
186
+ test("fetcher emits each indexed zswap root", () => {
187
+ const outputs = fixtures.flatMap((fixture) =>
188
+ fetcher.fetchZswapRoots(
189
+ fixture.block.height,
190
+ entry("Midnight:ZswapRoot", "root"),
191
+ blockOf(fixture),
192
+ )
193
+ );
194
+ expect(outputs.map((output: any) => output.output.payload.root)).toEqual([
195
+ "73f6f9984bd127b16af3dca1ffda8f7a7689d846b246c607748fb74bc0b7337a22",
196
+ "7339f5435fa37476c54e3095c6cefaa7e3ad8d60bb42ec9367a6d7d73ee2a7940f",
197
+ "739e04ceb9281e1c1802460e2bf1226b023763fa7fc35495715fea8a05e526db45",
198
+ "739e04ceb9281e1c1802460e2bf1226b023763fa7fc35495715fea8a05e526db45",
199
+ "7342acc92554fe542e87d2281d24741f2a58f644db807dd50fba1a1b4f3ef33e70",
200
+ "7342acc92554fe542e87d2281d24741f2a58f644db807dd50fba1a1b4f3ef33e70",
201
+ ]);
202
+ });
203
+
204
+ test("ledger-v9 and fetcher decode the real shielded and unshielded mints", () => {
205
+ for (const fixture of fixtures) {
206
+ expect(
207
+ decodeTokenMints(fixture.transaction.raw, fixture.transaction.transactionResult),
208
+ ).toEqual(fixture.expectedTokenMints);
209
+ }
210
+
211
+ const outputs = fixtures.flatMap((fixture) =>
212
+ fetcher.fetchTokenMints(
213
+ fixture.block.height,
214
+ entry("Midnight:TokenMint", "mints"),
215
+ blockOf(fixture),
216
+ )
217
+ );
218
+ expect(outputs.map((output: any) => output.output.payload)).toEqual([
219
+ {
220
+ contractAddress: "3f9b35fec2b6f7a069ee41999217ef90fda0ecbcb05d054549242650cbce1a63",
221
+ domainSep: "d4".repeat(32),
222
+ rawTokenType: "6bcabd711a426fb3d9d09be6ad2846ad8099ca63623d97e32a5dfc043f8887ff",
223
+ kind: "shielded",
224
+ amount: "1000000",
225
+ entryPoint: "mint_shielded",
226
+ txHash: "16005a2328950dd914bd2ef2c10838cd033f9ff802ed4047cf6ffedcd4a40818",
227
+ },
228
+ {
229
+ contractAddress: "3f9b35fec2b6f7a069ee41999217ef90fda0ecbcb05d054549242650cbce1a63",
230
+ domainSep: "e5".repeat(32),
231
+ rawTokenType: "ca03317e975b4148b7f6f4513d051f40d9d589f123e9dee3cc3e22bf69410357",
232
+ kind: "unshielded",
233
+ amount: "1000000",
234
+ entryPoint: "mint_unshielded",
235
+ txHash: "c570f6400d6799fe4934ac70b931430e68f255d4220a922954e7b4c4cff12d70",
236
+ },
237
+ ]);
238
+ });
239
+ });
@@ -1,11 +1,32 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { decodeTokenMints } from "../src/sync-protocols/midnight/mint-decoder.ts";
3
+ import {
4
+ decodeTokenMints,
5
+ MintTransactionDecodeError,
6
+ } from "../src/sync-protocols/midnight/mint-decoder.ts";
4
7
 
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", () => {
8
+ const mintFixture = await Bun.file(
9
+ `${import.meta.dir}/fixtures/midnight-rc4/counter-mint-shielded.json`,
10
+ ).json() as {
11
+ transaction: {
12
+ raw: string;
13
+ transactionResult: { status: "SUCCESS" };
14
+ };
15
+ };
16
+
17
+ function expectDecodeFailure(raw: string): void {
18
+ try {
19
+ decodeTokenMints(raw, { status: "SUCCESS" });
20
+ throw new Error("expected decodeTokenMints to throw");
21
+ } catch (error) {
22
+ expect(error).toBeInstanceOf(MintTransactionDecodeError);
23
+ expect((error as MintTransactionDecodeError).rawHex).toBe(raw);
24
+ expect(String(error)).toContain("ledger-v9");
25
+ expect(String(error)).toContain("protocolVersion 2000000");
26
+ }
27
+ }
28
+
29
+ describe("decodeTokenMints — result and failure semantics", () => {
9
30
  test("no transactionResult (system tx) → []", () => {
10
31
  expect(decodeTokenMints("deadbeef", undefined)).toEqual([]);
11
32
  expect(decodeTokenMints("deadbeef", null)).toEqual([]);
@@ -17,12 +38,20 @@ describe("decodeTokenMints — tolerance", () => {
17
38
  ).toEqual([]);
18
39
  });
19
40
 
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([]);
41
+ test("garbage bytes with SUCCESS status fail explicitly", () => {
42
+ for (const raw of ["not-even-hex", "00".repeat(64)]) {
43
+ expectDecodeFailure(raw);
44
+ }
45
+ });
46
+
47
+ test("rejects non-hex semantic bytes, invalid suffixes, and odd length", () => {
48
+ const raw = mintFixture.transaction.raw;
49
+ const domainSep = "d4".repeat(32);
50
+ const semanticByte = raw.replace(domainSep, `zz${"d4".repeat(31)}`);
51
+ expect(semanticByte).not.toBe(raw);
52
+
53
+ for (const malformed of [semanticByte, `${raw}zz`, `${raw}0`]) {
54
+ expectDecodeFailure(malformed);
55
+ }
27
56
  });
28
57
  });
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Buffer } from "node:buffer";
3
- import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
3
+ import {
4
+ decodeZswapEvent,
5
+ ZswapEventDecodeError,
6
+ } from "../src/sync-protocols/midnight/zswap-decoder.ts";
4
7
 
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
+ // Synthetic events built per the midnight-ledger 9.x wire format:
9
+ // "midnight:event[v14]:" tag + TopoSortedNodes envelope wrapping the event
10
+ // struct. Both blobs round-trip through the native ledger-v9 deserializer.
8
11
 
9
12
  function compactU32(n: number): Buffer {
10
13
  if (n < 64) return Buffer.from([n << 2]);
@@ -23,7 +26,7 @@ const CONTRACT_NONE = Buffer.from([0x01]);
23
26
 
24
27
  function wrapEnvelope(data: Buffer): string {
25
28
  return Buffer.concat([
26
- Buffer.from("midnight:event[v9]:", "ascii"),
29
+ Buffer.from("midnight:event[v14]:", "ascii"),
27
30
  compactU32(1), // node count
28
31
  compactU32(0), // child count of the single node
29
32
  compactU32(data.length),
@@ -31,16 +34,29 @@ function wrapEnvelope(data: Buffer): string {
31
34
  ]).toString("hex");
32
35
  }
33
36
 
37
+ const NULLIFIER_RAW = wrapEnvelope(Buffer.concat([
38
+ TX_HASH,
39
+ SEGMENTS,
40
+ Buffer.from([0x00]),
41
+ BODY32,
42
+ CONTRACT_NONE,
43
+ ]));
44
+
45
+ function expectDecodeFailure(raw: string): void {
46
+ try {
47
+ decodeZswapEvent(raw);
48
+ throw new Error("expected decodeZswapEvent to throw");
49
+ } catch (error) {
50
+ expect(error).toBeInstanceOf(ZswapEventDecodeError);
51
+ expect((error as ZswapEventDecodeError).rawHex).toBe(raw);
52
+ expect(String(error)).toContain("ledger-v9");
53
+ expect(String(error)).toContain("protocolVersion 2000000");
54
+ }
55
+ }
56
+
34
57
  describe("decodeZswapEvent", () => {
35
58
  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({
59
+ expect(decodeZswapEvent(NULLIFIER_RAW)).toEqual({
44
60
  kind: "nullifier",
45
61
  nullifier: "bb".repeat(32),
46
62
  txHash: "aa".repeat(32),
@@ -67,8 +83,22 @@ describe("decodeZswapEvent", () => {
67
83
  });
68
84
  });
69
85
 
70
- test("returns null for undecodable input", () => {
71
- expect(decodeZswapEvent("deadbeef")).toBeNull();
72
- expect(decodeZswapEvent("")).toBeNull();
86
+ test("fails explicitly for undecodable input", () => {
87
+ for (const raw of ["deadbeef", ""]) {
88
+ expectDecodeFailure(raw);
89
+ }
90
+ });
91
+
92
+ test("rejects non-hex semantic bytes, invalid suffixes, and odd length", () => {
93
+ const nullifier = "bb".repeat(32);
94
+ const semanticByte = NULLIFIER_RAW.replace(
95
+ nullifier,
96
+ `zz${"bb".repeat(31)}`,
97
+ );
98
+ expect(semanticByte).not.toBe(NULLIFIER_RAW);
99
+
100
+ for (const raw of [semanticByte, `${NULLIFIER_RAW}zz`, `${NULLIFIER_RAW}0`]) {
101
+ expectDecodeFailure(raw);
102
+ }
73
103
  });
74
104
  });