@mikeargento/bitgraph 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * BitGraph Sets, set/2: a Merkle root over the rows instead of the list.
5
+ * The tree, the root document, a member's evidence, and the verifier's
6
+ * verdicts for a member with its path, without it, and with a stolen one.
7
+ */
8
+
9
+ import { describe, test } from "node:test";
10
+ import * as assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
13
+ import { signAsync } from "@noble/ed25519";
14
+ import { sha256 } from "@noble/hashes/sha256";
15
+ import {
16
+ PLACEMENTS,
17
+ SET_METADATA_KEY,
18
+ SET_MEMBER_METADATA_KEY,
19
+ SET2_PLACEMENT_ID,
20
+ MAX_SET2_MEMBERS,
21
+ MerkleTree,
22
+ buildSetManifest,
23
+ buildSetMemberProof,
24
+ buildSetRoot,
25
+ buildSetTree,
26
+ bytesEqual,
27
+ bytesToBase64,
28
+ bytesToHex,
29
+ canonicalSlotBody,
30
+ canonicalize,
31
+ computeSlotCommitment,
32
+ fuseAttribution,
33
+ getPlacement,
34
+ hexToBytes,
35
+ merkleLeafHash,
36
+ merklePath,
37
+ merkleRoot,
38
+ merkleRootFromPath,
39
+ parseSetManifest,
40
+ parseSetMemberProof,
41
+ parseSetRoot,
42
+ setLeaf,
43
+ setMemberPath,
44
+ setRootFromMember,
45
+ verifyFuse,
46
+ verifyFuseMember,
47
+ } from "@mikeargento/bitgraph-verify";
48
+ import type { BitGraphProof, SlotAllocation, Attribution, SetMember, SetMemberProof } from "@mikeargento/bitgraph-verify";
49
+ import { makeKey, signBody, b64, utf8 } from "./audit-fixtures.js";
50
+ import type { ManualKey } from "./audit-fixtures.js";
51
+
52
+ const FIX = fileURLToPath(new URL("../../src/__tests__/fuse-fixtures/", import.meta.url));
53
+ const bytes = (name: string) => new Uint8Array(readFileSync(FIX + name));
54
+ const original = bytes("original.txt");
55
+ const png = bytes("image.png");
56
+ const dec = (b: Uint8Array) => new TextDecoder().decode(b);
57
+ const EPOCH = bytesToBase64(new Uint8Array(32).fill(0x5e));
58
+
59
+ async function allocateSlot(key: ManualKey, counter: string): Promise<SlotAllocation> {
60
+ const body = { version: "bitgraph/slot/1" as const, nonceB64: b64(crypto.getRandomValues(new Uint8Array(32))), counter, epochId: EPOCH, publicKeyB64: key.publicKeyB64, chainId: "bitgraph:main" };
61
+ return { ...body, signatureB64: b64(await signAsync(canonicalize(body), key.privateKey)) };
62
+ }
63
+
64
+ /** A set/2 proof: the root document is the artifact, set/2 the signed title, the slot consumed. */
65
+ async function mintRootProof(o: { key: ManualKey; slot: SlotAllocation; commitCounter: string; rootDoc: Uint8Array; withMetadata?: boolean; attribution?: Attribution | null; member?: SetMemberProof }): Promise<BitGraphProof> {
66
+ const commit: BitGraphProof["commit"] = { nonceB64: o.slot.nonceB64, counter: o.commitCounter, epochId: o.slot.epochId, slotCounter: o.slot.counter, slotHashB64: b64(sha256(canonicalize(canonicalSlotBody(o.slot)))) };
67
+ (commit as unknown as Record<string, unknown>)["chainId"] = "bitgraph:main";
68
+ const attribution = o.attribution === undefined ? fuseAttribution(SET2_PLACEMENT_ID) : o.attribution;
69
+ const proof = await signBody(o.key, { hashAlg: "sha256", digestB64: b64(sha256(o.rootDoc)) }, commit, "test-measurement-set2", attribution === null ? undefined : { attribution });
70
+ proof.slotAllocation = o.slot;
71
+ if (o.withMetadata !== false) proof.metadata = { [SET_METADATA_KEY]: JSON.parse(dec(o.rootDoc)) as Record<string, unknown>, ...(o.member !== undefined ? { [SET_MEMBER_METADATA_KEY]: o.member } : {}) };
72
+ return proof;
73
+ }
74
+
75
+ interface Fused { name: string; original: Uint8Array; placement: "trailer/1" | "container/1" | "container/2"; bytes: Uint8Array; row: SetMember }
76
+ function fuseWith(commitment: Uint8Array, original: Uint8Array, placement: Fused["placement"], name: string): Fused {
77
+ const fused = getPlacement(placement)!.build({ original, commitment });
78
+ return { name, original, placement, bytes: fused, row: { artifact: sha256(fused), origin: sha256(original), placement } };
79
+ }
80
+
81
+ /** A set/2 of five members across the three member placements, minted under a fresh slot. */
82
+ async function makeSet2() {
83
+ const key = await makeKey();
84
+ const slot = await allocateSlot(key, "40");
85
+ const commitment = computeSlotCommitment(slot);
86
+ const fused: Fused[] = [
87
+ fuseWith(commitment, original, "trailer/1", "original.txt"),
88
+ fuseWith(commitment, png, "container/1", "image.png"),
89
+ fuseWith(commitment, utf8("a plain note for the tree\n"), "container/2", "note.txt"),
90
+ fuseWith(commitment, utf8("a fourth member\n"), "trailer/1", "fourth.txt"),
91
+ fuseWith(commitment, utf8("the fifth, in the new container\n"), "container/2", "fifth.txt"),
92
+ ];
93
+ const tree = buildSetTree(fused.map((f) => f.row));
94
+ const rootDoc = buildSetRoot(commitment, tree.sorted.length, tree.root);
95
+ const proof = await mintRootProof({ key, slot, commitCounter: "41", rootDoc });
96
+ const evidenceOf = (f: Fused): SetMemberProof => {
97
+ const k = tree.sorted.findIndex((r) => bytesEqual(r.artifact, f.row.artifact));
98
+ return buildSetMemberProof(f.row, k, tree.sorted.length, tree.tree.path(k));
99
+ };
100
+ return { key, slot, commitment, fused, tree, rootDoc, proof, evidenceOf };
101
+ }
102
+
103
+ const leafInputs = ["", "00", "10", "2021", "3031", "40414243", "5051525354555657", "606162636465666768696a6b6c6d6e6f"].map((h) => hexToBytes(h) ?? new Uint8Array(0));
104
+
105
+ describe("the tree (RFC 6962)", () => {
106
+ test("1. the RFC 6962 vectors: one leaf, and the eight-leaf root", () => {
107
+ const hashes = leafInputs.map(merkleLeafHash);
108
+ assert.equal(bytesToHex(merkleRoot(hashes.slice(0, 1))), "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d");
109
+ assert.equal(bytesToHex(merkleRoot(hashes)), "5dc9da79a70659a9ad559cb701ded9a2ab9d823aad2f4960cfe370eff4604328");
110
+ });
111
+
112
+ test("2. every path of every tree up to 40 leaves recomputes the root, through both the recursive and the memoized tree", () => {
113
+ for (let n = 1; n <= 40; n++) {
114
+ const hashes = Array.from({ length: n }, (_, i) => merkleLeafHash(utf8(`leaf ${i} of ${n}`)));
115
+ const root = merkleRoot(hashes);
116
+ const tree = new MerkleTree(hashes);
117
+ assert.deepEqual(tree.root, root, `tree root n=${n}`);
118
+ for (let i = 0; i < n; i++) {
119
+ const path = merklePath(hashes, i);
120
+ assert.deepEqual(tree.path(i), path, `path n=${n} i=${i}`);
121
+ assert.ok(path.length <= (n === 1 ? 0 : Math.ceil(Math.log2(n))), `path length n=${n} i=${i}`);
122
+ assert.deepEqual(merkleRootFromPath(hashes[i]!, i, n, path), root, `recompute n=${n} i=${i}`);
123
+ // A different index, a different size, a truncated or extended path, or a changed node never reaches the root.
124
+ if (n > 1) {
125
+ const other = (i + 1) % n;
126
+ assert.notDeepEqual(merkleRootFromPath(hashes[i]!, other, n, path), root, `wrong index n=${n} i=${i}`);
127
+ assert.notDeepEqual(merkleRootFromPath(hashes[i]!, i, n, path.slice(0, -1)) ?? new Uint8Array(0), root, `short path n=${n} i=${i}`);
128
+ const tampered = path.map((p, j) => (j === 0 ? new Uint8Array(p.map((b, x) => (x === 0 ? b ^ 1 : b))) : p));
129
+ assert.notDeepEqual(merkleRootFromPath(hashes[i]!, i, n, tampered), root, `tampered node n=${n} i=${i}`);
130
+ }
131
+ }
132
+ // A path says nothing about the tree's size on its own: the same
133
+ // siblings can close a tree of another size to the same root (three
134
+ // leaves and a fourth whose subtree hash equals the third leaf). The
135
+ // size is committed in the root document and the verifier binds the
136
+ // evidence's count to it before the path is read.
137
+ assert.equal(merkleRootFromPath(hashes[0]!, n, n, []), null, "index out of range");
138
+ }
139
+ });
140
+
141
+ test("3. 20,000 leaves: the memoized tree builds and every path verifies in well under a few seconds", () => {
142
+ const n = 20_000;
143
+ const hashes = Array.from({ length: n }, (_, i) => merkleLeafHash(utf8(`leaf ${i}`)));
144
+ const t0 = performance.now();
145
+ const tree = new MerkleTree(hashes);
146
+ let ok = 0;
147
+ for (let i = 0; i < n; i++) if (bytesEqual(merkleRootFromPath(hashes[i]!, i, n, tree.path(i))!, tree.root)) ok++;
148
+ const sec = (performance.now() - t0) / 1000;
149
+ assert.equal(ok, n);
150
+ assert.ok(sec < 10, `took ${sec.toFixed(1)} s`);
151
+ });
152
+ });
153
+
154
+ describe("the root document and a member's evidence", () => {
155
+ const commitment = new Uint8Array(32).fill(7);
156
+ // Hex with letters in it, so an uppercase variant is a real change.
157
+ const root = new Uint8Array(32).fill(0xab);
158
+
159
+ test("4. buildSetRoot / parseSetRoot round trip; anything not byte-equal to its own rebuild is refused", () => {
160
+ const doc = buildSetRoot(commitment, 44_000, root);
161
+ assert.equal(dec(doc), `{"count":44000,"placement":"set/2","root":{"algorithm":"sha256","digest":"${bytesToHex(root)}"},"slotCommitment":{"algorithm":"sha256","digest":"${bytesToHex(commitment)}"},"type":"bitgraph-fuse/1"}`);
162
+ const parsed = parseSetRoot(doc)!;
163
+ assert.deepEqual(parsed.commitment, commitment);
164
+ assert.deepEqual(parsed.root, root);
165
+ assert.equal(parsed.count, 44_000);
166
+ for (const bad of [
167
+ dec(doc).replace("}", " }"),
168
+ dec(doc).replace('"set/2"', '"set/1"'),
169
+ dec(doc).replace('"count":44000', '"count":0'),
170
+ dec(doc).replace('"count":44000', `"count":${MAX_SET2_MEMBERS + 1}`),
171
+ dec(doc).replace('"count":44000', '"count":44000,"extra":1'),
172
+ dec(doc).replace(bytesToHex(root), bytesToHex(root).toUpperCase()),
173
+ ]) assert.equal(parseSetRoot(utf8(bad)), null, bad.slice(0, 60));
174
+ assert.throws(() => buildSetRoot(commitment, 0, root));
175
+ assert.throws(() => buildSetRoot(commitment, MAX_SET2_MEMBERS + 1, root));
176
+ assert.throws(() => buildSetRoot(new Uint8Array(31), 1, root));
177
+ });
178
+
179
+ test("5. a member's evidence round-trips, and a malformed one reads as null", () => {
180
+ const rows: SetMember[] = [0, 1, 2].map((i) => ({ artifact: new Uint8Array(32).fill(10 + i), origin: new Uint8Array(32).fill(20 + i), placement: "trailer/1" }));
181
+ const tree = buildSetTree(rows);
182
+ const ev = buildSetMemberProof(tree.sorted[1]!, 1, 3, tree.tree.path(1));
183
+ assert.equal(ev.placement, "set/2");
184
+ assert.equal(ev.count, 3);
185
+ assert.equal(ev.index, 1);
186
+ const back = parseSetMemberProof(JSON.parse(JSON.stringify(ev)))!;
187
+ assert.deepEqual(back.member, tree.sorted[1]);
188
+ assert.deepEqual(back.path, tree.tree.path(1));
189
+ assert.deepEqual(setRootFromMember(back.member, back.index, back.count, back.path), tree.root);
190
+ for (const mutate of [
191
+ (o: Record<string, unknown>) => { o["index"] = 3; },
192
+ (o: Record<string, unknown>) => { o["count"] = 0; },
193
+ (o: Record<string, unknown>) => { (o["path"] as string[])[0] = "zz"; },
194
+ (o: Record<string, unknown>) => { o["placement"] = "set/1"; },
195
+ (o: Record<string, unknown>) => { (o["member"] as Record<string, unknown>)["placement"] = "set/2"; },
196
+ (o: Record<string, unknown>) => { o["extra"] = true; },
197
+ ]) {
198
+ const o = JSON.parse(JSON.stringify(ev)) as Record<string, unknown>;
199
+ mutate(o);
200
+ assert.equal(parseSetMemberProof(o), null);
201
+ }
202
+ assert.throws(() => buildSetMemberProof(rows[0]!, 3, 3, []));
203
+ });
204
+
205
+ test("6. set/2 resolves through getPlacement as Form C and is not in PLACEMENTS; the attribution carries no origin; a manifest refuses a set/2 row", () => {
206
+ assert.deepEqual(PLACEMENTS.map((p) => p.id), ["trailer/1", "container/1", "container/2", "produced/1"]);
207
+ const p = getPlacement("set/2")!;
208
+ assert.equal(p.form, "C");
209
+ assert.equal(p.byteExact, false);
210
+ const doc = buildSetRoot(commitment, 3, root);
211
+ assert.deepEqual(p.locate(doc)!.commitment, commitment);
212
+ assert.equal(p.locate(utf8("{}")), null);
213
+ assert.throws(() => p.build({ commitment }));
214
+ assert.deepEqual(fuseAttribution("set/2"), { name: "bitgraph-fuse/1", title: "set/2" });
215
+ assert.throws(() => fuseAttribution("set/2", commitment), /no single origin/);
216
+ const row: SetMember = { artifact: new Uint8Array(32).fill(1), origin: new Uint8Array(32).fill(2), placement: "set/2" };
217
+ assert.throws(() => buildSetManifest(commitment, [row]), /cannot list a set/);
218
+ assert.throws(() => setLeaf(row));
219
+ const okRow: SetMember = { ...row, placement: "trailer/1" };
220
+ const manifest = buildSetManifest(commitment, [okRow]);
221
+ assert.ok(parseSetManifest(manifest));
222
+ assert.equal(parseSetManifest(utf8(dec(manifest).replace('"placement":"trailer/1"', '"placement":"set/2"'))), null);
223
+ });
224
+ });
225
+
226
+ describe("verifyFuseMember for a set/2 member", () => {
227
+ test("7. every member: direct with its evidence, from its original with its evidence, and with the evidence riding under proof.metadata", async () => {
228
+ const s = await makeSet2();
229
+ for (const f of s.fused) {
230
+ const ev = s.evidenceOf(f);
231
+ const direct = await verifyFuseMember({ proof: s.proof, bytes: f.bytes, member: ev });
232
+ assert.equal(direct.category, "SET_MEMBER_DIRECT", `${f.name}: ${direct.reason}`);
233
+ assert.equal(direct.set!.kind, "set/2");
234
+ assert.equal(direct.set!.memberCount, 5);
235
+ assert.equal(direct.set!.memberSource, "argument");
236
+ assert.equal(direct.set!.treeRootB64, bytesToBase64(s.tree.root));
237
+ assert.equal(direct.set!.member!.index, ev.index);
238
+ assert.equal(direct.placement, f.placement);
239
+ assert.ok(direct.statements.some((t) => t.includes(`member ${ev.index + 1} of 5`)), direct.statements.join(" | "));
240
+ assert.ok(direct.statements.some((t) => t.includes("recompute the committed root")));
241
+ const fromOrigin = await verifyFuseMember({ proof: s.proof, bytes: f.original, member: ev });
242
+ assert.equal(fromOrigin.category, "SET_MEMBER_FROM_ORIGIN", `${f.name}: ${fromOrigin.reason}`);
243
+ assert.equal(fromOrigin.set!.member!.index, ev.index);
244
+ const riding = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, member: ev });
245
+ const viaMetadata = await verifyFuseMember({ proof: riding, bytes: f.bytes });
246
+ assert.equal(viaMetadata.category, "SET_MEMBER_DIRECT");
247
+ assert.equal(viaMetadata.set!.memberSource, "metadata");
248
+ }
249
+ // The root document itself is not a member; verifyFuse answers for it.
250
+ const self = await verifyFuseMember({ proof: s.proof, bytes: s.rootDoc });
251
+ assert.equal(self.category, "NO_MATCH");
252
+ assert.match(self.reason ?? "", /root document itself/);
253
+ const asFuse = await verifyFuse({ proof: s.proof, bytes: s.rootDoc });
254
+ assert.equal(asFuse.category, "FUSED_DIRECT");
255
+ assert.equal(asFuse.placement, "set/2");
256
+ });
257
+
258
+ test("8. without evidence the floor still shows but membership does not: SET_MEMBERSHIP_UNPROVEN for a member's own bytes and for the 51st file alike; an original without evidence is NO_MATCH", async () => {
259
+ const s = await makeSet2();
260
+ const own = await verifyFuseMember({ proof: s.proof, bytes: s.fused[0]!.bytes });
261
+ assert.equal(own.category, "SET_MEMBERSHIP_UNPROVEN");
262
+ assert.deepEqual(own.statements, []);
263
+ assert.match(own.reason ?? "", /made after that slot existed/);
264
+ assert.match(own.reason ?? "", /no member evidence/);
265
+ assert.equal(own.set!.kind, "set/2");
266
+ assert.equal(own.set!.member, null);
267
+ const stray = getPlacement("trailer/1")!.build({ original: utf8("the 51st file\n"), commitment: s.commitment });
268
+ const strayVerdict = await verifyFuseMember({ proof: s.proof, bytes: stray });
269
+ assert.equal(strayVerdict.category, "SET_MEMBERSHIP_UNPROVEN");
270
+ const plain = await verifyFuseMember({ proof: s.proof, bytes: s.fused[0]!.original });
271
+ assert.equal(plain.category, "NO_MATCH");
272
+ const foreign = getPlacement("trailer/1")!.build({ original: utf8("x\n"), commitment: new Uint8Array(32).fill(3) });
273
+ assert.equal((await verifyFuseMember({ proof: s.proof, bytes: foreign })).category, "NO_MATCH");
274
+ });
275
+
276
+ test("9. a stolen path: the 51st file with a real member's evidence is INVALID_SET_PATH, as are a forged node, a wrong count, and evidence for another set", async () => {
277
+ const s = await makeSet2();
278
+ const ev = s.evidenceOf(s.fused[2]!);
279
+ const stray = getPlacement("container/2")!.build({ original: utf8("the 51st file\n"), commitment: s.commitment });
280
+ const stolen = await verifyFuseMember({ proof: s.proof, bytes: stray, member: ev });
281
+ assert.equal(stolen.category, "INVALID_SET_PATH");
282
+ assert.match(stolen.reason ?? "", /describes a different member/);
283
+ assert.deepEqual(stolen.statements, []);
284
+ const forged = JSON.parse(JSON.stringify(ev)) as SetMemberProof;
285
+ if (forged.path.length > 0) forged.path[0] = forged.path[0]!.replace(/^[0-9a-f]/, (c) => (c === "a" ? "b" : "a"));
286
+ else forged.index = 0;
287
+ const forgedVerdict = await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: forged });
288
+ assert.equal(forgedVerdict.category, "INVALID_SET_PATH");
289
+ const wrongCount = { ...JSON.parse(JSON.stringify(ev)), count: 6 } as SetMemberProof;
290
+ assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: wrongCount })).category, "INVALID_SET_PATH");
291
+ const other = await makeSet2();
292
+ const otherEv = other.evidenceOf(other.fused[2]!);
293
+ assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: otherEv })).category, "INVALID_SET_PATH");
294
+ assert.equal((await verifyFuseMember({ proof: s.proof, bytes: s.fused[2]!.bytes, member: { nonsense: true } })).category, "INVALID_SET_PATH");
295
+ });
296
+
297
+ test("10. the root document is bound before any row: a wrong commitment, a stripped document, or a set/1 title over a root document are refused", async () => {
298
+ const s = await makeSet2();
299
+ const ev = s.evidenceOf(s.fused[0]!);
300
+ const otherSlot = await allocateSlot(s.key, "50");
301
+ const wrongDoc = buildSetRoot(computeSlotCommitment(otherSlot), 5, s.tree.root);
302
+ const wrongCommitment = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: wrongDoc });
303
+ assert.equal((await verifyFuseMember({ proof: wrongCommitment, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SLOT_COMMITMENT");
304
+ const stripped = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, withMetadata: false });
305
+ const noDoc = await verifyFuseMember({ proof: stripped, bytes: s.fused[0]!.bytes, member: ev });
306
+ assert.equal(noDoc.category, "INVALID_SET_MANIFEST");
307
+ assert.match(noDoc.reason ?? "", /root document/);
308
+ const explicit = await verifyFuseMember({ proof: stripped, bytes: s.fused[0]!.bytes, member: ev, manifest: s.rootDoc });
309
+ assert.equal(explicit.category, "SET_MEMBER_DIRECT", "explicit root document bytes bind a stripped proof");
310
+ const asSet1 = await mintRootProof({ key: s.key, slot: s.slot, commitCounter: "41", rootDoc: s.rootDoc, attribution: fuseAttribution("set/1") });
311
+ assert.equal((await verifyFuseMember({ proof: asSet1, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SET_MANIFEST");
312
+ const tampered = JSON.parse(JSON.stringify(s.proof)) as BitGraphProof;
313
+ (tampered.metadata![SET_METADATA_KEY] as Record<string, unknown>)["count"] = 6;
314
+ assert.equal((await verifyFuseMember({ proof: tampered, bytes: s.fused[0]!.bytes, member: ev })).category, "INVALID_SET_MANIFEST");
315
+ });
316
+
317
+ test("11. an unregistered row placement in valid evidence is UNDETERMINED_PLACEMENT: the path proves the row, the bytes cannot be read", async () => {
318
+ const key = await makeKey();
319
+ const slot = await allocateSlot(key, "60");
320
+ const commitment = computeSlotCommitment(slot);
321
+ const real = fuseWith(commitment, original, "trailer/1", "a");
322
+ const odd: SetMember = { artifact: new Uint8Array(32).fill(5), origin: sha256(utf8("odd")), placement: "xmp/9" };
323
+ const tree = buildSetTree([real.row, odd]);
324
+ const proof = await mintRootProof({ key, slot, commitCounter: "61", rootDoc: buildSetRoot(commitment, 2, tree.root) });
325
+ const k = tree.sorted.findIndex((r) => r.placement === "xmp/9");
326
+ const ev = buildSetMemberProof(odd, k, 2, tree.tree.path(k));
327
+ // Bytes that hash to the odd row's artifact cannot exist here; a direct hit is not constructible, so the from-origin path reports the unregistered placement.
328
+ const v = await verifyFuseMember({ proof, bytes: utf8("odd"), member: ev });
329
+ assert.equal(v.category, "UNDETERMINED_PLACEMENT");
330
+ const good = await verifyFuseMember({ proof, bytes: real.bytes, member: buildSetMemberProof(real.row, 1 - k, 2, tree.tree.path(1 - k)) });
331
+ assert.equal(good.category, "SET_MEMBER_DIRECT");
332
+ });
333
+
334
+ test("12. paths from the tree helpers agree with the verifier: setMemberPath, setLeaf, setRootFromMember", async () => {
335
+ const s = await makeSet2();
336
+ for (let k = 0; k < s.tree.sorted.length; k++) {
337
+ const row = s.tree.sorted[k]!;
338
+ const path = setMemberPath(s.tree.tree, k);
339
+ assert.deepEqual(merkleRootFromPath(setLeaf(row), k, 5, path), s.tree.root);
340
+ assert.deepEqual(setRootFromMember(row, k, 5, path), s.tree.root);
341
+ }
342
+ });
343
+ });
package/src/fuse.ts CHANGED
@@ -39,6 +39,14 @@ import { sha256 } from "@noble/hashes/sha256";
39
39
  import {
40
40
  buildFrame,
41
41
  buildSetManifest,
42
+ buildSetMemberProof,
43
+ buildSetRoot,
44
+ buildSetTree,
45
+ parseSetRoot,
46
+ SET2_PLACEMENT_ID,
47
+ SET_PLACEMENT_ID as SET_PLACEMENT_ID_LOCAL,
48
+ setRootFromMember,
49
+ MAX_SET2_MEMBERS,
42
50
  bytesEqual,
43
51
  bytesToBase64,
44
52
  bytesToHex,
@@ -55,18 +63,7 @@ import {
55
63
  verifyFuseMember,
56
64
  base64ToBytes,
57
65
  } from "@mikeargento/bitgraph-verify";
58
- import type {
59
- BitGraphProof,
60
- FuseFrame,
61
- FuseMemberResult,
62
- FuseVerifyResult,
63
- Located,
64
- Placement,
65
- PlacementId,
66
- SetManifest,
67
- SetMember,
68
- SlotAllocation,
69
- } from "@mikeargento/bitgraph-verify";
66
+ import type { BitGraphProof, FuseFrame, FuseMemberResult, FuseVerifyResult, Located, Placement, PlacementId, SetManifest, SetMember, SetMemberProof, SetRoot, SlotAllocation } from "@mikeargento/bitgraph-verify";
70
67
 
71
68
  /**
72
69
  * SHA-256 over bytes: the platform's native hasher when one is present
@@ -561,15 +558,25 @@ export interface FuseSetProgress {
561
558
  /**
562
559
  * "hash": each member checked before any request (a bytes member's origin digest is taken here).
563
560
  * "fuse": each member's fused digest taken, after the slot is held.
561
+ * "tree": set/2 only, 0 of 1 before the tree is built, 1 of 1 after.
564
562
  * "commit": 0 of 1 before the request, 1 of 1 when the proof is back.
565
563
  * "verify": only with verifyMembers, one per member.
566
564
  */
567
- phase: "hash" | "fuse" | "commit" | "verify";
565
+ phase: "hash" | "fuse" | "tree" | "commit" | "verify";
568
566
  done: number;
569
567
  total: number;
570
568
  }
571
569
 
572
570
  export interface FuseSetOptions {
571
+ /**
572
+ * "set/1" (default): the committed artifact is the canonical manifest of
573
+ * every member, which rides in the commit and in the proof; at most
574
+ * MAX_SET_MEMBERS. "set/2": the committed artifact is the root of a Merkle
575
+ * tree over the same rows, a few hundred bytes whatever N is; each member
576
+ * gets its leaf index and path (see FuseSetMemberResult.path), which a
577
+ * reader needs beside the proof; at most MAX_SET2_MEMBERS.
578
+ */
579
+ set?: "set/1" | "set/2";
573
580
  /** Return each member's fused bytes. Default false: they are virtual, rebuilt from the original and the proof. A hashed member has none to return. */
574
581
  keepFused?: boolean;
575
582
  /**
@@ -606,14 +613,22 @@ export interface FuseSetMemberResult {
606
613
  fusedBytes?: Uint8Array;
607
614
  /** Present only with verifyMembers: the verifier's own verdict against this member's fused bytes. Always SET_MEMBER_DIRECT on success, with set.manifestSource "argument". */
608
615
  verification?: FuseMemberResult;
616
+ /** set/2 only: the member's inclusion path, siblings from the leaf up; with manifestIndex and the set's count it is the member's evidence (memberProof). */
617
+ path?: Uint8Array[];
618
+ /** set/2 only: the member's evidence as its JSON object, ready to ride beside the proof or under proof.metadata[SET_MEMBER_METADATA_KEY]. */
619
+ memberProof?: SetMemberProof;
609
620
  }
610
621
 
611
622
  export interface FuseSetResult {
623
+ /** Which set kind was made. */
624
+ set: "set/1" | "set/2";
612
625
  proof: BitGraphProof;
613
- /** The committed artifact. Keep it beside the proof. */
626
+ /** The committed artifact: the set/1 manifest, or the set/2 root document. Keep it beside the proof. */
614
627
  manifestBytes: Uint8Array;
615
- /** JSON.parse of manifestBytes; the exact object sent under metadata. */
616
- manifest: SetManifest;
628
+ /** JSON.parse of manifestBytes; the exact object sent under metadata. For set/2 a SetRoot. */
629
+ manifest: SetManifest | SetRoot;
630
+ /** set/2 only: the tree root, standard base64. */
631
+ treeRootB64?: string;
617
632
  /** SHA-256 of manifestBytes; equals proof.artifact.digestB64. */
618
633
  artifactDigestB64: string;
619
634
  slotCommitmentB64: string;
@@ -638,7 +653,10 @@ export interface FuseSetResult {
638
653
  export async function fuseSet(members: readonly FuseSetMember[], options: FuseSetOptions = {}): Promise<FuseSetResult> {
639
654
  // 0. validate, before any request. A refusal here burns nothing.
640
655
  if (!Array.isArray(members) || members.length === 0) throw new FuseError("bad-input", "a set lists at least one member");
641
- if (members.length > MAX_SET_MEMBERS) throw new FuseError("bad-input", `a set lists at most ${MAX_SET_MEMBERS} members (got ${members.length})`);
656
+ const setKind = options.set ?? "set/1";
657
+ if (setKind !== "set/1" && setKind !== "set/2") throw new FuseError("bad-input", `set must be "set/1" or "set/2" (got ${String(setKind)})`);
658
+ const cap = setKind === "set/1" ? MAX_SET_MEMBERS : MAX_SET2_MEMBERS;
659
+ if (members.length > cap) throw new FuseError("bad-input", `a ${setKind} set lists at most ${cap} members (got ${members.length})`);
642
660
  const keep = options.keepFused === true;
643
661
  const verifyMembers = options.verifyMembers === true;
644
662
  type Kind = "bytes" | "loaded" | "hashed";
@@ -774,23 +792,32 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
774
792
  report("fuse", i + 1, checked.length);
775
793
  }
776
794
 
777
- // 3. hash: the canonical manifest is the artifact
795
+ // 3. hash: the committed artifact. set/1: the canonical manifest of every
796
+ // row. set/2: the root document over the Merkle tree of the same rows.
778
797
  let manifestBytes: Uint8Array;
798
+ let tree: ReturnType<typeof buildSetTree> | null = null;
779
799
  try {
780
- manifestBytes = buildSetManifest(commitment, rows);
800
+ if (setKind === "set/1") {
801
+ manifestBytes = buildSetManifest(commitment, rows);
802
+ } else {
803
+ report("tree", 0, 1);
804
+ tree = buildSetTree(rows);
805
+ manifestBytes = buildSetRoot(commitment, tree.sorted.length, tree.root);
806
+ report("tree", 1, 1);
807
+ }
781
808
  } catch (err) {
782
- throw new FuseError("bad-input", `the set manifest could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
809
+ throw new FuseError("bad-input", `the set ${setKind === "set/1" ? "manifest" : "root document"} could not be built: ${err instanceof Error ? err.message : String(err)}; ${expiring}`);
783
810
  }
784
811
  const artifactDigestB64 = bytesToBase64(await digest(manifestBytes));
785
- const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest;
812
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as SetManifest | SetRoot;
786
813
 
787
- // 4. fill: one commit, the parsed manifest riding along as unsigned metadata
814
+ // 4. fill: one commit, the parsed artifact riding along as unsigned metadata
788
815
  const body: Record<string, unknown> = {
789
816
  digests: [{ digestB64: artifactDigestB64, hashAlg: "sha256" }],
790
817
  slotId: slot.nonceB64,
791
818
  slot,
792
819
  chainId: "bitgraph:main",
793
- attribution: fuseAttribution("set/1"),
820
+ attribution: fuseAttribution(setKind === "set/1" ? SET_PLACEMENT_ID_LOCAL : SET2_PLACEMENT_ID),
794
821
  metadata: { [SET_METADATA_KEY]: manifest },
795
822
  };
796
823
  if (options.agency !== undefined) body.agency = options.agency;
@@ -798,10 +825,10 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
798
825
  const { proof, recovered } = await commitUnderSlot(t, body, artifactDigestB64, slot);
799
826
  report("commit", 1, 1);
800
827
 
801
- // The manifest is verified by a reader before the proof is called a set proof.
828
+ // The committed artifact is verified by a reader before the proof is called a set proof.
802
829
  const verification = await verifyFuse({ proof, bytes: manifestBytes });
803
- if (verification.category !== "FUSED_DIRECT" || verification.placement !== "set/1") {
804
- throw new FuseError("verification-failed", `the returned proof does not verify as a set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
830
+ if (verification.category !== "FUSED_DIRECT" || verification.placement !== setKind) {
831
+ throw new FuseError("verification-failed", `the returned proof does not verify as a ${setKind} set: ${verification.category}${verification.reason ? ` (${verification.reason})` : ""}`);
805
832
  }
806
833
  // The echo is unsigned and advisory. Absent is normal for a boundary that
807
834
  // drops metadata on a held-slot commit (enclaves before v6, and a proxy
@@ -818,18 +845,39 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
818
845
  // member's bytes are read again. With verifyMembers the full verifier runs
819
846
  // over each member's fused bytes as well, against the explicit manifest
820
847
  // bytes so no verdict depends on the echo, and its verdict is returned.
821
- const parsed = parseSetManifest(manifestBytes);
822
- if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
848
+ // set/1: the strictly parsed manifest lists each computed row. set/2: the
849
+ // strictly parsed root document states the count and the root the tree
850
+ // over these rows computed, and each member's path recomputes that root
851
+ // (the verifier's own check, run here once per member).
852
+ let listedRows: readonly SetMember[];
853
+ if (setKind === "set/1") {
854
+ const parsed = parseSetManifest(manifestBytes);
855
+ if (parsed === null) throw new FuseError("verification-failed", "the committed manifest does not parse as a set manifest");
856
+ listedRows = parsed.members;
857
+ } else {
858
+ const doc = parseSetRoot(manifestBytes);
859
+ if (doc === null || tree === null) throw new FuseError("verification-failed", "the committed root document does not parse as a set root");
860
+ if (doc.count !== tree.sorted.length || !bytesEqual(doc.root, tree.root)) throw new FuseError("verification-failed", "the committed root document does not state this tree's count and root");
861
+ listedRows = tree.sorted;
862
+ }
823
863
  const rowIndex = new Map<string, number>();
824
- parsed.members.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
864
+ listedRows.forEach((row, k) => rowIndex.set(bytesToHex(row.artifact), k));
825
865
  const results: FuseSetMemberResult[] = [];
826
866
  for (let i = 0; i < checked.length; i++) {
827
867
  const c = checked[i]!;
828
868
  const row = rows[i]!;
829
869
  const k = rowIndex.get(bytesToHex(row.artifact));
830
- const listed = k !== undefined ? parsed.members[k] : undefined;
870
+ const listed = k !== undefined ? listedRows[k] : undefined;
831
871
  if (k === undefined || listed === undefined || !bytesEqual(listed.origin, row.origin) || listed.placement !== row.placement) {
832
- throw new FuseError("verification-failed", `member ${i}: the committed manifest does not list this member's fused digest with its origin and placement`, null, i);
872
+ throw new FuseError("verification-failed", `member ${i}: the committed ${setKind === "set/1" ? "manifest" : "tree"} does not list this member's fused digest with its origin and placement`, null, i);
873
+ }
874
+ let path: Uint8Array[] | undefined;
875
+ let memberProof: SetMemberProof | undefined;
876
+ if (tree !== null) {
877
+ path = tree.tree.path(k);
878
+ const reached = setRootFromMember(row, k, tree.sorted.length, path);
879
+ if (reached === null || !bytesEqual(reached, tree.root)) throw new FuseError("verification-failed", `member ${i}: its path does not recompute the committed root`, null, i);
880
+ memberProof = buildSetMemberProof(row, k, tree.sorted.length, path);
833
881
  }
834
882
  const memberArtifactB64 = bytesToBase64(row.artifact);
835
883
  let verification: FuseMemberResult | undefined;
@@ -854,12 +902,16 @@ export async function fuseSet(members: readonly FuseSetMember[], options: FuseSe
854
902
  frameName: names?.frameName ?? null,
855
903
  ...(keep && held !== null ? { fusedBytes: held } : {}),
856
904
  ...(verification !== undefined ? { verification } : {}),
905
+ ...(path !== undefined ? { path } : {}),
906
+ ...(memberProof !== undefined ? { memberProof } : {}),
857
907
  });
858
908
  }
859
909
  return {
910
+ set: setKind,
860
911
  proof,
861
912
  manifestBytes,
862
913
  manifest,
914
+ ...(tree !== null ? { treeRootB64: bytesToBase64(tree.root) } : {}),
863
915
  artifactDigestB64,
864
916
  slotCommitmentB64: bytesToBase64(commitment),
865
917
  members: results,
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export { Constructor } from "./constructor.js";
41
41
  // slot, write a commitment to it into the artifact, hash, commit under the
42
42
  // same slot. The resulting proof is ordinary bitgraph/1.
43
43
  export { fuse, fuseSet, MAX_SET_MEMBERS, trailerBytesFor, builderFor, FuseError, digestFromBase64, placementForBytes, fusedNamesFor } from "./fuse.js";
44
+ export { MAX_SET2_MEMBERS, SET2_PLACEMENT_ID, SET_MEMBER_METADATA_KEY } from "@mikeargento/bitgraph-verify";
44
45
  export type { FuseBuilder, BuilderInput, FuseOptions, FuseResult, FuseTransport, FuseErrorCode } from "./fuse.js";
45
46
  export type { FuseSetMember, FuseSetBytesMember, FuseSetLoadedMember, FuseSetHashedMember, FusedDigestInput, SetMemberPlacement, FuseSetOptions, FuseSetProgress, FuseSetMemberResult, FuseSetResult } from "./fuse.js";
46
47
  // The verify-package types those results are made of, so the core entry names everything it returns.