@forestrie/merklelog 0.0.3 → 0.1.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.
package/dist/index.d.ts CHANGED
@@ -12,10 +12,16 @@ export type { MassifStart, LeafComponent, LeafEnumeratorSpec, LeafEntry, } from
12
12
  export { MassifStartFmt } from "./massifs/types.js";
13
13
  export { LogFormat, Urkle, Bloom, IndexV2, leafCountForMassifHeight, leafTableBytes, nodeCountMax, nodeStoreBytes, bloomMBits, bloomBitsetBytes, bloomRegionBytes, indexDataBytesV2, peakStackEnd, massifLogEntries, urkleLeafTableStartFieldIndex, urkleLeafTableStartByteOffset, createLeafEnumerator, leafComponentByteOffset, leafComponentSize, computeLastMMRIndex, isMassifFull, } from "./massifs/types.js";
14
14
  export { parseV2StorageObjectPath } from "./massifs/v2storagepaths.js";
15
+ export { openMassifNodeStore } from "./massifs/nodestore.js";
16
+ export type { MassifNodeStore } from "./massifs/nodestore.js";
17
+ export { openMassifLeafIndex, MissingIndexError } from "./massifs/leafindex.js";
18
+ export type { MassifLeafIndex, LocatedLeaf } from "./massifs/leafindex.js";
15
19
  export { mmrIndex, massifFirstLeaf, leafMinusSpurSum } from "./mmr/index.js";
16
20
  export { heightIndex, height, mmrIndexFromLeafIndex, leafIndex, mmrPosition, mmrSizeFromHeightIndex, leafCount, leafCountFromHeightIndex, } from "./mmr/math.js";
17
21
  export { bagPeaks, calculateRoot, verifyInclusion, verifyConsistency, } from "./mmr/algorithms.js";
18
22
  export { createSyncHasher } from "./mmr/algorithms-sync.js";
19
23
  export type { Proof, Peak } from "./mmr/types.js";
20
24
  export type { Hasher } from "./mmr/types.js";
25
+ export { inclusionProof, peakMMRIndexes, peaksBitmap, peakIndexForLeafProof, indexHeight, firstMMRSize, massifIndexFromMMRIndex, peakStackMap, } from "./mmr/proof.js";
26
+ export type { NodeGetter } from "./mmr/proof.js";
21
27
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAG3C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,YAAY,EACV,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,SAAS,GACV,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EACL,SAAS,EACT,KAAK,EACL,KAAK,EACL,OAAO,EACP,wBAAwB,EACxB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,6BAA6B,EAC7B,6BAA6B,EAC7B,oBAAoB,EACpB,uBAAuB,EACvB,iBAAiB,EACjB,mBAAmB,EACnB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC;AAGvE,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC7E,OAAO,EACL,WAAW,EACX,MAAM,EACN,qBAAqB,EACrB,SAAS,EACT,WAAW,EACX,sBAAsB,EACtB,SAAS,EACT,wBAAwB,GACzB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,QAAQ,EACR,aAAa,EACb,eAAe,EACf,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,YAAY,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAG3C,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7C,YAAY,EACV,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,SAAS,GACV,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACpD,OAAO,EACL,SAAS,EACT,KAAK,EACL,KAAK,EACL,OAAO,EACP,wBAAwB,EACxB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,EAChB,YAAY,EACZ,gBAAgB,EAChB,6BAA6B,EAC7B,6BAA6B,EAC7B,oBAAoB,EACpB,uBAAuB,EACvB,iBAAiB,EACjB,mBAAmB,EACnB,YAAY,GACb,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAAE,wBAAwB,EAAE,MAAM,6BAA6B,CAAC;AAGvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAG9D,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAG3E,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAC7E,OAAO,EACL,WAAW,EACX,MAAM,EACN,qBAAqB,EACrB,SAAS,EACT,WAAW,EACX,sBAAsB,EACtB,SAAS,EACT,wBAAwB,GACzB,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,QAAQ,EACR,aAAa,EACb,eAAe,EACf,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAC5D,YAAY,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAG7C,OAAO,EACL,cAAc,EACd,cAAc,EACd,WAAW,EACX,qBAAqB,EACrB,WAAW,EACX,YAAY,EACZ,uBAAuB,EACvB,YAAY,GACb,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js CHANGED
@@ -13,8 +13,14 @@ export { Massif } from "./massifs/massif.js";
13
13
  export { MassifStartFmt } from "./massifs/types.js";
14
14
  export { LogFormat, Urkle, Bloom, IndexV2, leafCountForMassifHeight, leafTableBytes, nodeCountMax, nodeStoreBytes, bloomMBits, bloomBitsetBytes, bloomRegionBytes, indexDataBytesV2, peakStackEnd, massifLogEntries, urkleLeafTableStartFieldIndex, urkleLeafTableStartByteOffset, createLeafEnumerator, leafComponentByteOffset, leafComponentSize, computeLastMMRIndex, isMassifFull, } from "./massifs/types.js";
15
15
  export { parseV2StorageObjectPath } from "./massifs/v2storagepaths.js";
16
+ // Massif blob node access for MMR proof building (plan-2607-15 §4)
17
+ export { openMassifNodeStore } from "./massifs/nodestore.js";
18
+ // Content-hash leaf lookup over the massif index region (FOR-373)
19
+ export { openMassifLeafIndex, MissingIndexError } from "./massifs/leafindex.js";
16
20
  // Export mmr module
17
21
  export { mmrIndex, massifFirstLeaf, leafMinusSpurSum } from "./mmr/index.js";
18
22
  export { heightIndex, height, mmrIndexFromLeafIndex, leafIndex, mmrPosition, mmrSizeFromHeightIndex, leafCount, leafCountFromHeightIndex, } from "./mmr/math.js";
19
23
  export { bagPeaks, calculateRoot, verifyInclusion, verifyConsistency, } from "./mmr/algorithms.js";
20
24
  export { createSyncHasher } from "./mmr/algorithms-sync.js";
25
+ // MMR proof building (pure; store-agnostic) — plan-2607-15 §4 (go: mmr/proof.go, peaks.go)
26
+ export { inclusionProof, peakMMRIndexes, peaksBitmap, peakIndexForLeafProof, indexHeight, firstMMRSize, massifIndexFromMMRIndex, peakStackMap, } from "./mmr/proof.js";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Content-hash → mmrIndex leaf lookup over a v2 massif index region (FOR-373).
3
+ *
4
+ * The v2 massif index region carries, per leaf ordinal, a fixed-width record
5
+ * whose committed `valueBytes[32]` is the leaf's content hash — the same value
6
+ * committed by the urkle trie (go-merklelog `urkle/leafrecord.go`,
7
+ * `massifs/indexformat_v2.go`; plan-2607-15 §1.1). The urkle trie itself is
8
+ * keyed by the 8-byte leaf key (idtimestamp) for duplicate/exclusion detection,
9
+ * so content-hash resolution reads the leaf table's value column directly and
10
+ * maps the matching ordinal back to an MMR index
11
+ * (`urkle.LeafOrdinalToMMRIndex`).
12
+ *
13
+ * This lets `forestrie create-receipt --content-hash <hex>` locate a leaf's
14
+ * mmrIndex from a massif blob alone, with no external mapping. A negative
15
+ * lookup (`null`) is distinguishable from absence-of-index: a blob with no
16
+ * index region throws `MissingIndexError`, while a searched-but-not-present
17
+ * hash returns `null`.
18
+ *
19
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
20
+ */
21
+ /**
22
+ * Thrown when the blob carries no populated index region, so a content-hash
23
+ * lookup cannot even be attempted. Callers distinguish this from a `null`
24
+ * (searched, not found) result.
25
+ */
26
+ export declare class MissingIndexError extends Error {
27
+ constructor(message: string);
28
+ }
29
+ /**
30
+ * A located leaf: its MMR index plus the 8-byte idtimestamp (big-endian) the
31
+ * urkle leaf record is keyed by. Together these are the two halves of the
32
+ * permanent entry id (`idtimestamp_be8 || mmrIndex_be8`).
33
+ */
34
+ export interface LocatedLeaf {
35
+ mmrIndex: bigint;
36
+ idtimestampBe8: Uint8Array;
37
+ }
38
+ /** Content-hash leaf lookup over one massif blob (FOR-373). */
39
+ export interface MassifLeafIndex {
40
+ /**
41
+ * MMR index of the leaf whose committed content hash equals `h`, or `null`
42
+ * when no populated leaf in this massif carries that hash. `h` must be 32
43
+ * bytes.
44
+ */
45
+ findByContentHash(h: Uint8Array): bigint | null;
46
+ /**
47
+ * Like {@link findByContentHash} but also recovers the leaf's idtimestamp
48
+ * (the 8-byte urkle leaf key), so a caller can reconstruct the full entry id
49
+ * offline. Returns `null` when the hash is not present. `h` must be 32 bytes.
50
+ */
51
+ findLeafByContentHash(h: Uint8Array): LocatedLeaf | null;
52
+ /** Number of populated leaves in this blob. */
53
+ leafCount: number;
54
+ }
55
+ /**
56
+ * Open a v2 massif blob for content-hash leaf lookups.
57
+ *
58
+ * @throws MissingIndexError if the blob is too short to contain the fixed index
59
+ * region for its declared massif height (no index present).
60
+ */
61
+ export declare function openMassifLeafIndex(massifBytes: Uint8Array): MassifLeafIndex;
62
+ //# sourceMappingURL=leafindex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leafindex.d.ts","sourceRoot":"","sources":["../../src/massifs/leafindex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAgBH;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAED;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,UAAU,CAAC;CAC5B;AAED,+DAA+D;AAC/D,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,iBAAiB,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,GAAG,IAAI,CAAC;IAChD;;;;OAIG;IACH,qBAAqB,CAAC,CAAC,EAAE,UAAU,GAAG,WAAW,GAAG,IAAI,CAAC;IACzD,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;CACnB;AAkBD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,UAAU,GAAG,eAAe,CAgF5E"}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Content-hash → mmrIndex leaf lookup over a v2 massif index region (FOR-373).
3
+ *
4
+ * The v2 massif index region carries, per leaf ordinal, a fixed-width record
5
+ * whose committed `valueBytes[32]` is the leaf's content hash — the same value
6
+ * committed by the urkle trie (go-merklelog `urkle/leafrecord.go`,
7
+ * `massifs/indexformat_v2.go`; plan-2607-15 §1.1). The urkle trie itself is
8
+ * keyed by the 8-byte leaf key (idtimestamp) for duplicate/exclusion detection,
9
+ * so content-hash resolution reads the leaf table's value column directly and
10
+ * maps the matching ordinal back to an MMR index
11
+ * (`urkle.LeafOrdinalToMMRIndex`).
12
+ *
13
+ * This lets `forestrie create-receipt --content-hash <hex>` locate a leaf's
14
+ * mmrIndex from a massif blob alone, with no external mapping. A negative
15
+ * lookup (`null`) is distinguishable from absence-of-index: a blob with no
16
+ * index region throws `MissingIndexError`, while a searched-but-not-present
17
+ * hash returns `null`.
18
+ *
19
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
20
+ */
21
+ import { Massif } from "./massif.js";
22
+ import { LogFormat } from "./logformat.js";
23
+ import { Urkle, leafCountForMassifHeight } from "./indexformat.js";
24
+ import { urkleLeafTableStartByteOffset } from "./urkleindex.js";
25
+ import { peakStackEnd } from "./peakstackend.js";
26
+ import { massifLogEntries } from "./massiflogentries.js";
27
+ import { firstMMRSize, peaksBitmap } from "../mmr/proof.js";
28
+ import { mmrIndex as mmrIndexFromLeafIndex } from "../mmr/index.js";
29
+ const HASH_BYTES = Urkle.LeafValueBytes; // 32
30
+ const KEY_BYTES = Urkle.LeafKeyBytes; // 8 (idtimestamp, big-endian)
31
+ const VALUE_OFFSET = Urkle.LeafValueOffset; // 8 (after the 8-byte key)
32
+ const RECORD_BYTES = Urkle.LeafRecordBytes; // 128
33
+ /**
34
+ * Thrown when the blob carries no populated index region, so a content-hash
35
+ * lookup cannot even be attempted. Callers distinguish this from a `null`
36
+ * (searched, not found) result.
37
+ */
38
+ export class MissingIndexError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "MissingIndexError";
42
+ }
43
+ }
44
+ function equals32(a, aOff, b) {
45
+ for (let i = 0; i < HASH_BYTES; i++) {
46
+ if (a[aOff + i] !== b[i])
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+ /**
52
+ * Leaf index of the first leaf in the massif whose first MMR index is
53
+ * `firstMMRIndex`. (go-merklelog `mmr/leafcount.go` LeafIndex)
54
+ */
55
+ function leafIndexFromMMRIndex(firstMMRIndex) {
56
+ if (firstMMRIndex === 0n)
57
+ return 0n;
58
+ return peaksBitmap(firstMMRSize(firstMMRIndex)) - 1n;
59
+ }
60
+ /**
61
+ * Open a v2 massif blob for content-hash leaf lookups.
62
+ *
63
+ * @throws MissingIndexError if the blob is too short to contain the fixed index
64
+ * region for its declared massif height (no index present).
65
+ */
66
+ export function openMassifLeafIndex(massifBytes) {
67
+ const massif = new Massif(massifBytes);
68
+ const start = massif.getStart();
69
+ const massifHeight = start.massifHeight;
70
+ if (!Number.isInteger(massifHeight) ||
71
+ massifHeight < 1 ||
72
+ massifHeight > LogFormat.MaxMmrHeight) {
73
+ throw new Error(`massif header has invalid height ${massifHeight}`);
74
+ }
75
+ const firstIndex = start.firstIndex;
76
+ const leafTableStart = urkleLeafTableStartByteOffset(massifHeight);
77
+ const capacity = Number(leafCountForMassifHeight(massifHeight));
78
+ const leafTableBytes = capacity * RECORD_BYTES;
79
+ const stackEnd = peakStackEnd(massifHeight);
80
+ // The index region must fit before the fixed peak stack + log region. A blob
81
+ // shorter than that has no populated index to search.
82
+ if (massifHeight > 1 && BigInt(leafTableStart + leafTableBytes) > stackEnd) {
83
+ throw new Error("massif index region math is inconsistent");
84
+ }
85
+ if (BigInt(massifBytes.byteLength) < stackEnd) {
86
+ throw new MissingIndexError("massif blob too short for a v2 index region (no index present)");
87
+ }
88
+ // Only leaves actually appended have populated records. Derive the populated
89
+ // leaf count from the log node count (index records are written 1:1 with
90
+ // appended leaves). Never exceed the fixed capacity for the height.
91
+ const logNodeCount = massifLogEntries(massifBytes.byteLength, massifHeight);
92
+ const firstLeafIndex = leafIndexFromMMRIndex(firstIndex);
93
+ let populated = 0;
94
+ for (let ordinal = 0; ordinal < capacity; ordinal++) {
95
+ // MMR index of leaf (firstLeafIndex + ordinal), relative to this blob's
96
+ // firstIndex, must be within the log node count.
97
+ const leafMMRIndex = mmrIndexFromLeafIndex(firstLeafIndex + BigInt(ordinal));
98
+ if (leafMMRIndex - firstIndex >= logNodeCount)
99
+ break;
100
+ populated = ordinal + 1;
101
+ }
102
+ /** Ordinal of the populated leaf whose value column equals `h`, or -1. */
103
+ const ordinalForContentHash = (h) => {
104
+ if (h.length !== HASH_BYTES) {
105
+ throw new Error(`content hash must be ${HASH_BYTES} bytes, got ${h.length}`);
106
+ }
107
+ for (let ordinal = 0; ordinal < populated; ordinal++) {
108
+ const recordOff = leafTableStart + ordinal * RECORD_BYTES;
109
+ const valueOff = recordOff + VALUE_OFFSET;
110
+ if (valueOff + HASH_BYTES > massifBytes.byteLength)
111
+ break;
112
+ if (equals32(massifBytes, valueOff, h))
113
+ return ordinal;
114
+ }
115
+ return -1;
116
+ };
117
+ const findByContentHash = (h) => {
118
+ const ordinal = ordinalForContentHash(h);
119
+ if (ordinal < 0)
120
+ return null;
121
+ return mmrIndexFromLeafIndex(firstLeafIndex + BigInt(ordinal));
122
+ };
123
+ const findLeafByContentHash = (h) => {
124
+ const ordinal = ordinalForContentHash(h);
125
+ if (ordinal < 0)
126
+ return null;
127
+ // The leaf record is `[key(8) || value(32) || …]`; the key IS the
128
+ // big-endian idtimestamp the sealer assigned (go-merklelog urkle leafrecord).
129
+ const keyOff = leafTableStart + ordinal * RECORD_BYTES;
130
+ return {
131
+ mmrIndex: mmrIndexFromLeafIndex(firstLeafIndex + BigInt(ordinal)),
132
+ idtimestampBe8: massifBytes.slice(keyOff, keyOff + KEY_BYTES),
133
+ };
134
+ };
135
+ return { findByContentHash, findLeafByContentHash, leafCount: populated };
136
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Massif blob node access for MMR proof building.
3
+ *
4
+ * A v2 massif `.log` blob is self-sufficient for any leaf→peak inclusion path
5
+ * whose leaf lives in that massif: local nodes give the in-massif siblings and
6
+ * the fixed ancestor peak stack gives everything older (plan-2607-15 §1.1).
7
+ * Node lookup is pure arithmetic — `LogStart + (i − firstIndex)·32` for local
8
+ * nodes, `peakStackMap` for ancestors.
9
+ *
10
+ * Ported from the inline copies in canopy-api `resolve-receipt.ts` and
11
+ * receipt-verify `build-receipt-offline.ts` (plan-2607-15 §4, phase 2).
12
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
13
+ */
14
+ /** Node access over a single massif blob. */
15
+ export interface MassifNodeStore {
16
+ /** 32-byte node at MMR index `i` (log region for `i >= firstIndex`, else the ancestor peak stack). */
17
+ get(i: bigint): Uint8Array;
18
+ massifHeight: number;
19
+ massifIndex: bigint;
20
+ firstIndex: bigint;
21
+ /** Last MMR index with log data in this blob. */
22
+ lastIndex: bigint;
23
+ }
24
+ /**
25
+ * Open a v2 massif blob for MMR node reads. Nodes below `firstIndex` resolve
26
+ * through the ancestor peak stack; nodes above `lastIndex` are not present in
27
+ * this blob and throw when requested.
28
+ */
29
+ export declare function openMassifNodeStore(massifBytes: Uint8Array): MassifNodeStore;
30
+ //# sourceMappingURL=nodestore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"nodestore.d.ts","sourceRoot":"","sources":["../../src/massifs/nodestore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAUH,6CAA6C;AAC7C,MAAM,WAAW,eAAe;IAC9B,sGAAsG;IACtG,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,SAAS,EAAE,MAAM,CAAC;CACnB;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,UAAU,GAAG,eAAe,CA8C5E"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Massif blob node access for MMR proof building.
3
+ *
4
+ * A v2 massif `.log` blob is self-sufficient for any leaf→peak inclusion path
5
+ * whose leaf lives in that massif: local nodes give the in-massif siblings and
6
+ * the fixed ancestor peak stack gives everything older (plan-2607-15 §1.1).
7
+ * Node lookup is pure arithmetic — `LogStart + (i − firstIndex)·32` for local
8
+ * nodes, `peakStackMap` for ancestors.
9
+ *
10
+ * Ported from the inline copies in canopy-api `resolve-receipt.ts` and
11
+ * receipt-verify `build-receipt-offline.ts` (plan-2607-15 §4, phase 2).
12
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
13
+ */
14
+ import { Massif } from "./massif.js";
15
+ import { LogFormat } from "./logformat.js";
16
+ import { peakStackEnd } from "./peakstackend.js";
17
+ import { peakStackMap } from "../mmr/proof.js";
18
+ const VALUE_BYTES = BigInt(LogFormat.ValueBytes); // 32
19
+ const MAX_MMR_HEIGHT = BigInt(LogFormat.MaxMmrHeight); // 64
20
+ function slice32(buf, offset, label) {
21
+ if (offset < 0n || offset + VALUE_BYTES > BigInt(buf.byteLength)) {
22
+ throw new Error(`out of range read for ${label}: off=${offset.toString(10)}`);
23
+ }
24
+ const start = Number(offset);
25
+ return buf.slice(start, start + Number(VALUE_BYTES));
26
+ }
27
+ /**
28
+ * Open a v2 massif blob for MMR node reads. Nodes below `firstIndex` resolve
29
+ * through the ancestor peak stack; nodes above `lastIndex` are not present in
30
+ * this blob and throw when requested.
31
+ */
32
+ export function openMassifNodeStore(massifBytes) {
33
+ const massif = new Massif(massifBytes);
34
+ const start = massif.getStart();
35
+ const massifHeight = start.massifHeight;
36
+ if (!Number.isInteger(massifHeight) ||
37
+ massifHeight < 1 ||
38
+ massifHeight > LogFormat.MaxMmrHeight) {
39
+ throw new Error(`massif header has invalid height ${massifHeight}`);
40
+ }
41
+ const massifIndex = BigInt(start.massifIndex);
42
+ const firstIndex = start.firstIndex;
43
+ const logStart = peakStackEnd(massifHeight);
44
+ const peakStackStart = logStart - MAX_MMR_HEIGHT * VALUE_BYTES;
45
+ const blobLen = BigInt(massifBytes.byteLength);
46
+ if (blobLen < logStart) {
47
+ throw new Error("massif blob too short for v2 layout");
48
+ }
49
+ const logNodeCount = (blobLen - logStart) / VALUE_BYTES;
50
+ const lastIndex = firstIndex + logNodeCount - 1n;
51
+ const stackMap = peakStackMap(massifHeight, firstIndex);
52
+ const get = (i) => {
53
+ if (i >= firstIndex) {
54
+ if (i > lastIndex) {
55
+ throw new Error(`mmr index ${i.toString(10)} is beyond this massif's log data ` +
56
+ `(last ${lastIndex.toString(10)}); local content does not cover ` +
57
+ `the requested tree size`);
58
+ }
59
+ const off = logStart + (i - firstIndex) * VALUE_BYTES;
60
+ return slice32(massifBytes, off, "log-data");
61
+ }
62
+ const peakIdx = stackMap.get(i);
63
+ if (peakIdx === undefined) {
64
+ throw new Error(`missing ancestor peak for mmr index ${i.toString(10)}`);
65
+ }
66
+ const off = peakStackStart + BigInt(peakIdx) * VALUE_BYTES;
67
+ return slice32(massifBytes, off, "peak-stack");
68
+ };
69
+ return { get, massifHeight, massifIndex, firstIndex, lastIndex };
70
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * MMR proof building (pure, store-agnostic).
3
+ *
4
+ * Ports the proof-side arithmetic from go-merklelog `mmr/proof.go` and
5
+ * `mmr/peaks.go`. These were previously inline-duplicated in
6
+ * canopy-api `resolve-receipt.ts` and receipt-verify `build-receipt-offline.ts`;
7
+ * this is the single hoisted home (plan-2607-15 §4, phase 2).
8
+ *
9
+ * All arithmetic is BigInt and free of node builtins so the package stays
10
+ * browser-safe (ADR-0048). Function names mirror the go-merklelog originals.
11
+ */
12
+ /** Reads the 32-byte node at MMR index `i` from some backing store. */
13
+ export type NodeGetter = (i: bigint) => Uint8Array;
14
+ /**
15
+ * Zero-based height index of the node at MMR index `i` (leaves are 0).
16
+ * (go-merklelog `mmr/indexheight.go` IndexHeight)
17
+ */
18
+ export declare function indexHeight(i: bigint): number;
19
+ /**
20
+ * Witness path (siblings, ascending) for the node at MMR index `i` in the tree
21
+ * ending at `mmrLastIndex`. Terminates at the covering peak.
22
+ * (go-merklelog `mmr/proof.go` InclusionProof)
23
+ *
24
+ * @throws if `i > mmrLastIndex`.
25
+ */
26
+ export declare function inclusionProof(get: NodeGetter, mmrLastIndex: bigint, i: bigint): Uint8Array[];
27
+ /**
28
+ * Peaks bitmap for an MMR size: the numeric value equals the leaf count of the
29
+ * largest valid MMR with size <= `mmrSize`; each set bit marks a perfect
30
+ * subtree (peak). (go-merklelog `mmr/peaks.go` PeaksBitmap)
31
+ */
32
+ export declare function peaksBitmap(mmrSize: bigint): bigint;
33
+ /**
34
+ * Accumulator slot (left-to-right) committed by an inclusion proof of length
35
+ * `proofLen` in a tree of size `mmrSize`. (server resolve-receipt.ts:735,
36
+ * equivalent to go `mmr.PeakIndex(LeafCount(mmrSize), proofLen)`)
37
+ */
38
+ export declare function peakIndexForLeafProof(mmrSize: bigint, proofLen: number): number;
39
+ /**
40
+ * MMR indices of the peaks of the tree ending at `mmrIndex` (inclusive),
41
+ * ascending. Empty when `mmrIndex + 1` is not a valid MMR size (siblings
42
+ * without a parent). (go-merklelog `mmr/peaks.go` Peaks)
43
+ */
44
+ export declare function peakMMRIndexes(mmrIndex: bigint): bigint[];
45
+ /**
46
+ * Smallest complete MMR size that contains the node at MMR index `mmrIndex`.
47
+ * (go-merklelog `mmr/firstmmrsize.go` FirstMMRSize)
48
+ */
49
+ export declare function firstMMRSize(mmrIndex: bigint): bigint;
50
+ /**
51
+ * Massif index (0-based) that holds the node at MMR index `i` for a log of the
52
+ * given massif height. (go-merklelog `massifs/massifindex.go` MassifIndexFromMMRIndex)
53
+ */
54
+ export declare function massifIndexFromMMRIndex(massifHeight: number, i: bigint): bigint;
55
+ /**
56
+ * Map from ancestor-peak MMR index to peak-stack slot for the massif whose
57
+ * first MMR index is `firstIndex`. Nodes below `firstIndex` resolve through the
58
+ * fixed peak-stack region via this map.
59
+ * (go-merklelog `massifs/peakstack.go` PeakStackMap)
60
+ */
61
+ export declare function peakStackMap(massifHeight: number, firstIndex: bigint): Map<bigint, number>;
62
+ //# sourceMappingURL=proof.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proof.d.ts","sourceRoot":"","sources":["../../src/mmr/proof.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,uEAAuE;AACvE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,EAAE,MAAM,KAAK,UAAU,CAAC;AAoCnD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,UAAU,EACf,YAAY,EAAE,MAAM,EACpB,CAAC,EAAE,MAAM,GACR,UAAU,EAAE,CAyBd;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAcnD;AAaD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,MAAM,CAIR;AAQD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAczD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAUrD;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,YAAY,EAAE,MAAM,EACpB,CAAC,EAAE,MAAM,GACR,MAAM,CAKR;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,GACjB,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAWrB"}
@@ -0,0 +1,194 @@
1
+ /**
2
+ * MMR proof building (pure, store-agnostic).
3
+ *
4
+ * Ports the proof-side arithmetic from go-merklelog `mmr/proof.go` and
5
+ * `mmr/peaks.go`. These were previously inline-duplicated in
6
+ * canopy-api `resolve-receipt.ts` and receipt-verify `build-receipt-offline.ts`;
7
+ * this is the single hoisted home (plan-2607-15 §4, phase 2).
8
+ *
9
+ * All arithmetic is BigInt and free of node builtins so the package stays
10
+ * browser-safe (ADR-0048). Function names mirror the go-merklelog originals.
11
+ */
12
+ /** Number of bits needed to represent `num` (0 for 0). */
13
+ function bitLength(num) {
14
+ if (num <= 0n)
15
+ return 0;
16
+ return num.toString(2).length;
17
+ }
18
+ /** True when `num > 0` and its binary form is all ones. */
19
+ function allOnes(num) {
20
+ return num > 0n && (num & (num + 1n)) === 0n;
21
+ }
22
+ /**
23
+ * Jump left to the leftmost node at the same height.
24
+ * (go-merklelog `mmr/indexheight.go` JumpLeftPerfect)
25
+ */
26
+ function jumpLeftPerfect(pos) {
27
+ const bl = bitLength(pos);
28
+ if (bl === 0)
29
+ return pos;
30
+ const msb = 1n << BigInt(bl - 1);
31
+ return pos - (msb - 1n);
32
+ }
33
+ /**
34
+ * Height of a 1-based position.
35
+ * (go-merklelog `mmr/indexheight.go` PosHeight)
36
+ */
37
+ function posHeight(pos) {
38
+ let current = pos;
39
+ while (!allOnes(current)) {
40
+ current = jumpLeftPerfect(current);
41
+ }
42
+ return bitLength(current) - 1;
43
+ }
44
+ /**
45
+ * Zero-based height index of the node at MMR index `i` (leaves are 0).
46
+ * (go-merklelog `mmr/indexheight.go` IndexHeight)
47
+ */
48
+ export function indexHeight(i) {
49
+ return posHeight(i + 1n);
50
+ }
51
+ /**
52
+ * Witness path (siblings, ascending) for the node at MMR index `i` in the tree
53
+ * ending at `mmrLastIndex`. Terminates at the covering peak.
54
+ * (go-merklelog `mmr/proof.go` InclusionProof)
55
+ *
56
+ * @throws if `i > mmrLastIndex`.
57
+ */
58
+ export function inclusionProof(get, mmrLastIndex, i) {
59
+ if (i > mmrLastIndex) {
60
+ throw new Error("index out of range");
61
+ }
62
+ let g = BigInt(indexHeight(i));
63
+ const proof = [];
64
+ // iSibling out of range guarantees loop termination.
65
+ for (;;) {
66
+ const siblingOffset = 2n << g;
67
+ let iSibling;
68
+ if (BigInt(indexHeight(i + 1n)) > g) {
69
+ // right sibling
70
+ iSibling = i - siblingOffset + 1n;
71
+ i += 1n;
72
+ }
73
+ else {
74
+ // left sibling
75
+ iSibling = i + siblingOffset - 1n;
76
+ i += siblingOffset;
77
+ }
78
+ if (iSibling > mmrLastIndex) {
79
+ return proof;
80
+ }
81
+ proof.push(get(iSibling));
82
+ g += 1n;
83
+ }
84
+ }
85
+ /**
86
+ * Peaks bitmap for an MMR size: the numeric value equals the leaf count of the
87
+ * largest valid MMR with size <= `mmrSize`; each set bit marks a perfect
88
+ * subtree (peak). (go-merklelog `mmr/peaks.go` PeaksBitmap)
89
+ */
90
+ export function peaksBitmap(mmrSize) {
91
+ if (mmrSize === 0n)
92
+ return 0n;
93
+ let pos = mmrSize;
94
+ let peakSize = (1n << BigInt(bitLength(mmrSize))) - 1n;
95
+ let peakMap = 0n;
96
+ while (peakSize > 0n) {
97
+ peakMap <<= 1n;
98
+ if (pos >= peakSize) {
99
+ pos -= peakSize;
100
+ peakMap |= 1n;
101
+ }
102
+ peakSize >>= 1n;
103
+ }
104
+ return peakMap;
105
+ }
106
+ /** Population count of a non-negative BigInt. */
107
+ function popcount(x) {
108
+ let count = 0;
109
+ let v = x;
110
+ while (v > 0n) {
111
+ if ((v & 1n) === 1n)
112
+ count += 1;
113
+ v >>= 1n;
114
+ }
115
+ return count;
116
+ }
117
+ /**
118
+ * Accumulator slot (left-to-right) committed by an inclusion proof of length
119
+ * `proofLen` in a tree of size `mmrSize`. (server resolve-receipt.ts:735,
120
+ * equivalent to go `mmr.PeakIndex(LeafCount(mmrSize), proofLen)`)
121
+ */
122
+ export function peakIndexForLeafProof(mmrSize, proofLen) {
123
+ const leafCount = peaksBitmap(mmrSize);
124
+ const peaksMask = (1n << BigInt(proofLen + 1)) - 1n;
125
+ return popcount(leafCount) - popcount(leafCount & peaksMask);
126
+ }
127
+ /** Largest peak size <= size of the tree ending at index `i`. (go `mmr/peaks.go` TopPeak) */
128
+ function topPeak(i) {
129
+ const bl = bitLength(i + 2n);
130
+ return (1n << BigInt(bl - 1)) - 2n;
131
+ }
132
+ /**
133
+ * MMR indices of the peaks of the tree ending at `mmrIndex` (inclusive),
134
+ * ascending. Empty when `mmrIndex + 1` is not a valid MMR size (siblings
135
+ * without a parent). (go-merklelog `mmr/peaks.go` Peaks)
136
+ */
137
+ export function peakMMRIndexes(mmrIndex) {
138
+ let mmrSize = mmrIndex + 1n;
139
+ if (posHeight(mmrSize + 1n) > posHeight(mmrSize)) {
140
+ return [];
141
+ }
142
+ let peak = 0n;
143
+ const out = [];
144
+ while (mmrSize !== 0n) {
145
+ const peakSize = topPeak(mmrSize - 1n) + 1n;
146
+ peak = peak + peakSize;
147
+ out.push(peak - 1n);
148
+ mmrSize -= peakSize;
149
+ }
150
+ return out;
151
+ }
152
+ /**
153
+ * Smallest complete MMR size that contains the node at MMR index `mmrIndex`.
154
+ * (go-merklelog `mmr/firstmmrsize.go` FirstMMRSize)
155
+ */
156
+ export function firstMMRSize(mmrIndex) {
157
+ let i = mmrIndex;
158
+ let h0 = indexHeight(i);
159
+ let h1 = indexHeight(i + 1n);
160
+ while (h0 < h1) {
161
+ i += 1n;
162
+ h0 = h1;
163
+ h1 = indexHeight(i + 1n);
164
+ }
165
+ return i + 1n;
166
+ }
167
+ /**
168
+ * Massif index (0-based) that holds the node at MMR index `i` for a log of the
169
+ * given massif height. (go-merklelog `massifs/massifindex.go` MassifIndexFromMMRIndex)
170
+ */
171
+ export function massifIndexFromMMRIndex(massifHeight, i) {
172
+ const size = firstMMRSize(i);
173
+ const leafIndex = peaksBitmap(size) - 1n;
174
+ const massifMaxLeaves = 1n << BigInt(massifHeight - 1);
175
+ return leafIndex / massifMaxLeaves;
176
+ }
177
+ /**
178
+ * Map from ancestor-peak MMR index to peak-stack slot for the massif whose
179
+ * first MMR index is `firstIndex`. Nodes below `firstIndex` resolve through the
180
+ * fixed peak-stack region via this map.
181
+ * (go-merklelog `massifs/peakstack.go` PeakStackMap)
182
+ */
183
+ export function peakStackMap(massifHeight, firstIndex) {
184
+ const map = new Map();
185
+ const iPeaks = peakMMRIndexes(firstIndex);
186
+ for (let i = 0; i < iPeaks.length; i++) {
187
+ const ip = iPeaks[i];
188
+ if (indexHeight(ip) < massifHeight - 1) {
189
+ continue;
190
+ }
191
+ map.set(ip, i);
192
+ }
193
+ return map;
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/merklelog",
3
- "version": "0.0.3",
3
+ "version": "0.1.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "MMR (merkle mountain range) proofs and massif storage format for Forestrie transparency logs",
@@ -28,13 +28,15 @@
28
28
  ],
29
29
  "dependencies": {},
30
30
  "devDependencies": {
31
+ "esbuild": "^0.24.0",
31
32
  "typescript": "^5.9.2",
32
33
  "vitest": "^3.2.4"
33
34
  },
34
35
  "scripts": {
35
36
  "build": "tsc -p tsconfig.build.json",
36
37
  "typecheck": "tsc --noEmit",
37
- "test": "vitest --run",
38
+ "check:browser-safe": "node tools/check-browser-safe.mjs",
39
+ "test": "pnpm run check:browser-safe && vitest --run",
38
40
  "test:watch": "vitest",
39
41
  "test:debug": "vitest --inspect --no-file-parallelism"
40
42
  }
package/src/index.ts CHANGED
@@ -45,6 +45,14 @@ export {
45
45
 
46
46
  export { parseV2StorageObjectPath } from "./massifs/v2storagepaths.js";
47
47
 
48
+ // Massif blob node access for MMR proof building (plan-2607-15 §4)
49
+ export { openMassifNodeStore } from "./massifs/nodestore.js";
50
+ export type { MassifNodeStore } from "./massifs/nodestore.js";
51
+
52
+ // Content-hash leaf lookup over the massif index region (FOR-373)
53
+ export { openMassifLeafIndex, MissingIndexError } from "./massifs/leafindex.js";
54
+ export type { MassifLeafIndex, LocatedLeaf } from "./massifs/leafindex.js";
55
+
48
56
  // Export mmr module
49
57
  export { mmrIndex, massifFirstLeaf, leafMinusSpurSum } from "./mmr/index.js";
50
58
  export {
@@ -66,3 +74,16 @@ export {
66
74
  export { createSyncHasher } from "./mmr/algorithms-sync.js";
67
75
  export type { Proof, Peak } from "./mmr/types.js";
68
76
  export type { Hasher } from "./mmr/types.js";
77
+
78
+ // MMR proof building (pure; store-agnostic) — plan-2607-15 §4 (go: mmr/proof.go, peaks.go)
79
+ export {
80
+ inclusionProof,
81
+ peakMMRIndexes,
82
+ peaksBitmap,
83
+ peakIndexForLeafProof,
84
+ indexHeight,
85
+ firstMMRSize,
86
+ massifIndexFromMMRIndex,
87
+ peakStackMap,
88
+ } from "./mmr/proof.js";
89
+ export type { NodeGetter } from "./mmr/proof.js";
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Content-hash → mmrIndex leaf lookup over a v2 massif index region (FOR-373).
3
+ *
4
+ * The v2 massif index region carries, per leaf ordinal, a fixed-width record
5
+ * whose committed `valueBytes[32]` is the leaf's content hash — the same value
6
+ * committed by the urkle trie (go-merklelog `urkle/leafrecord.go`,
7
+ * `massifs/indexformat_v2.go`; plan-2607-15 §1.1). The urkle trie itself is
8
+ * keyed by the 8-byte leaf key (idtimestamp) for duplicate/exclusion detection,
9
+ * so content-hash resolution reads the leaf table's value column directly and
10
+ * maps the matching ordinal back to an MMR index
11
+ * (`urkle.LeafOrdinalToMMRIndex`).
12
+ *
13
+ * This lets `forestrie create-receipt --content-hash <hex>` locate a leaf's
14
+ * mmrIndex from a massif blob alone, with no external mapping. A negative
15
+ * lookup (`null`) is distinguishable from absence-of-index: a blob with no
16
+ * index region throws `MissingIndexError`, while a searched-but-not-present
17
+ * hash returns `null`.
18
+ *
19
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
20
+ */
21
+
22
+ import { Massif } from "./massif.js";
23
+ import { LogFormat } from "./logformat.js";
24
+ import { Urkle, leafCountForMassifHeight } from "./indexformat.js";
25
+ import { urkleLeafTableStartByteOffset } from "./urkleindex.js";
26
+ import { peakStackEnd } from "./peakstackend.js";
27
+ import { massifLogEntries } from "./massiflogentries.js";
28
+ import { firstMMRSize, peaksBitmap } from "../mmr/proof.js";
29
+ import { mmrIndex as mmrIndexFromLeafIndex } from "../mmr/index.js";
30
+
31
+ const HASH_BYTES = Urkle.LeafValueBytes; // 32
32
+ const KEY_BYTES = Urkle.LeafKeyBytes; // 8 (idtimestamp, big-endian)
33
+ const VALUE_OFFSET = Urkle.LeafValueOffset; // 8 (after the 8-byte key)
34
+ const RECORD_BYTES = Urkle.LeafRecordBytes; // 128
35
+
36
+ /**
37
+ * Thrown when the blob carries no populated index region, so a content-hash
38
+ * lookup cannot even be attempted. Callers distinguish this from a `null`
39
+ * (searched, not found) result.
40
+ */
41
+ export class MissingIndexError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = "MissingIndexError";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * A located leaf: its MMR index plus the 8-byte idtimestamp (big-endian) the
50
+ * urkle leaf record is keyed by. Together these are the two halves of the
51
+ * permanent entry id (`idtimestamp_be8 || mmrIndex_be8`).
52
+ */
53
+ export interface LocatedLeaf {
54
+ mmrIndex: bigint;
55
+ idtimestampBe8: Uint8Array;
56
+ }
57
+
58
+ /** Content-hash leaf lookup over one massif blob (FOR-373). */
59
+ export interface MassifLeafIndex {
60
+ /**
61
+ * MMR index of the leaf whose committed content hash equals `h`, or `null`
62
+ * when no populated leaf in this massif carries that hash. `h` must be 32
63
+ * bytes.
64
+ */
65
+ findByContentHash(h: Uint8Array): bigint | null;
66
+ /**
67
+ * Like {@link findByContentHash} but also recovers the leaf's idtimestamp
68
+ * (the 8-byte urkle leaf key), so a caller can reconstruct the full entry id
69
+ * offline. Returns `null` when the hash is not present. `h` must be 32 bytes.
70
+ */
71
+ findLeafByContentHash(h: Uint8Array): LocatedLeaf | null;
72
+ /** Number of populated leaves in this blob. */
73
+ leafCount: number;
74
+ }
75
+
76
+ function equals32(a: Uint8Array, aOff: number, b: Uint8Array): boolean {
77
+ for (let i = 0; i < HASH_BYTES; i++) {
78
+ if (a[aOff + i] !== b[i]) return false;
79
+ }
80
+ return true;
81
+ }
82
+
83
+ /**
84
+ * Leaf index of the first leaf in the massif whose first MMR index is
85
+ * `firstMMRIndex`. (go-merklelog `mmr/leafcount.go` LeafIndex)
86
+ */
87
+ function leafIndexFromMMRIndex(firstMMRIndex: bigint): bigint {
88
+ if (firstMMRIndex === 0n) return 0n;
89
+ return peaksBitmap(firstMMRSize(firstMMRIndex)) - 1n;
90
+ }
91
+
92
+ /**
93
+ * Open a v2 massif blob for content-hash leaf lookups.
94
+ *
95
+ * @throws MissingIndexError if the blob is too short to contain the fixed index
96
+ * region for its declared massif height (no index present).
97
+ */
98
+ export function openMassifLeafIndex(massifBytes: Uint8Array): MassifLeafIndex {
99
+ const massif = new Massif(massifBytes);
100
+ const start = massif.getStart();
101
+ const massifHeight = start.massifHeight;
102
+ if (
103
+ !Number.isInteger(massifHeight) ||
104
+ massifHeight < 1 ||
105
+ massifHeight > LogFormat.MaxMmrHeight
106
+ ) {
107
+ throw new Error(`massif header has invalid height ${massifHeight}`);
108
+ }
109
+ const firstIndex = start.firstIndex;
110
+
111
+ const leafTableStart = urkleLeafTableStartByteOffset(massifHeight);
112
+ const capacity = Number(leafCountForMassifHeight(massifHeight));
113
+ const leafTableBytes = capacity * RECORD_BYTES;
114
+ const stackEnd = peakStackEnd(massifHeight);
115
+
116
+ // The index region must fit before the fixed peak stack + log region. A blob
117
+ // shorter than that has no populated index to search.
118
+ if (massifHeight > 1 && BigInt(leafTableStart + leafTableBytes) > stackEnd) {
119
+ throw new Error("massif index region math is inconsistent");
120
+ }
121
+ if (BigInt(massifBytes.byteLength) < stackEnd) {
122
+ throw new MissingIndexError(
123
+ "massif blob too short for a v2 index region (no index present)",
124
+ );
125
+ }
126
+
127
+ // Only leaves actually appended have populated records. Derive the populated
128
+ // leaf count from the log node count (index records are written 1:1 with
129
+ // appended leaves). Never exceed the fixed capacity for the height.
130
+ const logNodeCount = massifLogEntries(massifBytes.byteLength, massifHeight);
131
+ const firstLeafIndex = leafIndexFromMMRIndex(firstIndex);
132
+ let populated = 0;
133
+ for (let ordinal = 0; ordinal < capacity; ordinal++) {
134
+ // MMR index of leaf (firstLeafIndex + ordinal), relative to this blob's
135
+ // firstIndex, must be within the log node count.
136
+ const leafMMRIndex = mmrIndexFromLeafIndex(
137
+ firstLeafIndex + BigInt(ordinal),
138
+ );
139
+ if (leafMMRIndex - firstIndex >= logNodeCount) break;
140
+ populated = ordinal + 1;
141
+ }
142
+
143
+ /** Ordinal of the populated leaf whose value column equals `h`, or -1. */
144
+ const ordinalForContentHash = (h: Uint8Array): number => {
145
+ if (h.length !== HASH_BYTES) {
146
+ throw new Error(
147
+ `content hash must be ${HASH_BYTES} bytes, got ${h.length}`,
148
+ );
149
+ }
150
+ for (let ordinal = 0; ordinal < populated; ordinal++) {
151
+ const recordOff = leafTableStart + ordinal * RECORD_BYTES;
152
+ const valueOff = recordOff + VALUE_OFFSET;
153
+ if (valueOff + HASH_BYTES > massifBytes.byteLength) break;
154
+ if (equals32(massifBytes, valueOff, h)) return ordinal;
155
+ }
156
+ return -1;
157
+ };
158
+
159
+ const findByContentHash = (h: Uint8Array): bigint | null => {
160
+ const ordinal = ordinalForContentHash(h);
161
+ if (ordinal < 0) return null;
162
+ return mmrIndexFromLeafIndex(firstLeafIndex + BigInt(ordinal));
163
+ };
164
+
165
+ const findLeafByContentHash = (h: Uint8Array): LocatedLeaf | null => {
166
+ const ordinal = ordinalForContentHash(h);
167
+ if (ordinal < 0) return null;
168
+ // The leaf record is `[key(8) || value(32) || …]`; the key IS the
169
+ // big-endian idtimestamp the sealer assigned (go-merklelog urkle leafrecord).
170
+ const keyOff = leafTableStart + ordinal * RECORD_BYTES;
171
+ return {
172
+ mmrIndex: mmrIndexFromLeafIndex(firstLeafIndex + BigInt(ordinal)),
173
+ idtimestampBe8: massifBytes.slice(keyOff, keyOff + KEY_BYTES),
174
+ };
175
+ };
176
+
177
+ return { findByContentHash, findLeafByContentHash, leafCount: populated };
178
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Massif blob node access for MMR proof building.
3
+ *
4
+ * A v2 massif `.log` blob is self-sufficient for any leaf→peak inclusion path
5
+ * whose leaf lives in that massif: local nodes give the in-massif siblings and
6
+ * the fixed ancestor peak stack gives everything older (plan-2607-15 §1.1).
7
+ * Node lookup is pure arithmetic — `LogStart + (i − firstIndex)·32` for local
8
+ * nodes, `peakStackMap` for ancestors.
9
+ *
10
+ * Ported from the inline copies in canopy-api `resolve-receipt.ts` and
11
+ * receipt-verify `build-receipt-offline.ts` (plan-2607-15 §4, phase 2).
12
+ * Browser-safe: pure buffer arithmetic, no node builtins (ADR-0048).
13
+ */
14
+
15
+ import { Massif } from "./massif.js";
16
+ import { LogFormat } from "./logformat.js";
17
+ import { peakStackEnd } from "./peakstackend.js";
18
+ import { peakStackMap } from "../mmr/proof.js";
19
+
20
+ const VALUE_BYTES = BigInt(LogFormat.ValueBytes); // 32
21
+ const MAX_MMR_HEIGHT = BigInt(LogFormat.MaxMmrHeight); // 64
22
+
23
+ /** Node access over a single massif blob. */
24
+ export interface MassifNodeStore {
25
+ /** 32-byte node at MMR index `i` (log region for `i >= firstIndex`, else the ancestor peak stack). */
26
+ get(i: bigint): Uint8Array;
27
+ massifHeight: number;
28
+ massifIndex: bigint;
29
+ firstIndex: bigint;
30
+ /** Last MMR index with log data in this blob. */
31
+ lastIndex: bigint;
32
+ }
33
+
34
+ function slice32(buf: Uint8Array, offset: bigint, label: string): Uint8Array {
35
+ if (offset < 0n || offset + VALUE_BYTES > BigInt(buf.byteLength)) {
36
+ throw new Error(
37
+ `out of range read for ${label}: off=${offset.toString(10)}`,
38
+ );
39
+ }
40
+ const start = Number(offset);
41
+ return buf.slice(start, start + Number(VALUE_BYTES));
42
+ }
43
+
44
+ /**
45
+ * Open a v2 massif blob for MMR node reads. Nodes below `firstIndex` resolve
46
+ * through the ancestor peak stack; nodes above `lastIndex` are not present in
47
+ * this blob and throw when requested.
48
+ */
49
+ export function openMassifNodeStore(massifBytes: Uint8Array): MassifNodeStore {
50
+ const massif = new Massif(massifBytes);
51
+ const start = massif.getStart();
52
+ const massifHeight = start.massifHeight;
53
+ if (
54
+ !Number.isInteger(massifHeight) ||
55
+ massifHeight < 1 ||
56
+ massifHeight > LogFormat.MaxMmrHeight
57
+ ) {
58
+ throw new Error(`massif header has invalid height ${massifHeight}`);
59
+ }
60
+ const massifIndex = BigInt(start.massifIndex);
61
+ const firstIndex = start.firstIndex;
62
+
63
+ const logStart = peakStackEnd(massifHeight);
64
+ const peakStackStart = logStart - MAX_MMR_HEIGHT * VALUE_BYTES;
65
+ const blobLen = BigInt(massifBytes.byteLength);
66
+ if (blobLen < logStart) {
67
+ throw new Error("massif blob too short for v2 layout");
68
+ }
69
+ const logNodeCount = (blobLen - logStart) / VALUE_BYTES;
70
+ const lastIndex = firstIndex + logNodeCount - 1n;
71
+
72
+ const stackMap = peakStackMap(massifHeight, firstIndex);
73
+
74
+ const get = (i: bigint): Uint8Array => {
75
+ if (i >= firstIndex) {
76
+ if (i > lastIndex) {
77
+ throw new Error(
78
+ `mmr index ${i.toString(10)} is beyond this massif's log data ` +
79
+ `(last ${lastIndex.toString(10)}); local content does not cover ` +
80
+ `the requested tree size`,
81
+ );
82
+ }
83
+ const off = logStart + (i - firstIndex) * VALUE_BYTES;
84
+ return slice32(massifBytes, off, "log-data");
85
+ }
86
+ const peakIdx = stackMap.get(i);
87
+ if (peakIdx === undefined) {
88
+ throw new Error(`missing ancestor peak for mmr index ${i.toString(10)}`);
89
+ }
90
+ const off = peakStackStart + BigInt(peakIdx) * VALUE_BYTES;
91
+ return slice32(massifBytes, off, "peak-stack");
92
+ };
93
+
94
+ return { get, massifHeight, massifIndex, firstIndex, lastIndex };
95
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * MMR proof building (pure, store-agnostic).
3
+ *
4
+ * Ports the proof-side arithmetic from go-merklelog `mmr/proof.go` and
5
+ * `mmr/peaks.go`. These were previously inline-duplicated in
6
+ * canopy-api `resolve-receipt.ts` and receipt-verify `build-receipt-offline.ts`;
7
+ * this is the single hoisted home (plan-2607-15 §4, phase 2).
8
+ *
9
+ * All arithmetic is BigInt and free of node builtins so the package stays
10
+ * browser-safe (ADR-0048). Function names mirror the go-merklelog originals.
11
+ */
12
+
13
+ /** Reads the 32-byte node at MMR index `i` from some backing store. */
14
+ export type NodeGetter = (i: bigint) => Uint8Array;
15
+
16
+ /** Number of bits needed to represent `num` (0 for 0). */
17
+ function bitLength(num: bigint): number {
18
+ if (num <= 0n) return 0;
19
+ return num.toString(2).length;
20
+ }
21
+
22
+ /** True when `num > 0` and its binary form is all ones. */
23
+ function allOnes(num: bigint): boolean {
24
+ return num > 0n && (num & (num + 1n)) === 0n;
25
+ }
26
+
27
+ /**
28
+ * Jump left to the leftmost node at the same height.
29
+ * (go-merklelog `mmr/indexheight.go` JumpLeftPerfect)
30
+ */
31
+ function jumpLeftPerfect(pos: bigint): bigint {
32
+ const bl = bitLength(pos);
33
+ if (bl === 0) return pos;
34
+ const msb = 1n << BigInt(bl - 1);
35
+ return pos - (msb - 1n);
36
+ }
37
+
38
+ /**
39
+ * Height of a 1-based position.
40
+ * (go-merklelog `mmr/indexheight.go` PosHeight)
41
+ */
42
+ function posHeight(pos: bigint): number {
43
+ let current = pos;
44
+ while (!allOnes(current)) {
45
+ current = jumpLeftPerfect(current);
46
+ }
47
+ return bitLength(current) - 1;
48
+ }
49
+
50
+ /**
51
+ * Zero-based height index of the node at MMR index `i` (leaves are 0).
52
+ * (go-merklelog `mmr/indexheight.go` IndexHeight)
53
+ */
54
+ export function indexHeight(i: bigint): number {
55
+ return posHeight(i + 1n);
56
+ }
57
+
58
+ /**
59
+ * Witness path (siblings, ascending) for the node at MMR index `i` in the tree
60
+ * ending at `mmrLastIndex`. Terminates at the covering peak.
61
+ * (go-merklelog `mmr/proof.go` InclusionProof)
62
+ *
63
+ * @throws if `i > mmrLastIndex`.
64
+ */
65
+ export function inclusionProof(
66
+ get: NodeGetter,
67
+ mmrLastIndex: bigint,
68
+ i: bigint,
69
+ ): Uint8Array[] {
70
+ if (i > mmrLastIndex) {
71
+ throw new Error("index out of range");
72
+ }
73
+ let g = BigInt(indexHeight(i));
74
+ const proof: Uint8Array[] = [];
75
+ // iSibling out of range guarantees loop termination.
76
+ for (;;) {
77
+ const siblingOffset = 2n << g;
78
+ let iSibling: bigint;
79
+ if (BigInt(indexHeight(i + 1n)) > g) {
80
+ // right sibling
81
+ iSibling = i - siblingOffset + 1n;
82
+ i += 1n;
83
+ } else {
84
+ // left sibling
85
+ iSibling = i + siblingOffset - 1n;
86
+ i += siblingOffset;
87
+ }
88
+ if (iSibling > mmrLastIndex) {
89
+ return proof;
90
+ }
91
+ proof.push(get(iSibling));
92
+ g += 1n;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Peaks bitmap for an MMR size: the numeric value equals the leaf count of the
98
+ * largest valid MMR with size <= `mmrSize`; each set bit marks a perfect
99
+ * subtree (peak). (go-merklelog `mmr/peaks.go` PeaksBitmap)
100
+ */
101
+ export function peaksBitmap(mmrSize: bigint): bigint {
102
+ if (mmrSize === 0n) return 0n;
103
+ let pos = mmrSize;
104
+ let peakSize = (1n << BigInt(bitLength(mmrSize))) - 1n;
105
+ let peakMap = 0n;
106
+ while (peakSize > 0n) {
107
+ peakMap <<= 1n;
108
+ if (pos >= peakSize) {
109
+ pos -= peakSize;
110
+ peakMap |= 1n;
111
+ }
112
+ peakSize >>= 1n;
113
+ }
114
+ return peakMap;
115
+ }
116
+
117
+ /** Population count of a non-negative BigInt. */
118
+ function popcount(x: bigint): number {
119
+ let count = 0;
120
+ let v = x;
121
+ while (v > 0n) {
122
+ if ((v & 1n) === 1n) count += 1;
123
+ v >>= 1n;
124
+ }
125
+ return count;
126
+ }
127
+
128
+ /**
129
+ * Accumulator slot (left-to-right) committed by an inclusion proof of length
130
+ * `proofLen` in a tree of size `mmrSize`. (server resolve-receipt.ts:735,
131
+ * equivalent to go `mmr.PeakIndex(LeafCount(mmrSize), proofLen)`)
132
+ */
133
+ export function peakIndexForLeafProof(
134
+ mmrSize: bigint,
135
+ proofLen: number,
136
+ ): number {
137
+ const leafCount = peaksBitmap(mmrSize);
138
+ const peaksMask = (1n << BigInt(proofLen + 1)) - 1n;
139
+ return popcount(leafCount) - popcount(leafCount & peaksMask);
140
+ }
141
+
142
+ /** Largest peak size <= size of the tree ending at index `i`. (go `mmr/peaks.go` TopPeak) */
143
+ function topPeak(i: bigint): bigint {
144
+ const bl = bitLength(i + 2n);
145
+ return (1n << BigInt(bl - 1)) - 2n;
146
+ }
147
+
148
+ /**
149
+ * MMR indices of the peaks of the tree ending at `mmrIndex` (inclusive),
150
+ * ascending. Empty when `mmrIndex + 1` is not a valid MMR size (siblings
151
+ * without a parent). (go-merklelog `mmr/peaks.go` Peaks)
152
+ */
153
+ export function peakMMRIndexes(mmrIndex: bigint): bigint[] {
154
+ let mmrSize = mmrIndex + 1n;
155
+ if (posHeight(mmrSize + 1n) > posHeight(mmrSize)) {
156
+ return [];
157
+ }
158
+ let peak = 0n;
159
+ const out: bigint[] = [];
160
+ while (mmrSize !== 0n) {
161
+ const peakSize = topPeak(mmrSize - 1n) + 1n;
162
+ peak = peak + peakSize;
163
+ out.push(peak - 1n);
164
+ mmrSize -= peakSize;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ /**
170
+ * Smallest complete MMR size that contains the node at MMR index `mmrIndex`.
171
+ * (go-merklelog `mmr/firstmmrsize.go` FirstMMRSize)
172
+ */
173
+ export function firstMMRSize(mmrIndex: bigint): bigint {
174
+ let i = mmrIndex;
175
+ let h0 = indexHeight(i);
176
+ let h1 = indexHeight(i + 1n);
177
+ while (h0 < h1) {
178
+ i += 1n;
179
+ h0 = h1;
180
+ h1 = indexHeight(i + 1n);
181
+ }
182
+ return i + 1n;
183
+ }
184
+
185
+ /**
186
+ * Massif index (0-based) that holds the node at MMR index `i` for a log of the
187
+ * given massif height. (go-merklelog `massifs/massifindex.go` MassifIndexFromMMRIndex)
188
+ */
189
+ export function massifIndexFromMMRIndex(
190
+ massifHeight: number,
191
+ i: bigint,
192
+ ): bigint {
193
+ const size = firstMMRSize(i);
194
+ const leafIndex = peaksBitmap(size) - 1n;
195
+ const massifMaxLeaves = 1n << BigInt(massifHeight - 1);
196
+ return leafIndex / massifMaxLeaves;
197
+ }
198
+
199
+ /**
200
+ * Map from ancestor-peak MMR index to peak-stack slot for the massif whose
201
+ * first MMR index is `firstIndex`. Nodes below `firstIndex` resolve through the
202
+ * fixed peak-stack region via this map.
203
+ * (go-merklelog `massifs/peakstack.go` PeakStackMap)
204
+ */
205
+ export function peakStackMap(
206
+ massifHeight: number,
207
+ firstIndex: bigint,
208
+ ): Map<bigint, number> {
209
+ const map = new Map<bigint, number>();
210
+ const iPeaks = peakMMRIndexes(firstIndex);
211
+ for (let i = 0; i < iPeaks.length; i++) {
212
+ const ip = iPeaks[i]!;
213
+ if (indexHeight(ip) < massifHeight - 1) {
214
+ continue;
215
+ }
216
+ map.set(ip, i);
217
+ }
218
+ return map;
219
+ }