@forestrie/receipt-verify 0.1.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.
Files changed (43) hide show
  1. package/dist/attach-transparent-statement-receipt.d.ts +4 -0
  2. package/dist/attach-transparent-statement-receipt.d.ts.map +1 -0
  3. package/dist/attach-transparent-statement-receipt.js +21 -0
  4. package/dist/build-receipt-offline.d.ts +78 -0
  5. package/dist/build-receipt-offline.d.ts.map +1 -0
  6. package/dist/build-receipt-offline.js +353 -0
  7. package/dist/decode-forestrie-grant-cose.d.ts +1 -1
  8. package/dist/decode-forestrie-grant-cose.d.ts.map +1 -1
  9. package/dist/entry-id.d.ts +13 -0
  10. package/dist/entry-id.d.ts.map +1 -0
  11. package/dist/entry-id.js +37 -0
  12. package/dist/grant-codec.d.ts +1 -1
  13. package/dist/grant-codec.d.ts.map +1 -1
  14. package/dist/grant-codec.js +1 -1
  15. package/dist/grant-commitment.d.ts +1 -1
  16. package/dist/grant-commitment.d.ts.map +1 -1
  17. package/dist/grant-commitment.js +1 -1
  18. package/dist/index.d.ts +8 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/parse-receipt.d.ts +4 -2
  22. package/dist/parse-receipt.d.ts.map +1 -1
  23. package/dist/parse-receipt.js +3 -3
  24. package/dist/verify-grant-receipt-offline.d.ts +1 -1
  25. package/dist/verify-grant-receipt-offline.d.ts.map +1 -1
  26. package/package.json +6 -4
  27. package/src/attach-transparent-statement-receipt.ts +30 -0
  28. package/src/build-receipt-offline.ts +477 -0
  29. package/src/decode-forestrie-grant-cose.ts +1 -1
  30. package/src/entry-id.ts +46 -0
  31. package/src/grant-codec.ts +2 -2
  32. package/src/grant-commitment.ts +2 -2
  33. package/src/index.ts +23 -1
  34. package/src/parse-receipt.ts +4 -4
  35. package/src/verify-grant-receipt-offline.ts +1 -1
  36. package/dist/grant-data.d.ts +0 -7
  37. package/dist/grant-data.d.ts.map +0 -1
  38. package/dist/grant-data.js +0 -7
  39. package/dist/grant.d.ts +0 -12
  40. package/dist/grant.d.ts.map +0 -1
  41. package/dist/grant.js +0 -1
  42. package/src/grant-data.ts +0 -12
  43. package/src/grant.ts +0 -12
@@ -1,6 +1,6 @@
1
1
  import { decode as decodeCbor } from "cbor-x";
2
2
  const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
3
- function unwrapCoseSign1Tag(value) {
3
+ export function unwrapCoseSign1Tag(value) {
4
4
  if (value && typeof value === "object" && !(value instanceof Map)) {
5
5
  const tagged = value;
6
6
  if (Object.prototype.hasOwnProperty.call(tagged, "value") &&
@@ -10,7 +10,7 @@ function unwrapCoseSign1Tag(value) {
10
10
  }
11
11
  return value;
12
12
  }
13
- function requireCoseSign1(value) {
13
+ export function requireCoseSign1(value) {
14
14
  if (!Array.isArray(value) || value.length !== 4) {
15
15
  throw new Error("Receipt is not a COSE Sign1 array");
16
16
  }
@@ -28,7 +28,7 @@ function requireCoseSign1(value) {
28
28
  sig,
29
29
  ];
30
30
  }
31
- function toHeaderMap(value) {
31
+ export function toHeaderMap(value) {
32
32
  if (value instanceof Map)
33
33
  return value;
34
34
  const out = new Map();
@@ -1,4 +1,4 @@
1
- import type { Grant } from "./grant.js";
1
+ import type { Grant } from "@forestrie/encoding";
2
2
  import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
3
3
  export type VerifyGrantReceiptOfflineInput = {
4
4
  genesisCbor: Uint8Array;
@@ -1 +1 @@
1
- {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAMxC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CA2C9B"}
1
+ {"version":3,"file":"verify-grant-receipt-offline.d.ts","sourceRoot":"","sources":["../src/verify-grant-receipt-offline.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAMjD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAGtE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,WAAW,EAAE,UAAU,CAAC;IACxB,WAAW,EAAE,UAAU,CAAC;IACxB,KAAK,EAAE,KAAK,CAAC;IACb,cAAc,EAAE,UAAU,CAAC;CAC5B,CAAC;AA4EF;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,KAAK,EAAE,8BAA8B,GACpC,OAAO,CAAC,mBAAmB,CAAC,CA2C9B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.1.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -29,17 +29,19 @@
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
31
  "cbor-x": "^1.5.9",
32
- "@forestrie/encoding": "0.0.2",
33
- "@forestrie/merklelog": "0.0.2"
32
+ "@forestrie/encoding": "0.2.0",
33
+ "@forestrie/merklelog": "0.0.3"
34
34
  },
35
35
  "devDependencies": {
36
+ "esbuild": "^0.24.0",
36
37
  "typescript": "^5.9.2",
37
38
  "vitest": "^3.2.4"
38
39
  },
39
40
  "scripts": {
40
41
  "build": "tsc -p tsconfig.build.json",
41
42
  "typecheck": "tsc --noEmit",
42
- "test": "vitest --run",
43
+ "check:browser-safe": "node tools/check-browser-safe.mjs",
44
+ "test": "pnpm run check:browser-safe && vitest --run",
43
45
  "test:watch": "vitest"
44
46
  }
45
47
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Attach resolve-receipt bytes and idtimestamp to SCITT transparent statement
3
+ * unprotected headers (Plan 0005). Deterministic receipt construction moved
4
+ * from @forestrie/canopy-e2e-kit (plan-2607-12 Phase 2, FOR-353).
5
+ *
6
+ * Golden vectors for receipt construction/verification are tracked by
7
+ * FOR-289.
8
+ */
9
+ import { mergeUnprotectedIntoCoseSign1 } from "@forestrie/encoding";
10
+ import { HEADER_IDTIMESTAMP } from "./forest-genesis-labels.js";
11
+
12
+ /** SCITT transparent statement unprotected receipt label (grants.md §3.2). */
13
+ export const HEADER_RECEIPT = 396;
14
+
15
+ export function attachReceiptAndIdtimestampToTransparentStatement(
16
+ statementBytes: Uint8Array,
17
+ receiptCborBytes: Uint8Array,
18
+ idtimestampBe8: Uint8Array,
19
+ ): Uint8Array {
20
+ if (idtimestampBe8.length !== 8) {
21
+ throw new Error("idtimestamp must be 8 bytes (big-endian)");
22
+ }
23
+ return mergeUnprotectedIntoCoseSign1(
24
+ statementBytes,
25
+ new Map<number, unknown>([
26
+ [HEADER_RECEIPT, receiptCborBytes],
27
+ [HEADER_IDTIMESTAMP, idtimestampBe8],
28
+ ]),
29
+ );
30
+ }
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Self-created SCITT receipts from local artifacts.
3
+ *
4
+ * Mirrors canopy-api resolve-receipt receipt assembly, but over local massif
5
+ * (`.log`) and checkpoint (`.sth`) bytes instead of R2 objects. The checkpoint
6
+ * signature covers only the accumulator, so the inclusion path is free to
7
+ * rebuild client-side: read massif nodes, build the leaf→peak path, attach it
8
+ * at header 396 to the checkpoint's pre-signed peak receipt. The result is
9
+ * verify-equivalent with an API-issued receipt (FOR-334 AC: both pass
10
+ * verifyGrantReceiptOffline identically; byte-equality is intentionally not
11
+ * claimed — encoder-level variation is known-benign, see plan-2607-15 §2).
12
+ *
13
+ * `computeAccumulatorPeak` supports the chain-anchored variant: build the path
14
+ * at an externally attested tree size (e.g. Univocity `logState` on Base
15
+ * Sepolia) and compare the computed peak with the published accumulator.
16
+ */
17
+
18
+ import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
19
+ import {
20
+ calculateRoot,
21
+ Massif,
22
+ peakStackEnd,
23
+ type Proof,
24
+ } from "@forestrie/merklelog";
25
+ import {
26
+ requireCoseSign1,
27
+ toHeaderMap,
28
+ unwrapCoseSign1Tag,
29
+ type CoseSign1,
30
+ } from "./parse-receipt.js";
31
+ import { SubtleHasher } from "./subtle-hasher.js";
32
+
33
+ const VDS_COSE_RECEIPT_PROOFS_TAG = 396;
34
+ const SEAL_PEAK_RECEIPTS_LABEL = -65931;
35
+ const DELEGATION_CERT_LABEL = 1000;
36
+ const VDP_CONSISTENCY_PROOF_KEY = -2;
37
+
38
+ const VALUE_BYTES = 32n;
39
+ const MAX_MMR_HEIGHT = 64n;
40
+
41
+ export type MassifNodeStore = {
42
+ /** 32-byte node at MMR index `i` (log data or ancestor peak stack). */
43
+ get(i: bigint): Uint8Array;
44
+ massifHeight: number;
45
+ massifIndex: bigint;
46
+ firstIndex: bigint;
47
+ /** Last MMR index with log data in this massif blob. */
48
+ lastIndex: bigint;
49
+ };
50
+
51
+ /**
52
+ * Open a v2 massif blob for MMR node reads. Nodes below `firstIndex` resolve
53
+ * through the ancestor peak stack; nodes above `lastIndex` are not present in
54
+ * this blob and throw.
55
+ */
56
+ export function openMassifNodeStore(massifBytes: Uint8Array): MassifNodeStore {
57
+ const massif = new Massif(massifBytes);
58
+ const start = massif.getStart();
59
+ const massifHeight = start.massifHeight;
60
+ if (
61
+ !Number.isInteger(massifHeight) ||
62
+ massifHeight < 1 ||
63
+ massifHeight > 64
64
+ ) {
65
+ throw new Error(`massif header has invalid height ${massifHeight}`);
66
+ }
67
+ const massifIndex = BigInt(start.massifIndex);
68
+ const firstIndex = start.firstIndex;
69
+
70
+ const logStart = peakStackEnd(massifHeight);
71
+ const peakStackStart = logStart - MAX_MMR_HEIGHT * VALUE_BYTES;
72
+ const blobLen = BigInt(massifBytes.byteLength);
73
+ if (blobLen < logStart) {
74
+ throw new Error("massif blob too short for v2 layout");
75
+ }
76
+ const logNodeCount = (blobLen - logStart) / VALUE_BYTES;
77
+ const lastIndex = firstIndex + logNodeCount - 1n;
78
+
79
+ const stackMap = peakStackMapForMassif(massifHeight, firstIndex);
80
+
81
+ const get = (i: bigint): Uint8Array => {
82
+ if (i >= firstIndex) {
83
+ if (i > lastIndex) {
84
+ throw new Error(
85
+ `mmr index ${i.toString(10)} is beyond this massif's log data ` +
86
+ `(last ${lastIndex.toString(10)}); local content does not cover ` +
87
+ `the requested tree size`,
88
+ );
89
+ }
90
+ const off = logStart + (i - firstIndex) * VALUE_BYTES;
91
+ return slice32(massifBytes, off, "log-data");
92
+ }
93
+ const peakIdx = stackMap.get(i);
94
+ if (peakIdx === undefined) {
95
+ throw new Error(`missing ancestor peak for mmr index ${i.toString(10)}`);
96
+ }
97
+ const off = peakStackStart + BigInt(peakIdx) * VALUE_BYTES;
98
+ return slice32(massifBytes, off, "peak-stack");
99
+ };
100
+
101
+ return { get, massifHeight, massifIndex, firstIndex, lastIndex };
102
+ }
103
+
104
+ export type ParsedCheckpoint = {
105
+ coseSign1: CoseSign1;
106
+ unprotected: Map<number, unknown>;
107
+ /** Pre-signed peak receipts (label -65931), or null when absent. */
108
+ peakReceipts: unknown[] | null;
109
+ /** Sealed tree size (tree-size-2 of the embedded consistency proof). */
110
+ mmrSize: bigint | null;
111
+ delegationCert: Uint8Array | null;
112
+ };
113
+
114
+ /** Parse a format-v3 checkpoint (`.sth`) COSE Sign1. */
115
+ export function parseCheckpoint(checkpointBytes: Uint8Array): ParsedCheckpoint {
116
+ const decoded = decodeCbor(checkpointBytes) as unknown;
117
+ const coseSign1 = requireCoseSign1(unwrapCoseSign1Tag(decoded));
118
+ const unprotected = toHeaderMap(coseSign1[1]);
119
+
120
+ const peakReceiptsRaw = unprotected.get(SEAL_PEAK_RECEIPTS_LABEL);
121
+ const peakReceipts = Array.isArray(peakReceiptsRaw) ? peakReceiptsRaw : null;
122
+
123
+ const delegationCertRaw = unprotected.get(DELEGATION_CERT_LABEL);
124
+ const delegationCert =
125
+ delegationCertRaw instanceof Uint8Array && delegationCertRaw.length > 0
126
+ ? delegationCertRaw
127
+ : null;
128
+
129
+ return {
130
+ coseSign1,
131
+ unprotected,
132
+ peakReceipts,
133
+ mmrSize: sealedSizeFromCheckpoint(unprotected),
134
+ delegationCert,
135
+ };
136
+ }
137
+
138
+ export type BuildReceiptOfflineInput = {
139
+ massifBytes: Uint8Array;
140
+ checkpointBytes: Uint8Array;
141
+ mmrIndex: bigint;
142
+ };
143
+
144
+ /**
145
+ * Assemble a receipt for the entry at `mmrIndex` from a local massif blob and
146
+ * its checkpoint. Pure over bytes; no network, no signing key — the signature
147
+ * is the checkpoint's pre-signed peak receipt. Throws with a specific reason
148
+ * on any failure.
149
+ */
150
+ export function buildReceiptOffline(
151
+ input: BuildReceiptOfflineInput,
152
+ ): Uint8Array {
153
+ const { mmrIndex } = input;
154
+ if (mmrIndex < 0n) {
155
+ throw new Error("mmrIndex must be non-negative");
156
+ }
157
+
158
+ const checkpoint = parseCheckpoint(input.checkpointBytes);
159
+ if (!checkpoint.peakReceipts) {
160
+ throw new Error(
161
+ "checkpoint carries no pre-signed peak receipts (label -65931)",
162
+ );
163
+ }
164
+ if (checkpoint.mmrSize === null || checkpoint.mmrSize <= 0n) {
165
+ throw new Error(
166
+ "checkpoint carries no consistency proof (cannot determine sealed size)",
167
+ );
168
+ }
169
+ const mmrSize = checkpoint.mmrSize;
170
+ const mmrLastIndex = mmrSize - 1n;
171
+ if (mmrIndex > mmrLastIndex) {
172
+ throw new Error(
173
+ `checkpoint does not cover entry: mmrIndex ${mmrIndex.toString(10)} ` +
174
+ `>= sealed size ${mmrSize.toString(10)}`,
175
+ );
176
+ }
177
+
178
+ const store = openMassifNodeStore(input.massifBytes);
179
+ const expectedMassifIndex = massifIndexFromMMRIndex(
180
+ store.massifHeight,
181
+ mmrIndex,
182
+ );
183
+ if (expectedMassifIndex !== store.massifIndex) {
184
+ throw new Error(
185
+ `entry at mmrIndex ${mmrIndex.toString(10)} lives in massif ` +
186
+ `${expectedMassifIndex.toString(10)}, but this blob is massif ` +
187
+ `${store.massifIndex.toString(10)}`,
188
+ );
189
+ }
190
+
191
+ const proof = inclusionProof(store.get, mmrLastIndex, mmrIndex);
192
+ const peakIdx = peakIndexForLeafProof(mmrSize, proof.length);
193
+ const receiptBytes = checkpoint.peakReceipts[peakIdx];
194
+ if (!(receiptBytes instanceof Uint8Array)) {
195
+ throw new Error(
196
+ `checkpoint peak receipt slot ${peakIdx} is missing or not bstr`,
197
+ );
198
+ }
199
+
200
+ const receiptSign1 = requireCoseSign1(
201
+ unwrapCoseSign1Tag(decodeCbor(receiptBytes) as unknown),
202
+ );
203
+ const receiptUnprotected = toHeaderMap(receiptSign1[1]);
204
+
205
+ if (checkpoint.delegationCert) {
206
+ receiptUnprotected.set(DELEGATION_CERT_LABEL, checkpoint.delegationCert);
207
+ }
208
+
209
+ const inclusionProofEntry = new Map<number, unknown>([
210
+ [1, mmrIndex],
211
+ [2, proof],
212
+ ]);
213
+ const verifiableProofs = new Map<number, unknown>([
214
+ [-1, [inclusionProofEntry]],
215
+ ]);
216
+ receiptUnprotected.set(VDS_COSE_RECEIPT_PROOFS_TAG, verifiableProofs);
217
+
218
+ // Peak receipts are signed with detached payload; emit nil so verify uses
219
+ // the peak derived from the inclusion proof.
220
+ const assembled: CoseSign1 = [
221
+ receiptSign1[0],
222
+ receiptUnprotected,
223
+ null,
224
+ receiptSign1[3],
225
+ ];
226
+ return cborBytes(assembled);
227
+ }
228
+
229
+ export type ComputedAccumulatorPeak = {
230
+ /** Root computed from the massif's leaf value and rebuilt path. */
231
+ peak: Uint8Array;
232
+ /** Position of that peak in the accumulator, left to right. */
233
+ peakIndex: number;
234
+ proof: Uint8Array[];
235
+ leafValue: Uint8Array;
236
+ };
237
+
238
+ /**
239
+ * Chain-anchored variant: rebuild the inclusion path for `mmrIndex` at an
240
+ * externally attested tree size and compute the accumulator peak it commits
241
+ * to. Compare `peak` with `accumulator[peakIndex]` published by the Univocity
242
+ * contract (`logState`). Requires the local massif to hold nodes up to
243
+ * `mmrSize`.
244
+ */
245
+ export async function computeAccumulatorPeak(opts: {
246
+ massifBytes: Uint8Array;
247
+ mmrIndex: bigint;
248
+ mmrSize: bigint;
249
+ }): Promise<ComputedAccumulatorPeak> {
250
+ const { mmrIndex, mmrSize } = opts;
251
+ if (mmrSize <= 0n || mmrIndex >= mmrSize) {
252
+ throw new Error(
253
+ `mmrIndex ${mmrIndex.toString(10)} is not covered by tree size ` +
254
+ `${mmrSize.toString(10)} (entry not anchored yet?)`,
255
+ );
256
+ }
257
+ const store = openMassifNodeStore(opts.massifBytes);
258
+ const leafValue = store.get(mmrIndex);
259
+ const path = inclusionProof(store.get, mmrSize - 1n, mmrIndex);
260
+ const proof: Proof = { path, mmrIndex };
261
+ const peak = await calculateRoot(
262
+ new SubtleHasher(),
263
+ leafValue,
264
+ proof,
265
+ mmrIndex,
266
+ );
267
+ return {
268
+ peak,
269
+ peakIndex: peakIndexForLeafProof(mmrSize, path.length),
270
+ proof: path,
271
+ leafValue,
272
+ };
273
+ }
274
+
275
+ // --- helpers ---
276
+
277
+ function cborBytes(value: unknown): Uint8Array {
278
+ const encoded = encodeCbor(value);
279
+ return encoded instanceof Uint8Array
280
+ ? encoded
281
+ : new Uint8Array(encoded as ArrayLike<number>);
282
+ }
283
+
284
+ function slice32(buf: Uint8Array, offset: bigint, label: string): Uint8Array {
285
+ if (offset < 0n || offset + 32n > BigInt(buf.byteLength)) {
286
+ throw new Error(
287
+ `out of range read for ${label}: off=${offset.toString(10)}`,
288
+ );
289
+ }
290
+ const start = Number(offset);
291
+ return buf.slice(start, start + 32);
292
+ }
293
+
294
+ /**
295
+ * Sealed mmr size from a format-v3 checkpoint: tree-size-2 of the consistency
296
+ * proof (`bstr .cbor [tree-size-1, tree-size-2, paths, right-peaks]`) under
297
+ * the verifiable-proofs unprotected header (label 396, key -2).
298
+ */
299
+ function sealedSizeFromCheckpoint(
300
+ unprotected: Map<number, unknown>,
301
+ ): bigint | null {
302
+ const vdpRaw = unprotected.get(VDS_COSE_RECEIPT_PROOFS_TAG);
303
+ if (vdpRaw === undefined || vdpRaw === null) return null;
304
+ const vdp = toHeaderMap(vdpRaw as Map<number, unknown>);
305
+ const proofBstr = vdp.get(VDP_CONSISTENCY_PROOF_KEY);
306
+ if (!(proofBstr instanceof Uint8Array)) return null;
307
+ const proof = decodeCbor(proofBstr) as unknown;
308
+ if (!Array.isArray(proof) || proof.length < 2) return null;
309
+ const treeSize2 = proof[1];
310
+ if (typeof treeSize2 === "bigint") return treeSize2;
311
+ if (typeof treeSize2 === "number" && Number.isSafeInteger(treeSize2)) {
312
+ return BigInt(treeSize2);
313
+ }
314
+ return null;
315
+ }
316
+
317
+ // --- MMR index math (ported from go-merklelog/mmr, mirrors resolve-receipt) ---
318
+
319
+ function bitLength(num: bigint): number {
320
+ if (num === 0n) return 0;
321
+ return num.toString(2).length;
322
+ }
323
+
324
+ function allOnes(num: bigint): boolean {
325
+ return num > 0n && (num & (num + 1n)) === 0n;
326
+ }
327
+
328
+ function jumpLeftPerfect(pos: bigint): bigint {
329
+ const bl = bitLength(pos);
330
+ if (bl === 0) return pos;
331
+ const msb = 1n << BigInt(bl - 1);
332
+ return pos - (msb - 1n);
333
+ }
334
+
335
+ function posHeight(pos: bigint): number {
336
+ let current = pos;
337
+ while (!allOnes(current)) {
338
+ current = jumpLeftPerfect(current);
339
+ }
340
+ return bitLength(current) - 1;
341
+ }
342
+
343
+ function indexHeight(i: bigint): number {
344
+ return posHeight(i + 1n);
345
+ }
346
+
347
+ function inclusionProof(
348
+ getNode: (i: bigint) => Uint8Array,
349
+ mmrLastIndex: bigint,
350
+ i: bigint,
351
+ ): Uint8Array[] {
352
+ if (i > mmrLastIndex) {
353
+ throw new Error("index out of range");
354
+ }
355
+ let g = BigInt(indexHeight(i));
356
+ const proof: Uint8Array[] = [];
357
+ while (true) {
358
+ const siblingOffset = 2n << g;
359
+ let iSibling: bigint;
360
+ if (BigInt(indexHeight(i + 1n)) > g) {
361
+ iSibling = i - siblingOffset + 1n;
362
+ i += 1n;
363
+ } else {
364
+ iSibling = i + siblingOffset - 1n;
365
+ i += siblingOffset;
366
+ }
367
+ if (iSibling > mmrLastIndex) {
368
+ return proof;
369
+ }
370
+ proof.push(getNode(iSibling));
371
+ g += 1n;
372
+ }
373
+ }
374
+
375
+ function peaksBitmap(mmrSize: bigint): bigint {
376
+ if (mmrSize === 0n) return 0n;
377
+ let pos = mmrSize;
378
+ let peakSize = (1n << BigInt(bitLength(mmrSize))) - 1n;
379
+ let peakMap = 0n;
380
+ while (peakSize > 0n) {
381
+ peakMap <<= 1n;
382
+ if (pos >= peakSize) {
383
+ pos -= peakSize;
384
+ peakMap |= 1n;
385
+ }
386
+ peakSize >>= 1n;
387
+ }
388
+ return peakMap;
389
+ }
390
+
391
+ function popcount64(x: bigint): number {
392
+ let count = 0;
393
+ let v = x;
394
+ while (v > 0n) {
395
+ if ((v & 1n) === 1n) count += 1;
396
+ v >>= 1n;
397
+ }
398
+ return count;
399
+ }
400
+
401
+ function peakIndexForLeafProof(mmrSize: bigint, proofLen: number): number {
402
+ const leafCount = peaksBitmap(mmrSize);
403
+ const peaksMask = (1n << BigInt(proofLen + 1)) - 1n;
404
+ return popcount64(leafCount) - popcount64(leafCount & peaksMask);
405
+ }
406
+
407
+ function topPeak(i: bigint): bigint {
408
+ const bl = bitLength(i + 2n);
409
+ return (1n << BigInt(bl - 1)) - 2n;
410
+ }
411
+
412
+ /** MMR indices of the peaks of the tree ending at `mmrIndex` (inclusive). */
413
+ export function peakMMRIndexes(mmrIndex: bigint): bigint[] {
414
+ let mmrSize = mmrIndex + 1n;
415
+ if (posHeight(mmrSize + 1n) > posHeight(mmrSize)) {
416
+ return [];
417
+ }
418
+ let peak = 0n;
419
+ const out: bigint[] = [];
420
+ while (mmrSize !== 0n) {
421
+ const peakSize = topPeak(mmrSize - 1n) + 1n;
422
+ peak = peak + peakSize;
423
+ out.push(peak - 1n);
424
+ mmrSize -= peakSize;
425
+ }
426
+ return out;
427
+ }
428
+
429
+ function peakStackMapForMassif(
430
+ massifHeight: number,
431
+ firstIndex: bigint,
432
+ ): Map<bigint, number> {
433
+ const map = new Map<bigint, number>();
434
+ const iPeaks = peakMMRIndexes(firstIndex);
435
+ for (let i = 0; i < iPeaks.length; i++) {
436
+ const ip = iPeaks[i]!;
437
+ if (indexHeight(ip) < massifHeight - 1) {
438
+ continue;
439
+ }
440
+ map.set(ip, i);
441
+ }
442
+ return map;
443
+ }
444
+
445
+ function mmrIndexFromLeafIndex(leafIndex: bigint): bigint {
446
+ let sum = 0n;
447
+ let current = leafIndex;
448
+ while (current > 0n) {
449
+ const h = BigInt(bitLength(current));
450
+ sum += (1n << h) - 1n;
451
+ const half = 1n << (h - 1n);
452
+ current -= half;
453
+ }
454
+ return sum;
455
+ }
456
+
457
+ function firstMMRSize(mmrIndex: bigint): bigint {
458
+ let i = mmrIndex;
459
+ let h0 = indexHeight(i);
460
+ let h1 = indexHeight(i + 1n);
461
+ while (h0 < h1) {
462
+ i += 1n;
463
+ h0 = h1;
464
+ h1 = indexHeight(i + 1n);
465
+ }
466
+ return i + 1n;
467
+ }
468
+
469
+ function massifIndexFromMMRIndex(
470
+ massifHeight: number,
471
+ mmrIndex: bigint,
472
+ ): bigint {
473
+ const size = firstMMRSize(mmrIndex);
474
+ const leafIndex = peaksBitmap(size) - 1n;
475
+ const massifMaxLeaves = 1n << BigInt(massifHeight - 1);
476
+ return leafIndex / massifMaxLeaves;
477
+ }
@@ -1,6 +1,6 @@
1
1
  import { sha256 } from "@noble/hashes/sha256";
2
2
  import { decode as decodeCbor } from "cbor-x";
3
- import type { Grant } from "./grant.js";
3
+ import type { Grant } from "@forestrie/encoding";
4
4
  import {
5
5
  HEADER_FORESTRIE_GRANT_V0,
6
6
  HEADER_IDTIMESTAMP,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Decode permanent SCRAPI entryId (32 hex chars = idtimestamp_be8 ||
3
+ * mmrIndex_be8). Deterministic receipt helpers moved from
4
+ * @forestrie/canopy-e2e-kit (plan-2607-12 Phase 2, FOR-353).
5
+ */
6
+
7
+ export interface DecodedEntryId {
8
+ idtimestamp: bigint;
9
+ mmrIndex: bigint;
10
+ }
11
+
12
+ function readU64BE(buf: Uint8Array, offset: number): bigint {
13
+ let v = 0n;
14
+ for (let i = 0; i < 8; i++) {
15
+ v = (v << 8n) | BigInt(buf[offset + i]!);
16
+ }
17
+ return v;
18
+ }
19
+
20
+ function hexToBytes(hex: string): Uint8Array {
21
+ const out = new Uint8Array(hex.length / 2);
22
+ for (let i = 0; i < out.length; i++) {
23
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export function decodeEntryIdHex(entryIdHex: string): DecodedEntryId {
29
+ if (!/^[0-9a-f]{32}$/i.test(entryIdHex)) {
30
+ throw new Error(`entryId must be 32 lowercase hex chars: ${entryIdHex}`);
31
+ }
32
+ const lower = entryIdHex.toLowerCase();
33
+ const bytes = hexToBytes(lower);
34
+ return {
35
+ idtimestamp: readU64BE(bytes, 0),
36
+ mmrIndex: readU64BE(bytes, 8),
37
+ };
38
+ }
39
+
40
+ /** First half of entryId (permanent URL): idtimestamp big-endian 8 bytes for header -65537. */
41
+ export function entryIdHexToIdtimestampBe8(entryIdHex: string): Uint8Array {
42
+ const { idtimestamp } = decodeEntryIdHex(entryIdHex);
43
+ const out = new Uint8Array(8);
44
+ new DataView(out.buffer).setBigUint64(0, idtimestamp, false);
45
+ return out;
46
+ }
@@ -1,6 +1,6 @@
1
1
  import { decode as decodeCbor, encode as encodeCbor } from "cbor-x";
2
- import type { Grant } from "./grant.js";
3
- import { grantDataToBytes } from "./grant-data.js";
2
+ import type { Grant } from "@forestrie/encoding";
3
+ import { grantDataToBytes } from "@forestrie/encoding";
4
4
  import { fromPaddedWire32, toPaddedWire32 } from "./uuid-bytes.js";
5
5
 
6
6
  const CBOR_KEY_IDTIMESTAMP = 0;
@@ -1,5 +1,5 @@
1
- import { grantDataToBytes } from "./grant-data.js";
2
- import type { Grant } from "./grant.js";
1
+ import { grantDataToBytes } from "@forestrie/encoding";
2
+ import type { Grant } from "@forestrie/encoding";
3
3
  import { toPaddedWire32 } from "./uuid-bytes.js";
4
4
 
5
5
  const GRANT_FLAGS_32_BYTES = 32;
package/src/index.ts CHANGED
@@ -2,10 +2,32 @@ export type {
2
2
  ReceiptVerifyResult,
3
3
  ReceiptVerifyStage,
4
4
  } from "./receipt-verify-result.js";
5
- export type { Grant } from "./grant.js";
5
+ /** Canonical grant wire type owned by @forestrie/encoding (FOR-353, ADR-0048). */
6
+ export type { Grant } from "@forestrie/encoding";
6
7
  export type { VerifyGrantReceiptOfflineInput } from "./verify-grant-receipt-offline.js";
7
8
  export { parseReceipt } from "./parse-receipt.js";
9
+ export type {
10
+ BuildReceiptOfflineInput,
11
+ ComputedAccumulatorPeak,
12
+ MassifNodeStore,
13
+ ParsedCheckpoint,
14
+ } from "./build-receipt-offline.js";
15
+ export {
16
+ buildReceiptOffline,
17
+ computeAccumulatorPeak,
18
+ openMassifNodeStore,
19
+ parseCheckpoint,
20
+ peakMMRIndexes,
21
+ } from "./build-receipt-offline.js";
8
22
  export { decodeTrustRootFromGenesis } from "./decode-trust-root-from-genesis.js";
9
23
  export { verifyGrantReceiptOffline } from "./verify-grant-receipt-offline.js";
10
24
  export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
11
25
  export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
26
+
27
+ /** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
28
+ export {
29
+ HEADER_RECEIPT,
30
+ attachReceiptAndIdtimestampToTransparentStatement,
31
+ } from "./attach-transparent-statement-receipt.js";
32
+ export { decodeEntryIdHex, entryIdHexToIdtimestampBe8 } from "./entry-id.js";
33
+ export type { DecodedEntryId } from "./entry-id.js";