@forestrie/forestrie-cli 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +425 -0
  3. package/TRUST-MODEL.md +8 -0
  4. package/dist/cli.js +33811 -0
  5. package/dist/decode-receipt.d.ts +19 -0
  6. package/dist/decode-receipt.d.ts.map +1 -0
  7. package/dist/decode-receipt.js +19 -0
  8. package/dist/decode-receipt.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/decode-receipt-cbor.d.ts +26 -0
  14. package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
  15. package/dist/lib/decode-receipt-cbor.js +182 -0
  16. package/dist/lib/decode-receipt-cbor.js.map +1 -0
  17. package/dist/lib/decode-receipt-decode.d.ts +103 -0
  18. package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
  19. package/dist/lib/decode-receipt-decode.js +237 -0
  20. package/dist/lib/decode-receipt-decode.js.map +1 -0
  21. package/dist/lib/decode-receipt-labels.d.ts +54 -0
  22. package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
  23. package/dist/lib/decode-receipt-labels.js +117 -0
  24. package/dist/lib/decode-receipt-labels.js.map +1 -0
  25. package/dist/lib/decode-receipt-render.d.ts +11 -0
  26. package/dist/lib/decode-receipt-render.d.ts.map +1 -0
  27. package/dist/lib/decode-receipt-render.js +242 -0
  28. package/dist/lib/decode-receipt-render.js.map +1 -0
  29. package/package.json +83 -0
  30. package/src/cli.ts +5 -0
  31. package/src/command.ts +51 -0
  32. package/src/commands/admin-onboard-token.ts +51 -0
  33. package/src/commands/admin.ts +20 -0
  34. package/src/commands/complete-grant.ts +41 -0
  35. package/src/commands/create-consistency-proof.ts +44 -0
  36. package/src/commands/create-log.ts +95 -0
  37. package/src/commands/decode-receipt.ts +20 -0
  38. package/src/commands/delegate.ts +55 -0
  39. package/src/commands/deploy.ts +72 -0
  40. package/src/commands/fetch-accumulator.ts +49 -0
  41. package/src/commands/onboard-genesis.ts +70 -0
  42. package/src/commands/onboard-request.ts +59 -0
  43. package/src/commands/register-grant.ts +74 -0
  44. package/src/commands/register.ts +54 -0
  45. package/src/commands/resolve-receipt.ts +101 -0
  46. package/src/commands/sign-statement.ts +53 -0
  47. package/src/commands/verify-grant.ts +89 -0
  48. package/src/commands/verify.ts +92 -0
  49. package/src/commoncli.ts +34 -0
  50. package/src/decode-receipt.ts +56 -0
  51. package/src/index.ts +10 -0
  52. package/src/lib/bytes.ts +7 -0
  53. package/src/lib/checkpoint-provider.ts +297 -0
  54. package/src/lib/complete-grant-derive.ts +237 -0
  55. package/src/lib/consistency-proof.ts +271 -0
  56. package/src/lib/create-log-build.ts +168 -0
  57. package/src/lib/create-log-prepare.ts +75 -0
  58. package/src/lib/create-receipt-chain-verify.ts +267 -0
  59. package/src/lib/create-receipt-derive.ts +208 -0
  60. package/src/lib/create-receipt-inputs.ts +79 -0
  61. package/src/lib/decode-checkpoint-calldata.ts +188 -0
  62. package/src/lib/decode-receipt-cbor.ts +202 -0
  63. package/src/lib/decode-receipt-decode.ts +333 -0
  64. package/src/lib/decode-receipt-labels.ts +132 -0
  65. package/src/lib/decode-receipt-render.ts +284 -0
  66. package/src/lib/delegate-flow.ts +289 -0
  67. package/src/lib/delegate-voucher.ts +122 -0
  68. package/src/lib/deploy-artifact.ts +79 -0
  69. package/src/lib/deploy-bootstrap.ts +100 -0
  70. package/src/lib/deploy-flow.ts +184 -0
  71. package/src/lib/deploy-genesis-log-id.ts +29 -0
  72. package/src/lib/deploy-rpc.ts +40 -0
  73. package/src/lib/deployment-record.ts +65 -0
  74. package/src/lib/fsio.ts +91 -0
  75. package/src/lib/grant-flags-local.ts +19 -0
  76. package/src/lib/onboard-attestation.ts +120 -0
  77. package/src/lib/onboard-genesis-body.ts +94 -0
  78. package/src/lib/openssl-error-queue.ts +56 -0
  79. package/src/lib/register-flow.ts +292 -0
  80. package/src/lib/register-grant-build.ts +173 -0
  81. package/src/lib/register-grant-complete.ts +47 -0
  82. package/src/lib/register-grant-flow.ts +258 -0
  83. package/src/lib/resolve-receipt-freshen.ts +221 -0
  84. package/src/lib/sign-statement-build.ts +117 -0
  85. package/src/lib/sign-statement-key.ts +145 -0
  86. package/src/lib/verify-anchored.ts +326 -0
  87. package/src/lib/verify-checkpoint-chain.ts +160 -0
  88. package/src/lib/verify-eventscan.ts +214 -0
  89. package/src/lib/verify-inputs.ts +143 -0
  90. package/src/lib/verify-known-accumulator.ts +161 -0
  91. package/src/lib/verify-report.ts +265 -0
  92. package/src/lib/x402-payment.ts +115 -0
  93. package/src/main/admin-onboard-token.ts +216 -0
  94. package/src/main/complete-grant.ts +265 -0
  95. package/src/main/create-consistency-proof.ts +83 -0
  96. package/src/main/create-log.ts +347 -0
  97. package/src/main/create-receipt.ts +763 -0
  98. package/src/main/decode-receipt.ts +72 -0
  99. package/src/main/delegate.ts +141 -0
  100. package/src/main/deploy.ts +273 -0
  101. package/src/main/fetch-accumulator.ts +181 -0
  102. package/src/main/not-implemented.ts +43 -0
  103. package/src/main/onboard-genesis.ts +192 -0
  104. package/src/main/onboard-request.ts +333 -0
  105. package/src/main/register-grant.ts +270 -0
  106. package/src/main/register.ts +243 -0
  107. package/src/main/sign-statement.ts +168 -0
  108. package/src/main/verify.ts +767 -0
  109. package/src/options/admin-onboard-token.ts +48 -0
  110. package/src/options/common.ts +69 -0
  111. package/src/options/complete-grant.ts +40 -0
  112. package/src/options/create-consistency-proof.ts +63 -0
  113. package/src/options/create-log.ts +99 -0
  114. package/src/options/create-receipt.ts +243 -0
  115. package/src/options/decode-receipt.ts +25 -0
  116. package/src/options/delegate.ts +93 -0
  117. package/src/options/deploy.ts +105 -0
  118. package/src/options/fetch-accumulator.ts +62 -0
  119. package/src/options/onboard-genesis.ts +74 -0
  120. package/src/options/onboard-request.ts +57 -0
  121. package/src/options/register-grant.ts +72 -0
  122. package/src/options/register.ts +71 -0
  123. package/src/options/sign-statement.ts +64 -0
  124. package/src/options/verify.ts +250 -0
@@ -0,0 +1,267 @@
1
+ import { bytesEqual } from "./bytes.js";
2
+ import {
3
+ computeAccumulatorPeak,
4
+ openMassifNodeStore,
5
+ peakMMRIndexes,
6
+ type MassifNodeStore,
7
+ } from "@forestrie/receipt-verify";
8
+ import {
9
+ fetchOnChainLogState,
10
+ type OnChainLogState,
11
+ } from "./verify-anchored.js";
12
+
13
+ /**
14
+ * Chain-anchored, report-only verification for `forestrie create-receipt`
15
+ * (FOR-345 phase 2, plan-2607-15 §3/§6). This is the option-B path: it emits
16
+ * NO signed receipt. Its selling point is receipt-free verification — the
17
+ * checkpoint signature was already enforced on-chain at `publishCheckpoint`
18
+ * time, so no `.sth` is needed at all.
19
+ *
20
+ * The computation:
21
+ * 1. read the on-chain `logState(bytes32) -> {accumulator, size}`;
22
+ * 2. build the leaf's inclusion path AT the on-chain size
23
+ * (`mmrLastIndex = size - 1`) from the local massif blob and compute the
24
+ * accumulator peak it commits to (`computeAccumulatorPeak`);
25
+ * 3. compare that peak against `accumulator[peakIndex]`, where `peakIndex`
26
+ * is the same proof-length -> accumulator-slot selection the contract's
27
+ * own verifier uses (returned by `computeAccumulatorPeak`).
28
+ *
29
+ * Because the path is built at the CURRENT size, the target peak is by
30
+ * construction a member of the current accumulator — burial cannot occur at
31
+ * creation time. There is NO exact-accumulator-size constraint: any leaf with
32
+ * `mmrIndex < size` works, given local node data up to that size (plan §3,
33
+ * decision 4).
34
+ *
35
+ * Pure over bytes plus exactly one `eth_call`; no signing key, no operator.
36
+ */
37
+
38
+ /** Outcome token — the `--json` shape and exit-code mapping are a contract. */
39
+ export type ChainVerifyOutcome =
40
+ | "verified"
41
+ | "not_yet_anchored"
42
+ | "wrong_massif"
43
+ | "coverage"
44
+ | "accumulator_short"
45
+ | "peak_mismatch";
46
+
47
+ /**
48
+ * Where a chain-mode *operational* error (as opposed to a verification
49
+ * outcome) broke the run: massif parse, or the on-chain read/decode.
50
+ */
51
+ export type ChainVerifyErrorStage = "parse" | "chain";
52
+
53
+ /** Stable snake_case reasons for operational failures (`--json` contract). */
54
+ export type ChainVerifyReason = "massif_parse_failed";
55
+
56
+ export class ChainVerifyFailure extends Error {
57
+ readonly stage: ChainVerifyErrorStage;
58
+ readonly reason: ChainVerifyReason;
59
+
60
+ constructor(
61
+ stage: ChainVerifyErrorStage,
62
+ reason: ChainVerifyReason,
63
+ message: string,
64
+ ) {
65
+ super(message);
66
+ this.name = "ChainVerifyFailure";
67
+ this.stage = stage;
68
+ this.reason = reason;
69
+ }
70
+ }
71
+
72
+ /** Facts for narration / `--json` reporting (bigints stay bigint here). */
73
+ export type ChainVerifyResult = {
74
+ outcome: ChainVerifyOutcome;
75
+ leaf: { mmrIndex: bigint };
76
+ massif: {
77
+ massifIndex: bigint;
78
+ massifHeight: number;
79
+ firstIndex: bigint;
80
+ lastIndex: bigint;
81
+ };
82
+ onchain: {
83
+ /** Anchored MMR size read from the contract. */
84
+ size: bigint;
85
+ /** Number of accumulator peaks the contract holds. */
86
+ peakCount: number;
87
+ };
88
+ /**
89
+ * Peak-check facts. Absent on `not_yet_anchored` / `coverage` (the peak was
90
+ * never computed) — present on `verified` and `peak_mismatch`.
91
+ */
92
+ peakCheck?: {
93
+ /** Inclusion path length (leaf -> accumulator peak) at the on-chain size. */
94
+ proofLength: number;
95
+ /** Accumulator slot selected for a proof of this length. */
96
+ peakIndex: number;
97
+ /** MMR index of the selected accumulator peak. */
98
+ peakMMRIndex: bigint;
99
+ /** The peak recomputed from local node data (hex). */
100
+ computedPeakHex: string;
101
+ /** The peak the contract holds at `peakIndex` (hex). */
102
+ onchainPeakHex: string;
103
+ /** True iff the two peaks match byte-for-byte. */
104
+ matched: boolean;
105
+ };
106
+ };
107
+
108
+ function errorMessage(err: unknown): string {
109
+ return err instanceof Error ? err.message : String(err);
110
+ }
111
+
112
+ function toHex(bytes: Uint8Array): string {
113
+ let hex = "";
114
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
115
+ return hex;
116
+ }
117
+
118
+
119
+ /** Dependencies, injectable so tests can mock the on-chain read. */
120
+ export type ChainVerifyDeps = {
121
+ fetchOnChainLogState: (opts: {
122
+ univocity: string;
123
+ logId: string;
124
+ rpcUrl: string;
125
+ }) => Promise<OnChainLogState>;
126
+ };
127
+
128
+ const defaultDeps: ChainVerifyDeps = { fetchOnChainLogState };
129
+
130
+ /**
131
+ * Run the report-only chain-anchored peak check.
132
+ *
133
+ * Failure taxonomy (plan §3, plan-2607-18 W3, all reported — never thrown):
134
+ * - `not_yet_anchored` — `mmrIndex >= size`: the leaf postdates the last
135
+ * anchor ("anchor lag"); the local massif may hold it, but the chain does
136
+ * not attest it yet.
137
+ * - `wrong_massif` — the loaded massif blob is for a *different* massif than
138
+ * the leaf: the leaf's `mmrIndex` falls outside this blob's own
139
+ * `firstIndex..lastIndex` window, so its inclusion path cannot be built
140
+ * from these bytes. The remedy is a different blob (the one holding the
141
+ * leaf), NOT a *later* blob — which is why this is distinct from
142
+ * `coverage`. Mirrors the offline builder's `leaf_not_in_massif` guard.
143
+ * - `coverage` — the leaf IS in this massif, but the blob does not hold nodes
144
+ * up to the on-chain size (the on-chain tip lives in a later massif); a
145
+ * later blob is required.
146
+ * - `peak_mismatch` — the computed peak differs from the anchored one: the
147
+ * local node data has been tampered with (or is for the wrong log).
148
+ *
149
+ * Operational errors (bad massif bytes, RPC transport/decode) throw
150
+ * `ChainVerifyFailure` / propagate — the caller maps them to exit 1.
151
+ */
152
+ export async function verifyChainAnchored(
153
+ input: {
154
+ massifBytes: Uint8Array;
155
+ mmrIndex: bigint;
156
+ univocity: string;
157
+ logId: string;
158
+ rpcUrl: string;
159
+ },
160
+ deps: ChainVerifyDeps = defaultDeps,
161
+ ): Promise<ChainVerifyResult> {
162
+ let store: MassifNodeStore;
163
+ try {
164
+ store = openMassifNodeStore(input.massifBytes);
165
+ } catch (err) {
166
+ throw new ChainVerifyFailure(
167
+ "parse",
168
+ "massif_parse_failed",
169
+ `massif blob is not v2 layout: ${errorMessage(err)}`,
170
+ );
171
+ }
172
+
173
+ // The on-chain read is the one networked call (throws -> operational error).
174
+ const state = await deps.fetchOnChainLogState({
175
+ univocity: input.univocity,
176
+ logId: input.logId,
177
+ rpcUrl: input.rpcUrl,
178
+ });
179
+
180
+ const massif = {
181
+ massifIndex: store.massifIndex,
182
+ massifHeight: store.massifHeight,
183
+ firstIndex: store.firstIndex,
184
+ lastIndex: store.lastIndex,
185
+ };
186
+ const onchain = { size: state.size, peakCount: state.accumulator.length };
187
+ const base = { leaf: { mmrIndex: input.mmrIndex }, massif, onchain };
188
+
189
+ // Anchor lag: the entry is newer than the last on-chain checkpoint.
190
+ if (input.mmrIndex >= state.size) {
191
+ return { outcome: "not_yet_anchored", ...base };
192
+ }
193
+
194
+ // Wrong massif: the loaded blob must actually hold the leaf. The leaf's own
195
+ // inclusion path is built from THIS blob's node data, so the leaf must fall
196
+ // inside `firstIndex..lastIndex`. A leaf below `firstIndex` belongs to an
197
+ // earlier massif; above `lastIndex`, a later one. Either way the remedy is a
198
+ // *different* blob (the one containing the leaf) — not a later one — so this
199
+ // is distinct from `coverage`. Mirrors `buildReceiptOffline`'s
200
+ // `leaf_not_in_massif` guard (create-receipt-derive.ts), which the chain
201
+ // path previously omitted (plan-2607-18 W3, V2/V3). Without it a
202
+ // wrong-massif blob was misreported as `coverage` ("fetch the covering
203
+ // massif"), a misleading remedy.
204
+ if (input.mmrIndex < store.firstIndex || input.mmrIndex > store.lastIndex) {
205
+ return { outcome: "wrong_massif", ...base };
206
+ }
207
+
208
+ // Coverage: the path is built at `size - 1`, so the local blob must hold
209
+ // the log tip up to `size - 1`. Nodes below `firstIndex` resolve through
210
+ // the ancestor peak stack, so only the upper bound can be short here.
211
+ const mmrLastIndex = state.size - 1n;
212
+ if (mmrLastIndex > store.lastIndex) {
213
+ return { outcome: "coverage", ...base };
214
+ }
215
+
216
+ // Build the path AT the on-chain size and compute the accumulator peak it
217
+ // commits to (no exact-size constraint — this works for any covered leaf).
218
+ let computed: Awaited<ReturnType<typeof computeAccumulatorPeak>>;
219
+ try {
220
+ computed = await computeAccumulatorPeak({
221
+ massifBytes: input.massifBytes,
222
+ mmrIndex: input.mmrIndex,
223
+ mmrSize: state.size,
224
+ });
225
+ } catch (err) {
226
+ // A throw here means the blob could not furnish a proof node up to the
227
+ // on-chain size despite the range check — treat as coverage, not a crash.
228
+ return { outcome: "coverage", ...base };
229
+ }
230
+
231
+ const peaks = peakMMRIndexes(mmrLastIndex);
232
+ const peakMMRIndex = peaks[computed.peakIndex] ?? -1n;
233
+ const onchainPeak = state.accumulator[computed.peakIndex];
234
+ if (onchainPeak === undefined) {
235
+ // The contract holds FEWER peaks than the selected slot: a shape/length
236
+ // disagreement, not a byte-level peak mismatch. Distinct reason
237
+ // (`accumulator_short`, plan-2607-18 W3 V4) so a truncated on-chain
238
+ // accumulator reads differently from tampered node data — the computed
239
+ // peak is fine; the chain simply has no slot to compare it against.
240
+ return {
241
+ outcome: "accumulator_short",
242
+ ...base,
243
+ peakCheck: {
244
+ proofLength: computed.proof.length,
245
+ peakIndex: computed.peakIndex,
246
+ peakMMRIndex,
247
+ computedPeakHex: toHex(computed.peak),
248
+ onchainPeakHex: "",
249
+ matched: false,
250
+ },
251
+ };
252
+ }
253
+
254
+ const matched = bytesEqual(computed.peak, onchainPeak);
255
+ return {
256
+ outcome: matched ? "verified" : "peak_mismatch",
257
+ ...base,
258
+ peakCheck: {
259
+ proofLength: computed.proof.length,
260
+ peakIndex: computed.peakIndex,
261
+ peakMMRIndex,
262
+ computedPeakHex: toHex(computed.peak),
263
+ onchainPeakHex: toHex(onchainPeak),
264
+ matched,
265
+ },
266
+ };
267
+ }
@@ -0,0 +1,208 @@
1
+ import {
2
+ buildReceiptOffline,
3
+ computeAccumulatorPeak,
4
+ openMassifNodeStore,
5
+ parseCheckpoint,
6
+ parseReceipt,
7
+ peakMMRIndexes,
8
+ type MassifNodeStore,
9
+ type ParsedCheckpoint,
10
+ } from "@forestrie/receipt-verify";
11
+
12
+ /**
13
+ * Checkpoint-mode receipt derivation for `forestrie create-receipt`
14
+ * (FOR-345 phase 1, plan-2607-15 §6): stage the
15
+ * `@forestrie/receipt-verify` primitives so every failure is a typed
16
+ * `CreateReceiptFailure` with a stable stage + reason, and collect the
17
+ * narration facts (massif index, proof length, selected peak, cert copy)
18
+ * alongside the receipt bytes. Pure over bytes — no network, no key.
19
+ */
20
+
21
+ /** Where derivation broke: artefact parsing vs. proof/receipt assembly. */
22
+ export type CreateReceiptStage = "parse" | "derive";
23
+
24
+ /** Stable snake_case reasons — the `--json` error shape is a contract. */
25
+ export type CreateReceiptReason =
26
+ | "massif_parse_failed"
27
+ | "checkpoint_parse_failed"
28
+ | "checkpoint_missing_peak_receipts"
29
+ | "checkpoint_missing_sealed_size"
30
+ | "checkpoint_does_not_cover_leaf"
31
+ | "leaf_not_in_massif"
32
+ | "derive_failed";
33
+
34
+ export class CreateReceiptFailure extends Error {
35
+ readonly stage: CreateReceiptStage;
36
+ readonly reason: CreateReceiptReason;
37
+
38
+ constructor(
39
+ stage: CreateReceiptStage,
40
+ reason: CreateReceiptReason,
41
+ message: string,
42
+ ) {
43
+ super(message);
44
+ this.name = "CreateReceiptFailure";
45
+ this.stage = stage;
46
+ this.reason = reason;
47
+ }
48
+ }
49
+
50
+ /** Facts for narration / `--json` reporting (bigints stay bigint here). */
51
+ export type DerivedReceiptDetails = {
52
+ massifIndex: bigint;
53
+ massifHeight: number;
54
+ firstIndex: bigint;
55
+ lastIndex: bigint;
56
+ /** Sealed tree size the checkpoint signature commits to. */
57
+ sealedSize: bigint;
58
+ /** Inclusion path length (leaf → accumulator peak). */
59
+ proofLength: number;
60
+ /** Accumulator slot of the selected pre-signed peak receipt. */
61
+ peakIndex: number;
62
+ /** Total accumulator peaks at the sealed size. */
63
+ peakCount: number;
64
+ /** MMR index of the selected peak. */
65
+ peakMMRIndex: bigint;
66
+ /** Delegation cert (label 1000) copied from checkpoint to receipt. */
67
+ certCopied: boolean;
68
+ };
69
+
70
+ export type DerivedReceipt = {
71
+ receiptCbor: Uint8Array;
72
+ details: DerivedReceiptDetails;
73
+ };
74
+
75
+ function fail(
76
+ stage: CreateReceiptStage,
77
+ reason: CreateReceiptReason,
78
+ message: string,
79
+ ): never {
80
+ throw new CreateReceiptFailure(stage, reason, message);
81
+ }
82
+
83
+ function errorMessage(err: unknown): string {
84
+ return err instanceof Error ? err.message : String(err);
85
+ }
86
+
87
+ /**
88
+ * Derive a receipt for `mmrIndex` from local massif + checkpoint bytes.
89
+ * The assembly itself is `buildReceiptOffline`; this wrapper pre-parses
90
+ * both artefacts so the precondition failures the taxonomy names —
91
+ * leaf-not-in-massif, checkpoint-doesn't-cover-leaf — are classified
92
+ * before derivation, and the checkpoint parse feeds the narration.
93
+ */
94
+ export async function deriveCheckpointReceipt(input: {
95
+ massifBytes: Uint8Array;
96
+ checkpointBytes: Uint8Array;
97
+ mmrIndex: bigint;
98
+ }): Promise<DerivedReceipt> {
99
+ const { massifBytes, checkpointBytes, mmrIndex } = input;
100
+
101
+ let checkpoint: ParsedCheckpoint;
102
+ try {
103
+ checkpoint = parseCheckpoint(checkpointBytes);
104
+ } catch (err) {
105
+ fail(
106
+ "parse",
107
+ "checkpoint_parse_failed",
108
+ `checkpoint is not a format-v3 COSE Sign1: ${errorMessage(err)}`,
109
+ );
110
+ }
111
+ if (!checkpoint.peakReceipts) {
112
+ fail(
113
+ "parse",
114
+ "checkpoint_missing_peak_receipts",
115
+ "checkpoint carries no pre-signed peak receipts (label -65931)",
116
+ );
117
+ }
118
+ const sealedSize = checkpoint.mmrSize;
119
+ if (sealedSize === null || sealedSize <= 0n) {
120
+ fail(
121
+ "parse",
122
+ "checkpoint_missing_sealed_size",
123
+ "checkpoint carries no consistency proof (cannot determine sealed size)",
124
+ );
125
+ }
126
+
127
+ let store: MassifNodeStore;
128
+ try {
129
+ store = openMassifNodeStore(massifBytes);
130
+ } catch (err) {
131
+ fail(
132
+ "parse",
133
+ "massif_parse_failed",
134
+ `massif blob is not v2 layout: ${errorMessage(err)}`,
135
+ );
136
+ }
137
+
138
+ if (mmrIndex >= sealedSize) {
139
+ fail(
140
+ "derive",
141
+ "checkpoint_does_not_cover_leaf",
142
+ `checkpoint does not cover the leaf: mmrIndex ${mmrIndex} >= sealed ` +
143
+ `size ${sealedSize} — the entry postdates this checkpoint`,
144
+ );
145
+ }
146
+ if (mmrIndex < store.firstIndex || mmrIndex > store.lastIndex) {
147
+ fail(
148
+ "derive",
149
+ "leaf_not_in_massif",
150
+ `leaf mmrIndex ${mmrIndex} is not in this massif blob (massif ` +
151
+ `${store.massifIndex} holds mmr indexes ${store.firstIndex}..` +
152
+ `${store.lastIndex})`,
153
+ );
154
+ }
155
+
156
+ let receiptCbor: Uint8Array;
157
+ try {
158
+ receiptCbor = buildReceiptOffline({ massifBytes, checkpointBytes, mmrIndex });
159
+ } catch (err) {
160
+ fail("derive", "derive_failed", errorMessage(err));
161
+ }
162
+
163
+ // Narration facts. The proof length comes from the assembled receipt.
164
+ //
165
+ // The reported peak MUST identify the exact same accumulator peak whose
166
+ // pre-signed receipt buildReceiptOffline attached — this narration is the
167
+ // demo's proof of *which* peak was signed, so it cannot drift from the
168
+ // builder. We therefore reuse the builder's own selection rather than
169
+ // recomputing it independently: computeAccumulatorPeak returns the same
170
+ // `peakIndex` (peakIndexForLeafProof(mmrSize, proofLen)) buildReceiptOffline
171
+ // indexes the -65931 array with, plus the peak hash it commits to. The
172
+ // -65931 array and peakMMRIndexes share the sealer's descending-height /
173
+ // ascending-mmr-index order (go-merklelog PeakHashes), so peakIndex maps
174
+ // straight into peakMMRIndexes — no inversion.
175
+ const proofLength = parseReceipt(receiptCbor).proof.path.length;
176
+ const peaks = peakMMRIndexes(sealedSize - 1n);
177
+ let peakIndex: number;
178
+ try {
179
+ ({ peakIndex } = await computeAccumulatorPeak({
180
+ massifBytes,
181
+ mmrIndex,
182
+ mmrSize: sealedSize,
183
+ }));
184
+ } catch (err) {
185
+ fail("derive", "derive_failed", errorMessage(err));
186
+ }
187
+ const peakMMRIndex = peaks[peakIndex];
188
+ if (peakMMRIndex === undefined) {
189
+ // Unreachable given mmrIndex < sealedSize; keep the failure typed.
190
+ fail("derive", "derive_failed", "no accumulator peak covers the leaf");
191
+ }
192
+
193
+ return {
194
+ receiptCbor,
195
+ details: {
196
+ massifIndex: store.massifIndex,
197
+ massifHeight: store.massifHeight,
198
+ firstIndex: store.firstIndex,
199
+ lastIndex: store.lastIndex,
200
+ sealedSize,
201
+ proofLength,
202
+ peakIndex,
203
+ peakCount: peaks.length,
204
+ peakMMRIndex,
205
+ certCopied: checkpoint.delegationCert !== null,
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { decodeEntryIdHex } from "@forestrie/receipt-verify";
3
+ import type { CreateReceiptOptions } from "../options/create-receipt.js";
4
+
5
+ /**
6
+ * Load and resolve `forestrie create-receipt` inputs (FOR-345). Pure
7
+ * file/decode work — strictly no network.
8
+ *
9
+ * Leaf addressing: `--mmr-index` is used verbatim; `--entry-id` is the
10
+ * permanent SCRAPI entry id whose second 8 bytes ARE the big-endian
11
+ * mmrIndex (`decodeEntryIdHex`), so entry-id addressing is fully
12
+ * derivable offline — no massif index-region (urkle) lookup is involved.
13
+ * (Content-hash addressing is the phase-2 feature that waits on the
14
+ * urkle index reader, FOR-373; entry-id does not.)
15
+ */
16
+ export type CreateReceiptLeaf = {
17
+ mmrIndex: bigint;
18
+ source: "mmr-index" | "entry-id";
19
+ /** Present with `--entry-id`: the id as given. */
20
+ entryId?: string;
21
+ /** Present with `--entry-id`: the decoded idtimestamp (decimal). */
22
+ idtimestamp?: bigint;
23
+ };
24
+
25
+ export type CreateReceiptArtifacts = {
26
+ massifBytes: Uint8Array;
27
+ /** Present in checkpoint mode (`--checkpoint`). */
28
+ checkpointBytes: Uint8Array | undefined;
29
+ leaf: CreateReceiptLeaf;
30
+ };
31
+
32
+ function readBytes(path: string, flag: string): Uint8Array {
33
+ try {
34
+ return new Uint8Array(readFileSync(path));
35
+ } catch (err) {
36
+ const message = err instanceof Error ? err.message : String(err);
37
+ throw new Error(`cannot read ${flag} '${path}': ${message}`);
38
+ }
39
+ }
40
+
41
+ /** Resolve the leaf mmrIndex from whichever addressing flag was given. */
42
+ export function resolveLeaf(options: CreateReceiptOptions): CreateReceiptLeaf {
43
+ if (options.mmrIndex !== undefined) {
44
+ return { mmrIndex: options.mmrIndex, source: "mmr-index" };
45
+ }
46
+ // Options parsing guarantees exactly one of the two flags.
47
+ const entryId = options.entryId!;
48
+ let decoded: ReturnType<typeof decodeEntryIdHex>;
49
+ try {
50
+ decoded = decodeEntryIdHex(entryId);
51
+ } catch (err) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ throw new Error(`invalid --entry-id '${entryId}': ${message}`);
54
+ }
55
+ return {
56
+ mmrIndex: decoded.mmrIndex,
57
+ source: "entry-id",
58
+ entryId,
59
+ idtimestamp: decoded.idtimestamp,
60
+ };
61
+ }
62
+
63
+ /** Read massif/checkpoint artefacts from disk and resolve the leaf. */
64
+ export function loadCreateReceiptArtifacts(
65
+ options: CreateReceiptOptions,
66
+ ): CreateReceiptArtifacts {
67
+ const leaf = resolveLeaf(options);
68
+ if (options.massif === undefined) {
69
+ // Unreachable: only tile anchors (checkpoint/chain) reach here, and options
70
+ // parsing requires --massif for those. Guard keeps the type honest.
71
+ throw new Error("--massif is required to build a receipt from tiles");
72
+ }
73
+ const massifBytes = readBytes(options.massif, "--massif");
74
+ const checkpointBytes =
75
+ options.checkpoint !== undefined
76
+ ? readBytes(options.checkpoint, "--checkpoint")
77
+ : undefined;
78
+ return { massifBytes, checkpointBytes, leaf };
79
+ }