@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.
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/TRUST-MODEL.md +8 -0
- package/dist/cli.js +33811 -0
- package/dist/decode-receipt.d.ts +19 -0
- package/dist/decode-receipt.d.ts.map +1 -0
- package/dist/decode-receipt.js +19 -0
- package/dist/decode-receipt.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/decode-receipt-cbor.d.ts +26 -0
- package/dist/lib/decode-receipt-cbor.d.ts.map +1 -0
- package/dist/lib/decode-receipt-cbor.js +182 -0
- package/dist/lib/decode-receipt-cbor.js.map +1 -0
- package/dist/lib/decode-receipt-decode.d.ts +103 -0
- package/dist/lib/decode-receipt-decode.d.ts.map +1 -0
- package/dist/lib/decode-receipt-decode.js +237 -0
- package/dist/lib/decode-receipt-decode.js.map +1 -0
- package/dist/lib/decode-receipt-labels.d.ts +54 -0
- package/dist/lib/decode-receipt-labels.d.ts.map +1 -0
- package/dist/lib/decode-receipt-labels.js +117 -0
- package/dist/lib/decode-receipt-labels.js.map +1 -0
- package/dist/lib/decode-receipt-render.d.ts +11 -0
- package/dist/lib/decode-receipt-render.d.ts.map +1 -0
- package/dist/lib/decode-receipt-render.js +242 -0
- package/dist/lib/decode-receipt-render.js.map +1 -0
- package/package.json +83 -0
- package/src/cli.ts +5 -0
- package/src/command.ts +51 -0
- package/src/commands/admin-onboard-token.ts +51 -0
- package/src/commands/admin.ts +20 -0
- package/src/commands/complete-grant.ts +41 -0
- package/src/commands/create-consistency-proof.ts +44 -0
- package/src/commands/create-log.ts +95 -0
- package/src/commands/decode-receipt.ts +20 -0
- package/src/commands/delegate.ts +55 -0
- package/src/commands/deploy.ts +72 -0
- package/src/commands/fetch-accumulator.ts +49 -0
- package/src/commands/onboard-genesis.ts +70 -0
- package/src/commands/onboard-request.ts +59 -0
- package/src/commands/register-grant.ts +74 -0
- package/src/commands/register.ts +54 -0
- package/src/commands/resolve-receipt.ts +101 -0
- package/src/commands/sign-statement.ts +53 -0
- package/src/commands/verify-grant.ts +89 -0
- package/src/commands/verify.ts +92 -0
- package/src/commoncli.ts +34 -0
- package/src/decode-receipt.ts +56 -0
- package/src/index.ts +10 -0
- package/src/lib/bytes.ts +7 -0
- package/src/lib/checkpoint-provider.ts +297 -0
- package/src/lib/complete-grant-derive.ts +237 -0
- package/src/lib/consistency-proof.ts +271 -0
- package/src/lib/create-log-build.ts +168 -0
- package/src/lib/create-log-prepare.ts +75 -0
- package/src/lib/create-receipt-chain-verify.ts +267 -0
- package/src/lib/create-receipt-derive.ts +208 -0
- package/src/lib/create-receipt-inputs.ts +79 -0
- package/src/lib/decode-checkpoint-calldata.ts +188 -0
- package/src/lib/decode-receipt-cbor.ts +202 -0
- package/src/lib/decode-receipt-decode.ts +333 -0
- package/src/lib/decode-receipt-labels.ts +132 -0
- package/src/lib/decode-receipt-render.ts +284 -0
- package/src/lib/delegate-flow.ts +289 -0
- package/src/lib/delegate-voucher.ts +122 -0
- package/src/lib/deploy-artifact.ts +79 -0
- package/src/lib/deploy-bootstrap.ts +100 -0
- package/src/lib/deploy-flow.ts +184 -0
- package/src/lib/deploy-genesis-log-id.ts +29 -0
- package/src/lib/deploy-rpc.ts +40 -0
- package/src/lib/deployment-record.ts +65 -0
- package/src/lib/fsio.ts +91 -0
- package/src/lib/grant-flags-local.ts +19 -0
- package/src/lib/onboard-attestation.ts +120 -0
- package/src/lib/onboard-genesis-body.ts +94 -0
- package/src/lib/openssl-error-queue.ts +56 -0
- package/src/lib/register-flow.ts +292 -0
- package/src/lib/register-grant-build.ts +173 -0
- package/src/lib/register-grant-complete.ts +47 -0
- package/src/lib/register-grant-flow.ts +258 -0
- package/src/lib/resolve-receipt-freshen.ts +221 -0
- package/src/lib/sign-statement-build.ts +117 -0
- package/src/lib/sign-statement-key.ts +145 -0
- package/src/lib/verify-anchored.ts +326 -0
- package/src/lib/verify-checkpoint-chain.ts +160 -0
- package/src/lib/verify-eventscan.ts +214 -0
- package/src/lib/verify-inputs.ts +143 -0
- package/src/lib/verify-known-accumulator.ts +161 -0
- package/src/lib/verify-report.ts +265 -0
- package/src/lib/x402-payment.ts +115 -0
- package/src/main/admin-onboard-token.ts +216 -0
- package/src/main/complete-grant.ts +265 -0
- package/src/main/create-consistency-proof.ts +83 -0
- package/src/main/create-log.ts +347 -0
- package/src/main/create-receipt.ts +763 -0
- package/src/main/decode-receipt.ts +72 -0
- package/src/main/delegate.ts +141 -0
- package/src/main/deploy.ts +273 -0
- package/src/main/fetch-accumulator.ts +181 -0
- package/src/main/not-implemented.ts +43 -0
- package/src/main/onboard-genesis.ts +192 -0
- package/src/main/onboard-request.ts +333 -0
- package/src/main/register-grant.ts +270 -0
- package/src/main/register.ts +243 -0
- package/src/main/sign-statement.ts +168 -0
- package/src/main/verify.ts +767 -0
- package/src/options/admin-onboard-token.ts +48 -0
- package/src/options/common.ts +69 -0
- package/src/options/complete-grant.ts +40 -0
- package/src/options/create-consistency-proof.ts +63 -0
- package/src/options/create-log.ts +99 -0
- package/src/options/create-receipt.ts +243 -0
- package/src/options/decode-receipt.ts +25 -0
- package/src/options/delegate.ts +93 -0
- package/src/options/deploy.ts +105 -0
- package/src/options/fetch-accumulator.ts +62 -0
- package/src/options/onboard-genesis.ts +74 -0
- package/src/options/onboard-request.ts +57 -0
- package/src/options/register-grant.ts +72 -0
- package/src/options/register.ts +71 -0
- package/src/options/sign-statement.ts +64 -0
- package/src/options/verify.ts +250 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ReceiptVerifyResult,
|
|
3
|
+
ReceiptVerifyStage,
|
|
4
|
+
} from "@forestrie/receipt-verify";
|
|
5
|
+
import type { AnchorCheck } from "./verify-anchored.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Result → report shaping for `forestrie verify` (FOR-347). Pure — the
|
|
9
|
+
* `--json` shape emitted from here is a contract for demo scripting.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Verification pipeline in narration order (ADR-0045 layers A–C). */
|
|
13
|
+
export const VERIFY_STAGES: readonly ReceiptVerifyStage[] = [
|
|
14
|
+
"parse",
|
|
15
|
+
"signature",
|
|
16
|
+
"inclusion",
|
|
17
|
+
"binding",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** One line per stage: what the audience sees at every demo step. */
|
|
21
|
+
export const STAGE_NARRATION: Record<ReceiptVerifyStage, string> = {
|
|
22
|
+
parse: "receipt COSE decodes; genesis trust root loads (ES256)",
|
|
23
|
+
signature: "checkpoint signature verifies under the genesis trust key",
|
|
24
|
+
inclusion: "proof path recomputes the checkpoint peak",
|
|
25
|
+
binding: "leaf commits the payload (SHA-256) at the receipt idtimestamp",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type StageStatus = "ok" | "failed" | "skipped";
|
|
29
|
+
|
|
30
|
+
export type StageRow = {
|
|
31
|
+
stage: ReceiptVerifyStage;
|
|
32
|
+
status: StageStatus;
|
|
33
|
+
reason?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Anchor-only chain verification (FOR-297 approach C): the checkpoint was
|
|
38
|
+
* sealed under a delegation the offline verifier cannot resolve (a child
|
|
39
|
+
* log's per-log delegation does not chain to the root genesis). The
|
|
40
|
+
* signature stage still reports **ok** — not because the COSE check re-ran
|
|
41
|
+
* locally, but because a match against the on-chain accumulator *implies*
|
|
42
|
+
* it: univocity only accepts a checkpoint whose signature verifies under
|
|
43
|
+
* the log's live delegation, whose publish grant's inclusion is re-proven
|
|
44
|
+
* against the parent's on-chain state on EVERY publish, and whose
|
|
45
|
+
* accumulator is consistency-gated against the previously published state.
|
|
46
|
+
* So if the locally-recomputed peak (from `leaf(payload, idtimestamp)` +
|
|
47
|
+
* the receipt's proof path) appears in the accumulator, the publishing
|
|
48
|
+
* signature was valid and a fresh grant chain to the bootstrap existed at
|
|
49
|
+
* publish time — the anchor match combines inclusion, binding, signature
|
|
50
|
+
* validity AND a split-view check (status-2607-09 D2).
|
|
51
|
+
*
|
|
52
|
+
* Enforcement cited from `_Univocity.sol publishCheckpoint` @ univocity
|
|
53
|
+
* ea410d5a90e4c2337fc1dec288d660551daf36ab:
|
|
54
|
+
* - signature under the live delegation: `_verifyCheckpointSignature`,
|
|
55
|
+
* src/contracts/_Univocity.sol#L199-L208 (reverts on failure)
|
|
56
|
+
* - grant inclusion re-verified each publish (rules 1–3):
|
|
57
|
+
* `_applyInclusionGrant`, src/contracts/_Univocity.sol#L211-L219
|
|
58
|
+
* - grant bounds (rule 4): src/contracts/_Univocity.sol#L181-L188
|
|
59
|
+
* - consistency gating: `verifyConsistencyProofChain`,
|
|
60
|
+
* src/contracts/_Univocity.sol#L189-L193
|
|
61
|
+
*/
|
|
62
|
+
export const SIGNATURE_ANCHORED_REASON =
|
|
63
|
+
"verified against accumulator from chain — signature enforced by univocity at publish";
|
|
64
|
+
|
|
65
|
+
export function anchorOnlyStageRows(): StageRow[] {
|
|
66
|
+
return [
|
|
67
|
+
{ stage: "parse", status: "ok" },
|
|
68
|
+
{
|
|
69
|
+
stage: "signature",
|
|
70
|
+
status: "ok",
|
|
71
|
+
reason: SIGNATURE_ANCHORED_REASON,
|
|
72
|
+
},
|
|
73
|
+
{ stage: "inclusion", status: "ok" },
|
|
74
|
+
{ stage: "binding", status: "ok" },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Expand the library's `{ok, stage, reason}` into one row per stage:
|
|
80
|
+
* on success all four pass; on failure the reported stage failed, earlier
|
|
81
|
+
* stages passed, later stages were not reached.
|
|
82
|
+
*
|
|
83
|
+
* A stage this CLI does not know (a future `@forestrie/receipt-verify`
|
|
84
|
+
* addition) still renders explicitly as the failed row — degrading it to
|
|
85
|
+
* four silent "skipped" rows would hide the failure (F3, plan-2607-14
|
|
86
|
+
* W1.3). We cannot order an unknown stage among the known ones, so the
|
|
87
|
+
* known stages read "skipped" (not evaluated by this CLI's knowledge) and
|
|
88
|
+
* the unknown stage carries the failure.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Caller-known trust anchor (FOR-297 D1): the claim is verified under a key
|
|
92
|
+
* the CALLER supplied out of band, not one derived from genesis — the output
|
|
93
|
+
* must name that anchor so the two claims are never confusable. The known key
|
|
94
|
+
* asserts the key↔log binding (its provenance is the only defence), gives no
|
|
95
|
+
* grant-lifecycle visibility and no split-view protection.
|
|
96
|
+
*/
|
|
97
|
+
export const KNOWN_KEY_PARSE_REASON =
|
|
98
|
+
"receipt COSE decodes; caller-known log key loads (ES256)";
|
|
99
|
+
export const KNOWN_KEY_SIGNATURE_REASON =
|
|
100
|
+
"verifies under caller-known log key (not genesis-derived)";
|
|
101
|
+
|
|
102
|
+
/** Stage rows for a verify run anchored on `--known-log-key` (FOR-297 D1). */
|
|
103
|
+
export function knownKeyStageRows(result: ReceiptVerifyResult): StageRow[] {
|
|
104
|
+
return stageRows(result).map((row) => {
|
|
105
|
+
if (row.status !== "ok") return row;
|
|
106
|
+
if (row.stage === "parse") return { ...row, reason: KNOWN_KEY_PARSE_REASON };
|
|
107
|
+
if (row.stage === "signature")
|
|
108
|
+
return { ...row, reason: KNOWN_KEY_SIGNATURE_REASON };
|
|
109
|
+
return row;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function stageRows(result: ReceiptVerifyResult): StageRow[] {
|
|
114
|
+
if (result.ok) {
|
|
115
|
+
return VERIFY_STAGES.map((stage) => ({ stage, status: "ok" }));
|
|
116
|
+
}
|
|
117
|
+
const failedAt = VERIFY_STAGES.indexOf(result.stage);
|
|
118
|
+
if (failedAt === -1) {
|
|
119
|
+
return [
|
|
120
|
+
...VERIFY_STAGES.map((stage) => ({
|
|
121
|
+
stage,
|
|
122
|
+
status: "skipped" as const,
|
|
123
|
+
})),
|
|
124
|
+
{
|
|
125
|
+
stage: result.stage,
|
|
126
|
+
status: "failed" as const,
|
|
127
|
+
reason: result.reason ?? "unknown",
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
return VERIFY_STAGES.map((stage, i) => {
|
|
132
|
+
if (i < failedAt) return { stage, status: "ok" as const };
|
|
133
|
+
if (i === failedAt) {
|
|
134
|
+
return {
|
|
135
|
+
stage,
|
|
136
|
+
status: "failed" as const,
|
|
137
|
+
reason: result.reason ?? "unknown",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { stage, status: "skipped" as const };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `--json` anchor block (chain / known-accumulator modes). Sizes are
|
|
145
|
+
* decimal strings. */
|
|
146
|
+
export type AnchorReport = {
|
|
147
|
+
univocity: string;
|
|
148
|
+
logId: string;
|
|
149
|
+
anchored: boolean;
|
|
150
|
+
anchoredSize: string;
|
|
151
|
+
peakCount: number;
|
|
152
|
+
matchedPeak: number | null;
|
|
153
|
+
reason?: string;
|
|
154
|
+
/** known-accumulator anchors only: the audited chain read (FOR-297 D5). */
|
|
155
|
+
blockNumber?: string;
|
|
156
|
+
blockHash?: string;
|
|
157
|
+
/** True when the match required proof-path extension via massif nodes. */
|
|
158
|
+
extended?: boolean;
|
|
159
|
+
/** Historical event-scan anchor (FOR-368): the peak matched a prior
|
|
160
|
+
* CheckpointPublished accumulator; consistency-gated forward. */
|
|
161
|
+
historical?: boolean;
|
|
162
|
+
anchoredAtBlock?: string;
|
|
163
|
+
txHash?: string;
|
|
164
|
+
/** Checkpoint-chain anchors only (FOR-368 Phase 3): links folded and the
|
|
165
|
+
* sealed size of the link that held the peak. */
|
|
166
|
+
linkCount?: number;
|
|
167
|
+
matchedLinkSize?: string;
|
|
168
|
+
/** Consistency-proof top-up (FOR-368 Phase 3): base size the untrusted
|
|
169
|
+
* artifact extended the receipt from into the trusted snapshot. */
|
|
170
|
+
topUpBaseSize?: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export type VerifyMode =
|
|
174
|
+
| "offline"
|
|
175
|
+
| "chain-anchored"
|
|
176
|
+
| "accumulator-anchored"
|
|
177
|
+
| "checkpoint-chain-anchored";
|
|
178
|
+
|
|
179
|
+
/** `--json` report on stdout — stable shape for demo scripting. */
|
|
180
|
+
export type VerifyReport = {
|
|
181
|
+
command: "verify";
|
|
182
|
+
mode: VerifyMode;
|
|
183
|
+
ok: boolean;
|
|
184
|
+
stage: ReceiptVerifyStage;
|
|
185
|
+
reason?: string;
|
|
186
|
+
stages: StageRow[];
|
|
187
|
+
anchor?: AnchorReport;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Extra anchor facts present only for known-accumulator checks. */
|
|
191
|
+
type SnapshotAnchorFacts = {
|
|
192
|
+
blockNumber?: bigint;
|
|
193
|
+
blockHashHex?: string;
|
|
194
|
+
extended?: boolean;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export function buildVerifyReport(opts: {
|
|
198
|
+
ok: boolean;
|
|
199
|
+
result: ReceiptVerifyResult;
|
|
200
|
+
mode: VerifyMode;
|
|
201
|
+
anchor?: (AnchorCheck & SnapshotAnchorFacts) | undefined;
|
|
202
|
+
univocity?: string | undefined;
|
|
203
|
+
logId?: string | undefined;
|
|
204
|
+
/** Anchor-only mode: rows that reflect the skipped signature stage. */
|
|
205
|
+
stagesOverride?: StageRow[] | undefined;
|
|
206
|
+
}): VerifyReport {
|
|
207
|
+
const report: VerifyReport = {
|
|
208
|
+
command: "verify",
|
|
209
|
+
mode: opts.mode,
|
|
210
|
+
ok: opts.ok,
|
|
211
|
+
stage: opts.result.stage,
|
|
212
|
+
stages: opts.stagesOverride ?? stageRows(opts.result),
|
|
213
|
+
};
|
|
214
|
+
if (opts.result.reason !== undefined) {
|
|
215
|
+
report.reason = opts.result.reason;
|
|
216
|
+
}
|
|
217
|
+
if (opts.anchor !== undefined) {
|
|
218
|
+
const anchor: AnchorReport = {
|
|
219
|
+
univocity: opts.univocity ?? "",
|
|
220
|
+
logId: opts.logId ?? "",
|
|
221
|
+
anchored: opts.anchor.anchored,
|
|
222
|
+
anchoredSize: opts.anchor.size.toString(),
|
|
223
|
+
peakCount: opts.anchor.accumulator.length,
|
|
224
|
+
matchedPeak: opts.anchor.matchedPeak,
|
|
225
|
+
};
|
|
226
|
+
if (opts.anchor.reason !== undefined) {
|
|
227
|
+
anchor.reason = opts.anchor.reason;
|
|
228
|
+
}
|
|
229
|
+
if (opts.anchor.blockNumber !== undefined) {
|
|
230
|
+
anchor.blockNumber = opts.anchor.blockNumber.toString();
|
|
231
|
+
anchor.blockHash = `0x${opts.anchor.blockHashHex ?? ""}`;
|
|
232
|
+
anchor.extended = opts.anchor.extended === true;
|
|
233
|
+
}
|
|
234
|
+
const historical = opts.anchor as {
|
|
235
|
+
historical?: boolean;
|
|
236
|
+
anchoredAtBlock?: bigint;
|
|
237
|
+
txHash?: string;
|
|
238
|
+
};
|
|
239
|
+
if (historical.historical === true) {
|
|
240
|
+
anchor.historical = true;
|
|
241
|
+
if (historical.anchoredAtBlock !== undefined) {
|
|
242
|
+
anchor.anchoredAtBlock = historical.anchoredAtBlock.toString();
|
|
243
|
+
}
|
|
244
|
+
if (historical.txHash !== undefined) {
|
|
245
|
+
anchor.txHash = historical.txHash;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const chained = opts.anchor as {
|
|
249
|
+
linkCount?: number;
|
|
250
|
+
matchedLinkSize?: bigint | null;
|
|
251
|
+
};
|
|
252
|
+
if (chained.linkCount !== undefined) {
|
|
253
|
+
anchor.linkCount = chained.linkCount;
|
|
254
|
+
if (chained.matchedLinkSize !== undefined && chained.matchedLinkSize !== null) {
|
|
255
|
+
anchor.matchedLinkSize = chained.matchedLinkSize.toString();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const topUp = opts.anchor as { topUpBaseSize?: bigint };
|
|
259
|
+
if (topUp.topUpBaseSize !== undefined) {
|
|
260
|
+
anchor.topUpBaseSize = topUp.topUpBaseSize.toString();
|
|
261
|
+
}
|
|
262
|
+
report.anchor = anchor;
|
|
263
|
+
}
|
|
264
|
+
return report;
|
|
265
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 `exact` payer: sign an EIP-3009 `transferWithAuthorization` against a
|
|
3
|
+
* decoded `X-PAYMENT-REQUIRED` challenge (base64 header value in, base64
|
|
4
|
+
* `X-PAYMENT` header value out). Matches the payload shape canopy's
|
|
5
|
+
* `parsePaymentHeader` expects and the facilitator settles.
|
|
6
|
+
*
|
|
7
|
+
* REAL money moves when a signed payment settles (testnet USDC on dev). The
|
|
8
|
+
* payer key never leaves this process and is never logged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
12
|
+
|
|
13
|
+
interface X402Option {
|
|
14
|
+
scheme: string;
|
|
15
|
+
network: string;
|
|
16
|
+
payTo: string;
|
|
17
|
+
asset: string;
|
|
18
|
+
amount: string;
|
|
19
|
+
maxTimeoutSeconds?: number;
|
|
20
|
+
extra?: { name?: string; version?: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function b64ToUtf8(b64: string): string {
|
|
24
|
+
return Buffer.from(b64, "base64").toString("utf8");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function randomNonceHex(): `0x${string}` {
|
|
28
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
29
|
+
return `0x${Array.from(bytes)
|
|
30
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
31
|
+
.join("")}` as `0x${string}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function signX402Payment(
|
|
35
|
+
paymentRequiredB64: string,
|
|
36
|
+
payerKeyHex: string,
|
|
37
|
+
nowSec = Math.floor(Date.now() / 1000),
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
const decoded = JSON.parse(b64ToUtf8(paymentRequiredB64)) as {
|
|
40
|
+
accepts?: X402Option[];
|
|
41
|
+
};
|
|
42
|
+
const chosen = (decoded.accepts ?? []).find((o) => o.scheme === "exact");
|
|
43
|
+
if (!chosen) {
|
|
44
|
+
throw new Error("X-PAYMENT-REQUIRED has no 'exact' scheme option");
|
|
45
|
+
}
|
|
46
|
+
if (!chosen.extra?.name || !chosen.extra?.version) {
|
|
47
|
+
throw new Error("challenge lacks EIP-712 domain name/version in extra");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const key = payerKeyHex.startsWith("0x")
|
|
51
|
+
? (payerKeyHex as `0x${string}`)
|
|
52
|
+
: (`0x${payerKeyHex}` as `0x${string}`);
|
|
53
|
+
const account = privateKeyToAccount(key);
|
|
54
|
+
const nonce = randomNonceHex();
|
|
55
|
+
const validAfter = BigInt(nowSec - 600);
|
|
56
|
+
const validBefore = BigInt(nowSec + (chosen.maxTimeoutSeconds ?? 300));
|
|
57
|
+
|
|
58
|
+
const signature = await account.signTypedData({
|
|
59
|
+
domain: {
|
|
60
|
+
name: chosen.extra.name,
|
|
61
|
+
version: chosen.extra.version,
|
|
62
|
+
chainId: Number(chosen.network.split(":")[1]),
|
|
63
|
+
verifyingContract: chosen.asset as `0x${string}`,
|
|
64
|
+
},
|
|
65
|
+
types: {
|
|
66
|
+
TransferWithAuthorization: [
|
|
67
|
+
{ name: "from", type: "address" },
|
|
68
|
+
{ name: "to", type: "address" },
|
|
69
|
+
{ name: "value", type: "uint256" },
|
|
70
|
+
{ name: "validAfter", type: "uint256" },
|
|
71
|
+
{ name: "validBefore", type: "uint256" },
|
|
72
|
+
{ name: "nonce", type: "bytes32" },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
primaryType: "TransferWithAuthorization",
|
|
76
|
+
message: {
|
|
77
|
+
from: account.address,
|
|
78
|
+
to: chosen.payTo as `0x${string}`,
|
|
79
|
+
value: BigInt(chosen.amount),
|
|
80
|
+
validAfter,
|
|
81
|
+
validBefore,
|
|
82
|
+
nonce,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const payload = {
|
|
87
|
+
x402Version: 2,
|
|
88
|
+
payload: {
|
|
89
|
+
authorization: {
|
|
90
|
+
from: account.address,
|
|
91
|
+
to: chosen.payTo,
|
|
92
|
+
value: chosen.amount,
|
|
93
|
+
validAfter: validAfter.toString(),
|
|
94
|
+
validBefore: validBefore.toString(),
|
|
95
|
+
nonce,
|
|
96
|
+
},
|
|
97
|
+
signature,
|
|
98
|
+
},
|
|
99
|
+
resource: {
|
|
100
|
+
url: "",
|
|
101
|
+
description: "forestrie onboard request",
|
|
102
|
+
mimeType: "application/cbor",
|
|
103
|
+
},
|
|
104
|
+
accepted: {
|
|
105
|
+
scheme: "exact",
|
|
106
|
+
network: chosen.network,
|
|
107
|
+
asset: chosen.asset,
|
|
108
|
+
amount: chosen.amount,
|
|
109
|
+
payTo: chosen.payTo,
|
|
110
|
+
maxTimeoutSeconds: chosen.maxTimeoutSeconds ?? 300,
|
|
111
|
+
extra: chosen.extra,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
return Buffer.from(JSON.stringify(payload)).toString("base64");
|
|
115
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { Out } from "@forestrie/cli-kit/reporting";
|
|
2
|
+
import { writeOutputFile } from "../lib/fsio.js";
|
|
3
|
+
import {
|
|
4
|
+
decodeCborDeterministic,
|
|
5
|
+
encodeCborDeterministic,
|
|
6
|
+
} from "@forestrie/encoding";
|
|
7
|
+
import type { AdminOnboardTokenOptions } from "../options/admin-onboard-token.js";
|
|
8
|
+
import {
|
|
9
|
+
readDeploymentRecord,
|
|
10
|
+
univocityAddrHex,
|
|
11
|
+
} from "../lib/deployment-record.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FOR-406 (plan-2607-27 W1): mint a forest onboard token from
|
|
15
|
+
* `POST /api/payments/onboard-tokens` under the operator credential.
|
|
16
|
+
*
|
|
17
|
+
* The minted token is the ONLY product on stdout (or `--out`) so the
|
|
18
|
+
* command composes: `ONBOARD_TOKEN=$(forestrie admin onboard-token …)`.
|
|
19
|
+
* The operator credential is never logged or echoed, including in errors.
|
|
20
|
+
* Public onboarding is x402 (ARC-0015) — a future sibling token source
|
|
21
|
+
* feeding the same `onboard-genesis --onboard-token` input.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Mint request body keys (canopy payments onboard-tokens contract). */
|
|
25
|
+
const MINT_LABEL_KEY = 1;
|
|
26
|
+
const MINT_CHAIN_ID_KEY = 3;
|
|
27
|
+
const MINT_UNIVOCITY_ADDR_KEY = 4;
|
|
28
|
+
|
|
29
|
+
/** `--json` success shape (the token itself; cref for operator records). */
|
|
30
|
+
export type AdminOnboardTokenReport = {
|
|
31
|
+
command: "admin onboard-token";
|
|
32
|
+
status: "minted";
|
|
33
|
+
label: string;
|
|
34
|
+
token: string;
|
|
35
|
+
cref?: string;
|
|
36
|
+
/** Present when `--out` was given (token then omitted from stdout). */
|
|
37
|
+
out?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AdminOnboardTokenErrorReport = {
|
|
41
|
+
error: "input_failed" | "mint_failed" | "network_failed" | "response_malformed";
|
|
42
|
+
command: "admin onboard-token";
|
|
43
|
+
message: string;
|
|
44
|
+
httpStatus?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Test seam. */
|
|
48
|
+
export type AdminOnboardTokenDeps = {
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function mintUrl(baseUrl: string): string {
|
|
53
|
+
return `${baseUrl.replace(/\/$/, "")}/api/payments/onboard-tokens`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extract a required text field from the decoded CBOR response. */
|
|
57
|
+
function responseText(
|
|
58
|
+
decoded: unknown,
|
|
59
|
+
field: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
const value =
|
|
62
|
+
decoded instanceof Map
|
|
63
|
+
? decoded.get(field)
|
|
64
|
+
: decoded && typeof decoded === "object"
|
|
65
|
+
? (decoded as Record<string, unknown>)[field]
|
|
66
|
+
: undefined;
|
|
67
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function reportError(
|
|
71
|
+
out: Out,
|
|
72
|
+
options: AdminOnboardTokenOptions,
|
|
73
|
+
report: AdminOnboardTokenErrorReport,
|
|
74
|
+
): void {
|
|
75
|
+
if (options.json) {
|
|
76
|
+
out.out(JSON.stringify(report, null, 2));
|
|
77
|
+
} else {
|
|
78
|
+
out.warn("forestrie admin onboard-token: %s", report.message);
|
|
79
|
+
if (report.httpStatus !== undefined) {
|
|
80
|
+
out.warn(" httpStatus: %d", report.httpStatus);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runAdminOnboardToken(
|
|
87
|
+
out: Out,
|
|
88
|
+
options: AdminOnboardTokenOptions,
|
|
89
|
+
deps: AdminOnboardTokenDeps = {},
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
92
|
+
|
|
93
|
+
// Bindings are mandatory on every token (ADR-0059 D7): the mint reserves
|
|
94
|
+
// the named univocity instance, and canopy 400s an unbound mint.
|
|
95
|
+
let chainId: string;
|
|
96
|
+
let univocityAddr: string;
|
|
97
|
+
try {
|
|
98
|
+
if (options.deployment !== undefined) {
|
|
99
|
+
const record = readDeploymentRecord(options.deployment);
|
|
100
|
+
if (record.chainId === undefined) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${options.deployment}: deployment carries no chainId; pass --chain-id`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
chainId = record.chainId;
|
|
106
|
+
univocityAddr = univocityAddrHex(record);
|
|
107
|
+
} else if (options.chainId !== undefined && options.univocityAddr !== undefined) {
|
|
108
|
+
chainId = options.chainId.trim();
|
|
109
|
+
univocityAddr = univocityAddrHex({
|
|
110
|
+
imutableUnivocity: options.univocityAddr,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"chain binding required: pass --deployment, or --chain-id with --univocity",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
reportError(out, options, {
|
|
119
|
+
error: "input_failed",
|
|
120
|
+
command: "admin onboard-token",
|
|
121
|
+
message: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const body = encodeCborDeterministic(
|
|
127
|
+
new Map<number, string>([
|
|
128
|
+
[MINT_LABEL_KEY, options.label],
|
|
129
|
+
[MINT_CHAIN_ID_KEY, chainId],
|
|
130
|
+
[MINT_UNIVOCITY_ADDR_KEY, univocityAddr],
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
let res: Response;
|
|
135
|
+
try {
|
|
136
|
+
res = await fetchImpl(mintUrl(options.baseUrl), {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: {
|
|
139
|
+
Authorization: `Bearer ${options.opsToken}`,
|
|
140
|
+
"Content-Type": "application/cbor",
|
|
141
|
+
Accept: "application/cbor",
|
|
142
|
+
},
|
|
143
|
+
body: body.buffer.slice(
|
|
144
|
+
body.byteOffset,
|
|
145
|
+
body.byteOffset + body.byteLength,
|
|
146
|
+
) as ArrayBuffer,
|
|
147
|
+
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
reportError(out, options, {
|
|
150
|
+
error: "network_failed",
|
|
151
|
+
command: "admin onboard-token",
|
|
152
|
+
// The ops token never appears in fetch errors; message is URL-level.
|
|
153
|
+
message: err instanceof Error ? err.message : String(err),
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
reportError(out, options, {
|
|
160
|
+
error: "mint_failed",
|
|
161
|
+
command: "admin onboard-token",
|
|
162
|
+
message: `mint rejected: HTTP ${res.status}`,
|
|
163
|
+
httpStatus: res.status,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let token: string | undefined;
|
|
169
|
+
let cref: string | undefined;
|
|
170
|
+
try {
|
|
171
|
+
const decoded = decodeCborDeterministic(
|
|
172
|
+
new Uint8Array(await res.arrayBuffer()),
|
|
173
|
+
);
|
|
174
|
+
token = responseText(decoded, "token");
|
|
175
|
+
cref = responseText(decoded, "cref");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
reportError(out, options, {
|
|
178
|
+
error: "response_malformed",
|
|
179
|
+
command: "admin onboard-token",
|
|
180
|
+
message: `mint response is not valid CBOR: ${
|
|
181
|
+
err instanceof Error ? err.message : String(err)
|
|
182
|
+
}`,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (token === undefined) {
|
|
187
|
+
reportError(out, options, {
|
|
188
|
+
error: "response_malformed",
|
|
189
|
+
command: "admin onboard-token",
|
|
190
|
+
message: "mint response carried no token field",
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (options.out !== undefined) {
|
|
196
|
+
await writeOutputFile(options.out, token);
|
|
197
|
+
}
|
|
198
|
+
if (options.json) {
|
|
199
|
+
const report: AdminOnboardTokenReport = {
|
|
200
|
+
command: "admin onboard-token",
|
|
201
|
+
status: "minted",
|
|
202
|
+
label: options.label,
|
|
203
|
+
token,
|
|
204
|
+
};
|
|
205
|
+
if (cref !== undefined) report.cref = cref;
|
|
206
|
+
if (options.out !== undefined) report.out = options.out;
|
|
207
|
+
out.out(JSON.stringify(report, null, 2));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (options.out !== undefined) {
|
|
211
|
+
out.print("wrote onboard token (label %s) to %s", options.label, options.out);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// The token is the pipeable product: nothing else on stdout.
|
|
215
|
+
out.out("%s", token);
|
|
216
|
+
}
|