@forestrie/forestrie-cli 0.9.1 → 0.11.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.
@@ -39,6 +39,7 @@ import {
39
39
  calldataCheckpointChain,
40
40
  sthCheckpointChain,
41
41
  type CheckpointLink,
42
+ singleProofChain,
42
43
  } from "./checkpoint-provider.js";
43
44
  import { univocityLeafHash } from "./verify-anchored.js";
44
45
  import { bytesEqual } from "./bytes.js";
@@ -102,16 +103,11 @@ async function emitFreshened(opts: {
102
103
  const result = await freshenReceipt({
103
104
  oldReceiptBytes: opts.oldReceiptBytes,
104
105
  leafValue: opts.leafValue,
105
- // `freshenReceipt`'s consistency-proof array type carries
106
- // `signedTreeSize2` (checked at `.sth` decode time), but the fold itself
107
- // never reads it — see `FoldableConsistencyProof`'s doc comment in
108
- // checkpoint-provider.ts. Backfilled from `treeSize2` to satisfy the
109
- // parameter type for calldata-sourced links, which have no per-proof
110
- // signed size.
111
- consistencyProofs: opts.links.map((l) => ({
112
- ...l.proof,
113
- signedTreeSize2: l.proof.treeSize2,
114
- })),
106
+ // `freshenReceipt` takes chains; each link is one step, wrapped as a
107
+ // one-proof chain — see `singleProofChain` in checkpoint-provider.ts.
108
+ consistencyProofs: opts.links.map((l) =>
109
+ singleProofChain(l.proof, l.signedTreeSize2),
110
+ ),
115
111
  latestCheckpointBytes: opts.latestCheckpointBytes,
116
112
  });
117
113
 
@@ -177,7 +173,9 @@ export async function freshenFromSthChain(opts: {
177
173
  leafValue,
178
174
  links,
179
175
  latestCheckpointBytes: opts.checkpoints[opts.checkpoints.length - 1]!,
180
- sourceRefs: [...(opts.sourceRefs ?? [])],
176
+ // One entry per LINK, as the calldata path reports: a checkpoint that
177
+ // relays several proofs yields several links that all name it.
178
+ sourceRefs: links.map((l) => l.sourceRef ?? ""),
181
179
  knownAccumulator: opts.knownAccumulator,
182
180
  });
183
181
  }
@@ -59,7 +59,7 @@ export type AnchorCheck = OnChainLogState & {
59
59
  * - the leaf is at/after the anchored size — the entry simply is not
60
60
  * anchored yet (`receipt_newer_than_anchored_state`).
61
61
  * Tampered receipts surface earlier (signature/inclusion) or as a
62
- * recomputed peak that matches no anchored state ever; the chain rungs
62
+ * recomputed peak that matches no anchored state ever; the chain roots
63
63
  * that PROVE the buried case land in later plan phases.
64
64
  */
65
65
  export function classifyUnanchoredPeak(
@@ -15,7 +15,7 @@ import type { AnchorCheck } from "./verify-anchored.js";
15
15
  * (ADR-0056: every checkpoint's embedded consistency proof spans its
16
16
  * massif's entry boundary to its seal). `@forestrie/receipt-verify`'s
17
17
  * `verifyCheckpointChain` folds that chain from size 0, authenticating the
18
- * accumulator at EVERY retained seal — this rung depends only on the
18
+ * accumulator at EVERY retained seal — this root depends only on the
19
19
  * public log store: no tiles, no RPC, no holder cache. It is the
20
20
  * complement of the `CheckpointPublished` event scan (public chain data
21
21
  * only); the two paths are independently sufficient and cross-checkable.
@@ -109,7 +109,7 @@ export type CheckpointChainAnchorCheck = AnchorCheck & {
109
109
 
110
110
  /**
111
111
  * Find the recomputed receipt peak in the authenticated chain. Newest-first
112
- * (mirrors the event-scan rung): the freshest cover gives the most useful
112
+ * (mirrors the event-scan route): the freshest cover gives the most useful
113
113
  * report, and a match at ANY link is proof — later links' signed
114
114
  * consistency proofs commit it forward. Newer-than-chain fails CLOSED with
115
115
  * a refresh remedy: retention limits coverage, never validity.
@@ -11,8 +11,8 @@
11
11
  * record of those accumulators (`CheckpointPublished` carries `size` and
12
12
  * `accumulator`; the contract stores nothing retrievable historically).
13
13
  *
14
- * This rung depends only on public blockchain data — deliberately
15
- * independent of the log store (the retained-checkpoint chain rung is its
14
+ * This route depends only on public blockchain data — deliberately
15
+ * independent of the log store (the retained-checkpoint chain root is its
16
16
  * complement with the opposite dependency; see plan-2607-29).
17
17
  */
18
18
  import { bytesEqual } from "./bytes.js";
@@ -64,7 +64,7 @@ export type LoadedVerifyArtifacts = Omit<
64
64
  "genesisCbor"
65
65
  > & { genesisCbor: Uint8Array | undefined };
66
66
 
67
- function readBytes(path: string, flag: string): Uint8Array {
67
+ export function readBytes(path: string, flag: string): Uint8Array {
68
68
  try {
69
69
  return new Uint8Array(readFileSync(path));
70
70
  } catch (err) {
@@ -2,6 +2,7 @@ import type {
2
2
  ReceiptVerifyResult,
3
3
  ReceiptVerifyStage,
4
4
  } from "@forestrie/receipt-verify";
5
+ import type { Ks256Attestation } from "@forestrie/receipt-verify";
5
6
  import type { AnchorCheck } from "./verify-anchored.js";
6
7
 
7
8
  /**
@@ -110,6 +111,46 @@ export function knownKeyStageRows(result: ReceiptVerifyResult): StageRow[] {
110
111
  });
111
112
  }
112
113
 
114
+ /**
115
+ * KS256 root facts for the report: whether the root is a KS256 address, how
116
+ * it was supplied, and any attestation given (with whether the certificate
117
+ * needed it, i.e. did not verify by ecrecover alone).
118
+ */
119
+ export type Ks256ReportInfo = {
120
+ ks256Root: boolean;
121
+ knownKey: boolean;
122
+ attestation?: Ks256Attestation;
123
+ attestationUsed?: boolean;
124
+ };
125
+
126
+ /** Reword the parse/signature rows for a KS256 root or an attestation. */
127
+ export function applyKs256Rows(
128
+ rows: StageRow[],
129
+ info: Ks256ReportInfo,
130
+ ): StageRow[] {
131
+ const att = info.attestation;
132
+ return rows.map((row) => {
133
+ if (row.status !== "ok") return row;
134
+ if (row.stage === "parse" && info.ks256Root) {
135
+ return {
136
+ ...row,
137
+ reason: `receipt COSE decodes; ${info.knownKey ? "caller-known log key" : "genesis trust root"} loads (KS256 address)`,
138
+ };
139
+ }
140
+ if (row.stage === "signature" && info.ks256Root) {
141
+ const anchorName = info.knownKey ? "caller-known log key" : "genesis root";
142
+ return {
143
+ ...row,
144
+ reason:
145
+ att !== undefined && info.attestationUsed === true
146
+ ? `delegation certificate valid under the contract's signers at block ${att.blockNumber} (ERC-1271 attestation; chain ${att.chainId}) — not a signature by one key`
147
+ : `delegation certificate recovers to the KS256 ${anchorName} (ecrecover)`,
148
+ };
149
+ }
150
+ return row;
151
+ });
152
+ }
153
+
113
154
  export function stageRows(result: ReceiptVerifyResult): StageRow[] {
114
155
  if (result.ok) {
115
156
  return VERIFY_STAGES.map((stage) => ({ stage, status: "ok" }));
@@ -185,6 +226,19 @@ export type VerifyReport = {
185
226
  reason?: string;
186
227
  stages: StageRow[];
187
228
  anchor?: AnchorReport;
229
+ /** KS256 root and ERC-1271 attestation facts (present for a KS256 root). */
230
+ ks256?: {
231
+ root: boolean;
232
+ /** An unused attestation reports `used: false` only: its fields were
233
+ * never checked against anything, so they are not echoed. */
234
+ attestation?: {
235
+ used: boolean;
236
+ chainId?: string;
237
+ address?: string;
238
+ blockNumber?: string;
239
+ blockHash?: string;
240
+ };
241
+ };
188
242
  };
189
243
 
190
244
  /** Extra anchor facts present only for known-accumulator checks. */
@@ -203,6 +257,7 @@ export function buildVerifyReport(opts: {
203
257
  logId?: string | undefined;
204
258
  /** Anchor-only mode: rows that reflect the skipped signature stage. */
205
259
  stagesOverride?: StageRow[] | undefined;
260
+ ks256?: Ks256ReportInfo | undefined;
206
261
  }): VerifyReport {
207
262
  const report: VerifyReport = {
208
263
  command: "verify",
@@ -214,6 +269,25 @@ export function buildVerifyReport(opts: {
214
269
  if (opts.result.reason !== undefined) {
215
270
  report.reason = opts.result.reason;
216
271
  }
272
+ if (opts.ks256 !== undefined) {
273
+ const att = opts.ks256.attestation;
274
+ report.ks256 = {
275
+ root: opts.ks256.ks256Root,
276
+ ...(att !== undefined && opts.ks256.attestationUsed !== true
277
+ ? { attestation: { used: false } }
278
+ : att !== undefined
279
+ ? {
280
+ attestation: {
281
+ used: true,
282
+ chainId: att.chainId.toString(),
283
+ address: toHex(att.address),
284
+ blockNumber: att.blockNumber.toString(),
285
+ blockHash: toHex(att.blockHash),
286
+ },
287
+ }
288
+ : {}),
289
+ };
290
+ }
217
291
  if (opts.anchor !== undefined) {
218
292
  const anchor: AnchorReport = {
219
293
  univocity: opts.univocity ?? "",
@@ -263,3 +337,7 @@ export function buildVerifyReport(opts: {
263
337
  }
264
338
  return report;
265
339
  }
340
+
341
+ function toHex(bytes: Uint8Array): string {
342
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
343
+ }
@@ -0,0 +1,245 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import type { Out } from "@forestrie/cli-kit/reporting";
3
+ import {
4
+ bytesToHex,
5
+ encodeIsValidSignatureCall,
6
+ ethRpc,
7
+ hexAddressToBytes,
8
+ normalizeHexAddress,
9
+ } from "@forestrie/chain-rpc";
10
+ import {
11
+ decodeChainBindingFromGenesis,
12
+ decodeTrustRootFromGenesis,
13
+ encodeKs256Attestation,
14
+ ERC1271_MAGIC_VALUE,
15
+ extractKs256DelegationDigest,
16
+ } from "@forestrie/receipt-verify";
17
+ import { keccak256 } from "viem";
18
+ import type { FetchKs256AttestationOptions } from "../options/fetch-ks256-attestation.js";
19
+ import { isKs256Root } from "../lib/known-log-key.js";
20
+
21
+ /** `--json` success shape on stdout — stable for demo scripting. */
22
+ export type FetchKs256AttestationReport = {
23
+ command: "fetch-ks256-attestation";
24
+ chainId: string;
25
+ address: string;
26
+ digest: string;
27
+ blockNumber: string;
28
+ blockHash: string;
29
+ out: string;
30
+ };
31
+
32
+ export type FetchKs256AttestationErrorReport = {
33
+ error: "fetch_ks256_attestation_failed";
34
+ command: "fetch-ks256-attestation";
35
+ message: string;
36
+ };
37
+
38
+ function readFile(path: string, flag: string): Uint8Array {
39
+ try {
40
+ return new Uint8Array(readFileSync(path));
41
+ } catch (err) {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ throw new Error(`cannot read ${flag} '${path}': ${message}`);
44
+ }
45
+ }
46
+
47
+ function hexToBigint(v: unknown, what: string): bigint {
48
+ if (typeof v !== "string" || !/^0x[0-9a-fA-F]+$/.test(v)) {
49
+ throw new Error(`${what}: expected a hex quantity, got ${String(v)}`);
50
+ }
51
+ return BigInt(v);
52
+ }
53
+
54
+ function hexToBytes32(v: unknown, what: string): Uint8Array {
55
+ if (typeof v !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(v)) {
56
+ throw new Error(`${what}: expected a 32-byte hex value, got ${String(v)}`);
57
+ }
58
+ return new Uint8Array(Buffer.from(v.slice(2), "hex"));
59
+ }
60
+
61
+ /**
62
+ * Pin to one block, then: refuse an EOA root (no code), and require the
63
+ * contract's `isValidSignature(digest, signature)` to return the ERC-1271
64
+ * magic value for the certificate's own digest and signature. Nothing is
65
+ * written unless every check passes.
66
+ */
67
+ export async function runFetchKs256Attestation(
68
+ out: Out,
69
+ options: FetchKs256AttestationOptions,
70
+ ): Promise<void> {
71
+ try {
72
+ const receiptCbor = readFile(options.receipt, "--receipt");
73
+ const genesisCbor =
74
+ options.genesis !== undefined
75
+ ? readFile(options.genesis, "--genesis")
76
+ : undefined;
77
+
78
+ let root: Uint8Array;
79
+ if (options.root !== undefined) {
80
+ const normalized = normalizeHexAddress(options.root);
81
+ if (normalized === null) {
82
+ throw new Error(`--root is not a valid address: '${options.root}'`);
83
+ }
84
+ root = hexAddressToBytes(normalized);
85
+ } else {
86
+ const trust = await decodeTrustRootFromGenesis(genesisCbor!);
87
+ if (!isKs256Root(trust)) {
88
+ throw new Error(
89
+ "the genesis root is not a KS256 address (an ES256 root needs no attestation)",
90
+ );
91
+ }
92
+ root = (trust as { address: Uint8Array }).address;
93
+ }
94
+
95
+ let genesisChainId: bigint | undefined;
96
+ if (genesisCbor !== undefined) {
97
+ genesisChainId = BigInt(decodeChainBindingFromGenesis(genesisCbor).chainId);
98
+ }
99
+
100
+ let parts: ReturnType<typeof extractKs256DelegationDigest>;
101
+ try {
102
+ parts = extractKs256DelegationDigest(receiptCbor);
103
+ } catch (err) {
104
+ const message = err instanceof Error ? err.message : String(err);
105
+ throw new Error(`the receipt's delegation certificate is not KS256: ${message}`);
106
+ }
107
+ if (parts === null) {
108
+ throw new Error("receipt carries no delegation certificate");
109
+ }
110
+
111
+ const chainId = hexToBigint(
112
+ await ethRpc(options.rpcUrl, "eth_chainId", []),
113
+ "eth_chainId",
114
+ );
115
+ if (genesisChainId !== undefined && chainId !== genesisChainId) {
116
+ throw new Error(
117
+ `RPC chain id ${chainId} differs from the genesis chain id ${genesisChainId}`,
118
+ );
119
+ }
120
+
121
+ const blockTag =
122
+ options.block === "latest" ? "latest" : `0x${options.block.toString(16)}`;
123
+ const block = (await ethRpc(options.rpcUrl, "eth_getBlockByNumber", [
124
+ blockTag,
125
+ false,
126
+ ])) as { number?: unknown; hash?: unknown } | null;
127
+ if (block === null || typeof block !== "object") {
128
+ throw new Error(`eth_getBlockByNumber returned no block for ${blockTag}`);
129
+ }
130
+ const blockNumber = hexToBigint(block.number, "block number");
131
+ if (options.block !== "latest" && blockNumber !== options.block) {
132
+ throw new Error(
133
+ `RPC returned block ${blockNumber} for --block ${options.block}`,
134
+ );
135
+ }
136
+ const blockHash = hexToBytes32(block.hash, "block hash");
137
+ // EIP-1898: read state at exactly the recorded block hash, so the
138
+ // attestation's blockHash is the state actually read (a reorg or a
139
+ // load-balanced RPC cannot answer from another block).
140
+ const pinned = {
141
+ blockHash: `0x${bytesToHex(blockHash)}`,
142
+ requireCanonical: true,
143
+ };
144
+ const rootHex = `0x${bytesToHex(root)}`;
145
+
146
+ const code = await ethRpc(options.rpcUrl, "eth_getCode", [rootHex, pinned]);
147
+ if (typeof code !== "string" || code === "0x" || code === "") {
148
+ throw new Error(
149
+ `${rootHex} has no contract code at block ${blockNumber}: an EOA root needs no attestation (verify recovers it by ecrecover)`,
150
+ );
151
+ }
152
+
153
+ let ret: unknown;
154
+ try {
155
+ ret = await ethRpc(options.rpcUrl, "eth_call", [
156
+ {
157
+ to: rootHex,
158
+ data: encodeIsValidSignatureCall(parts.digest, parts.signature),
159
+ },
160
+ pinned,
161
+ ]);
162
+ } catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ // ethRpc surfaces only the JSON-RPC error message (not its code), so a
165
+ // revert is recognised by message; anything else (e.g. header not
166
+ // found) is an RPC failure that says nothing about the contract.
167
+ if (/revert/i.test(message)) {
168
+ throw new Error(
169
+ `${rootHex} did not validate the certificate signature (isValidSignature reverted: ${message})`,
170
+ );
171
+ }
172
+ throw new Error(
173
+ `RPC error on isValidSignature eth_call at block ${blockNumber}: ${message}`,
174
+ );
175
+ }
176
+ // Exactly one 32-byte word: bytes4 magic, left-aligned, zero padded.
177
+ // Anything else (extra data, dirty padding, a calldata echo from a
178
+ // fallback — the magic is also the selector) is not an attestation.
179
+ const expectedWord = `0x${bytesToHex(ERC1271_MAGIC_VALUE)}${"00".repeat(28)}`;
180
+ if (typeof ret !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(ret)) {
181
+ throw new Error(
182
+ `isValidSignature did not return exactly one 32-byte word: ${String(ret)}`,
183
+ );
184
+ }
185
+ if (ret.toLowerCase() !== expectedWord) {
186
+ throw new Error(
187
+ `${rootHex} did not validate the certificate signature: isValidSignature returned ${ret}, not the ERC-1271 magic value word ${expectedWord}`,
188
+ );
189
+ }
190
+ const magic = ERC1271_MAGIC_VALUE.slice();
191
+
192
+ const signatureHash = new Uint8Array(
193
+ Buffer.from(keccak256(parts.signature).slice(2), "hex"),
194
+ );
195
+ writeFileSync(
196
+ options.out,
197
+ encodeKs256Attestation({
198
+ version: 1,
199
+ chainId,
200
+ address: root,
201
+ digest: parts.digest,
202
+ signatureHash,
203
+ blockNumber,
204
+ blockHash,
205
+ magic,
206
+ }),
207
+ );
208
+
209
+ if (options.json) {
210
+ const report: FetchKs256AttestationReport = {
211
+ command: "fetch-ks256-attestation",
212
+ chainId: chainId.toString(),
213
+ address: rootHex,
214
+ digest: `0x${bytesToHex(parts.digest)}`,
215
+ blockNumber: blockNumber.toString(),
216
+ blockHash: `0x${bytesToHex(blockHash)}`,
217
+ out: options.out,
218
+ };
219
+ out.out(JSON.stringify(report, null, 2));
220
+ } else {
221
+ out.print(
222
+ "fetch-ks256-attestation: %s signs digest 0x%s at block %s (chain %s)",
223
+ rootHex,
224
+ bytesToHex(parts.digest),
225
+ blockNumber.toString(),
226
+ chainId.toString(),
227
+ );
228
+ out.out(`wrote ERC-1271 attestation: ${options.out}`);
229
+ }
230
+ process.exitCode = 0;
231
+ } catch (err) {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ if (options.json) {
234
+ const report: FetchKs256AttestationErrorReport = {
235
+ error: "fetch_ks256_attestation_failed",
236
+ command: "fetch-ks256-attestation",
237
+ message,
238
+ };
239
+ out.out(JSON.stringify(report, null, 2));
240
+ } else {
241
+ out.warn("forestrie fetch-ks256-attestation: %s", message);
242
+ }
243
+ process.exitCode = 1;
244
+ }
245
+ }
@@ -10,6 +10,8 @@ import {
10
10
  } from "../lib/onboard-genesis-body.js";
11
11
  import type { OnboardGenesisOptions } from "../options/onboard-genesis.js";
12
12
 
13
+ import { responseProblemDetail } from "../lib/http-problem.js";
14
+
13
15
  /**
14
16
  * FOR-406 (plan-2607-27 W2): operator genesis onboarding — turn a
15
17
  * `forestrie deploy` into a usable forest. POSTs the five-label v2
@@ -138,7 +140,7 @@ export async function runOnboardGenesis(
138
140
  command: "onboard-genesis",
139
141
  message: `genesis POST rejected: HTTP ${res.status}`,
140
142
  httpStatus: res.status,
141
- detail: (await res.text()).slice(0, 512),
143
+ detail: await responseProblemDetail(res),
142
144
  });
143
145
  return;
144
146
  }
@@ -16,6 +16,8 @@ import { loadEs256SigningKey } from "../lib/sign-statement-key.js";
16
16
  import { signX402Payment } from "../lib/x402-payment.js";
17
17
  import type { OnboardRequestOptions } from "../options/onboard-request.js";
18
18
 
19
+ import { responseProblemDetail } from "../lib/http-problem.js";
20
+
19
21
  /**
20
22
  * Self-service onboarding (plan-2607-43 slice 06): create → (pay) → redeem.
21
23
  *
@@ -183,7 +185,7 @@ export async function runOnboardRequest(
183
185
  reportError(out, options, {
184
186
  error: "create_failed",
185
187
  command: "onboard-request",
186
- message: `request rejected: HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`,
188
+ message: `request rejected: HTTP ${res.status}: ${await responseProblemDetail(res, 300)}`,
187
189
  httpStatus: res.status,
188
190
  });
189
191
  return;
@@ -271,7 +273,7 @@ export async function runOnboardRequest(
271
273
  reportError(out, options, {
272
274
  error: "redeem_failed",
273
275
  command: "onboard-request",
274
- message: `redeem rejected: HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`,
276
+ message: `redeem rejected: HTTP ${res.status}: ${await responseProblemDetail(res, 300)}`,
275
277
  httpStatus: res.status,
276
278
  });
277
279
  return;