@mikeargento/bitgraph-verify 1.6.0 → 1.7.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.
@@ -7,6 +7,11 @@
7
7
  * canonical set manifest (placement set/1, see fuse.ts) and every member's
8
8
  * fused bytes carry the set's commitment via the member's own placement.
9
9
  * verifyFuse answers for the manifest; this module answers for a member.
10
+ * A set/2 (fuse.ts) commits the ROOT of a Merkle tree over the same rows
11
+ * instead of the list; its member carries its row, its leaf index and its
12
+ * sibling path, and binding means: root document hashed to the signed
13
+ * artifact and sealed to the slot, then the path reaching that root. From
14
+ * there the two set kinds share every step below.
10
15
  *
11
16
  * Given a proof whose signed attribution declares set/1 and a file:
12
17
  * 1. Verify the proof as an ordinary bitgraph/1 proof. Fail: stop.
@@ -47,9 +52,14 @@ import {
47
52
  PLACEMENTS,
48
53
  parseSetManifest,
49
54
  readFuseAttribution,
55
+ parseSetMemberProof,
56
+ parseSetRoot,
50
57
  readSetMetadata,
58
+ SET_MEMBER_METADATA_KEY,
51
59
  SET_METADATA_KEY,
52
60
  SET_PLACEMENT_ID,
61
+ SET2_PLACEMENT_ID,
62
+ setRootFromMember,
53
63
  type Located,
54
64
  type Placement,
55
65
  type SetMember,
@@ -60,6 +70,8 @@ export type FuseMemberCategory =
60
70
  | "SET_MEMBER_DIRECT"
61
71
  | "SET_MEMBER_FROM_ORIGIN"
62
72
  | "SET_NOT_MEMBER"
73
+ | "SET_MEMBERSHIP_UNPROVEN"
74
+ | "INVALID_SET_PATH"
63
75
  | "RECONSTRUCTION_MISMATCH"
64
76
  | "INVALID_SET_MANIFEST"
65
77
  | "INVALID_SLOT_COMMITMENT"
@@ -69,20 +81,29 @@ export type FuseMemberCategory =
69
81
  | "NO_MATCH";
70
82
 
71
83
  export interface FuseSetEvidence {
72
- /** Where the manifest bytes came from: the proof's metadata, or the caller's argument. */
84
+ /** "set/1": the whole member list is the committed artifact. "set/2": a Merkle root over the rows is. */
85
+ kind: "set/1" | "set/2";
86
+ /** Where the committed artifact's bytes came from (the set/1 manifest or the set/2 root document): the proof's metadata, or the caller's argument. */
73
87
  manifestSource: "metadata" | "argument";
74
- /** SHA-256 of the bound manifest bytes; equals artifactDigestB64 once bound. */
88
+ /** SHA-256 of the bound bytes; equals artifactDigestB64 once bound. */
75
89
  manifestDigestB64: string;
90
+ /** set/1: the rows listed. set/2: the count the root document states. */
76
91
  memberCount: number;
77
- /** The matched row. Non-null ONLY on the two member categories. */
92
+ /** set/2 only: where the member's evidence (row, index, path) came from; null when none was in hand. */
93
+ memberSource: "metadata" | "argument" | null;
94
+ /** set/2 only: the tree root, standard base64. */
95
+ treeRootB64: string | null;
96
+ /** The matched row. Non-null ONLY on the two member categories. `index` is the row's index in the list (set/1) or its leaf index in the tree (set/2). */
78
97
  member: { index: number; placement: string; fusedDigestB64: string; originDigestB64: string } | null;
79
98
  }
80
99
 
81
100
  export interface FuseMemberOptions {
82
101
  proof: BitGraphProof;
83
102
  bytes: Uint8Array;
84
- /** Canonical set manifest bytes; when given they replace proof.metadata as the source and are never silently replaced by it. */
103
+ /** Canonical bytes of the committed artifact (a set/1 manifest, or a set/2 root document); when given they replace proof.metadata as the source and are never silently replaced by it. */
85
104
  manifest?: Uint8Array | null;
105
+ /** set/2: the member's evidence (row, index, count, path) as its JSON object; when given it replaces proof.metadata[SET_MEMBER_METADATA_KEY]. */
106
+ member?: unknown;
86
107
  trustAnchors?: VerificationPolicy;
87
108
  /** Refuse to accept a span wider than this many positions (M - N). Never affects validity categories. */
88
109
  maxPositions?: bigint | number;
@@ -109,10 +130,18 @@ declare const admittedBrand: unique symbol;
109
130
  */
110
131
  interface BoundSet {
111
132
  readonly [boundBrand]: true;
133
+ readonly kind: "set/1" | "set/2";
112
134
  readonly source: "metadata" | "argument";
113
135
  readonly digestB64: string;
114
136
  readonly commitment: Uint8Array;
137
+ /** set/1: every listed row. set/2: the one row whose path reached the root, or none when no evidence was in hand. */
115
138
  readonly rows: readonly SetMember[];
139
+ /** The set's member count: rows.length for set/1, the root document's count for set/2. */
140
+ readonly count: number;
141
+ /** set/2: the leaf index of each of `rows` in the tree; null for set/1, where a row's index is its position in `rows`. */
142
+ readonly treeIndices: readonly number[] | null;
143
+ readonly memberSource: "metadata" | "argument" | null;
144
+ readonly rootB64: string | null;
116
145
  /** Lowercase hex of a row's artifact digest to its index. */
117
146
  readonly byArtifact: ReadonlyMap<string, number>;
118
147
  /** Lowercase hex of an origin digest to the indices listing it, in manifest order. */
@@ -167,7 +196,64 @@ function bindSetManifest(proof: BitGraphProof, commitment: Uint8Array, bytes: Ui
167
196
  const origin = bytesToHex(row.origin);
168
197
  byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), index]);
169
198
  });
170
- const bound: Omit<BoundSet, typeof boundBrand> = { source, digestB64: bytesToBase64(digest), commitment, rows: manifest.members, byArtifact, byOrigin };
199
+ const bound: Omit<BoundSet, typeof boundBrand> = { kind: "set/1", source, digestB64: bytesToBase64(digest), commitment, rows: manifest.members, count: manifest.members.length, treeIndices: null, memberSource: null, rootB64: null, byArtifact, byOrigin };
200
+ return bound as BoundSet;
201
+ }
202
+
203
+ /**
204
+ * The only constructor of a set/2 BoundSet. In order: strict parse of the
205
+ * root document, SHA-256 of its bytes equal to the SIGNED artifact digest,
206
+ * commitment equal to the one recomputed from the proof's own slot record;
207
+ * then, when member evidence is in hand, its strict parse, its count equal
208
+ * to the root document's, and its leaf and path recomputing exactly the
209
+ * committed root. A set bound without evidence lists no row: nothing can be
210
+ * admitted against it, and the caller's verdict is that membership was not
211
+ * shown. Rows never enter a BoundSet without their path reaching the root.
212
+ */
213
+ function bindSetRoot(
214
+ proof: BitGraphProof,
215
+ commitment: Uint8Array,
216
+ bytes: Uint8Array,
217
+ source: "metadata" | "argument",
218
+ evidence: { value: unknown; from: "metadata" | "argument" } | null,
219
+ ): BoundSet | Unbound | { category: "INVALID_SET_PATH"; reason: string } {
220
+ const doc = parseSetRoot(bytes);
221
+ if (doc === null) {
222
+ return { category: "INVALID_SET_MANIFEST", reason: "the set root document is not canonical bitgraph-fuse/1 set/2 bytes" };
223
+ }
224
+ const digest = sha256(bytes);
225
+ const artifact = base64ToBytes(proof.artifact.digestB64);
226
+ if (artifact === null || !bytesEqual(digest, artifact)) {
227
+ return { category: "INVALID_SET_MANIFEST", reason: "the set root document does not hash to the committed artifact digest" };
228
+ }
229
+ if (!bytesEqual(doc.commitment, commitment)) {
230
+ return { category: "INVALID_SLOT_COMMITMENT", reason: "the commitment in the committed set root document does not match the proof's slot record" };
231
+ }
232
+ const common = { kind: "set/2" as const, source, digestB64: bytesToBase64(digest), commitment, count: doc.count, rootB64: bytesToBase64(doc.root) };
233
+ if (evidence === null) {
234
+ const empty: Omit<BoundSet, typeof boundBrand> = { ...common, rows: [], treeIndices: [], memberSource: null, byArtifact: new Map(), byOrigin: new Map() };
235
+ return empty as BoundSet;
236
+ }
237
+ const parsed = parseSetMemberProof(evidence.value);
238
+ if (parsed === null) {
239
+ return { category: "INVALID_SET_PATH", reason: "the member evidence is not a well-formed bitgraph-fuse/1 set/2 member proof" };
240
+ }
241
+ if (parsed.count !== doc.count) {
242
+ return { category: "INVALID_SET_PATH", reason: `the member evidence names a tree of ${parsed.count} leaves; the committed root document states ${doc.count}` };
243
+ }
244
+ const reached = setRootFromMember(parsed.member, parsed.index, parsed.count, parsed.path);
245
+ if (reached === null || !bytesEqual(reached, doc.root)) {
246
+ return { category: "INVALID_SET_PATH", reason: "the member's leaf and path do not recompute the committed root; the row is not shown to be in the set" };
247
+ }
248
+ const row = parsed.member;
249
+ const bound: Omit<BoundSet, typeof boundBrand> = {
250
+ ...common,
251
+ rows: [row],
252
+ treeIndices: [parsed.index],
253
+ memberSource: evidence.from,
254
+ byArtifact: new Map([[bytesToHex(row.artifact), 0]]),
255
+ byOrigin: new Map([[bytesToHex(row.origin), [0]]]),
256
+ };
171
257
  return bound as BoundSet;
172
258
  }
173
259
 
@@ -225,6 +311,16 @@ function strayReason(span: FuseSpan | null, memberCount: number): string {
225
311
  return `these bytes carry the commitment of ${slot}, so they were made after that slot existed, but their digest is not among the ${memberCount} members listed in the set manifest ${commit}; the set proof does not cover them`;
226
312
  }
227
313
 
314
+ /** set/2: the two-part reason when the floor holds but no evidence, or evidence for another member, is in hand. */
315
+ function unprovenReason(span: FuseSpan | null, memberCount: number, otherMember: boolean): string {
316
+ const slot = span !== null ? `the slot allocated at position ${span.slotCounter}` : "its slot";
317
+ const commit = span !== null ? `committed at position ${span.commitCounter}` : "at its commit";
318
+ const shown = otherMember
319
+ ? "the member evidence in hand describes a different member, so"
320
+ : "no member evidence (row, index, path) is in hand, so";
321
+ return `these bytes carry the commitment of ${slot}, so they were made after that slot existed, but ${shown} their place among the ${memberCount} members of the set ${commit} is not shown; a set/2 proof covers a member only with its path`;
322
+ }
323
+
228
324
  export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMemberResult> {
229
325
  const { proof, bytes } = opts;
230
326
  const fileDigest = sha256(bytes);
@@ -274,11 +370,12 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
274
370
  maxPositions: maxPositions === null ? null : maxPositions.toString(),
275
371
  };
276
372
  const common = { proof: { valid: true }, marker, span, policy };
277
- if (marker === null || marker.placement !== SET_PLACEMENT_ID) {
278
- return base("INVALID_SET_MANIFEST", common, "the proof is not marked set/1; verifyFuse answers for this proof");
373
+ if (marker === null || (marker.placement !== SET_PLACEMENT_ID && marker.placement !== SET2_PLACEMENT_ID)) {
374
+ return base("INVALID_SET_MANIFEST", common, "the proof is not marked set/1 or set/2; verifyFuse answers for this proof");
279
375
  }
376
+ const setKind: "set/1" | "set/2" = marker.placement === SET2_PLACEMENT_ID ? "set/2" : "set/1";
280
377
  if (marker.originDigest !== undefined) {
281
- return base("INVALID_SET_MANIFEST", common, "the proof is marked set/1 but its signed attribution names an origin digest; a set has no single origin");
378
+ return base("INVALID_SET_MANIFEST", common, `the proof is marked ${setKind} but its signed attribution names an origin digest; a set has no single origin`);
282
379
  }
283
380
 
284
381
  // 3. The commitment, from the proof's own slot record.
@@ -295,20 +392,36 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
295
392
  const slotCommitmentB64 = bytesToBase64(commitment);
296
393
  const withSlot = { ...common, slotCommitmentB64 };
297
394
 
298
- // 4. Bind the manifest. An explicit argument replaces the metadata source
299
- // and is never silently replaced by it.
395
+ // 4. Bind the committed artifact: a set/1 manifest, or a set/2 root
396
+ // document and, when in hand, the member's evidence. An explicit
397
+ // argument replaces the metadata source and is never silently replaced
398
+ // by it.
300
399
  const explicit = opts.manifest === undefined || opts.manifest === null ? null : opts.manifest;
301
400
  const metadata = explicit === null ? readSetMetadata(proof) : null;
302
401
  const source = explicit !== null ? { bytes: explicit, from: "argument" as const } : metadata !== null ? { bytes: metadata, from: "metadata" as const } : null;
303
402
  if (source === null) {
304
- return base("INVALID_SET_MANIFEST", withSlot, `no set manifest: none under proof.metadata["${SET_METADATA_KEY}"] and none supplied`);
403
+ return base("INVALID_SET_MANIFEST", withSlot, `no set ${setKind === "set/1" ? "manifest" : "root document"}: none under proof.metadata["${SET_METADATA_KEY}"] and none supplied`);
404
+ }
405
+ let bound: BoundSet;
406
+ if (setKind === "set/1") {
407
+ const b = bindSetManifest(proof, commitment, source.bytes, source.from);
408
+ if ("category" in b) return base(b.category, withSlot, b.reason);
409
+ bound = b;
410
+ } else {
411
+ const explicitMember = opts.member === undefined || opts.member === null ? null : opts.member;
412
+ const metadataMember = explicitMember === null ? (proof.metadata?.[SET_MEMBER_METADATA_KEY] ?? null) : null;
413
+ const evidenceSource = explicitMember !== null ? { value: explicitMember, from: "argument" as const } : metadataMember !== null ? { value: metadataMember, from: "metadata" as const } : null;
414
+ const b = bindSetRoot(proof, commitment, source.bytes, source.from, evidenceSource);
415
+ if ("category" in b) return base(b.category, withSlot, b.reason);
416
+ bound = b;
305
417
  }
306
- const bound = bindSetManifest(proof, commitment, source.bytes, source.from);
307
- if ("category" in bound) return base(bound.category, withSlot, bound.reason);
308
418
  const evidence = (member: FuseSetEvidence["member"]): FuseSetEvidence => ({
419
+ kind: bound.kind,
309
420
  manifestSource: bound.source,
310
421
  manifestDigestB64: bound.digestB64,
311
- memberCount: bound.rows.length,
422
+ memberCount: bound.count,
423
+ memberSource: bound.memberSource,
424
+ treeRootB64: bound.rootB64,
312
425
  member,
313
426
  });
314
427
  const withSet = { ...withSlot, set: evidence(null) };
@@ -324,19 +437,23 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
324
437
  if (bound.byArtifact.get(bytesToHex(row.artifact)) !== index || bound.rows[index] !== row) {
325
438
  throw new Error("verifyFuseMember: membership re-assertion failed");
326
439
  }
327
- const count = bound.rows.length;
440
+ const count = bound.count;
441
+ // set/1: a row's index is its place in the list. set/2: its leaf index in the tree, carried by the bound evidence.
442
+ const shown = bound.treeIndices !== null ? bound.treeIndices[index]! : index;
443
+ const listed = bound.kind === "set/1" ? "listed in the set manifest committed" : "of the set whose Merkle root was committed";
328
444
  const statements: string[] = [];
329
445
  if (span !== null) {
330
446
  if (path === "direct") {
331
- statements.push(`These exact fused bytes are member ${index + 1} of ${count} listed in the set manifest committed at position ${span.commitCounter}.`);
447
+ statements.push(`These exact fused bytes are member ${shown + 1} of ${count} ${listed} at position ${span.commitCounter}.`);
332
448
  if (admitted.originCompared) {
333
- statements.push("The fused bytes carry an origin digest that matches the set manifest; the original itself was not supplied and was not checked.");
449
+ statements.push(`The fused bytes carry an origin digest that matches the set ${bound.kind === "set/1" ? "manifest" : "member evidence"}; the original itself was not supplied and was not checked.`);
334
450
  }
335
451
  } else {
336
452
  statements.push(
337
- `The supplied original rebuilds member ${index + 1} of ${count} of the committed set byte for byte, so these exact original bytes existed no later than commit position ${span.commitCounter}.`,
453
+ `The supplied original rebuilds member ${shown + 1} of ${count} of the committed set byte for byte, so these exact original bytes existed no later than commit position ${span.commitCounter}.`,
338
454
  );
339
455
  }
456
+ if (bound.kind === "set/2") statements.push(`The member's leaf and its ${bound.treeIndices !== null ? "path" : "path"} recompute the committed root, so the row is one of the ${count} the set commits to.`);
340
457
  statements.push(floorStatement(span));
341
458
  }
342
459
  return {
@@ -352,13 +469,13 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
352
469
  policy,
353
470
  statements,
354
471
  reason: null,
355
- set: evidence({ index, placement: placement.id, fusedDigestB64: bytesToBase64(row.artifact), originDigestB64: bytesToBase64(row.origin) }),
472
+ set: evidence({ index: shown, placement: placement.id, fusedDigestB64: bytesToBase64(row.artifact), originDigestB64: bytesToBase64(row.origin) }),
356
473
  };
357
474
  };
358
475
 
359
- // 5. The bytes are the committed manifest itself: verifyFuse answers for it.
476
+ // 5. The bytes are the committed artifact itself: verifyFuse answers for it.
360
477
  if (fileDigestB64 === artifactDigestB64) {
361
- return base("NO_MATCH", withSet, "these bytes are the committed set manifest itself, not a member; verifyFuse answers for the manifest");
478
+ return base("NO_MATCH", withSet, `these bytes are the committed set ${bound.kind === "set/1" ? "manifest" : "root document"} itself, not a member; verifyFuse answers for it`);
362
479
  }
363
480
 
364
481
  // 6. Direct path: the bytes hash to a listed member. Authoritative when it
@@ -417,14 +534,19 @@ export async function verifyFuseMember(opts: FuseMemberOptions): Promise<FuseMem
417
534
  );
418
535
  }
419
536
 
420
- // 8. Stray scan: bytes that carry this slot's commitment yet are listed
421
- // nowhere. Made after the slot, not part of the committed set: the
422
- // two-part verdict, with no rendered floor.
537
+ // 8. Stray scan: bytes that carry this slot's commitment yet match no row
538
+ // in hand. set/1 lists every row, so this is the two-part verdict: made
539
+ // after the slot, not part of the committed set. set/2 has in hand at
540
+ // most the one row its evidence proved, so absence is not shown: the
541
+ // floor holds, membership is unproven (no evidence) or the evidence is
542
+ // another member's. No rendered floor in either case.
423
543
  for (const p of PLACEMENTS) {
424
544
  const l = p.locate(bytes);
425
545
  if (l === null) continue;
426
546
  if (bytesEqual(l.commitment, commitment)) {
427
- return base("SET_NOT_MEMBER", { ...withSet, placement: p.id, statements: [] }, strayReason(span, bound.rows.length));
547
+ if (bound.kind === "set/1") return base("SET_NOT_MEMBER", { ...withSet, placement: p.id, statements: [] }, strayReason(span, bound.count));
548
+ const other = bound.rows.length > 0;
549
+ return base(other ? "INVALID_SET_PATH" : "SET_MEMBERSHIP_UNPROVEN", { ...withSet, placement: p.id, statements: [] }, unprovenReason(span, bound.count, other));
428
550
  }
429
551
  return base("NO_MATCH", withSet, "these bytes carry a commitment to a different slot; the proof proves nothing about them");
430
552
  }
@@ -0,0 +1,177 @@
1
+ // Copyright (c) 2024-2026 Mike Argento. Licensed under the MIT License. See LICENSE.
2
+
3
+ /**
4
+ * The Merkle tree a set/2 commits to: RFC 6962 (Certificate Transparency)
5
+ * hashing over an ordered list of leaf hashes, with the RFC 9162 inclusion
6
+ * proof. Domain separation is the RFC's: a leaf hash is SHA-256 of 0x00 and
7
+ * the leaf's bytes, an inner node SHA-256 of 0x01, left, right. A list of
8
+ * n leaves splits at k, the largest power of two below n, so every list has
9
+ * exactly one root and every leaf exactly one inclusion path of at most
10
+ * ceil(log2 n) siblings. Nothing here knows what a leaf is; fuse.ts says.
11
+ */
12
+
13
+ import { sha256 } from "@noble/hashes/sha256";
14
+
15
+ const LEAF_PREFIX = new Uint8Array([0x00]);
16
+ const NODE_PREFIX = new Uint8Array([0x01]);
17
+
18
+ function concat(...parts: Uint8Array[]): Uint8Array {
19
+ let n = 0;
20
+ for (const p of parts) n += p.length;
21
+ const out = new Uint8Array(n);
22
+ let o = 0;
23
+ for (const p of parts) {
24
+ out.set(p, o);
25
+ o += p.length;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ /** SHA-256(0x00 || bytes): the hash of one leaf. */
31
+ export function merkleLeafHash(bytes: Uint8Array): Uint8Array {
32
+ return sha256(concat(LEAF_PREFIX, bytes));
33
+ }
34
+
35
+ /** SHA-256(0x01 || left || right): one inner node. */
36
+ export function merkleNodeHash(left: Uint8Array, right: Uint8Array): Uint8Array {
37
+ return sha256(concat(NODE_PREFIX, left, right));
38
+ }
39
+
40
+ /** The largest power of two strictly below n (n >= 2). */
41
+ function split(n: number): number {
42
+ let k = 1;
43
+ while (k * 2 < n) k *= 2;
44
+ return k;
45
+ }
46
+
47
+ /**
48
+ * The root over leaf HASHES (each already merkleLeafHash of its leaf), in
49
+ * list order. Throws on an empty list; the root of one leaf hash is that
50
+ * hash itself, as in RFC 6962.
51
+ */
52
+ export function merkleRoot(leafHashes: readonly Uint8Array[]): Uint8Array {
53
+ if (leafHashes.length === 0) throw new TypeError("a Merkle tree needs at least one leaf");
54
+ for (const h of leafHashes) if (h.length !== 32) throw new TypeError("a leaf hash is 32 bytes");
55
+ const build = (lo: number, hi: number): Uint8Array => {
56
+ const n = hi - lo;
57
+ if (n === 1) return leafHashes[lo]!;
58
+ const k = split(n);
59
+ return merkleNodeHash(build(lo, lo + k), build(lo + k, hi));
60
+ };
61
+ return build(0, leafHashes.length);
62
+ }
63
+
64
+ /**
65
+ * The inclusion path of the leaf at `index`: its siblings from the leaf's
66
+ * own level up to the root, in that order (RFC 6962 PATH). Empty for a tree
67
+ * of one leaf.
68
+ */
69
+ export function merklePath(leafHashes: readonly Uint8Array[], index: number): Uint8Array[] {
70
+ const n = leafHashes.length;
71
+ if (n === 0) throw new TypeError("a Merkle tree needs at least one leaf");
72
+ if (!Number.isInteger(index) || index < 0 || index >= n) throw new RangeError("leaf index out of range");
73
+ const path: Uint8Array[] = [];
74
+ const walk = (lo: number, hi: number, m: number): void => {
75
+ const size = hi - lo;
76
+ if (size === 1) return;
77
+ const k = split(size);
78
+ const build = (a: number, b: number): Uint8Array => merkleRoot(leafHashes.slice(a, b));
79
+ if (m < lo + k) {
80
+ walk(lo, lo + k, m);
81
+ path.push(build(lo + k, hi));
82
+ } else {
83
+ walk(lo + k, hi, m);
84
+ path.push(build(lo, lo + k));
85
+ }
86
+ };
87
+ walk(0, n, index);
88
+ return path;
89
+ }
90
+
91
+ /**
92
+ * Recompute the root from one leaf hash, its index, the tree size and its
93
+ * path (RFC 9162 section 2.1.3.2). Null when the path does not fit the
94
+ * index and size: too short, too long, or an index outside the tree. A
95
+ * result that equals the committed root proves the leaf is at `index` in a
96
+ * tree of `size` leaves with that root; nothing else is proven.
97
+ */
98
+ export function merkleRootFromPath(leafHash: Uint8Array, index: number, size: number, path: readonly Uint8Array[]): Uint8Array | null {
99
+ if (!Number.isInteger(index) || !Number.isInteger(size) || size < 1 || index < 0 || index >= size) return null;
100
+ if (leafHash.length !== 32) return null;
101
+ let fn = index;
102
+ let sn = size - 1;
103
+ let r = leafHash;
104
+ for (const p of path) {
105
+ if (p.length !== 32) return null;
106
+ if (sn === 0) return null;
107
+ if ((fn & 1) === 1 || fn === sn) {
108
+ r = merkleNodeHash(p, r);
109
+ if ((fn & 1) === 0) {
110
+ while ((fn & 1) === 0 && fn !== 0) {
111
+ fn >>>= 1;
112
+ sn >>>= 1;
113
+ }
114
+ }
115
+ } else {
116
+ r = merkleNodeHash(r, p);
117
+ }
118
+ fn >>>= 1;
119
+ sn >>>= 1;
120
+ }
121
+ if (sn !== 0) return null;
122
+ return r;
123
+ }
124
+
125
+ /**
126
+ * The whole tree over a list of leaf hashes, every subtree root computed
127
+ * once, so the paths of all N leaves cost N log N hashes in total instead of
128
+ * N per path. The split rule and the path order are merkleRoot's and
129
+ * merklePath's exactly; a test pins them equal.
130
+ */
131
+ export class MerkleTree {
132
+ readonly size: number;
133
+ readonly root: Uint8Array;
134
+ private readonly leafHashes: readonly Uint8Array[];
135
+ /** Subtree root by "lo:hi". */
136
+ private readonly memo = new Map<string, Uint8Array>();
137
+
138
+ constructor(leafHashes: readonly Uint8Array[]) {
139
+ if (leafHashes.length === 0) throw new TypeError("a Merkle tree needs at least one leaf");
140
+ for (const h of leafHashes) if (h.length !== 32) throw new TypeError("a leaf hash is 32 bytes");
141
+ this.leafHashes = leafHashes;
142
+ this.size = leafHashes.length;
143
+ this.root = this.subtree(0, this.size);
144
+ }
145
+
146
+ private subtree(lo: number, hi: number): Uint8Array {
147
+ const n = hi - lo;
148
+ if (n === 1) return this.leafHashes[lo]!;
149
+ const key = `${lo}:${hi}`;
150
+ const known = this.memo.get(key);
151
+ if (known !== undefined) return known;
152
+ const k = split(n);
153
+ const h = merkleNodeHash(this.subtree(lo, lo + k), this.subtree(lo + k, hi));
154
+ this.memo.set(key, h);
155
+ return h;
156
+ }
157
+
158
+ /** The inclusion path of the leaf at `index`, siblings from the leaf's level up. */
159
+ path(index: number): Uint8Array[] {
160
+ if (!Number.isInteger(index) || index < 0 || index >= this.size) throw new RangeError("leaf index out of range");
161
+ const out: Uint8Array[] = [];
162
+ const walk = (lo: number, hi: number): void => {
163
+ const n = hi - lo;
164
+ if (n === 1) return;
165
+ const k = split(n);
166
+ if (index < lo + k) {
167
+ walk(lo, lo + k);
168
+ out.push(this.subtree(lo + k, hi));
169
+ } else {
170
+ walk(lo + k, hi);
171
+ out.push(this.subtree(lo, lo + k));
172
+ }
173
+ };
174
+ walk(0, this.size);
175
+ return out;
176
+ }
177
+ }