@supercuts-walrus/backend 0.1.4 → 0.1.5

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.
@@ -0,0 +1,122 @@
1
+ {
2
+ "version": "supercuts.proofs.v2",
3
+ "githubRepo": "wurli-sh/supercuts-walrus",
4
+ "tag": "proofs-v2",
5
+ "baseUrl": "https://github.com/wurli-sh/supercuts-walrus/releases/download/proofs-v2",
6
+ "files": [
7
+ {
8
+ "path": "proving-manifest.json",
9
+ "asset": "proving-manifest.json",
10
+ "size": 1743,
11
+ "sha256": "fca6f28a579057a2b8bc4eb9068b99bf377a4d930d60f5462658a3bb184ab7ff"
12
+ },
13
+ {
14
+ "path": "deposit_v2_js/generate_witness.js",
15
+ "asset": "deposit_v2_js--generate_witness.js",
16
+ "size": 697,
17
+ "sha256": "4995a2e6b01432f76182abec6a831fdaf7f840384035072002ccaa43101b14d4"
18
+ },
19
+ {
20
+ "path": "deposit_v2_js/witness_calculator.js",
21
+ "asset": "deposit_v2_js--witness_calculator.js",
22
+ "size": 10356,
23
+ "sha256": "cc2d37b2cff2a589dd86f8636f1a1dc9fe4a854550b021aa0cea2e235d14e942"
24
+ },
25
+ {
26
+ "path": "deposit_v2_js/deposit_v2.wasm",
27
+ "asset": "deposit_v2_js--deposit_v2.wasm",
28
+ "size": 4948782,
29
+ "sha256": "45f887ebab9b395932934c644a3018b1c6447d1dbda508bd04026fa1d3b7fdc4"
30
+ },
31
+ {
32
+ "path": "proofs/deposit_v2/deposit_v2.zkey",
33
+ "asset": "proofs--deposit_v2--deposit_v2.zkey",
34
+ "size": 84649008,
35
+ "sha256": "d398776a54628c390e8bf822007e47ed8fac00e92e8ea95bb17d20db517998dc"
36
+ },
37
+ {
38
+ "path": "proofs/deposit_v2/verifying-key.bin",
39
+ "asset": "proofs--deposit_v2--verifying-key.bin",
40
+ "size": 328,
41
+ "sha256": "838404ff0ae3a9fc82da0856b68a11428cf3ffef335b4c6539fa1860d8e1ba26"
42
+ },
43
+ {
44
+ "path": "proofs/deposit_v2/verification-key.json",
45
+ "asset": "proofs--deposit_v2--verification-key.json",
46
+ "size": 3108,
47
+ "sha256": "cb4338c9a155908ed241406726f1f7b7e284fb2c37bbc8062cb996d893748379"
48
+ },
49
+ {
50
+ "path": "transfer_v2_js/generate_witness.js",
51
+ "asset": "transfer_v2_js--generate_witness.js",
52
+ "size": 697,
53
+ "sha256": "4995a2e6b01432f76182abec6a831fdaf7f840384035072002ccaa43101b14d4"
54
+ },
55
+ {
56
+ "path": "transfer_v2_js/witness_calculator.js",
57
+ "asset": "transfer_v2_js--witness_calculator.js",
58
+ "size": 10356,
59
+ "sha256": "cc2d37b2cff2a589dd86f8636f1a1dc9fe4a854550b021aa0cea2e235d14e942"
60
+ },
61
+ {
62
+ "path": "transfer_v2_js/transfer_v2.wasm",
63
+ "asset": "transfer_v2_js--transfer_v2.wasm",
64
+ "size": 8272696,
65
+ "sha256": "aaf3a7d9c29dab52211682e30934c7cdf911eb6f51a2ae670f7109e04950088e"
66
+ },
67
+ {
68
+ "path": "proofs/transfer_v2/transfer_v2.zkey",
69
+ "asset": "proofs--transfer_v2--transfer_v2.zkey",
70
+ "size": 158316569,
71
+ "sha256": "6e7bcac98b8d2a78fd1811e2caadd83d0d6a301951f8cf27ac55a67006b001c2"
72
+ },
73
+ {
74
+ "path": "proofs/transfer_v2/verifying-key.bin",
75
+ "asset": "proofs--transfer_v2--verifying-key.bin",
76
+ "size": 328,
77
+ "sha256": "8a6896f3f330769aa69e0dc1251a19fb69c4d6264b5729d0104df8d262e573ba"
78
+ },
79
+ {
80
+ "path": "proofs/transfer_v2/verification-key.json",
81
+ "asset": "proofs--transfer_v2--verification-key.json",
82
+ "size": 3111,
83
+ "sha256": "431c8320b41a1b853c09b9bf1be8408621ce93972334d0d8c98e35f116072d67"
84
+ },
85
+ {
86
+ "path": "withdraw_v2_js/generate_witness.js",
87
+ "asset": "withdraw_v2_js--generate_witness.js",
88
+ "size": 697,
89
+ "sha256": "4995a2e6b01432f76182abec6a831fdaf7f840384035072002ccaa43101b14d4"
90
+ },
91
+ {
92
+ "path": "withdraw_v2_js/witness_calculator.js",
93
+ "asset": "withdraw_v2_js--witness_calculator.js",
94
+ "size": 10356,
95
+ "sha256": "cc2d37b2cff2a589dd86f8636f1a1dc9fe4a854550b021aa0cea2e235d14e942"
96
+ },
97
+ {
98
+ "path": "withdraw_v2_js/withdraw_v2.wasm",
99
+ "asset": "withdraw_v2_js--withdraw_v2.wasm",
100
+ "size": 5748363,
101
+ "sha256": "1a57b0cf692012838d7d4d00f397b71907fc386d00fcb756e7b166374129033c"
102
+ },
103
+ {
104
+ "path": "proofs/withdraw_v2/withdraw_v2.zkey",
105
+ "asset": "proofs--withdraw_v2--withdraw_v2.zkey",
106
+ "size": 104313713,
107
+ "sha256": "fd303dd0dd53b9b752dac0de546dabdd03198a7a4defde5e8d6018bccd419c56"
108
+ },
109
+ {
110
+ "path": "proofs/withdraw_v2/verifying-key.bin",
111
+ "asset": "proofs--withdraw_v2--verifying-key.bin",
112
+ "size": 328,
113
+ "sha256": "445c1982bea5e465fb0f5665023bb279637d820a9425f16a4c2f2ee7460e7e4a"
114
+ },
115
+ {
116
+ "path": "proofs/withdraw_v2/verification-key.json",
117
+ "asset": "proofs--withdraw_v2--verification-key.json",
118
+ "size": 3107,
119
+ "sha256": "f5cdca32c58e2b0c66891f4a5d774468d276f0579efe3faef50765409a625a75"
120
+ }
121
+ ]
122
+ }
@@ -136,6 +136,7 @@ function setupBuyerConfig(home, config) {
136
136
  `Fund only this buyer with Testnet SUI: supercuts funding execute --home ${home} --public-payment <MIST> --shielded-payment 0 --operation-id fund-1 --request-faucet --confirm (or use https://faucet.sui.io for ${address})`,
137
137
  ...(config.allowedCounterparties?.length ? [] : [`Review the directory, then approve a seller: supercuts policy approve-seller --home ${home} --capability-id <id>`]),
138
138
  ...(config.memoryConfigFile ? [`Keep the Walrus Memory embedding adapter running (supercuts-embedding-adapter). An MCP agent flushes outcome memories itself; SDK-only hosts also run: supercuts-memory-worker ${home}`] : []),
139
+ "For shielded (private) payments, install the hash-pinned proving keys once per machine (~370 MB): supercuts proofs install",
139
140
  `Check readiness: supercuts onboarding doctor --home ${home}`,
140
141
  `Connect an agent with ${session.mcpConfig}, or call BuyerClient.executeDeal from @supercuts-walrus/sdk.`,
141
142
  ] };
package/dist/cli.js CHANGED
@@ -7,16 +7,24 @@ import { packageHostedSeller } from "./seller-package.js";
7
7
  import { promptSellerSetup, setupSeller } from "./seller-setup.js";
8
8
  import { scaffoldSeller } from "./seller-scaffold.js";
9
9
  import { generateSellerResearch } from "./seller-research.js";
10
+ import { installProofs, proofsStatus } from "./proofs-install.js";
10
11
  const words = process.argv.slice(2);
11
12
  let command = words.shift() ?? "";
12
- if (["wallet", "offer", "pay", "resource", "memory", "audit", "capability", "settlement", "setup", "policy", "onboarding", "onboard", "funding", "seller"].includes(command))
13
+ if (["wallet", "offer", "pay", "resource", "memory", "audit", "capability", "settlement", "setup", "policy", "onboarding", "onboard", "funding", "seller", "proofs"].includes(command))
13
14
  command += `.${words.shift() ?? ""}`;
14
15
  try {
15
16
  if (!command || command === "help" || command === "--help" || command === "setup.") {
16
- process.stdout.write(`${JSON.stringify({ schemaVersion: "supercuts.help.v1", ok: true, commands: ["onboard buyer", "onboard seller", "seller scaffold", "seller research", "seller package", "funding prepare", "funding execute", "setup buyer", "setup seller", "setup renew", "setup session", "init", "doctor", "status", "wallet", "offer", "pay", "memory", "memory insights", "capability", "audit"], setup: "Run supercuts onboard buyer or supercuts onboard seller with an isolated --home. Then use seller scaffold to generate a capability. Research sellers can use seller research to create a cited owner-review draft before packaging.", live: "Fund only the generated buyer address. A seller does not need faucet funds to publish or receive a capability payment." })}\n`);
17
+ process.stdout.write(`${JSON.stringify({ schemaVersion: "supercuts.help.v1", ok: true, commands: ["onboard buyer", "onboard seller", "seller scaffold", "seller research", "seller package", "funding prepare", "funding execute", "setup buyer", "setup seller", "setup renew", "setup session", "init", "doctor", "status", "wallet", "offer", "pay", "memory", "memory insights", "proofs install", "proofs status", "capability", "audit"], setup: "Run supercuts onboard buyer or supercuts onboard seller with an isolated --home. Then use seller scaffold to generate a capability. Research sellers can use seller research to create a cited owner-review draft before packaging.", live: "Fund only the generated buyer address. A seller does not need faucet funds to publish or receive a capability payment." })}\n`);
17
18
  process.exit(0);
18
19
  }
19
20
  const args = parseArgs(words);
21
+ if (command === "proofs.install" || command === "proofs.status") {
22
+ // Proving keys are shared, public, hash-pinned files; they need no role home.
23
+ const dir = typeof args.dir === "string" ? args.dir : undefined;
24
+ const data = command === "proofs.status" ? await proofsStatus(dir) : await installProofs({ ...(dir ? { dir } : {}), ...(typeof args.source === "string" ? { source: args.source } : {}), onProgress: (file) => process.stderr.write(`verifying ${file.path} (${Math.ceil(file.size / 1048576)} MB)\n`) });
25
+ process.stdout.write(`${JSON.stringify({ schemaVersion: "supercuts.proofs.v1", ok: true, command, data })}\n`);
26
+ process.exit(0);
27
+ }
20
28
  const home = homePath(args);
21
29
  const fundingInput = { publicPayment: String(args["public-payment"] ?? "0"), shieldedPayment: String(args["shielded-payment"] ?? "0"), ...(typeof args["gas-reserve"] === "string" ? { gasReserve: args["gas-reserve"] } : {}), ...(typeof args["change-reserve"] === "string" ? { changeReserve: args["change-reserve"] } : {}) };
22
30
  const result = command === "init" ? initialize(home, args) : command === "setup.buyer" || command === "onboard.buyer" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: typeof args.config === "string" ? setupBuyer(home, args.config) : await promptBuyerSetup(home) } : command === "setup.seller" || command === "onboard.seller" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: typeof args.config === "string" ? setupSeller(home, args.config) : await promptSellerSetup(home) } : command === "seller.scaffold" ? { schemaVersion: "supercuts.seller-scaffold.v1", ok: true, command, data: scaffoldSeller({ ...args, home }) } : command === "seller.research" ? { schemaVersion: "supercuts.seller-research.v1", ok: true, command, data: await generateSellerResearch({ kind: String(args.kind ?? ""), topic: String(args.topic ?? ""), output: String(args.output ?? ""), ...(typeof args["context-file"] === "string" ? { contextFile: args["context-file"] } : {}), ...(typeof args["allowed-domains"] === "string" ? { allowedDomains: args["allowed-domains"] } : {}), ...(typeof args.model === "string" ? { model: args.model } : {}) }) } : command === "setup.renew" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: renewBuyer(home, String(args.config ?? "")) } : command === "setup.session" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: createBuyerSession(home) } : command === "funding.prepare" ? { schemaVersion: "supercuts.funding.v1", ok: true, command, data: await prepareFunding(home, fundingInput) } : command === "funding.execute" ? { schemaVersion: "supercuts.funding.v1", ok: true, command, data: await executeFunding(home, { ...fundingInput, operationId: String(args["operation-id"] ?? ""), requestFaucet: args["request-faucet"] === true, confirmed: args.confirm === true }) } : command === "seller.package" ? { schemaVersion: "supercuts.seller-package.v1", ok: true, command, data: await packageHostedSeller({ home, spec: String(args.spec ?? ""), artifact: String(args.artifact ?? ""), metadata: String(args.metadata ?? ""), registration: String(args.registration ?? ""), bundle: String(args.bundle ?? ""), ...(typeof args["content-type"] === "string" ? { contentType: args["content-type"] } : {}) }) } : command === "policy.add" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: await addBuyerPrivacyRule(home, String(args["capability-id"] ?? "")) } : command === "policy.approve-seller" ? { schemaVersion: "supercuts.setup.v1", ok: true, command, data: await approveBuyerSeller(home, String(args["capability-id"] ?? "")) } : command === "memory.insights" ? await (async () => { const app = new Phase3App(home); try {
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { validateCapabilityPayload, verifySignedCapability } from "./capability.js";
3
3
  import { dealChildId } from "./deal.js";
4
4
  import { fail, SupercutsError } from "./errors.js";
5
- import { canonicalJson, validateSettlementPlan } from "./settlement.js";
5
+ import { canonicalJson, planHonoursRail, validateSettlementPlan } from "./settlement.js";
6
6
  /**
7
7
  * Codes the buyer runtime verified against seller-supplied evidence. Buyer-side, network, and Walrus readback
8
8
  * errors are excluded: a readback mismatch can come from an aggregator, not the seller.
@@ -81,7 +81,7 @@ export class DealEngine {
81
81
  validateSettlementPlan(plan);
82
82
  if (plan.operationId !== paymentOperationId || plan.manifestHash !== registration.manifest.manifestHash || plan.asset !== input.budget.asset || BigInt(plan.maximumAmount) > BigInt(input.budget.maximum))
83
83
  fail("DEAL_PLAN_MISMATCH", "saved plan exceeds the deal's capability or budget");
84
- if (input.requestedRail && plan.rail !== input.requestedRail || input.privacyHint === "shielded" && plan.rail !== "shielded")
84
+ if (!planHonoursRail(plan, input.requestedRail) || input.privacyHint === "shielded" && plan.rail !== "shielded")
85
85
  fail("DEAL_PRIVACY_UNAVAILABLE", "planned rail differs from the requested privacy");
86
86
  if (!input.requestedRail && registration.manifest.supportedRails.includes("shielded") && plan.rail !== "shielded")
87
87
  fail("DEAL_PRIVACY_UNAVAILABLE", "shielded is the default rail when the seller supports it");
@@ -0,0 +1,7 @@
1
+ export declare function encodeG1(value: unknown, label?: string): Buffer;
2
+ export declare function encodeG2(value: unknown, label?: string): Buffer;
3
+ export declare function encodePublicInputs(value: unknown): Buffer;
4
+ export declare function encodeProof(proof: Record<string, unknown>): Buffer;
5
+ export declare function encodeVerifyingKey(key: Record<string, unknown>): Buffer;
6
+ /** Same outputs as the Rust converter: verifying-key.bin, proof.bin, public-inputs.bin in `outputDir`. */
7
+ export declare function convertGroth16(verificationKeyPath: string, proofPath: string, publicPath: string, outputDir: string): void;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * SnarkJS Groth16 JSON -> Arkworks canonical compressed BN254 bytes (the format Sui's groth16 module expects).
3
+ *
4
+ * TypeScript port of `crates/supercuts-groth16`, so npm installs do not need a Rust toolchain. The prover runs
5
+ * `snarkjs groth16 verify` before conversion and then checks the converted verifying key against the pinned
6
+ * deployment hash, so a conversion bug cannot produce an accepted-but-wrong proof.
7
+ */
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { fail } from "./errors.js";
11
+ const FQ = 21888242871839275222246405745257275088696311157297823662689037894645226208583n;
12
+ const FR = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
13
+ const NEGATIVE = 0x80, INFINITY = 0x40;
14
+ function field(value, modulus, label) {
15
+ if (typeof value !== "string" || !/^\d{1,80}$/.test(value))
16
+ return fail("PROOF_INVALID", `${label} must be a decimal string`);
17
+ const parsed = BigInt(value);
18
+ if (parsed >= modulus)
19
+ fail("PROOF_INVALID", `${label} is outside its field`);
20
+ return parsed;
21
+ }
22
+ function le32(value) {
23
+ const out = Buffer.alloc(32);
24
+ for (let index = 0, rest = value; index < 32; index += 1, rest >>= 8n)
25
+ out[index] = Number(rest & 0xffn);
26
+ return out;
27
+ }
28
+ const negative = (value) => value > (FQ - value) % FQ;
29
+ function array(value, length, label) {
30
+ if (!Array.isArray(value) || value.length < length)
31
+ fail("PROOF_INVALID", `${label} must have ${length} coordinates`);
32
+ return value;
33
+ }
34
+ /** Projective SnarkJS points carry z = 1 for affine points; anything else is not a canonical affine point. */
35
+ function affine(z, one, label) {
36
+ if (z === undefined)
37
+ return;
38
+ const value = Array.isArray(z) ? z : [z];
39
+ if (value[0] !== "1" || value.slice(1).some((item) => item !== one))
40
+ fail("PROOF_INVALID", `${label} must be an affine point`);
41
+ }
42
+ export function encodeG1(value, label = "G1") {
43
+ const point = array(value, 2, label);
44
+ affine(point[2], undefined, label);
45
+ const x = field(point[0], FQ, `${label}.x`), y = field(point[1], FQ, `${label}.y`);
46
+ if (x === 0n && y === 0n)
47
+ fail("PROOF_INVALID", `${label} is the point at infinity`);
48
+ const out = le32(x);
49
+ if (out[31] & (NEGATIVE | INFINITY))
50
+ fail("PROOF_INVALID", `${label}.x overlaps the flag bits`);
51
+ if (negative(y))
52
+ out[31] |= NEGATIVE;
53
+ return out;
54
+ }
55
+ export function encodeG2(value, label = "G2") {
56
+ const point = array(value, 2, label);
57
+ affine(point[2], "0", label);
58
+ const [x0, x1] = array(point[0], 2, `${label}.x`).map((item, index) => field(item, FQ, `${label}.x.c${index}`));
59
+ const [y0, y1] = array(point[1], 2, `${label}.y`).map((item, index) => field(item, FQ, `${label}.y.c${index}`));
60
+ if (x0 === 0n && x1 === 0n && y0 === 0n && y1 === 0n)
61
+ fail("PROOF_INVALID", `${label} is the point at infinity`);
62
+ const out = Buffer.concat([le32(x0), le32(x1)]);
63
+ if (out[63] & (NEGATIVE | INFINITY))
64
+ fail("PROOF_INVALID", `${label}.x overlaps the flag bits`);
65
+ // Arkworks orders Fq2 lexicographically from the highest coefficient: c1 decides unless it is zero.
66
+ if (y1 !== 0n ? negative(y1) : negative(y0))
67
+ out[63] |= NEGATIVE;
68
+ return out;
69
+ }
70
+ export function encodePublicInputs(value) {
71
+ if (!Array.isArray(value) || !value.length)
72
+ return fail("PROOF_INVALID", "public inputs must be a non-empty array");
73
+ return Buffer.concat(value.map((item, index) => le32(field(item, FR, `public[${index}]`))));
74
+ }
75
+ export function encodeProof(proof) {
76
+ return Buffer.concat([
77
+ encodeG1(proof.pi_a, "pi_a"),
78
+ encodeG2(proof.pi_b, "pi_b"),
79
+ encodeG1(proof.pi_c, "pi_c"),
80
+ ]);
81
+ }
82
+ export function encodeVerifyingKey(key) {
83
+ const ic = key.IC;
84
+ if (!Array.isArray(ic) || ic.length < 2)
85
+ return fail("PROOF_INVALID", "verification key IC is missing");
86
+ const length = Buffer.alloc(8);
87
+ length.writeBigUInt64LE(BigInt(ic.length));
88
+ return Buffer.concat([
89
+ encodeG1(key.vk_alpha_1, "vk_alpha_1"),
90
+ encodeG2(key.vk_beta_2, "vk_beta_2"),
91
+ encodeG2(key.vk_gamma_2, "vk_gamma_2"),
92
+ encodeG2(key.vk_delta_2, "vk_delta_2"),
93
+ length,
94
+ ...ic.map((point, index) => encodeG1(point, `IC[${index}]`)),
95
+ ]);
96
+ }
97
+ /** Same outputs as the Rust converter: verifying-key.bin, proof.bin, public-inputs.bin in `outputDir`. */
98
+ export function convertGroth16(verificationKeyPath, proofPath, publicPath, outputDir) {
99
+ const read = (path) => JSON.parse(readFileSync(path, "utf8"));
100
+ const key = read(verificationKeyPath), proof = read(proofPath), inputs = read(publicPath);
101
+ const publicInputs = encodePublicInputs(inputs);
102
+ if (key.IC.length !== publicInputs.length / 32 + 1)
103
+ fail("PROOF_INVALID", "verification key and public input counts differ");
104
+ mkdirSync(outputDir, { recursive: true, mode: 0o700 });
105
+ writeFileSync(join(outputDir, "verifying-key.bin"), encodeVerifyingKey(key));
106
+ writeFileSync(join(outputDir, "proof.bin"), encodeProof(proof));
107
+ writeFileSync(join(outputDir, "public-inputs.bin"), publicInputs);
108
+ }
@@ -1,4 +1,5 @@
1
1
  import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
2
+ import { Transaction } from "@mysten/sui/transactions";
2
3
  import { LivePoolIndex } from "./live-index.js";
3
4
  import { ProtocolEngine } from "./protocol-engine.js";
4
5
  import { Groth16Prover, type ProverConfig } from "./prover.js";
@@ -7,6 +8,9 @@ import { TatumLedger } from "./tatum.js";
7
8
  import type { RoleStore } from "./store.js";
8
9
  export declare function x402ResourceFailure(status: number, response: Uint8Array): string;
9
10
  export declare function timestampExpirationUnsupported(value: unknown): boolean;
11
+ /** A replacement signature is safe only after every validator must reject the old bytes.
12
+ * Timestamp expiry gets a clock/finality margin; epoch expiry is checked conservatively. */
13
+ export declare function x402TransactionValidityEnded(expiration: ReturnType<Transaction["getData"]>["expiration"], currentEpoch: string | undefined, now?: number, safetyMarginMs?: number): boolean;
10
14
  export interface LiveRuntimeProfile {
11
15
  role: "buyer" | "seller" | "auditor";
12
16
  packageId: string;
@@ -49,7 +53,9 @@ export declare class LiveRuntime {
49
53
  static create(profile: LiveRuntimeProfile, store: RoleStore): Promise<LiveRuntime>;
50
54
  doctor(proofsRequired?: boolean): Promise<Record<string, unknown>>;
51
55
  balance(): Promise<Record<string, unknown>>;
56
+ private signTransaction;
52
57
  private signed;
58
+ private signedX402;
53
59
  private operationContext;
54
60
  private existingSigned;
55
61
  private submit;
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
3
3
  import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
4
+ import { Transaction } from "@mysten/sui/transactions";
5
+ import { TransactionError } from "@mysten/sui/client";
4
6
  import { fail, SupercutsError } from "./errors.js";
5
7
  import { assertSuiFinality, assertTatumFinality } from "./finality.js";
6
8
  import { LivePoolIndex } from "./live-index.js";
@@ -9,6 +11,7 @@ import { Groth16Prover } from "./prover.js";
9
11
  import { buildDeposit, buildPrivateTransfer, buildWithdraw, writeClient } from "./sui.js";
10
12
  import { V2ExitQueue } from "./v2-exits.js";
11
13
  import { TatumLedger } from "./tatum.js";
14
+ import { resolveProverPaths } from "./proofs-install.js";
12
15
  import { paymentBindingBytes, requirementsHash } from "./x402.js";
13
16
  export function x402ResourceFailure(status, response) {
14
17
  const fallback = `paid resource returned ${status}`;
@@ -42,6 +45,18 @@ export function timestampExpirationUnsupported(value) {
42
45
  visit(value);
43
46
  return messages.some((message) => /timestamp-based transaction expiration is not yet supported|timestamp.*expiration.*not supported/i.test(message));
44
47
  }
48
+ /** A replacement signature is safe only after every validator must reject the old bytes.
49
+ * Timestamp expiry gets a clock/finality margin; epoch expiry is checked conservatively. */
50
+ export function x402TransactionValidityEnded(expiration, currentEpoch, now = Date.now(), safetyMarginMs = 60_000) {
51
+ if (!expiration || expiration.$kind !== "ValidDuring")
52
+ return false;
53
+ const validity = expiration.ValidDuring;
54
+ if (validity.maxTimestamp != null && now > Number(validity.maxTimestamp) + safetyMarginMs)
55
+ return true;
56
+ if (validity.maxEpoch != null && currentEpoch !== undefined && BigInt(currentEpoch) > BigInt(validity.maxEpoch))
57
+ return true;
58
+ return false;
59
+ }
45
60
  const hex = (value) => value.toString(16).padStart(64, "0");
46
61
  const bytes32 = (value) => Buffer.from(hex(value), "hex");
47
62
  const hash = (value) => createHash("sha256").update(value).digest("hex");
@@ -64,12 +79,16 @@ export class LiveRuntime {
64
79
  this.engine = engine;
65
80
  this.client = writeClient(profile.live.writeGrpcEndpoint);
66
81
  this.index = new LivePoolIndex(store, this.client, engine, profile.packageId, profile.poolId, BigInt(profile.live.deploymentCheckpoint), profile.verifierVersion === 2 ? "pool_v2" : "pool");
67
- this.prover = new Groth16Prover({ artifactRoot: profile.live.artifactRoot, snarkjsPath: profile.live.snarkjsPath, converterPath: profile.live.converterPath, ...(profile.live.rapidsnarkPath ? { rapidsnarkPath: profile.live.rapidsnarkPath } : {}), ...(profile.verifierVersion === 2 ? { version: 2 } : {}), expected: profile.live.artifacts });
82
+ this.prover = new Groth16Prover({ ...resolveProverPaths(profile.live, profile.verifierVersion === 2 ? "transfer_v2" : "transfer"), ...(profile.verifierVersion === 2 ? { version: 2 } : {}), expected: profile.live.artifacts });
68
83
  store.db.exec(`CREATE TABLE IF NOT EXISTS live_signed_transactions (
69
84
  operation_id TEXT PRIMARY KEY, intent_hash TEXT NOT NULL, transaction_bytes BLOB NOT NULL,
70
85
  signature TEXT NOT NULL, digest TEXT NOT NULL UNIQUE, status TEXT NOT NULL, updated_at INTEGER NOT NULL
71
86
  ); CREATE TABLE IF NOT EXISTS live_x402_deliveries (
72
87
  operation_id TEXT PRIMARY KEY, resource_url TEXT NOT NULL, receipt TEXT NOT NULL, response BLOB NOT NULL, updated_at INTEGER NOT NULL
88
+ ); CREATE TABLE IF NOT EXISTS live_superseded_x402_transactions (
89
+ operation_id TEXT NOT NULL, intent_hash TEXT NOT NULL, transaction_bytes BLOB NOT NULL,
90
+ signature TEXT NOT NULL, digest TEXT NOT NULL UNIQUE, status TEXT NOT NULL, superseded_at INTEGER NOT NULL,
91
+ PRIMARY KEY(operation_id, digest)
73
92
  ); CREATE TABLE IF NOT EXISTS live_agreements (
74
93
  session_id TEXT PRIMARY KEY, agreement TEXT NOT NULL, deal_commitment TEXT NOT NULL, accepted_at INTEGER NOT NULL
75
94
  ); CREATE TABLE IF NOT EXISTS live_seller_agreements (
@@ -110,9 +129,8 @@ export class LiveRuntime {
110
129
  const identity = this.engine.identity(babySeed);
111
130
  const fixes = [], shieldedFixes = [];
112
131
  if (shieldedProofs === "unavailable")
113
- shieldedFixes.push(`Shielded payments are unavailable (${shieldedProofsReason}): install the hash-pinned v2 proving artifacts, SnarkJS, and the converter. Public x402 payments do not need them.`);
114
- else if (!artifacts.rapidSnark)
115
- shieldedFixes.push("Set an executable RapidSnark path in the role profile for v2 proofs.");
132
+ shieldedFixes.push(`Shielded payments are unavailable (${shieldedProofsReason}): run \`supercuts proofs install\` to download and verify the hash-pinned v2 proving keys (~370 MB). Public x402 payments do not need them.`);
133
+ const warnings = shieldedProofs === "ready" && !artifacts.rapidSnark ? ["RapidSnark is not configured; shielded proofs use SnarkJS, which is slower. Set SUPERCUTS_RAPIDSNARK to a RapidSnark prover binary to speed them up."] : [];
116
134
  if (!this.profile.live.walrusPublisherUrl)
117
135
  fixes.push("Configure the Walrus publisher URL for capability and transcript writes.");
118
136
  if (!this.profile.live.walrusAggregatorUrl)
@@ -129,7 +147,7 @@ export class LiveRuntime {
129
147
  fixes.push(`Walrus ${name} is unreachable; check the configured URL and network access.`);
130
148
  }
131
149
  }
132
- return { role: this.profile.role, network: "testnet", packageId: this.profile.packageId, poolId: this.profile.poolId, asset: this.profile.coinType, verifierVersion: this.profile.verifierVersion, keyPermissions: "owner-only", rpc: this.profile.live.writeGrpcEndpoint, healthy: true, readyForDemo: fixes.length === 0 && (!proofsRequired || shieldedFixes.length === 0), fixes: [...fixes, ...(proofsRequired ? shieldedFixes : [])], publicFixes: fixes, shieldedProofs, shieldedFixes, readyForPublicRail: fixes.length === 0, runtime: "testnet-live", signer: this.signer.toSuiAddress(), babyJubPublicKey: identity.publicKey.map(String), x25519PublicKey: this.store.getSecret("identity:x25519-public")?.toString("base64"), memoryConfigured: Boolean(this.store.getSecret("live:memory-config")), proofsRequired, rapidSnark: artifacts.rapidSnark, artifacts: artifacts.artifacts, poolObjectVerified: true, tatumServiceVerified: true };
150
+ return { role: this.profile.role, network: "testnet", packageId: this.profile.packageId, poolId: this.profile.poolId, asset: this.profile.coinType, verifierVersion: this.profile.verifierVersion, keyPermissions: "owner-only", rpc: this.profile.live.writeGrpcEndpoint, healthy: true, readyForDemo: fixes.length === 0 && (!proofsRequired || shieldedFixes.length === 0), fixes: [...fixes, ...(proofsRequired ? shieldedFixes : [])], publicFixes: fixes, shieldedProofs, shieldedFixes, warnings, readyForPublicRail: fixes.length === 0, runtime: "testnet-live", signer: this.signer.toSuiAddress(), babyJubPublicKey: identity.publicKey.map(String), x25519PublicKey: this.store.getSecret("identity:x25519-public")?.toString("base64"), memoryConfigured: Boolean(this.store.getSecret("live:memory-config")), proofsRequired, rapidSnark: artifacts.rapidSnark, artifacts: artifacts.artifacts, poolObjectVerified: true, tatumServiceVerified: true };
133
151
  }
134
152
  async balance() {
135
153
  const state = await this.index.sync();
@@ -147,6 +165,15 @@ export class LiveRuntime {
147
165
  return { available: available.toString(), notes, root: hex(state.root), nextLeaf: state.nextLeaf.toString(), checkpoint: state.lastCheckpoint.toString(), evidence: "event-derived-and-pool-object-verified",
148
166
  ...(state.activity ? { poolActivity: state.activity, privacyWarning: state.activity.reducedPrivacy ? "Sparse observed pool use reduces privacy; distinct depositing addresses do not prove independent users" : undefined } : {}) };
149
167
  }
168
+ async signTransaction(tx) {
169
+ tx.setSender(this.signer.toSuiAddress());
170
+ const simulation = await this.client.simulateTransaction({ transaction: tx, include: { effects: true } });
171
+ const simulated = simulation.Transaction ?? simulation.FailedTransaction;
172
+ if (!simulated?.status.success)
173
+ fail("TRANSACTION_SIMULATION_FAILED", "Sui rejected the transaction during simulation");
174
+ const transaction = await tx.build({ client: this.client }), digest = await tx.getDigest({ client: this.client }), signature = (await this.signer.signTransaction(transaction)).signature;
175
+ return { transaction, signature, digest };
176
+ }
150
177
  async signed(operationId, intentHash, tx, legacyIntentHash) {
151
178
  const existing = this.store.db.prepare("SELECT intent_hash,transaction_bytes,signature,digest FROM live_signed_transactions WHERE operation_id=?").get(operationId);
152
179
  if (existing) {
@@ -154,15 +181,77 @@ export class LiveRuntime {
154
181
  fail("OPERATION_ID_CONFLICT", "operation ID identifies different transaction intent");
155
182
  return { digest: existing.digest, transaction: existing.transaction_bytes, signature: existing.signature };
156
183
  }
157
- tx.setSender(this.signer.toSuiAddress());
158
- const simulation = await this.client.simulateTransaction({ transaction: tx, include: { effects: true } });
159
- const simulated = simulation.Transaction ?? simulation.FailedTransaction;
160
- if (!simulated?.status.success)
161
- fail("TRANSACTION_SIMULATION_FAILED", "Sui rejected the transaction during simulation");
162
- const transaction = await tx.build({ client: this.client }), digest = await tx.getDigest({ client: this.client }), signature = (await this.signer.signTransaction(transaction)).signature;
184
+ const { transaction, signature, digest } = await this.signTransaction(tx);
163
185
  this.store.db.prepare("INSERT INTO live_signed_transactions(operation_id,intent_hash,transaction_bytes,signature,digest,status,updated_at) VALUES(?,?,?,?,?,'signed',?)").run(operationId, intentHash, Buffer.from(transaction), signature, digest, Date.now());
164
186
  return { transaction, signature, digest };
165
187
  }
188
+ async signedX402(operationId, intentHash, tx, legacyIntentHash) {
189
+ const existing = this.store.db.prepare("SELECT intent_hash,transaction_bytes,signature,digest,status FROM live_signed_transactions WHERE operation_id=?").get(operationId);
190
+ if (!existing || existing.intent_hash === intentHash || existing.intent_hash === legacyIntentHash)
191
+ return this.signed(operationId, intentHash, tx, legacyIntentHash);
192
+ // A fresh seller nonce does not itself authorize another transfer. First reuse the exact old
193
+ // signed bytes. Replace them only if the old digest is finalized-failed, or is absent from the
194
+ // authoritative RPC after its validity window and safety margin have ended.
195
+ let observed;
196
+ try {
197
+ observed = await this.client.getTransaction({ digest: existing.digest, include: { effects: true } });
198
+ }
199
+ catch (error) {
200
+ if (!(error instanceof TransactionError) || error.reason !== "notFound")
201
+ fail("X402_RESIGN_UNSAFE", "could not prove the prior signed payment absent; retry the same operation without creating another signature", error);
202
+ }
203
+ if (observed) {
204
+ const envelope = observed.Transaction ?? observed.FailedTransaction ?? observed, observedDigest = envelope?.digest ?? envelope?.effects?.transactionDigest;
205
+ if (observedDigest !== existing.digest || !envelope?.checkpoint)
206
+ fail("X402_RESIGN_UNSAFE", "prior payment lookup returned ambiguous settlement evidence");
207
+ if (envelope.status?.success === true) {
208
+ const checkpoint = assertSuiFinality(observed.Transaction || observed.FailedTransaction ? observed : { Transaction: envelope }, existing.digest);
209
+ await this.confirmTatum(existing.digest, checkpoint);
210
+ this.store.db.prepare("UPDATE live_signed_transactions SET status='finalized',updated_at=? WHERE operation_id=?").run(Date.now(), operationId);
211
+ return { digest: existing.digest, transaction: existing.transaction_bytes, signature: existing.signature };
212
+ }
213
+ if (envelope.status?.success !== false)
214
+ fail("X402_RESIGN_UNSAFE", "prior payment finality is ambiguous; refusing a replacement signature");
215
+ }
216
+ else {
217
+ let expiration;
218
+ try {
219
+ expiration = Transaction.from(existing.transaction_bytes).getData().expiration;
220
+ }
221
+ catch (error) {
222
+ return fail("X402_RESIGN_UNSAFE", "prior signed payment validity could not be decoded", error);
223
+ }
224
+ let currentEpoch;
225
+ if (expiration?.$kind === "ValidDuring" && expiration.ValidDuring.maxTimestamp == null && expiration.ValidDuring.maxEpoch != null) {
226
+ try {
227
+ currentEpoch = (await this.client.core.getCurrentSystemState()).systemState.epoch;
228
+ }
229
+ catch (error) {
230
+ return fail("X402_RESIGN_UNSAFE", "could not verify that the prior payment validity window ended", error);
231
+ }
232
+ }
233
+ if (!x402TransactionValidityEnded(expiration, currentEpoch)) {
234
+ if (expiration?.$kind === "ValidDuring" && expiration.ValidDuring.maxTimestamp != null && Date.now() > Number(expiration.ValidDuring.maxTimestamp)) {
235
+ fail("X402_RESIGN_UNSAFE", "prior payment expired but its finality safety margin has not ended; retry this same operation ID shortly");
236
+ }
237
+ return { digest: existing.digest, transaction: existing.transaction_bytes, signature: existing.signature };
238
+ }
239
+ }
240
+ const replacement = await this.signTransaction(tx), now = Date.now();
241
+ this.store.db.exec("BEGIN IMMEDIATE");
242
+ try {
243
+ this.store.db.prepare("INSERT OR IGNORE INTO live_superseded_x402_transactions(operation_id,intent_hash,transaction_bytes,signature,digest,status,superseded_at) VALUES(?,?,?,?,?,?,?)").run(operationId, existing.intent_hash, existing.transaction_bytes, existing.signature, existing.digest, existing.status, now);
244
+ const updated = this.store.db.prepare("UPDATE live_signed_transactions SET intent_hash=?,transaction_bytes=?,signature=?,digest=?,status='signed',updated_at=? WHERE operation_id=? AND digest=?").run(intentHash, Buffer.from(replacement.transaction), replacement.signature, replacement.digest, now, operationId, existing.digest);
245
+ if (updated.changes !== 1)
246
+ fail("X402_RESIGN_UNSAFE", "payment journal changed while replacing an expired signature; retry the same operation ID");
247
+ this.store.db.exec("COMMIT");
248
+ }
249
+ catch (error) {
250
+ this.store.db.exec("ROLLBACK");
251
+ throw error;
252
+ }
253
+ return replacement;
254
+ }
166
255
  operationContext(operationId, kind, subject) {
167
256
  const existing = this.store.db.prepare("SELECT kind,subject FROM live_operation_context WHERE operation_id=?").get(operationId);
168
257
  if (existing) {
@@ -625,7 +714,7 @@ export class LiveRuntime {
625
714
  tx.setExpiration({ ValidDuring: { minEpoch: validity.minEpoch, maxEpoch: validity.maxEpoch, minTimestamp: validity.minTimestamp ?? null, maxTimestamp: null, chain: validity.chain, nonce: validity.nonce } });
626
715
  }
627
716
  const legacyIntentHash = hash(JSON.stringify({ domain: "supercuts-x402-v2", operationId, resourceUrl, payTo, asset, amount: amount.toString() }));
628
- const intentHash = hash(JSON.stringify({ domain: "supercuts-x402-v2", operationId, resourceUrl, payTo, asset, amount: amount.toString(), requestId, requirementsHash: String(extra?.requirementsHash) })), signed = await this.signed(operationId, intentHash, tx, legacyIntentHash);
717
+ const intentHash = hash(JSON.stringify({ domain: "supercuts-x402-v2", operationId, resourceUrl, payTo, asset, amount: amount.toString(), requestId, requirementsHash: String(extra?.requirementsHash) })), signed = await this.signedX402(operationId, intentHash, tx, legacyIntentHash);
629
718
  const unsignedBinding = { requestId, nonce, requirementsHash: requirementsHash(unsignedRequirements), transactionDigest: signed.digest, payer: this.signer.toSuiAddress() };
630
719
  const payerSignature = (await this.signer.signPersonalMessage(paymentBindingBytes(unsignedBinding))).signature;
631
720
  const paymentPayload = { x402Version: 2, resource, accepted: requirements, payload: { signature: signed.signature, transaction: Buffer.from(signed.transaction).toString("base64") } };
package/dist/phase3.js CHANGED
@@ -11,7 +11,7 @@ import { validateCapabilityManifest } from "./capability.js";
11
11
  import { ManualMemory, openMemoryText, withMemoryDefaults } from "./memwal.js";
12
12
  import { WorkflowMemory } from "./workflow-memory.js";
13
13
  import { buildScorecard } from "./memory-scorecard.js";
14
- import { canonicalJson, createSettlementPlan, explainMemoryInfluence, isRelevantMemory, settlementMandateHash, settlementPlanHash, validateSettlementMemory, validateSettlementPlan } from "./settlement.js";
14
+ import { canonicalJson, createSettlementPlan, explainMemoryInfluence, planHonoursRail, isRelevantMemory, settlementMandateHash, settlementPlanHash, validateSettlementMemory, validateSettlementPlan } from "./settlement.js";
15
15
  const PACKAGE = "0xf887433ddfd1068f6df3020cd757939b99ceb418f0d8b9924092b01c7aee846e";
16
16
  const POOL = "0x873e37b4745316f21a2780893f5fc36f87da559b08923fb4dad6237cd2e95d98";
17
17
  const V2_PACKAGE = "0x32d104c1ab422d497df4ae3a6861258694949c86428f886ca338e07bd055cf76";
@@ -459,7 +459,7 @@ export class Phase3App {
459
459
  const requestedAmount = typeof args["requested-amount"] === "string" ? BigInt(args["requested-amount"]) : BigInt(existing.manifestMinimum);
460
460
  const boundedAmount = requestedAmount < BigInt(existing.manifestMinimum) ? BigInt(existing.manifestMinimum) : requestedAmount > BigInt(existing.maximumAmount) ? BigInt(existing.maximumAmount) : requestedAmount;
461
461
  const requestedRail = args["requested-rail"] === "x402" || args["requested-rail"] === "shielded" ? args["requested-rail"] : undefined;
462
- if (existing.mandateHash !== settlementMandateHash(mandate) || !manifests.some((manifest) => manifest.manifestHash === existing.manifestHash) || requestedRail && requestedRail !== existing.rail || args["privacy-hint"] === "shielded" && existing.rail !== "shielded" || boundedAmount.toString() !== existing.openingAmount) {
462
+ if (existing.mandateHash !== settlementMandateHash(mandate) || !manifests.some((manifest) => manifest.manifestHash === existing.manifestHash) || !planHonoursRail(existing, requestedRail) || args["privacy-hint"] === "shielded" && existing.rail !== "shielded" || boundedAmount.toString() !== existing.openingAmount) {
463
463
  fail("OPERATION_ID_CONFLICT", "planning operation already identifies another immutable plan");
464
464
  }
465
465
  return existing;
@@ -536,23 +536,26 @@ export class Phase3App {
536
536
  const recalled = await this.recallSettlementMemories(allowed.map(memoryQuery)), now = Date.now(), maxDistance = this.profile.policy.maxMemoryDistance ?? 0.35;
537
537
  const relevant = recalled.filter((item) => isRelevantMemory(item, maxDistance));
538
538
  // Only rank sellers that the deal could actually plan and pay: budget, rail, privacy, and price bounds.
539
- const payable = allowed.filter((manifest) => {
539
+ const payableWith = (memories) => (manifest) => {
540
540
  try {
541
- const plan = createSettlementPlan({ operationId: `rank:${operationId}`, mandate, manifests: [manifest], requestedAmount: manifest.priceBand.minimum, recalled, maxMemoryDistance: maxDistance, now, ...(request.requestedRail ? { requestedRail: request.requestedRail } : {}), ...(request.privacyHint ? { privacyHint: request.privacyHint } : {}) });
541
+ const plan = createSettlementPlan({ operationId: `rank:${operationId}`, mandate, manifests: [manifest], requestedAmount: manifest.priceBand.minimum, recalled: memories, maxMemoryDistance: maxDistance, now, ...(request.requestedRail ? { requestedRail: request.requestedRail } : {}), ...(request.privacyHint ? { privacyHint: request.privacyHint } : {}) });
542
542
  if (request.budgetMaximum !== undefined && BigInt(plan.maximumAmount) > BigInt(request.budgetMaximum))
543
543
  return false;
544
- return request.requestedRail ? plan.rail === request.requestedRail : mandate.version !== "supercuts.mandate.v2" || !manifest.supportedRails.includes("shielded") || plan.rail === "shielded";
544
+ return request.requestedRail ? planHonoursRail(plan, request.requestedRail) : mandate.version !== "supercuts.mandate.v2" || !manifest.supportedRails.includes("shielded") || plan.rail === "shielded";
545
545
  }
546
546
  catch {
547
547
  return false;
548
548
  }
549
- });
549
+ };
550
+ const payable = allowed.filter(payableWith(recalled));
551
+ // The counterfactual must see sellers that memory excluded, so it is computed over the memory-free payable set.
552
+ const baseline = allowed.filter(payableWith([]));
550
553
  const pool = payable.length ? payable : allowed;
551
554
  const scored = pool.map((manifest) => ({ manifest, card: buildScorecard(relevant, { counterparty: manifest.sellerId, capabilityClass: manifest.capability }, now) }));
552
555
  const review = (card) => Number(card.lastOutcome === "unresolved" || card.observations > 0 && card.reliability < 0.5);
553
556
  scored.sort((a, b) => review(a.card) - review(b.card) || b.card.reliability - a.card.reliability || (BigInt(a.manifest.priceBand.minimum) < BigInt(b.manifest.priceBand.minimum) ? -1 : BigInt(a.manifest.priceBand.minimum) > BigInt(b.manifest.priceBand.minimum) ? 1 : 0) || a.manifest.capabilityId.localeCompare(b.manifest.capabilityId));
554
557
  const result = { ordered: scored.map((item) => item.manifest), scorecards: scored.map((item) => ({ ...item.card, capabilityId: item.manifest.capabilityId, priceMinimum: item.manifest.priceBand.minimum })) };
555
- record(this.store, "deal-ranking", operationId, { candidates: pool, scorecards: result.scorecards, chosen: result.ordered[0]?.capabilityId, createdAt: now });
558
+ record(this.store, "deal-ranking", operationId, { candidates: pool, baselineCandidates: baseline.length ? baseline : allowed, scorecards: result.scorecards, chosen: result.ordered[0]?.capabilityId, createdAt: now });
556
559
  return result;
557
560
  }
558
561
  /** Stores the counterfactual (same mandate, no memory, every candidate) next to the binding deal plan. */
@@ -563,7 +566,7 @@ export class Phase3App {
563
566
  const existing = get(this.store, "memory-influence", `deal:${dealOperationId}`);
564
567
  if (existing)
565
568
  return existing;
566
- const influence = explainMemoryInfluence({ operationId: plan.operationId, mandate, manifests: ranking.candidates, requestedAmount: plan.openingAmount, ...request }, plan);
569
+ const influence = explainMemoryInfluence({ operationId: plan.operationId, mandate, manifests: ranking.baselineCandidates ?? ranking.candidates, requestedAmount: plan.openingAmount, ...request }, plan);
567
570
  const cited = [...new Set([...influence.citedBlobIds, ...ranking.scorecards.flatMap((item) => item.citedBlobIds)])].sort();
568
571
  const value = { ...influence, citedBlobIds: cited };
569
572
  record(this.store, "memory-influence", `deal:${dealOperationId}`, value);
@@ -831,10 +834,24 @@ export class Phase3App {
831
834
  const value = BigInt(actual.amount);
832
835
  if (value < BigInt(plan.manifestMinimum) || value > BigInt(plan.maximumAmount))
833
836
  fail("SETTLEMENT_PLAN_AMOUNT_EXCEEDED", "execution amount is outside the planned manifest bounds");
834
- const binding = { planHash: plan.planHash, operationId, capabilityId: plan.capabilityId, manifestHash: plan.manifestHash, rail, amount: actual.amount, counterparty: actual.counterparty, asset: actual.asset, endpoint: actual.endpoint ?? null, requestId: actual.requestId, session: actual.session ?? null, requirementsHash: actual.requirementsHash ?? null };
835
- const bindingHash = hash(canonicalJson({ domain: "supercuts-settlement-execution-v1", binding })), existing = this.store.db.prepare("SELECT binding_hash FROM settlement_execution_bindings WHERE operation_id=? OR plan_hash=?").get(operationId, plan.planHash);
836
- if (existing && existing.binding_hash !== bindingHash)
837
- fail("SETTLEMENT_PLAN_CONSUMED", "plan or operation is already bound to another execution");
837
+ const stableBinding = { planHash: plan.planHash, operationId, capabilityId: plan.capabilityId, manifestHash: plan.manifestHash, rail, amount: actual.amount, counterparty: actual.counterparty, asset: actual.asset, endpoint: actual.endpoint ?? null, requestId: actual.requestId, session: actual.session ?? null };
838
+ const binding = { ...stableBinding, requirementsHash: actual.requirementsHash ?? null };
839
+ // A renewed x402 challenge changes only its nonce-derived requirements hash. The plan remains
840
+ // consumed by the same economic payment and request; treating the ephemeral hash as the plan
841
+ // identity would make an expired challenge permanently strand the reserved buyer budget.
842
+ const bindingHash = hash(canonicalJson({ domain: "supercuts-settlement-execution-v2", binding: stableBinding })), existing = this.store.db.prepare("SELECT binding_hash,binding_json FROM settlement_execution_bindings WHERE operation_id=? OR plan_hash=?").get(operationId, plan.planHash);
843
+ if (existing && existing.binding_hash !== bindingHash) {
844
+ let old;
845
+ try {
846
+ old = JSON.parse(existing.binding_json);
847
+ }
848
+ catch (error) {
849
+ return fail("SETTLEMENT_PLAN_CONSUMED", "saved settlement execution binding is invalid", error);
850
+ }
851
+ const oldStable = Object.fromEntries(Object.keys(stableBinding).map((key) => [key, old[key]]));
852
+ if (canonicalJson(oldStable) !== canonicalJson(stableBinding))
853
+ fail("SETTLEMENT_PLAN_CONSUMED", "plan or operation is already bound to another execution");
854
+ }
838
855
  if (!existing && now >= plan.expiresAt)
839
856
  fail("SETTLEMENT_PLAN_EXPIRED", "settlement plan has expired");
840
857
  if (!existing) {
@@ -1256,7 +1273,7 @@ export class Phase3App {
1256
1273
  export function output(command, data) { return { schemaVersion: "supercuts.v1", ok: true, command, data }; }
1257
1274
  export function errorOutput(error) {
1258
1275
  const known = error instanceof SupercutsError ? error : new SupercutsError("INTERNAL_ERROR", error instanceof Error ? error.message : "unknown error");
1259
- const retryable = ["BROADCAST_UNRESOLVED", "DELIVERY_IN_PROGRESS", "DELIVERY_PENDING", "DEAL_SELLER_UNAVAILABLE", "ONBOARDING_SERVICE_UNAVAILABLE", "SELLER_DIRECTORY_UNAVAILABLE", "WALRUS_WRITE_FAILED", "WALRUS_READ_FAILED", "X402_FACILITATOR_UNAVAILABLE"].includes(known.code);
1276
+ const retryable = ["BROADCAST_UNRESOLVED", "DELIVERY_IN_PROGRESS", "DELIVERY_PENDING", "DEAL_SELLER_UNAVAILABLE", "ONBOARDING_SERVICE_UNAVAILABLE", "SELLER_DIRECTORY_UNAVAILABLE", "WALRUS_WRITE_FAILED", "WALRUS_READ_FAILED", "X402_FACILITATOR_UNAVAILABLE", "X402_RESIGN_UNSAFE"].includes(known.code);
1260
1277
  const sameOperation = retryable || ["OPERATION_ID_CONFLICT", "DEAL_OPERATION_CONFLICT"].includes(known.code);
1261
1278
  return { schemaVersion: "supercuts.v1", ok: false, error: { code: known.code, message: known.message, recovery: recovery(known.code), retryable, retryWithSameOperationId: sameOperation } };
1262
1279
  }
@@ -1265,6 +1282,8 @@ function recovery(code) {
1265
1282
  return "repeat with --authorize after reviewing the operation";
1266
1283
  if (code === "PROFILE_NOT_FOUND")
1267
1284
  return "run supercuts init --role <role> --mock";
1285
+ if (code === "X402_RESIGN_UNSAFE")
1286
+ return "retry with the same operation ID; never start a second payment while the prior signed transaction can still settle";
1268
1287
  if (code.startsWith("POLICY_"))
1269
1288
  return "change the request or update the owner-controlled profile policy";
1270
1289
  if (code === "ARTIFACT_HASH_MISMATCH")
@@ -0,0 +1,54 @@
1
+ export interface ProofFile {
2
+ path: string;
3
+ asset: string;
4
+ size: number;
5
+ sha256: string;
6
+ }
7
+ export interface ProofIndex {
8
+ version: "supercuts.proofs.v2";
9
+ githubRepo: string;
10
+ tag: string;
11
+ baseUrl: string;
12
+ files: ProofFile[];
13
+ }
14
+ export declare function bundledProofIndexPath(): string;
15
+ export declare function loadProofIndex(path?: string): ProofIndex;
16
+ /** `SUPERCUTS_PROOFS_DIR`, else `~/.supercuts/proofs/v2`. */
17
+ export declare function defaultProofsDir(): string;
18
+ export declare function proofsStatus(dir?: string, index?: ProofIndex): Promise<{
19
+ dir: string;
20
+ installed: boolean;
21
+ missing: string[];
22
+ mismatched: string[];
23
+ }>;
24
+ type Fetch = typeof fetch;
25
+ /**
26
+ * Installs every pinned file into `dir`. Files are written to a sibling staging directory and only moved into
27
+ * place after every hash matches, so a failed or tampered download never leaves a half-installed tree.
28
+ */
29
+ export declare function installProofs(options?: {
30
+ dir?: string;
31
+ source?: string;
32
+ index?: ProofIndex;
33
+ fetchImpl?: Fetch;
34
+ token?: string;
35
+ onProgress?: (file: ProofFile) => void;
36
+ }): Promise<Record<string, unknown>>;
37
+ /** Locates the SnarkJS CLI shipped as a backend dependency (pnpm does not link bins of transitive dependencies). */
38
+ export declare function bundledSnarkjsCli(): string | undefined;
39
+ /**
40
+ * Runtime prover paths. Profiles written by older releases point at a repository checkout that npm installs do
41
+ * not have; those paths are kept only when they exist, otherwise the installed proofs and bundled tools are used.
42
+ */
43
+ export declare function resolveProverPaths(stored: {
44
+ artifactRoot: string;
45
+ snarkjsPath: string;
46
+ converterPath: string;
47
+ rapidsnarkPath?: string;
48
+ }, artifactName: string): {
49
+ artifactRoot: string;
50
+ snarkjsPath: string;
51
+ converterPath: string;
52
+ rapidsnarkPath?: string;
53
+ };
54
+ export {};
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Shielded proving artifacts for npm installs (`supercuts proofs install|status`).
3
+ *
4
+ * The v2 proving keys are about 370 MB, too large for the npm tarball. The package pins every runtime file by
5
+ * SHA-256 in `config/proofs-v2.json`; this module downloads them from the pinned GitHub Release (or a local copy
6
+ * or mirror), verifies each one, and installs them atomically in `~/.supercuts/proofs/v2`. A download host cannot
7
+ * substitute a file: anything that differs from the pinned hash is rejected before it is installed.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { accessSync, chmodSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
11
+ import { createRequire } from "node:module";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import { Readable, Transform } from "node:stream";
15
+ import { pipeline } from "node:stream/promises";
16
+ import { fileURLToPath } from "node:url";
17
+ import { fail } from "./errors.js";
18
+ const MARKER = ".supercuts-proofs";
19
+ const SAFE_PATH = /^(?:[a-z0-9_]+_js\/[a-z0-9_.]+|proofs\/[a-z0-9_]+\/[a-z0-9_.-]+|proving-manifest\.json)$/;
20
+ export function bundledProofIndexPath() {
21
+ return join(dirname(fileURLToPath(import.meta.url)), "../config/proofs-v2.json");
22
+ }
23
+ export function loadProofIndex(path = bundledProofIndexPath()) {
24
+ const index = JSON.parse(readFileSync(path, "utf8"));
25
+ if (index.version !== "supercuts.proofs.v2" ||
26
+ !Array.isArray(index.files) ||
27
+ !index.files.length)
28
+ fail("PROOF_INDEX_INVALID", "proof index is not a supercuts.proofs.v2 index");
29
+ for (const file of index.files)
30
+ if (!SAFE_PATH.test(file.path) ||
31
+ file.path.includes("..") ||
32
+ !/^[a-f0-9]{64}$/.test(file.sha256) ||
33
+ !Number.isSafeInteger(file.size) ||
34
+ file.size < 1 ||
35
+ !/^[A-Za-z0-9_.-]+$/.test(file.asset))
36
+ fail("PROOF_INDEX_INVALID", `proof index entry ${file.path} is invalid`);
37
+ return index;
38
+ }
39
+ /** `SUPERCUTS_PROOFS_DIR`, else `~/.supercuts/proofs/v2`. */
40
+ export function defaultProofsDir() {
41
+ return process.env.SUPERCUTS_PROOFS_DIR
42
+ ? resolve(process.env.SUPERCUTS_PROOFS_DIR)
43
+ : join(homedir(), ".supercuts", "proofs", "v2");
44
+ }
45
+ const sha256File = async (path) => {
46
+ const hash = createHash("sha256");
47
+ await pipeline(createReadStream(path), hash);
48
+ return hash.digest("hex");
49
+ };
50
+ export async function proofsStatus(dir = defaultProofsDir(), index = loadProofIndex()) {
51
+ const missing = [], mismatched = [];
52
+ for (const file of index.files) {
53
+ const path = join(dir, file.path);
54
+ if (!existsSync(path))
55
+ missing.push(file.path);
56
+ else if (statSync(path).size !== file.size ||
57
+ (await sha256File(path)) !== file.sha256)
58
+ mismatched.push(file.path);
59
+ }
60
+ return {
61
+ dir,
62
+ installed: !missing.length && !mismatched.length,
63
+ missing,
64
+ mismatched,
65
+ };
66
+ }
67
+ async function githubAssetUrls(index, token, fetchImpl) {
68
+ const response = await fetchImpl(`https://api.github.com/repos/${index.githubRepo}/releases/tags/${encodeURIComponent(index.tag)}`, {
69
+ headers: {
70
+ accept: "application/vnd.github+json",
71
+ authorization: `Bearer ${token}`,
72
+ "user-agent": "supercuts-proofs",
73
+ },
74
+ signal: AbortSignal.timeout(30_000),
75
+ });
76
+ if (!response.ok)
77
+ fail("PROOF_DOWNLOAD_FAILED", `GitHub release ${index.tag} lookup returned HTTP ${response.status}`);
78
+ const release = (await response.json());
79
+ return new Map((release.assets ?? []).map((asset) => [asset.name, asset.url]));
80
+ }
81
+ /**
82
+ * Installs every pinned file into `dir`. Files are written to a sibling staging directory and only moved into
83
+ * place after every hash matches, so a failed or tampered download never leaves a half-installed tree.
84
+ */
85
+ export async function installProofs(options = {}) {
86
+ const index = options.index ?? loadProofIndex(), dir = resolve(options.dir ?? defaultProofsDir()), fetchImpl = options.fetchImpl ?? fetch;
87
+ const before = await proofsStatus(dir, index);
88
+ if (before.installed)
89
+ return { dir, alreadyInstalled: true, files: index.files.length };
90
+ const token = options.token ?? process.env.GITHUB_TOKEN;
91
+ const source = options.source, local = source && !/^https?:\/\//i.test(source) ? resolve(source) : undefined;
92
+ const assets = !source && token
93
+ ? await githubAssetUrls(index, token, fetchImpl)
94
+ : undefined;
95
+ // Never replace a directory this installer did not create: --dir or SUPERCUTS_PROOFS_DIR could name $HOME.
96
+ if (existsSync(dir) && readdirSync(dir).length && !existsSync(join(dir, MARKER)))
97
+ fail("PROOFS_DIR_UNSAFE", `${dir} is not empty and was not created by supercuts proofs install; choose an empty or new directory`);
98
+ mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
99
+ const lock = `${dir}.lock`;
100
+ try {
101
+ mkdirSync(lock, { mode: 0o700 });
102
+ }
103
+ catch {
104
+ // A lock older than the longest possible download belongs to a killed install.
105
+ if (Date.now() - statSync(lock).mtimeMs < 2 * 60 * 60_000)
106
+ fail("PROOFS_INSTALL_BUSY", `another proofs install holds ${lock}; wait for it or remove the lock if no install is running`);
107
+ }
108
+ const staging = `${dir}.partial`, previous = `${dir}.previous`;
109
+ rmSync(staging, { recursive: true, force: true });
110
+ mkdirSync(staging, { recursive: true, mode: 0o700 });
111
+ let bytes = 0;
112
+ try {
113
+ for (const file of index.files) {
114
+ options.onProgress?.(file);
115
+ const target = join(staging, file.path);
116
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
117
+ const existing = join(dir, file.path);
118
+ let input;
119
+ if (existsSync(existing) &&
120
+ !before.missing.includes(file.path) &&
121
+ !before.mismatched.includes(file.path))
122
+ input = { body: createReadStream(existing) };
123
+ else if (local)
124
+ input = { body: createReadStream(join(local, file.path)) };
125
+ else {
126
+ const assetUrl = assets?.get(file.asset);
127
+ if (assets && !assetUrl)
128
+ fail("PROOF_DOWNLOAD_FAILED", `release ${index.tag} has no asset ${file.asset}`);
129
+ const url = assetUrl ??
130
+ `${(source ?? index.baseUrl).replace(/\/$/, "")}/${file.asset}`;
131
+ const response = await fetchImpl(url, {
132
+ headers: {
133
+ "user-agent": "supercuts-proofs",
134
+ ...(assetUrl
135
+ ? {
136
+ accept: "application/octet-stream",
137
+ authorization: `Bearer ${token}`,
138
+ }
139
+ : {}),
140
+ },
141
+ redirect: "follow",
142
+ signal: AbortSignal.timeout(30 * 60_000),
143
+ });
144
+ if (!response.ok || !response.body)
145
+ fail("PROOF_DOWNLOAD_FAILED", `${file.asset} returned HTTP ${response.status}${response.status === 404 && !token ? "; set GITHUB_TOKEN while the release repository is private" : ""}`);
146
+ input = {
147
+ body: Readable.fromWeb(response.body),
148
+ };
149
+ }
150
+ const hash = createHash("sha256");
151
+ let size = 0;
152
+ const meter = new Transform({
153
+ transform(chunk, _encoding, done) {
154
+ size += chunk.length;
155
+ if (size > file.size)
156
+ return done(new Error("file is larger than pinned"));
157
+ hash.update(chunk);
158
+ done(null, chunk);
159
+ },
160
+ });
161
+ try {
162
+ await pipeline(input.body, meter, createWriteStream(target, { mode: 0o600 }));
163
+ }
164
+ catch (error) {
165
+ if (size > file.size)
166
+ fail("ARTIFACT_HASH_MISMATCH", `${file.path} is larger than its pinned size`, error);
167
+ fail("PROOF_DOWNLOAD_FAILED", `${file.path} could not be read or downloaded; retry the install`, error);
168
+ }
169
+ if (size !== file.size || hash.digest("hex") !== file.sha256)
170
+ fail("ARTIFACT_HASH_MISMATCH", `${file.path} does not match the pinned SHA-256`);
171
+ bytes += size;
172
+ }
173
+ writeFileSync(join(staging, MARKER), `${index.version}\n`, { mode: 0o600 });
174
+ // Move the old install aside before swapping, so an interruption leaves either install recoverable.
175
+ rmSync(previous, { recursive: true, force: true });
176
+ if (existsSync(dir))
177
+ renameSync(dir, previous);
178
+ renameSync(staging, dir);
179
+ chmodSync(dir, 0o700);
180
+ rmSync(previous, { recursive: true, force: true });
181
+ }
182
+ catch (error) {
183
+ rmSync(staging, { recursive: true, force: true });
184
+ if (!existsSync(dir) && existsSync(previous))
185
+ renameSync(previous, dir);
186
+ throw error;
187
+ }
188
+ finally {
189
+ rmSync(lock, { recursive: true, force: true });
190
+ }
191
+ return { dir, installed: true, files: index.files.length, bytes };
192
+ }
193
+ const isExecutable = (path) => {
194
+ try {
195
+ accessSync(path, constants.X_OK);
196
+ return true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ };
202
+ /** Locates the SnarkJS CLI shipped as a backend dependency (pnpm does not link bins of transitive dependencies). */
203
+ export function bundledSnarkjsCli() {
204
+ try {
205
+ let directory = dirname(createRequire(import.meta.url).resolve("snarkjs"));
206
+ while (!existsSync(join(directory, "package.json")) &&
207
+ dirname(directory) !== directory)
208
+ directory = dirname(directory);
209
+ const cli = join(directory, "build", "cli.cjs");
210
+ return existsSync(cli) ? cli : undefined;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
216
+ /**
217
+ * Runtime prover paths. Profiles written by older releases point at a repository checkout that npm installs do
218
+ * not have; those paths are kept only when they exist, otherwise the installed proofs and bundled tools are used.
219
+ */
220
+ export function resolveProverPaths(stored, artifactName) {
221
+ const hasKeys = (root) => Boolean(root) &&
222
+ existsSync(join(root, "proofs", artifactName, `${artifactName}.zkey`));
223
+ const artifactRoot = hasKeys(stored.artifactRoot)
224
+ ? stored.artifactRoot
225
+ : hasKeys(defaultProofsDir())
226
+ ? defaultProofsDir()
227
+ : stored.artifactRoot;
228
+ const snarkjsPath = isExecutable(stored.snarkjsPath)
229
+ ? stored.snarkjsPath
230
+ : (bundledSnarkjsCli() ?? stored.snarkjsPath);
231
+ const converterPath = isExecutable(stored.converterPath)
232
+ ? stored.converterPath
233
+ : "builtin";
234
+ const rapidsnark = stored.rapidsnarkPath ?? process.env.SUPERCUTS_RAPIDSNARK;
235
+ return {
236
+ artifactRoot,
237
+ snarkjsPath,
238
+ converterPath,
239
+ ...(rapidsnark ? { rapidsnarkPath: resolve(rapidsnark) } : {}),
240
+ };
241
+ }
package/dist/prover.d.ts CHANGED
@@ -24,5 +24,6 @@ export declare class Groth16Prover {
24
24
  rapidSnark: boolean;
25
25
  artifacts: Record<CircuitName, string>;
26
26
  };
27
+ private snarkjs;
27
28
  prove(name: CircuitName, input: Record<string, unknown>): Promise<ProofResult>;
28
29
  }
package/dist/prover.js CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { fail } from "./errors.js";
8
+ import { convertGroth16 } from "./groth16-convert.js";
8
9
  const run = promisify(execFile);
9
10
  const sha256 = (path) => createHash("sha256").update(readFileSync(path)).digest("hex");
10
11
  function executable(path) { try {
@@ -14,6 +15,9 @@ function executable(path) { try {
14
15
  catch {
15
16
  return false;
16
17
  } }
18
+ /** SnarkJS may be a native bin or the npm package's `cli.cjs`, which runs under the current Node binary. */
19
+ const isScript = (path) => /\.(?:c|m)?js$/.test(path);
20
+ const toolReady = (path) => isScript(path) ? existsSync(path) : executable(path);
17
21
  export class Groth16Prover {
18
22
  config;
19
23
  constructor(config) {
@@ -31,10 +35,14 @@ export class Groth16Prover {
31
35
  fail("ARTIFACT_HASH_MISMATCH", `${name} artifact hash differs from deployment configuration`);
32
36
  artifacts[name] = expected.zkeySha256;
33
37
  }
34
- if (!executable(this.config.snarkjsPath) || !executable(this.config.converterPath))
38
+ if (!toolReady(this.config.snarkjsPath) || this.config.converterPath !== "builtin" && !executable(this.config.converterPath))
35
39
  fail("PROVER_TOOL_MISSING", "SnarkJS or the Arkworks converter is not executable");
36
40
  return { rapidSnark: Boolean(this.config.rapidsnarkPath && executable(this.config.rapidsnarkPath)), artifacts };
37
41
  }
42
+ snarkjs(args, timeout) {
43
+ const [command, prefix] = isScript(this.config.snarkjsPath) ? [process.execPath, [this.config.snarkjsPath]] : [this.config.snarkjsPath, []];
44
+ return run(command, [...prefix, ...args], { timeout, maxBuffer: 4 * 1024 * 1024 });
45
+ }
38
46
  async prove(name, input) {
39
47
  this.doctor();
40
48
  const directory = mkdtempSync(join(tmpdir(), `supercuts-${name}-`));
@@ -51,10 +59,13 @@ export class Groth16Prover {
51
59
  if (useRapid)
52
60
  await run(this.config.rapidsnarkPath, [zkey, witness, proofJson, publicJson], { timeout: 10 * 60_000, maxBuffer: 4 * 1024 * 1024 });
53
61
  else
54
- await run(this.config.snarkjsPath, ["groth16", "prove", zkey, witness, proofJson, publicJson], { timeout: 20 * 60_000, maxBuffer: 4 * 1024 * 1024 });
62
+ await this.snarkjs(["groth16", "prove", zkey, witness, proofJson, publicJson], 20 * 60_000);
55
63
  const proveMs = Date.now() - started;
56
- await run(this.config.snarkjsPath, ["groth16", "verify", verificationKey, publicJson, proofJson], { timeout: 2 * 60_000, maxBuffer: 4 * 1024 * 1024 });
57
- await run(this.config.converterPath, [name, verificationKey, proofJson, publicJson, converted], { timeout: 2 * 60_000, maxBuffer: 4 * 1024 * 1024 });
64
+ await this.snarkjs(["groth16", "verify", verificationKey, publicJson, proofJson], 2 * 60_000);
65
+ if (this.config.converterPath === "builtin")
66
+ convertGroth16(verificationKey, proofJson, publicJson, converted);
67
+ else
68
+ await run(this.config.converterPath, [name, verificationKey, proofJson, publicJson, converted], { timeout: 2 * 60_000, maxBuffer: 4 * 1024 * 1024 });
58
69
  const preparedVk = join(converted, "verifying-key.bin");
59
70
  if (sha256(preparedVk) !== this.config.expected[name].verifyingKeySha256)
60
71
  fail("ARTIFACT_HASH_MISMATCH", "converter produced an unexpected verifying key");
@@ -71,7 +71,7 @@ function setupSellerConfig(home, config) {
71
71
  fundingRequired: false,
72
72
  warning: "Do not faucet-fund the seller. Only a generated buyer address is funded for live purchases.",
73
73
  ...createSellerSession(home),
74
- nextStep: `Run supercuts seller scaffold --home ${JSON.stringify(home)} --output <offer-dir> with the capability, origin, price, rails, and expiry options. Review the handler, then run the printed registration and service commands.`,
74
+ nextStep: `Run supercuts seller scaffold --home ${JSON.stringify(home)} --output <offer-dir> with the capability, origin, price, rails, and expiry options. Review the handler, then run the printed registration and service commands. Offers with the shielded rail also need the proving keys once per machine to recover and exit notes: supercuts proofs install.`,
75
75
  };
76
76
  }
77
77
  export function setupSeller(home, configPath) {
@@ -37,7 +37,7 @@ export interface SettlementCitation {
37
37
  provenance: SettlementMemoryProvenance;
38
38
  distance: number;
39
39
  }
40
- export type SettlementReasonCode = "MANDATE_DEFAULTS_APPLIED" | "MEMORY_PRIVACY_APPLIED" | "MEMORY_BUDGET_APPLIED" | "MEMORY_OUTCOME_APPLIED" | "MEMORY_LOW_RELEVANCE_IGNORED" | "MEMORY_UNTRUSTED_ADVISORY" | "OWNER_POLICY_CONFLICT" | "RISK_LOW" | "RISK_MEDIUM" | "RISK_REVIEW_REQUIRED" | "RAIL_X402_SELECTED" | "RAIL_SHIELDED_SELECTED" | "REQUESTED_AMOUNT_CLAMPED" | "OWNER_REVIEW_APPROVED" | "MEMORY_RELIABILITY_LEARNED" | "MEMORY_SELLER_DEMOTED" | "MEMORY_SPEND_LIMIT_REACHED";
40
+ export type SettlementReasonCode = "MANDATE_DEFAULTS_APPLIED" | "MEMORY_PRIVACY_APPLIED" | "MEMORY_BUDGET_APPLIED" | "MEMORY_OUTCOME_APPLIED" | "MEMORY_LOW_RELEVANCE_IGNORED" | "MEMORY_UNTRUSTED_ADVISORY" | "OWNER_POLICY_CONFLICT" | "RISK_LOW" | "RISK_MEDIUM" | "RISK_REVIEW_REQUIRED" | "RAIL_X402_SELECTED" | "RAIL_SHIELDED_SELECTED" | "REQUESTED_AMOUNT_CLAMPED" | "OWNER_REVIEW_APPROVED" | "MEMORY_RELIABILITY_LEARNED" | "MEMORY_SELLER_DEMOTED" | "MEMORY_SPEND_LIMIT_REACHED" | "MEMORY_PRIVACY_OVERRODE_RAIL";
41
41
  export interface SettlementPlanV1 {
42
42
  version: typeof SETTLEMENT_PLAN_VERSION;
43
43
  planHash: string;
@@ -121,4 +121,6 @@ export interface MemoryInfluence {
121
121
  citedBlobIds: string[];
122
122
  }
123
123
  /** Counterfactual: what the same mandate would have paid without any recalled memory. Not part of the plan hash. */
124
+ /** True when the plan honours the agent's requested rail, or an owner privacy memory rerouted x402 to shielded. */
125
+ export declare function planHonoursRail(plan: Pick<SettlementPlanV1, "rail" | "reasonCodes">, requestedRail?: CapabilityRail): boolean;
124
126
  export declare function explainMemoryInfluence(input: PlanningInput, plan: SettlementPlanV1): MemoryInfluence;
@@ -117,7 +117,10 @@ export function createSettlementPlan(input) {
117
117
  };
118
118
  const policies = latest("privacy_rule"), conflict = policies[0]?.provenance === "owner_policy" && new Set(policies.map((item) => item.signal.requiresShielded)).size > 1;
119
119
  const requiresShielded = input.mandate.requiredPrivacy === "shielded" || input.privacyHint === "shielded" || policies.some((item) => item.signal.requiresShielded === true);
120
- const rails = manifest.supportedRails.filter((rail) => (!input.mandate.allowedRails || input.mandate.allowedRails.includes(rail)) && (!requiresShielded || rail === "shielded") && (input.mandate.version !== "supercuts.mandate.v2" || rail !== "x402" || input.mandate.allowPublicX402 === true) && (!input.requestedRail || rail === input.requestedRail));
120
+ // The owner's remembered privacy rule outranks the agent's x402 request when the seller and mandate allow shielded.
121
+ const overridesRail = input.requestedRail === "x402" && policies.some((item) => item.provenance === "owner_policy" && item.signal.requiresShielded === true) && !conflict && manifest.supportedRails.includes("shielded") && (!input.mandate.allowedRails || input.mandate.allowedRails.includes("shielded"));
122
+ const requestedRail = overridesRail ? "shielded" : input.requestedRail;
123
+ const rails = manifest.supportedRails.filter((rail) => (!input.mandate.allowedRails || input.mandate.allowedRails.includes(rail)) && (!requiresShielded || rail === "shielded") && (input.mandate.version !== "supercuts.mandate.v2" || rail !== "x402" || input.mandate.allowPublicX402 === true) && (!requestedRail || rail === requestedRail));
121
124
  if (!rails.length)
122
125
  continue;
123
126
  const budgetMemories = trusted.filter((item) => item.memory.kind === "budget_limit").map((item) => item.memory);
@@ -137,7 +140,7 @@ export function createSettlementPlan(input) {
137
140
  const quality = latest("quality")[0]?.signal.quality ?? 0.5, outcome = card.lastOutcome ?? latest("outcome")[0]?.signal.outcome;
138
141
  const risk = conflict || spendLimited || outcome === "unresolved" || reliability < 0.5 ? "review_required" : reliability >= 0.8 ? "low" : "medium";
139
142
  for (const rail of rails)
140
- candidates.push({ manifest, rail, reliability, quality, maximum, risk, citations, conflict, card, reasons: ["MANDATE_DEFAULTS_APPLIED", ...(policies.length ? ["MEMORY_PRIVACY_APPLIED"] : []), ...(budgetMemories.length ? ["MEMORY_BUDGET_APPLIED"] : []), ...(spendLimited ? ["MEMORY_SPEND_LIMIT_REACHED"] : []), ...(outcome ? ["MEMORY_OUTCOME_APPLIED"] : []), ...(card.observations ? ["MEMORY_RELIABILITY_LEARNED"] : []), ...(citations.some((c) => ["agent_observation", "legacy_untrusted"].includes(c.provenance)) ? ["MEMORY_UNTRUSTED_ADVISORY"] : []), ...(ignored.length ? ["MEMORY_LOW_RELEVANCE_IGNORED"] : [])] });
143
+ candidates.push({ manifest, rail, reliability, quality, maximum, risk, citations, conflict, card, reasons: ["MANDATE_DEFAULTS_APPLIED", ...(policies.length ? ["MEMORY_PRIVACY_APPLIED"] : []), ...(overridesRail ? ["MEMORY_PRIVACY_OVERRODE_RAIL"] : []), ...(budgetMemories.length ? ["MEMORY_BUDGET_APPLIED"] : []), ...(spendLimited ? ["MEMORY_SPEND_LIMIT_REACHED"] : []), ...(outcome ? ["MEMORY_OUTCOME_APPLIED"] : []), ...(card.observations ? ["MEMORY_RELIABILITY_LEARNED"] : []), ...(citations.some((c) => ["agent_observation", "legacy_untrusted"].includes(c.provenance)) ? ["MEMORY_UNTRUSTED_ADVISORY"] : []), ...(ignored.length ? ["MEMORY_LOW_RELEVANCE_IGNORED"] : [])] });
141
144
  }
142
145
  candidates.sort((a, b) => Number(a.risk === "review_required") - Number(b.risk === "review_required") || b.reliability - a.reliability || b.quality - a.quality || (a.maximum < b.maximum ? -1 : a.maximum > b.maximum ? 1 : 0) || a.manifest.capabilityId.localeCompare(b.manifest.capabilityId) || (a.rail === b.rail ? 0 : input.mandate.version === "supercuts.mandate.v2" ? a.rail === "shielded" ? -1 : 1 : a.rail === "x402" ? -1 : 1));
143
146
  const selected = candidates[0] ?? fail("SETTLEMENT_PLAN_DENIED", "no capability remains within the owner mandate, privacy rules, and price bounds");
@@ -173,6 +176,10 @@ export function validateSettlementPlan(value) {
173
176
  }
174
177
  const decision = (plan) => ({ sellerId: plan.sellerId, capabilityId: plan.capabilityId, rail: plan.rail, riskLevel: plan.riskLevel, maximumAmount: plan.maximumAmount });
175
178
  /** Counterfactual: what the same mandate would have paid without any recalled memory. Not part of the plan hash. */
179
+ /** True when the plan honours the agent's requested rail, or an owner privacy memory rerouted x402 to shielded. */
180
+ export function planHonoursRail(plan, requestedRail) {
181
+ return !requestedRail || plan.rail === requestedRail || requestedRail === "x402" && plan.rail === "shielded" && plan.reasonCodes.includes("MEMORY_PRIVACY_OVERRODE_RAIL");
182
+ }
176
183
  export function explainMemoryInfluence(input, plan) {
177
184
  let withoutMemory;
178
185
  try {
@@ -64,10 +64,13 @@ export class X402ResourceService {
64
64
  return { scheme: "exact", network: this.config.network, amount: this.config.amount, asset: this.config.asset, payTo: this.config.payTo, maxTimeoutSeconds: this.config.maxTimeoutSeconds ?? 300, extra: { requestId: artifact.requestId, operationId: artifact.operationId } };
65
65
  }
66
66
  resourceUrl(requestId) { return new URL(`${this.#prefix}/${encodeURIComponent(requestId)}`, this.#origin).toString(); }
67
- challenge(artifact) {
67
+ challenge(artifact, renewExpired) {
68
68
  // The live Sui exact facilitator requires the x402 nonce to be exactly
69
69
  // 32 random bytes (hex encoded), not merely an application nonce.
70
- const requirements = this.requirements(artifact), baseHash = requirementsHash(requirements), nonce = this.#ledger.challenge(artifact.requestId, baseHash, () => randomBytes(32).toString("hex"));
70
+ const requirements = this.requirements(artifact), baseHash = requirementsHash(requirements), timeoutMs = requirements.maxTimeoutSeconds * 1_000;
71
+ const nonce = renewExpired
72
+ ? this.#ledger.challenge(artifact.requestId, baseHash, () => randomBytes(32).toString("hex"), timeoutMs)
73
+ : this.#ledger.getChallenge(artifact.requestId, baseHash)?.nonce ?? fail("X402_CHALLENGE_MISSING", "resource challenge was not persisted");
71
74
  const stored = this.#ledger.getChallenge(artifact.requestId, baseHash) ?? fail("X402_CHALLENGE_MISSING", "resource challenge was not persisted");
72
75
  const expiresAt = stored.createdAt + requirements.maxTimeoutSeconds * 1_000;
73
76
  requirements.extra = { ...requirements.extra, nonce, expiresAt };
@@ -85,14 +88,14 @@ export class X402ResourceService {
85
88
  const requestId = decodeURIComponent(url.pathname.slice(prefix.length)), artifact = this.#artifacts.get(requestId);
86
89
  if (!artifact)
87
90
  return this.send(response, 404, Buffer.from("Not Found"));
88
- const challenge = this.challenge(artifact), requirements = challenge.accepts instanceof Array ? challenge.accepts[0] : fail("X402_STATE_INVALID", "challenge omitted requirements");
89
91
  const paymentHeader = Array.isArray(request.headers["payment-signature"]) ? request.headers["payment-signature"][0] : request.headers["payment-signature"];
92
+ const challenge = this.challenge(artifact, !paymentHeader), requirements = challenge.accepts instanceof Array ? challenge.accepts[0] : fail("X402_STATE_INVALID", "challenge omitted requirements");
90
93
  if (!paymentHeader) {
91
94
  await this.persist?.();
92
95
  return this.send(response, 402, Buffer.from("Payment Required"), { "payment-required": encode(challenge) });
93
96
  }
94
97
  const stored = this.#ledger.getChallenge(artifact.requestId, requirementsHash(this.requirements(artifact))) ?? fail("X402_CHALLENGE_MISSING", "resource challenge was not persisted");
95
- if (!this.#ledger.isDeliveryComplete(artifact.requestId) && Date.now() > stored.createdAt + requirements.maxTimeoutSeconds * 1_000)
98
+ if (!this.#ledger.hasClaim(artifact.requestId) && Date.now() > stored.createdAt + requirements.maxTimeoutSeconds * 1_000)
96
99
  fail("X402_CHALLENGE_EXPIRED", "payment challenge has expired");
97
100
  const payload = decode(paymentHeader, "X402_PAYMENT_INVALID"), bindingValue = Array.isArray(request.headers["x-supercuts-binding"]) ? request.headers["x-supercuts-binding"][0] : request.headers["x-supercuts-binding"], binding = decode(bindingValue, "X402_REQUEST_BINDING_INVALID");
98
101
  const offered = payload.accepted;
package/dist/x402.d.ts CHANGED
@@ -29,6 +29,7 @@ export declare function paymentBindingBytes(binding: Omit<PaymentBinding, "payer
29
29
  export declare class PaymentConsumptionLedger {
30
30
  private readonly store;
31
31
  constructor(store: RoleStore);
32
+ hasClaim(requestId: string): boolean;
32
33
  claim(payment: FinalPayment, requestId: string): void;
33
34
  recordDelivery(requestId: string, response: Uint8Array): void;
34
35
  getDelivery(requestId: string): Buffer | undefined;
@@ -36,7 +37,7 @@ export declare class PaymentConsumptionLedger {
36
37
  acquireDelivery(requestId: string, now?: number, leaseMs?: number): boolean;
37
38
  releaseDelivery(requestId: string): void;
38
39
  completeDelivery(requestId: string, response: Uint8Array): void;
39
- challenge(requestId: string, requirementsHashValue: string, createNonce: () => string): string;
40
+ challenge(requestId: string, requirementsHashValue: string, createNonce: () => string, renewAfterMs?: number, now?: number): string;
40
41
  getChallenge(requestId: string, requirementsHashValue: string): {
41
42
  nonce: string;
42
43
  createdAt: number;
package/dist/x402.js CHANGED
@@ -19,6 +19,9 @@ export class PaymentConsumptionLedger {
19
19
  constructor(store) {
20
20
  this.store = store;
21
21
  }
22
+ hasClaim(requestId) {
23
+ return Boolean(this.store.db.prepare("SELECT 1 FROM deliveries WHERE request_id = ?").get(requestId));
24
+ }
22
25
  claim(payment, requestId) {
23
26
  if (!payment.finalized)
24
27
  fail("PAYMENT_PENDING", "payment is not finalized");
@@ -85,16 +88,31 @@ export class PaymentConsumptionLedger {
85
88
  throw error;
86
89
  }
87
90
  }
88
- challenge(requestId, requirementsHashValue, createNonce) {
89
- const existing = this.store.db.prepare("SELECT nonce, requirements_hash FROM x402_challenges WHERE request_id = ?").get(requestId);
90
- if (existing) {
91
- if (existing.requirements_hash !== requirementsHashValue)
92
- fail("X402_REQUEST_CONFLICT", "request identity was reused with different requirements");
93
- return existing.nonce;
91
+ challenge(requestId, requirementsHashValue, createNonce, renewAfterMs, now = Date.now()) {
92
+ this.store.db.exec("BEGIN IMMEDIATE");
93
+ try {
94
+ const existing = this.store.db.prepare("SELECT nonce, requirements_hash, created_at FROM x402_challenges WHERE request_id = ?").get(requestId);
95
+ if (existing) {
96
+ if (existing.requirements_hash !== requirementsHashValue)
97
+ fail("X402_REQUEST_CONFLICT", "request identity was reused with different requirements");
98
+ if (renewAfterMs !== undefined && now > existing.created_at + renewAfterMs && !this.hasClaim(requestId)) {
99
+ const nonce = createNonce();
100
+ this.store.db.prepare("UPDATE x402_challenges SET nonce = ?, created_at = ? WHERE request_id = ?").run(nonce, now, requestId);
101
+ this.store.db.exec("COMMIT");
102
+ return nonce;
103
+ }
104
+ this.store.db.exec("COMMIT");
105
+ return existing.nonce;
106
+ }
107
+ const nonce = createNonce();
108
+ this.store.db.prepare("INSERT INTO x402_challenges(request_id, nonce, requirements_hash, created_at) VALUES (?, ?, ?, ?)").run(requestId, nonce, requirementsHashValue, now);
109
+ this.store.db.exec("COMMIT");
110
+ return nonce;
111
+ }
112
+ catch (error) {
113
+ this.store.db.exec("ROLLBACK");
114
+ throw error;
94
115
  }
95
- const nonce = createNonce();
96
- this.store.db.prepare("INSERT INTO x402_challenges(request_id, nonce, requirements_hash, created_at) VALUES (?, ?, ?, ?)").run(requestId, nonce, requirementsHashValue, Date.now());
97
- return nonce;
98
116
  }
99
117
  getChallenge(requestId, requirementsHashValue) {
100
118
  const row = this.store.db.prepare("SELECT nonce, requirements_hash, created_at FROM x402_challenges WHERE request_id = ?").get(requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supercuts-walrus/backend",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,7 +44,8 @@
44
44
  "@mysten/walrus": "1.2.37",
45
45
  "ajv": "8.17.1",
46
46
  "circomlibjs": "0.1.7",
47
- "pg": "^8.23.1"
47
+ "pg": "^8.23.1",
48
+ "snarkjs": "0.7.6"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "^22.10.0",