@forestrie/merklelog 0.2.1 → 0.4.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.
package/dist/mmr/proof.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * browser-safe (ADR-0048). Function names mirror the go-merklelog originals.
11
11
  */
12
12
  /** Number of bits needed to represent `num` (0 for 0). */
13
- function bitLength(num) {
13
+ export function bitLength(num) {
14
14
  if (num <= 0n)
15
15
  return 0;
16
16
  return num.toString(2).length;
@@ -89,6 +89,89 @@ export function inclusionProof(get, mmrLastIndex, i) {
89
89
  g += 1n;
90
90
  }
91
91
  }
92
+ /**
93
+ * The sibling *indices* of an inclusion proof for the node at MMR index `i` in
94
+ * the tree ending at `mmrLastIndex` — the index-only counterpart of
95
+ * {@link inclusionProof} (go-merklelog `mmr/proof.go` InclusionProofPath). The
96
+ * values at these indices, in order, are the proof. Store-free: pure position
97
+ * arithmetic, so a caller can assemble a proof's node values from any source
98
+ * (a receipt path, consistency-proof paths, tiles).
99
+ */
100
+ export function inclusionProofPath(mmrLastIndex, i) {
101
+ let g = BigInt(indexHeight(i));
102
+ const proof = [];
103
+ for (;;) {
104
+ const siblingOffset = 2n << g;
105
+ let iSibling;
106
+ if (BigInt(indexHeight(i + 1n)) > g) {
107
+ iSibling = i - siblingOffset + 1n;
108
+ i += 1n;
109
+ }
110
+ else {
111
+ iSibling = i + siblingOffset - 1n;
112
+ i += siblingOffset;
113
+ }
114
+ if (iSibling > mmrLastIndex) {
115
+ return proof;
116
+ }
117
+ proof.push(iSibling);
118
+ g += 1n;
119
+ }
120
+ }
121
+ /**
122
+ * Extend the inclusion proof for node `i` from MMR size A to size B, reading
123
+ * sibling values from `get`. The result's `path` proves `i` at size B and shares
124
+ * its first `heightA` elements with the size-A proof — so a stale receipt's
125
+ * path (leaf → its old peak) is exactly `path[0..heightA]`, and the remainder is
126
+ * the tile-free extension. Direct port of go-merklelog `InclusionProofLocalExtend`.
127
+ *
128
+ * @throws if `mmrSizeB <= mmrSizeA`.
129
+ */
130
+ export function inclusionProofLocalExtend(get, mmrSizeA, mmrSizeB, i) {
131
+ if (mmrSizeB <= mmrSizeA) {
132
+ throw new Error(`mmrSizeB (${mmrSizeB}) must be greater than mmrSizeA (${mmrSizeA})`);
133
+ }
134
+ const logIndex = i;
135
+ const path = [];
136
+ let height = 0n;
137
+ let prevComplete = false;
138
+ let peakIndexA = 0n;
139
+ let heightA = 0;
140
+ while (i < mmrSizeB) {
141
+ const siblingOffset = (2n << height) - 1n; // go-merklelog SiblingOffset
142
+ let iSibling;
143
+ let delta;
144
+ if (BigInt(indexHeight(i + 1n)) > BigInt(indexHeight(i))) {
145
+ iSibling = i - siblingOffset;
146
+ delta = 1n;
147
+ }
148
+ else {
149
+ iSibling = i + siblingOffset;
150
+ delta = 2n << height;
151
+ }
152
+ // The first sibling that lies outside MMR(A) marks the end of the size-A
153
+ // proof: `i` is the size-A covering peak and the prefix so far is its proof.
154
+ if (iSibling >= mmrSizeA && !prevComplete) {
155
+ peakIndexA = i;
156
+ heightA = path.length;
157
+ prevComplete = true;
158
+ }
159
+ if (iSibling >= mmrSizeB)
160
+ break;
161
+ path.push(get(iSibling));
162
+ i += delta;
163
+ height += 1n;
164
+ }
165
+ return {
166
+ logIndex,
167
+ path,
168
+ sizeA: mmrSizeA,
169
+ peakIndexA,
170
+ heightA,
171
+ sizeB: mmrSizeB,
172
+ peakIndexB: i,
173
+ };
174
+ }
92
175
  /**
93
176
  * Peaks bitmap for an MMR size: the numeric value equals the leaf count of the
94
177
  * largest valid MMR with size <= `mmrSize`; each set bit marks a perfect
@@ -110,8 +193,24 @@ export function peaksBitmap(mmrSize) {
110
193
  }
111
194
  return peakMap;
112
195
  }
196
+ /**
197
+ * Node count of the complete MMR with `leaves` leaves (reference
198
+ * `mmr_size_for_leaf_count`, Solidity `mmrSizeForLeafCount`).
199
+ *
200
+ * Every leaf adds itself plus one interior node per binary carry, and each
201
+ * peak is a carry that has not happened, so the count is
202
+ * `2 * leaves - popcount(leaves)`.
203
+ *
204
+ * Because {@link peaksBitmap} rounds an incomplete size down to the largest
205
+ * complete MMR below it, `mmrSizeForLeafCount(peaksBitmap(size)) === size`
206
+ * holds exactly for complete sizes; that identity is the completeness test
207
+ * used by `consistentRootsForSizes`.
208
+ */
209
+ export function mmrSizeForLeafCount(leaves) {
210
+ return 2n * leaves - BigInt(popcount(leaves));
211
+ }
113
212
  /** Population count of a non-negative BigInt. */
114
- function popcount(x) {
213
+ export function popcount(x) {
115
214
  let count = 0;
116
215
  let v = x;
117
216
  while (v > 0n) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/merklelog",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "MMR (merkle mountain range) proofs and massif storage format for Forestrie transparency logs",
package/src/index.ts CHANGED
@@ -71,9 +71,20 @@ export {
71
71
  verifyInclusion,
72
72
  verifyConsistency,
73
73
  consistentRoots,
74
+ consistentRootsForSizes,
74
75
  indexConsistencyProof,
75
76
  type ConsistencyProof,
76
77
  } from "./mmr/algorithms.js";
78
+ // Typed proof-shape rejections raised by consistentRootsForSizes (names
79
+ // mirror the Solidity IUnivocityErrors members) — plan-2609-10 s4.3, FOR-568.
80
+ export {
81
+ ConsistencyShapeError,
82
+ IncompleteTreeSize,
83
+ ConsistencyPeakCountMismatch,
84
+ ConsistencyPathLengthMismatch,
85
+ ConsistencyRootMismatch,
86
+ SizeMustIncrease,
87
+ } from "./mmr/errors.js";
77
88
  export { createSyncHasher } from "./mmr/algorithms-sync.js";
78
89
  export type { Proof, Peak } from "./mmr/types.js";
79
90
  export type { Hasher } from "./mmr/types.js";
@@ -81,12 +92,15 @@ export type { Hasher } from "./mmr/types.js";
81
92
  // MMR proof building (pure; store-agnostic) — plan-2607-15 §4 (go: mmr/proof.go, peaks.go)
82
93
  export {
83
94
  inclusionProof,
95
+ inclusionProofPath,
96
+ inclusionProofLocalExtend,
84
97
  peakMMRIndexes,
85
98
  peaksBitmap,
86
99
  peakIndexForLeafProof,
100
+ mmrSizeForLeafCount,
87
101
  indexHeight,
88
102
  firstMMRSize,
89
103
  massifIndexFromMMRIndex,
90
104
  peakStackMap,
91
105
  } from "./mmr/proof.js";
92
- export type { NodeGetter } from "./mmr/proof.js";
106
+ export type { NodeGetter, ConsistencyProofLocal } from "./mmr/proof.js";
@@ -12,7 +12,22 @@
12
12
  */
13
13
 
14
14
  import type { Proof, Hasher } from "./types.js";
15
- import { inclusionProof, peakMMRIndexes, type NodeGetter } from "./proof.js";
15
+ import {
16
+ inclusionProof,
17
+ peakMMRIndexes,
18
+ bitLength,
19
+ popcount,
20
+ peaksBitmap,
21
+ mmrSizeForLeafCount,
22
+ type NodeGetter,
23
+ } from "./proof.js";
24
+ import {
25
+ ConsistencyPathLengthMismatch,
26
+ ConsistencyPeakCountMismatch,
27
+ ConsistencyRootMismatch,
28
+ IncompleteTreeSize,
29
+ SizeMustIncrease,
30
+ } from "./errors.js";
16
31
  import { Uint64 } from "../uint64/index.js";
17
32
  import { heightIndex } from "./math.js";
18
33
  import { arraysEqual } from "../utils/arrays.js";
@@ -185,6 +200,12 @@ export interface ConsistencyProof {
185
200
  * duplicate roots collapse (many old peaks share one new peak). Requires
186
201
  * one path per MMR(A) peak (draft: `len(peaks(ifrom)) == len(accumulatorfrom)`).
187
202
  *
203
+ * This fold does not enforce the proof shape the two sizes imply: it reads
204
+ * whatever path lengths it is given and collapses whatever roots coincide.
205
+ * Use {@link consistentRootsForSizes} for verification — it checks each path
206
+ * against the length MMR(A) -> MMR(B) fixes and requires the paths under one
207
+ * target peak to agree.
208
+ *
188
209
  * @param ifrom - last node index of the complete MMR(A) (`mmrSizeA - 1`)
189
210
  * @param accumulatorFrom - MMR(A) peak values, descending height order
190
211
  * @param paths - inclusion path per peak, proven in MMR(B)
@@ -222,50 +243,212 @@ export async function consistentRoots(
222
243
  return roots;
223
244
  }
224
245
 
246
+ /**
247
+ * Produce the peaks of MMR(sizeTo) that `paths` prove from the peaks of
248
+ * MMR(sizeFrom), requiring the paths to have exactly the shape the two sizes
249
+ * imply (draft-bryce-cose-receipts-mmr-profile, "Verifying the Receipt of
250
+ * consistency", with the draft's SHOULD on path lengths enforced in the same
251
+ * pass). Line-for-line port of the reference `consistent_roots_for_sizes`
252
+ * (algorithms.py) and of Solidity `consistentRootsForSizes`
253
+ * (univocity `src/algorithms/consistentRoots.sol`).
254
+ *
255
+ * Sizes are node counts (the MMR ending at index `i` has `i + 1` nodes).
256
+ * `sizeFrom` MUST be the size of the state the verifier already trusts; taken
257
+ * from the proof instead, the check is void. Only `sizeTo` is required to be
258
+ * a complete MMR size: every trusted size was itself a checked target.
259
+ *
260
+ * For a complete MMR the set bits of `peaksBitmap(size)` are the peak heights,
261
+ * high to low, which is accumulator order. Let `split` be the highest bit on
262
+ * which the two bitmaps differ; as `sizeTo > sizeFrom` the target has it and
263
+ * the origin does not. An origin peak above `split` is also a peak of the
264
+ * target: its path is empty and it is returned unchanged. Every origin peak
265
+ * below `split` is committed by the target peak of height `split`: its path
266
+ * has length `split - h`, and every such path must prove the same root. The
267
+ * target's remaining peaks lie below every origin peak, so no path reaches
268
+ * them; the prover supplies them separately and their count is returned.
269
+ *
270
+ * Because the shape is fixed by the sizes alone, no peak index list and no
271
+ * per-hop bookkeeping beyond the hash itself is needed: the bitmaps are
272
+ * iterated directly.
273
+ *
274
+ * @param hasher - cryptographic hasher instance
275
+ * @param sizeFrom - node count of the trusted origin state (0 for an empty log)
276
+ * @param sizeTo - node count of the target state; must exceed `sizeFrom` and
277
+ * must be a complete MMR size
278
+ * @param accumulatorFrom - peaks of MMR(sizeFrom), descending height order
279
+ * @param paths - one path per origin peak, in the same order
280
+ * @returns `roots`, the peaks of MMR(sizeTo) proven from the origin peaks in
281
+ * descending height order (the unchanged peaks, then the one proven root if
282
+ * any), and `expectedRight`, the number of MMR(sizeTo) peaks the prover must
283
+ * supply as right peaks. `roots` followed by those right peaks is the
284
+ * accumulator of MMR(sizeTo).
285
+ * @throws {SizeMustIncrease} if `sizeTo <= sizeFrom`
286
+ * @throws {IncompleteTreeSize} if `sizeTo` is not a complete MMR size
287
+ * @throws {ConsistencyPeakCountMismatch} if `accumulatorFrom` or `paths` does
288
+ * not have one entry per origin peak
289
+ * @throws {ConsistencyPathLengthMismatch} if a path length differs from the
290
+ * length the two sizes imply
291
+ * @throws {ConsistencyRootMismatch} if two paths under one target peak produce
292
+ * different roots
293
+ */
294
+ export async function consistentRootsForSizes(
295
+ hasher: Hasher,
296
+ sizeFrom: bigint,
297
+ sizeTo: bigint,
298
+ accumulatorFrom: Uint8Array[],
299
+ paths: Uint8Array[][],
300
+ ): Promise<{ roots: Uint8Array[]; expectedRight: number }> {
301
+ if (sizeTo <= sizeFrom) {
302
+ throw new SizeMustIncrease(sizeFrom, sizeTo);
303
+ }
304
+ const to = peaksBitmap(sizeTo);
305
+ // peaksBitmap rounds an incomplete size down to the largest MMR below it,
306
+ // so `to` describes MMR(sizeTo) only if sizeTo is complete. Without this a
307
+ // target such as 6 anchors an accumulator that is no MMR's, and a verifier
308
+ // later reads its entries at the wrong heights.
309
+ if (mmrSizeForLeafCount(to) !== sizeTo) {
310
+ throw new IncompleteTreeSize(sizeTo);
311
+ }
312
+ const from = peaksBitmap(sizeFrom);
313
+ const n = popcount(from);
314
+ if (accumulatorFrom.length !== n) {
315
+ throw new ConsistencyPeakCountMismatch(n, accumulatorFrom.length);
316
+ }
317
+ if (paths.length !== n) {
318
+ throw new ConsistencyPeakCountMismatch(n, paths.length);
319
+ }
320
+ const nto = popcount(to);
321
+ if (n === 0) {
322
+ return { roots: [], expectedRight: nto };
323
+ }
324
+
325
+ const split = bitLength(from ^ to) - 1;
326
+ const roots: Uint8Array[] = [];
327
+ // Nodes preceding the current origin peak's subtree; a peak of height h
328
+ // sits at offset + 2^(h+1) - 2 and its subtree has 2^(h+1) - 1 nodes.
329
+ let offset = 0n;
330
+ let i = 0;
331
+
332
+ // Origin peaks above the split are also peaks of the target. The path is
333
+ // not read; requiring it to be empty rejects unused material (a shape
334
+ // check: the result does not depend on it).
335
+ for (let h = bitLength(from) - 1; h > split; h--) {
336
+ if (((from >> BigInt(h)) & 1n) === 0n) continue;
337
+ if (paths[i].length !== 0) {
338
+ throw new ConsistencyPathLengthMismatch(i, 0, paths[i].length);
339
+ }
340
+ roots.push(accumulatorFrom[i]);
341
+ offset += (1n << BigInt(h + 1)) - 1n;
342
+ i += 1;
343
+ }
344
+
345
+ // Origin peaks below the split are all committed by the target peak of
346
+ // height `split` (bit `split` itself is clear in `from`), so each path must
347
+ // have length split - h and every path must prove the same root. The first
348
+ // `above` peaks were returned unchanged, so i === above at the first peak
349
+ // below the split.
350
+ const above = roots.length;
351
+ let root: Uint8Array | undefined;
352
+ for (let h = split - 1; h >= 0; h--) {
353
+ if (((from >> BigInt(h)) & 1n) === 0n) continue;
354
+ const expected = split - h;
355
+ if (paths[i].length !== expected) {
356
+ throw new ConsistencyPathLengthMismatch(i, expected, paths[i].length);
357
+ }
358
+ const subtree = (1n << BigInt(h + 1)) - 1n;
359
+ const peakMMRIndex = offset + subtree - 1n;
360
+ const proven = await calculateRoot(
361
+ hasher,
362
+ accumulatorFrom[i],
363
+ { path: paths[i], mmrIndex: peakMMRIndex },
364
+ peakMMRIndex,
365
+ );
366
+ if (i === above) {
367
+ root = proven;
368
+ } else if (!arraysEqual(proven, root as Uint8Array)) {
369
+ throw new ConsistencyRootMismatch(i);
370
+ }
371
+ offset += subtree;
372
+ i += 1;
373
+ }
374
+ if (n > above) {
375
+ roots.push(root as Uint8Array);
376
+ }
377
+
378
+ return { roots, expectedRight: nto - roots.length };
379
+ }
380
+
225
381
  /**
226
382
  * Verify MMR(A) is a committed prefix of MMR(B)
227
383
  * (draft-bryce "Verifying the Receipt of consistency";
228
384
  * go-merklelog `VerifyConsistency`).
229
385
  *
386
+ * BOTH sizes are CALLER-SUPPLIED trusted state (ADR-0066 D5.4), never read
387
+ * off the proof: `sizeFrom` is the size of the state `peaksFrom` is the
388
+ * accumulator of, and `sizeTo` the size `peaksTo` is claimed to be the
389
+ * accumulator of. The proof contributes only `paths`. Taking either size
390
+ * from the proof makes the check void: the fold constrains a declared
391
+ * `sizeFrom` by the origin peak COUNT alone, which a whole family of sizes
392
+ * share, so one set of paths and peaks verified at many size pairs — with
393
+ * the draft's own MMR(11) -> MMR(39) material, 24 distinct pairs, of which
394
+ * one is the true relation. `indexConsistencyProof` still returns the
395
+ * {@link ConsistencyProof} wire type with its declared sizes; a caller that
396
+ * relays that type passes `proof.paths` here and supplies the sizes it
397
+ * trusts, comparing the declared sizes with them separately if it wants to
398
+ * report a disagreement.
399
+ *
400
+ * Routed through {@link consistentRootsForSizes}, so the proof must have the
401
+ * shape `sizeFrom -> sizeTo` implies: one path per MMR(A) peak, empty above
402
+ * the split and of length `split - h` below it, with every path below the
403
+ * split proving the same root. The proven roots must then be the leading
404
+ * entries of `peaksTo`, and `peaksTo` must hold exactly those plus the
405
+ * `expectedRight` right peaks no path reaches — so a truncated or padded
406
+ * target accumulator is rejected on its length alone.
407
+ *
230
408
  * Replaces the plan-0027 always-true stub (FOR-368 Phase 1,
231
409
  * plan-2607-29): the previous signature took two inclusion proofs and
232
410
  * returned true unconditionally; no caller existed.
233
411
  *
234
- * @param proof - consistency proof `mmrSizeA -> mmrSizeB`
412
+ * @param sizeFrom - TRUSTED node count of MMR(A)
413
+ * @param sizeTo - TRUSTED node count of MMR(B)
235
414
  * @param peaksFrom - TRUSTED MMR(A) accumulator (e.g. a signed checkpoint
236
415
  * payload), descending height order
416
+ * @param paths - one inclusion path per MMR(A) peak, in the same order
237
417
  * @param peaksTo - MMR(B) accumulator to prove against (e.g. an anchored
238
418
  * on-chain state), descending height order
239
- * @returns ok, with the MMR(B) accumulator on success
419
+ * @returns ok, with the MMR(B) accumulator on success. A value that does not
420
+ * match `peaksTo` returns `{ok: false, accumulator: []}`; a proof that does
421
+ * not have the shape the sizes imply throws one of the typed errors in
422
+ * `./errors.js` (all `ConsistencyShapeError` subclasses), which callers
423
+ * report as a malformed proof rather than a failed comparison.
240
424
  */
241
425
  export async function verifyConsistency(
242
426
  hasher: Hasher,
243
- proof: ConsistencyProof,
427
+ sizeFrom: bigint,
428
+ sizeTo: bigint,
244
429
  peaksFrom: Uint8Array[],
430
+ paths: Uint8Array[][],
245
431
  peaksTo: Uint8Array[],
246
432
  ): Promise<{ ok: boolean; accumulator: Uint8Array[] }> {
247
- const proven = await consistentRoots(
433
+ const { roots, expectedRight } = await consistentRootsForSizes(
248
434
  hasher,
249
- proof.mmrSizeA - 1n,
435
+ sizeFrom,
436
+ sizeTo,
250
437
  peaksFrom,
251
- proof.paths,
438
+ paths,
252
439
  );
253
- // Both lists are in descending height order, so every proven root must
254
- // appear in order within peaksTo (a linear scan; go-merklelog semantics:
255
- // a proven root matches the current peak or exactly the next one down).
256
- let ito = 0;
257
- for (const root of proven) {
258
- if (ito < peaksTo.length && arraysEqual(peaksTo[ito], root)) {
259
- continue;
260
- }
261
- ito += 1;
262
- if (ito >= peaksTo.length || !arraysEqual(peaksTo[ito], root)) {
440
+ // roots is the leading run of the MMR(B) accumulator and expectedRight
441
+ // counts the peaks below every MMR(A) peak, which no path reaches. Together
442
+ // they fix the length of MMR(B)'s accumulator, so the supplied peaksTo must
443
+ // have that length and must start with the proven roots.
444
+ if (peaksTo.length !== roots.length + expectedRight) {
445
+ return { ok: false, accumulator: [] };
446
+ }
447
+ for (let i = 0; i < roots.length; i++) {
448
+ if (!arraysEqual(peaksTo[i], roots[i])) {
263
449
  return { ok: false, accumulator: [] };
264
450
  }
265
451
  }
266
- // The full MMR(B) accumulator is the proven prefix plus any right-peaks;
267
- // returning peaksTo is safe because consistentRoots enforced one proof
268
- // per MMR(A) peak (see go-merklelog VerifyConsistency).
269
452
  return { ok: true, accumulator: peaksTo };
270
453
  }
271
454
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Typed errors for the size-driven consistency fold
3
+ * (`consistentRootsForSizes`).
4
+ *
5
+ * The names mirror the Solidity `IUnivocityErrors` members raised by the
6
+ * on-chain implementation (`univocity/src/algorithms/consistentRoots.sol`),
7
+ * so a shape rejection reads the same on both sides. Every one of these
8
+ * reports a condition the two sizes fix in advance — the proof does not
9
+ * have the shape MMR(sizeFrom) -> MMR(sizeTo) implies — as distinct from a
10
+ * value mismatch against a trusted accumulator, which callers surface as a
11
+ * plain verification failure.
12
+ *
13
+ * All derive from {@link ConsistencyShapeError} so a caller can map the whole
14
+ * family to "malformed proof" with a single `instanceof`.
15
+ */
16
+
17
+ /** Base class for every consistency proof-shape rejection. */
18
+ export class ConsistencyShapeError extends Error {
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "ConsistencyShapeError";
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The target size is not a complete MMR size.
27
+ *
28
+ * `peaksBitmap` rounds an incomplete size down to the largest complete MMR
29
+ * below it, so for an incomplete `sizeTo` the bitmap describes a different
30
+ * tree than the one named. Accepting it would anchor an accumulator that is
31
+ * no MMR's, and a later verifier would read its entries at the wrong heights.
32
+ */
33
+ export class IncompleteTreeSize extends ConsistencyShapeError {
34
+ constructor(readonly size: bigint) {
35
+ super(`size ${size} is not a complete MMR size`);
36
+ this.name = "IncompleteTreeSize";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The accumulator or the proof list does not have one entry per origin peak.
42
+ * The origin peak count is fixed by `sizeFrom` alone.
43
+ */
44
+ export class ConsistencyPeakCountMismatch extends ConsistencyShapeError {
45
+ constructor(
46
+ readonly expected: number,
47
+ readonly actual: number,
48
+ ) {
49
+ super(`expected ${expected} entries for the origin peaks, got ${actual}`);
50
+ this.name = "ConsistencyPeakCountMismatch";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A path does not have the length the two sizes imply: 0 for an origin peak
56
+ * above the split, `split - h` for an origin peak of height `h` below it.
57
+ */
58
+ export class ConsistencyPathLengthMismatch extends ConsistencyShapeError {
59
+ constructor(
60
+ readonly index: number,
61
+ readonly expected: number,
62
+ readonly actual: number,
63
+ ) {
64
+ super(`path ${index}: expected length ${expected}, got ${actual}`);
65
+ this.name = "ConsistencyPathLengthMismatch";
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Two origin peaks below the split proved different roots. Every origin peak
71
+ * below the split is committed by the single target peak of height `split`,
72
+ * so all of their paths must produce the same value.
73
+ */
74
+ export class ConsistencyRootMismatch extends ConsistencyShapeError {
75
+ constructor(readonly index: number) {
76
+ super(`path ${index} proves a different root from the paths before it`);
77
+ this.name = "ConsistencyRootMismatch";
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The target size does not exceed the origin size. Consistency is defined for
83
+ * a growing log; equal or decreasing sizes have no proof shape to check.
84
+ */
85
+ export class SizeMustIncrease extends ConsistencyShapeError {
86
+ constructor(
87
+ readonly sizeFrom: bigint,
88
+ readonly sizeTo: bigint,
89
+ ) {
90
+ super(`sizeTo (${sizeTo}) must exceed sizeFrom (${sizeFrom})`);
91
+ this.name = "SizeMustIncrease";
92
+ }
93
+ }
package/src/mmr/proof.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  export type NodeGetter = (i: bigint) => Uint8Array;
15
15
 
16
16
  /** Number of bits needed to represent `num` (0 for 0). */
17
- function bitLength(num: bigint): number {
17
+ export function bitLength(num: bigint): number {
18
18
  if (num <= 0n) return 0;
19
19
  return num.toString(2).length;
20
20
  }
@@ -100,6 +100,115 @@ export function inclusionProof(
100
100
  }
101
101
  }
102
102
 
103
+ /**
104
+ * The sibling *indices* of an inclusion proof for the node at MMR index `i` in
105
+ * the tree ending at `mmrLastIndex` — the index-only counterpart of
106
+ * {@link inclusionProof} (go-merklelog `mmr/proof.go` InclusionProofPath). The
107
+ * values at these indices, in order, are the proof. Store-free: pure position
108
+ * arithmetic, so a caller can assemble a proof's node values from any source
109
+ * (a receipt path, consistency-proof paths, tiles).
110
+ */
111
+ export function inclusionProofPath(mmrLastIndex: bigint, i: bigint): bigint[] {
112
+ let g = BigInt(indexHeight(i));
113
+ const proof: bigint[] = [];
114
+ for (;;) {
115
+ const siblingOffset = 2n << g;
116
+ let iSibling: bigint;
117
+ if (BigInt(indexHeight(i + 1n)) > g) {
118
+ iSibling = i - siblingOffset + 1n;
119
+ i += 1n;
120
+ } else {
121
+ iSibling = i + siblingOffset - 1n;
122
+ i += siblingOffset;
123
+ }
124
+ if (iSibling > mmrLastIndex) {
125
+ return proof;
126
+ }
127
+ proof.push(iSibling);
128
+ g += 1n;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * An inclusion proof that has been extended from MMR size A to size B: the size-B
134
+ * proof is an *extension* of the size-A proof, sharing its first `heightA`
135
+ * elements. (go-merklelog `mmr/proofrefresh.go` ConsistencyProofLocal.)
136
+ */
137
+ export type ConsistencyProofLocal = {
138
+ /** The node (leaf) MMR index being proven. */
139
+ logIndex: bigint;
140
+ /** Full inclusion path at size B; `path[0..heightA]` is the size-A proof. */
141
+ path: Uint8Array[];
142
+ sizeA: bigint;
143
+ /** Peak (root) index covering `logIndex` at size A. */
144
+ peakIndexA: bigint;
145
+ /** Length of the shared prefix — the size-A proof; the rest extends it. */
146
+ heightA: number;
147
+ sizeB: bigint;
148
+ /** Peak (root) index covering `logIndex` at size B. */
149
+ peakIndexB: bigint;
150
+ };
151
+
152
+ /**
153
+ * Extend the inclusion proof for node `i` from MMR size A to size B, reading
154
+ * sibling values from `get`. The result's `path` proves `i` at size B and shares
155
+ * its first `heightA` elements with the size-A proof — so a stale receipt's
156
+ * path (leaf → its old peak) is exactly `path[0..heightA]`, and the remainder is
157
+ * the tile-free extension. Direct port of go-merklelog `InclusionProofLocalExtend`.
158
+ *
159
+ * @throws if `mmrSizeB <= mmrSizeA`.
160
+ */
161
+ export function inclusionProofLocalExtend(
162
+ get: NodeGetter,
163
+ mmrSizeA: bigint,
164
+ mmrSizeB: bigint,
165
+ i: bigint,
166
+ ): ConsistencyProofLocal {
167
+ if (mmrSizeB <= mmrSizeA) {
168
+ throw new Error(
169
+ `mmrSizeB (${mmrSizeB}) must be greater than mmrSizeA (${mmrSizeA})`,
170
+ );
171
+ }
172
+ const logIndex = i;
173
+ const path: Uint8Array[] = [];
174
+ let height = 0n;
175
+ let prevComplete = false;
176
+ let peakIndexA = 0n;
177
+ let heightA = 0;
178
+ while (i < mmrSizeB) {
179
+ const siblingOffset = (2n << height) - 1n; // go-merklelog SiblingOffset
180
+ let iSibling: bigint;
181
+ let delta: bigint;
182
+ if (BigInt(indexHeight(i + 1n)) > BigInt(indexHeight(i))) {
183
+ iSibling = i - siblingOffset;
184
+ delta = 1n;
185
+ } else {
186
+ iSibling = i + siblingOffset;
187
+ delta = 2n << height;
188
+ }
189
+ // The first sibling that lies outside MMR(A) marks the end of the size-A
190
+ // proof: `i` is the size-A covering peak and the prefix so far is its proof.
191
+ if (iSibling >= mmrSizeA && !prevComplete) {
192
+ peakIndexA = i;
193
+ heightA = path.length;
194
+ prevComplete = true;
195
+ }
196
+ if (iSibling >= mmrSizeB) break;
197
+ path.push(get(iSibling));
198
+ i += delta;
199
+ height += 1n;
200
+ }
201
+ return {
202
+ logIndex,
203
+ path,
204
+ sizeA: mmrSizeA,
205
+ peakIndexA,
206
+ heightA,
207
+ sizeB: mmrSizeB,
208
+ peakIndexB: i,
209
+ };
210
+ }
211
+
103
212
  /**
104
213
  * Peaks bitmap for an MMR size: the numeric value equals the leaf count of the
105
214
  * largest valid MMR with size <= `mmrSize`; each set bit marks a perfect
@@ -121,8 +230,25 @@ export function peaksBitmap(mmrSize: bigint): bigint {
121
230
  return peakMap;
122
231
  }
123
232
 
233
+ /**
234
+ * Node count of the complete MMR with `leaves` leaves (reference
235
+ * `mmr_size_for_leaf_count`, Solidity `mmrSizeForLeafCount`).
236
+ *
237
+ * Every leaf adds itself plus one interior node per binary carry, and each
238
+ * peak is a carry that has not happened, so the count is
239
+ * `2 * leaves - popcount(leaves)`.
240
+ *
241
+ * Because {@link peaksBitmap} rounds an incomplete size down to the largest
242
+ * complete MMR below it, `mmrSizeForLeafCount(peaksBitmap(size)) === size`
243
+ * holds exactly for complete sizes; that identity is the completeness test
244
+ * used by `consistentRootsForSizes`.
245
+ */
246
+ export function mmrSizeForLeafCount(leaves: bigint): bigint {
247
+ return 2n * leaves - BigInt(popcount(leaves));
248
+ }
249
+
124
250
  /** Population count of a non-negative BigInt. */
125
- function popcount(x: bigint): number {
251
+ export function popcount(x: bigint): number {
126
252
  let count = 0;
127
253
  let v = x;
128
254
  while (v > 0n) {