@supercuts-walrus/backend 0.0.0-stage → 0.1.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 +201 -0
- package/README.md +249 -2
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +157 -0
- package/dist/backend.d.ts +27 -0
- package/dist/backend.js +62 -0
- package/dist/buyer-setup.d.ts +6 -0
- package/dist/buyer-setup.js +241 -0
- package/dist/capability.d.ts +32 -0
- package/dist/capability.js +87 -0
- package/dist/claude.d.ts +44 -0
- package/dist/claude.js +98 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +29 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +58 -0
- package/dist/dashboard.d.ts +5 -0
- package/dist/dashboard.js +21 -0
- package/dist/deal-adapter.d.ts +3 -0
- package/dist/deal-adapter.js +39 -0
- package/dist/deal-engine.d.ts +62 -0
- package/dist/deal-engine.js +211 -0
- package/dist/deal-wire.d.ts +16 -0
- package/dist/deal-wire.js +65 -0
- package/dist/deal.d.ts +79 -0
- package/dist/deal.js +228 -0
- package/dist/demo.d.ts +2 -0
- package/dist/demo.js +449 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +13 -0
- package/dist/events.d.ts +27 -0
- package/dist/events.js +68 -0
- package/dist/facilitator.d.ts +21 -0
- package/dist/facilitator.js +173 -0
- package/dist/finality.d.ts +2 -0
- package/dist/finality.js +27 -0
- package/dist/hosted-exit-cli.d.ts +2 -0
- package/dist/hosted-exit-cli.js +37 -0
- package/dist/hosted-exit.d.ts +24 -0
- package/dist/hosted-exit.js +70 -0
- package/dist/hosted-seller-import.d.ts +2 -0
- package/dist/hosted-seller-import.js +70 -0
- package/dist/hosted-seller-repository.d.ts +31 -0
- package/dist/hosted-seller-repository.js +214 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +37 -0
- package/dist/indexer.d.ts +63 -0
- package/dist/indexer.js +60 -0
- package/dist/live-deal-ports.d.ts +41 -0
- package/dist/live-deal-ports.js +212 -0
- package/dist/live-index.d.ts +28 -0
- package/dist/live-index.js +149 -0
- package/dist/live-runtime.d.ts +82 -0
- package/dist/live-runtime.js +570 -0
- package/dist/live-seller-transport.d.ts +30 -0
- package/dist/live-seller-transport.js +169 -0
- package/dist/live-seller-verifier.d.ts +11 -0
- package/dist/live-seller-verifier.js +65 -0
- package/dist/mandate.d.ts +34 -0
- package/dist/mandate.js +178 -0
- package/dist/mcp-bootstrap.d.ts +2 -0
- package/dist/mcp-bootstrap.js +39 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +187 -0
- package/dist/memory-worker.d.ts +2 -0
- package/dist/memory-worker.js +38 -0
- package/dist/memory.d.ts +15 -0
- package/dist/memory.js +17 -0
- package/dist/memwal.d.ts +35 -0
- package/dist/memwal.js +83 -0
- package/dist/negotiation.d.ts +23 -0
- package/dist/negotiation.js +58 -0
- package/dist/notes.d.ts +11 -0
- package/dist/notes.js +26 -0
- package/dist/onboarding-doctor.d.ts +1 -0
- package/dist/onboarding-doctor.js +76 -0
- package/dist/phase3.d.ts +100 -0
- package/dist/phase3.js +1144 -0
- package/dist/procurement.d.ts +8 -0
- package/dist/procurement.js +19 -0
- package/dist/protocol-engine.d.ts +121 -0
- package/dist/protocol-engine.js +149 -0
- package/dist/prover.d.ts +28 -0
- package/dist/prover.js +70 -0
- package/dist/relayer.d.ts +32 -0
- package/dist/relayer.js +43 -0
- package/dist/remote-buyer.d.ts +67 -0
- package/dist/remote-buyer.js +231 -0
- package/dist/resource-server.d.ts +2 -0
- package/dist/resource-server.js +24 -0
- package/dist/resource.d.ts +22 -0
- package/dist/resource.js +55 -0
- package/dist/seam.d.ts +2 -0
- package/dist/seam.js +117 -0
- package/dist/seller-catalog-generate.d.ts +2 -0
- package/dist/seller-catalog-generate.js +68 -0
- package/dist/seller-database.d.ts +41 -0
- package/dist/seller-database.js +80 -0
- package/dist/seller-directory-cli.d.ts +2 -0
- package/dist/seller-directory-cli.js +24 -0
- package/dist/seller-directory.d.ts +5 -0
- package/dist/seller-directory.js +50 -0
- package/dist/seller-live-cli.d.ts +2 -0
- package/dist/seller-live-cli.js +53 -0
- package/dist/seller-provision.d.ts +2 -0
- package/dist/seller-provision.js +84 -0
- package/dist/seller-register-cli.d.ts +2 -0
- package/dist/seller-register-cli.js +40 -0
- package/dist/seller-runtime.d.ts +60 -0
- package/dist/seller-runtime.js +96 -0
- package/dist/seller-service.d.ts +34 -0
- package/dist/seller-service.js +416 -0
- package/dist/seller-state.d.ts +19 -0
- package/dist/seller-state.js +60 -0
- package/dist/seller-stdio.d.ts +6 -0
- package/dist/seller-stdio.js +29 -0
- package/dist/seller-v2-provision.d.ts +2 -0
- package/dist/seller-v2-provision.js +71 -0
- package/dist/seller-watch.d.ts +2 -0
- package/dist/seller-watch.js +45 -0
- package/dist/settlement.d.ts +95 -0
- package/dist/settlement.js +141 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.js +193 -0
- package/dist/sui.d.ts +49 -0
- package/dist/sui.js +77 -0
- package/dist/tatum.d.ts +17 -0
- package/dist/tatum.js +83 -0
- package/dist/transcript.d.ts +31 -0
- package/dist/transcript.js +52 -0
- package/dist/v2-exit-relayer.d.ts +34 -0
- package/dist/v2-exit-relayer.js +102 -0
- package/dist/v2-exit-worker.d.ts +2 -0
- package/dist/v2-exit-worker.js +97 -0
- package/dist/v2-exits.d.ts +30 -0
- package/dist/v2-exits.js +78 -0
- package/dist/v2-prefund.d.ts +19 -0
- package/dist/v2-prefund.js +43 -0
- package/dist/v2-relayer-cli.d.ts +2 -0
- package/dist/v2-relayer-cli.js +39 -0
- package/dist/wallet.d.ts +32 -0
- package/dist/wallet.js +90 -0
- package/dist/workflow-memory.d.ts +22 -0
- package/dist/workflow-memory.js +49 -0
- package/dist/x402-resource-service.d.ts +34 -0
- package/dist/x402-resource-service.js +154 -0
- package/dist/x402.d.ts +46 -0
- package/dist/x402.js +136 -0
- package/package.json +60 -3
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { fail } from "./errors.js";
|
|
2
|
+
/** Buyer-side scheduling; the callback must be the live, finalized deposit path. */
|
|
3
|
+
export class V2PrefundQueue {
|
|
4
|
+
store;
|
|
5
|
+
constructor(store) {
|
|
6
|
+
this.store = store;
|
|
7
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS v2_prefund_queue (
|
|
8
|
+
operation_id TEXT PRIMARY KEY, amount TEXT NOT NULL, scheduled_at INTEGER NOT NULL,
|
|
9
|
+
status TEXT NOT NULL, transaction_digest TEXT UNIQUE
|
|
10
|
+
)`);
|
|
11
|
+
}
|
|
12
|
+
queue(operationId, amount, scheduledAt, approved, now = Date.now()) {
|
|
13
|
+
if (!approved)
|
|
14
|
+
fail("OWNER_APPROVAL_REQUIRED", "owner approval is required to schedule prefunding");
|
|
15
|
+
if (!operationId || operationId.length > 256 || (amount !== 10000000n && amount !== 100000000n) || !Number.isSafeInteger(scheduledAt) || scheduledAt < now)
|
|
16
|
+
fail("PREFUND_PLAN_INVALID", "v2 prefunding needs a standard amount and future time");
|
|
17
|
+
const prior = this.get(operationId);
|
|
18
|
+
if (prior) {
|
|
19
|
+
if (prior.amount !== amount || prior.scheduledAt !== scheduledAt)
|
|
20
|
+
fail("OPERATION_ID_CONFLICT", "prefunding operation ID identifies another plan");
|
|
21
|
+
return prior;
|
|
22
|
+
}
|
|
23
|
+
this.store.db.prepare("INSERT INTO v2_prefund_queue(operation_id,amount,scheduled_at,status) VALUES(?,?,?,'queued')").run(operationId, amount.toString(), scheduledAt);
|
|
24
|
+
return this.get(operationId);
|
|
25
|
+
}
|
|
26
|
+
get(operationId) {
|
|
27
|
+
const row = this.store.db.prepare("SELECT operation_id,amount,scheduled_at,status,transaction_digest FROM v2_prefund_queue WHERE operation_id=?").get(operationId);
|
|
28
|
+
return row && { operationId: row.operation_id, amount: BigInt(row.amount), scheduledAt: row.scheduled_at, status: row.status, ...(row.transaction_digest ? { transactionDigest: row.transaction_digest } : {}) };
|
|
29
|
+
}
|
|
30
|
+
async runDue(now, deposit) {
|
|
31
|
+
const rows = this.store.db.prepare("SELECT operation_id FROM v2_prefund_queue WHERE status='queued' AND scheduled_at<=? ORDER BY scheduled_at").all(now);
|
|
32
|
+
const finalized = [];
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const plan = this.get(row.operation_id);
|
|
35
|
+
const receipt = await deposit(plan.amount, plan.operationId);
|
|
36
|
+
if (!receipt.finalized || !receipt.transactionDigest)
|
|
37
|
+
fail("PREFUND_NOT_FINAL", "prefunding callback did not return a finalized live deposit");
|
|
38
|
+
this.store.db.prepare("UPDATE v2_prefund_queue SET status='finalized',transaction_digest=? WHERE operation_id=? AND status='queued'").run(receipt.transactionDigest, plan.operationId);
|
|
39
|
+
finalized.push(this.get(plan.operationId));
|
|
40
|
+
}
|
|
41
|
+
return finalized;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import { loadStateKey } from "./config.js";
|
|
5
|
+
import { RoleStore } from "./store.js";
|
|
6
|
+
import { V2ExitRelayer } from "./v2-exit-relayer.js";
|
|
7
|
+
const [command, ...words] = process.argv.slice(2);
|
|
8
|
+
const args = new Map();
|
|
9
|
+
for (let index = 0; index < words.length; index += 2) {
|
|
10
|
+
if (!words[index]?.startsWith("--") || !words[index + 1])
|
|
11
|
+
throw Error("expected --name value arguments");
|
|
12
|
+
args.set(words[index].slice(2), words[index + 1]);
|
|
13
|
+
}
|
|
14
|
+
const required = (name) => args.get(name) ?? (() => { throw Error(`--${name} is required`); })();
|
|
15
|
+
const stateDir = resolve(required("state-dir"));
|
|
16
|
+
const config = JSON.parse(readFileSync(resolve(required("config")), "utf8"));
|
|
17
|
+
const store = new RoleStore(join(stateDir, "state.sqlite"), loadStateKey(join(stateDir, "state.key")));
|
|
18
|
+
try {
|
|
19
|
+
const relayer = new V2ExitRelayer(store, config);
|
|
20
|
+
if (command === "doctor")
|
|
21
|
+
console.log(JSON.stringify(await relayer.doctor()));
|
|
22
|
+
else if (command === "submit") {
|
|
23
|
+
const requestPath = resolve(required("request-file"));
|
|
24
|
+
if ((statSync(requestPath).mode & 0o077) !== 0)
|
|
25
|
+
throw Error("request file must be owner-only");
|
|
26
|
+
const raw = JSON.parse(readFileSync(requestPath, "utf8"));
|
|
27
|
+
const bytes32 = (name) => Buffer.from(String(raw[name] ?? "").replace(/^0x/, ""), "hex");
|
|
28
|
+
const result = await relayer.submit({ operationId: String(raw.operationId), root: bytes32("root"), noteNullifier: bytes32("noteNullifier"),
|
|
29
|
+
recipient: String(raw.recipient), amount: BigInt(String(raw.amount)), exactExit: raw.exactExit === true,
|
|
30
|
+
changeCommitment: bytes32("changeCommitment"), newRoot: bytes32("newRoot"), newIndex: BigInt(String(raw.newIndex)),
|
|
31
|
+
proof: Buffer.from(String(raw.proof), "base64") });
|
|
32
|
+
console.log(JSON.stringify(result));
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
throw Error("use doctor or submit");
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
store.close();
|
|
39
|
+
}
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PoolEvent } from "./indexer.js";
|
|
2
|
+
import { RoleStore, StoredOperation } from "./store.js";
|
|
3
|
+
import { NoteOpening } from "./notes.js";
|
|
4
|
+
export interface SettlementEvidence {
|
|
5
|
+
digest: string;
|
|
6
|
+
finalized: boolean;
|
|
7
|
+
successful: boolean;
|
|
8
|
+
packageId: string;
|
|
9
|
+
poolId: string;
|
|
10
|
+
coinType: string;
|
|
11
|
+
events: PoolEvent[];
|
|
12
|
+
}
|
|
13
|
+
export type EvidenceReader = (digest: string) => Promise<SettlementEvidence | undefined>;
|
|
14
|
+
export interface SettlementExpectation {
|
|
15
|
+
packageId: string;
|
|
16
|
+
poolId: string;
|
|
17
|
+
coinType: string;
|
|
18
|
+
inputNullifier: string;
|
|
19
|
+
dealNullifier: string;
|
|
20
|
+
paymentCommitment: string;
|
|
21
|
+
changeCommitment: string;
|
|
22
|
+
}
|
|
23
|
+
export declare class PrivateWallet {
|
|
24
|
+
private readonly store;
|
|
25
|
+
constructor(store: RoleStore);
|
|
26
|
+
prepare(operationId: string, payloadHash: string, inputCommitment: string, expected: SettlementExpectation, proof: Uint8Array): void;
|
|
27
|
+
recordBroadcast(operationId: string, digest: string): void;
|
|
28
|
+
reconcile(operationId: string, read: EvidenceReader): Promise<StoredOperation>;
|
|
29
|
+
recoverOutput(operationId: string, role: "payment" | "change", opening: NoteOpening, event: Extract<PoolEvent, {
|
|
30
|
+
kind: "transfer";
|
|
31
|
+
}>): Promise<string>;
|
|
32
|
+
}
|
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { fail } from "./errors.js";
|
|
2
|
+
import { encodeNoteOpening, noteCommitment } from "./notes.js";
|
|
3
|
+
function encodeExpectation(expected) {
|
|
4
|
+
const entries = Object.entries(expected);
|
|
5
|
+
if (entries.some(([, value]) => !value || typeof value !== "string" || value.length > 512))
|
|
6
|
+
fail("SETTLEMENT_EXPECTATION_INVALID", "settlement expectation is incomplete or too large");
|
|
7
|
+
return Buffer.from(JSON.stringify(Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))));
|
|
8
|
+
}
|
|
9
|
+
function rejectEvidence(store, operationId, code, message) {
|
|
10
|
+
const status = store.getOperation(operationId)?.status;
|
|
11
|
+
if (status && status !== "unresolved" && status !== "finalized" && status !== "confirmed" && status !== "delivered")
|
|
12
|
+
store.transitionOperation(operationId, "unresolved");
|
|
13
|
+
return fail(code, message);
|
|
14
|
+
}
|
|
15
|
+
export class PrivateWallet {
|
|
16
|
+
store;
|
|
17
|
+
constructor(store) {
|
|
18
|
+
this.store = store;
|
|
19
|
+
}
|
|
20
|
+
prepare(operationId, payloadHash, inputCommitment, expected, proof) {
|
|
21
|
+
this.store.beginOperation(operationId, payloadHash);
|
|
22
|
+
this.store.reserveNote(inputCommitment, operationId);
|
|
23
|
+
this.store.putSecretOnce(`settlement:${operationId}`, encodeExpectation(expected));
|
|
24
|
+
this.store.putNote(inputCommitment, "input", operationId, undefined, expected.inputNullifier);
|
|
25
|
+
const operation = this.store.getOperation(operationId);
|
|
26
|
+
if (operation.status === "reserved") {
|
|
27
|
+
this.store.putSecretOnce(`proof:${operationId}`, proof);
|
|
28
|
+
this.store.transitionOperation(operationId, "proof_persisted");
|
|
29
|
+
}
|
|
30
|
+
else if (!this.store.getSecret(`proof:${operationId}`)?.equals(Buffer.from(proof))) {
|
|
31
|
+
fail("PROOF_RETRY_CONFLICT", "retry proof differs from the persisted proof");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
recordBroadcast(operationId, digest) {
|
|
35
|
+
const operation = this.store.getOperation(operationId) ?? fail("OPERATION_NOT_FOUND", `operation ${operationId} does not exist`);
|
|
36
|
+
if (operation.txDigest && operation.txDigest !== digest)
|
|
37
|
+
fail("OPERATION_DIGEST_CONFLICT", "operation cannot be rebound to another transaction");
|
|
38
|
+
if (operation.status === "proof_persisted" || (operation.status === "unresolved" && this.store.getSecret(`proof:${operationId}`)))
|
|
39
|
+
this.store.transitionOperation(operationId, "broadcast", digest);
|
|
40
|
+
else if (operation.status !== "broadcast" && operation.status !== "finalized" && operation.status !== "confirmed" && operation.status !== "delivered")
|
|
41
|
+
fail("OPERATION_NOT_READY", "proof must be persisted before broadcast");
|
|
42
|
+
}
|
|
43
|
+
async reconcile(operationId, read) {
|
|
44
|
+
const operation = this.store.getOperation(operationId) ?? fail("OPERATION_NOT_FOUND", `operation ${operationId} does not exist`);
|
|
45
|
+
const encodedExpectation = this.store.getSecret(`settlement:${operationId}`) ?? fail("SETTLEMENT_EXPECTATION_MISSING", "operation has no persisted settlement expectation");
|
|
46
|
+
let expected;
|
|
47
|
+
try {
|
|
48
|
+
expected = JSON.parse(encodedExpectation.toString());
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return fail("SETTLEMENT_EXPECTATION_INVALID", "persisted settlement expectation is invalid", error);
|
|
52
|
+
}
|
|
53
|
+
if (!operation.txDigest)
|
|
54
|
+
return operation;
|
|
55
|
+
const evidence = await read(operation.txDigest);
|
|
56
|
+
if (!evidence || !evidence.finalized) {
|
|
57
|
+
if (operation.status !== "unresolved")
|
|
58
|
+
this.store.transitionOperation(operationId, "unresolved");
|
|
59
|
+
return this.store.getOperation(operationId);
|
|
60
|
+
}
|
|
61
|
+
if (evidence.digest !== operation.txDigest)
|
|
62
|
+
rejectEvidence(this.store, operationId, "SETTLEMENT_DIGEST_MISMATCH", "evidence reader returned another transaction");
|
|
63
|
+
if (!evidence.successful)
|
|
64
|
+
rejectEvidence(this.store, operationId, "SETTLEMENT_FAILED", "finalized transaction did not execute successfully");
|
|
65
|
+
if (evidence.packageId !== expected.packageId || evidence.poolId !== expected.poolId || evidence.coinType !== expected.coinType)
|
|
66
|
+
rejectEvidence(this.store, operationId, "SETTLEMENT_TARGET_MISMATCH", "transaction does not settle against the pinned pool and asset");
|
|
67
|
+
const transfer = evidence.events.find((event) => event.kind === "transfer" && event.inputNullifier === expected.inputNullifier && event.dealNullifier === expected.dealNullifier && event.paymentCommitment === expected.paymentCommitment && event.changeCommitment === expected.changeCommitment);
|
|
68
|
+
if (!transfer)
|
|
69
|
+
rejectEvidence(this.store, operationId, "SETTLEMENT_EVENT_MISSING", "finalized transaction does not contain the expected transfer");
|
|
70
|
+
if (operation.status === "broadcast" || operation.status === "unresolved")
|
|
71
|
+
this.store.transitionOperation(operationId, "finalized", evidence.digest);
|
|
72
|
+
this.store.markNoteSpent(expected.inputNullifier);
|
|
73
|
+
return this.store.getOperation(operationId);
|
|
74
|
+
}
|
|
75
|
+
async recoverOutput(operationId, role, opening, event) {
|
|
76
|
+
const operation = this.store.getOperation(operationId) ?? fail("OPERATION_NOT_FOUND", `operation ${operationId} does not exist`);
|
|
77
|
+
if (!["finalized", "confirmed", "delivered"].includes(operation.status))
|
|
78
|
+
fail("NOTE_RECOVERY_NOT_FINAL", "output notes can only be recovered from a finalized operation");
|
|
79
|
+
const expected = JSON.parse((this.store.getSecret(`settlement:${operationId}`) ?? fail("SETTLEMENT_EXPECTATION_MISSING", "operation has no persisted settlement expectation")).toString());
|
|
80
|
+
if (event.inputNullifier !== expected.inputNullifier || event.dealNullifier !== expected.dealNullifier || event.paymentCommitment !== expected.paymentCommitment || event.changeCommitment !== expected.changeCommitment)
|
|
81
|
+
fail("NOTE_RECOVERY_EVENT_MISMATCH", "recovery event does not match the persisted settlement");
|
|
82
|
+
const commitment = await noteCommitment(opening);
|
|
83
|
+
const expectedCommitment = role === "payment" ? event.paymentCommitment : event.changeCommitment;
|
|
84
|
+
if (commitment !== expectedCommitment)
|
|
85
|
+
fail("NOTE_RECOVERY_MISMATCH", `${role} opening does not match the finalized commitment`);
|
|
86
|
+
this.store.putSecretOnce(`note:${commitment}`, encodeNoteOpening(opening));
|
|
87
|
+
this.store.putNote(commitment, role, operationId, event.firstLeafIndex + (role === "change" ? 1n : 0n));
|
|
88
|
+
return commitment;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { MemoryFact, RailPolicy } from "./memory.js";
|
|
2
|
+
import { RoleStore } from "./store.js";
|
|
3
|
+
export interface MemoryWriter {
|
|
4
|
+
remember(fact: MemoryFact): Promise<string>;
|
|
5
|
+
}
|
|
6
|
+
export interface RecalledRule {
|
|
7
|
+
hiddenAmountRequired?: boolean;
|
|
8
|
+
seller?: string;
|
|
9
|
+
maxAmount?: bigint;
|
|
10
|
+
}
|
|
11
|
+
export declare class WorkflowMemory {
|
|
12
|
+
private readonly store;
|
|
13
|
+
private readonly memory;
|
|
14
|
+
constructor(store: RoleStore, memory: MemoryWriter);
|
|
15
|
+
record(fact: MemoryFact): Promise<string>;
|
|
16
|
+
completed(): Array<{
|
|
17
|
+
operationId: string;
|
|
18
|
+
blobId: string;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare function policyFromRecall(rules: RecalledRule[]): RailPolicy;
|
|
22
|
+
export declare function decideRailFromRecall(rules: RecalledRule[], requested: "shielded" | "x402"): "shielded" | "x402";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fail } from "./errors.js";
|
|
3
|
+
import { selectRail, validateMemoryFact } from "./memory.js";
|
|
4
|
+
export class WorkflowMemory {
|
|
5
|
+
store;
|
|
6
|
+
memory;
|
|
7
|
+
constructor(store, memory) {
|
|
8
|
+
this.store = store;
|
|
9
|
+
this.memory = memory;
|
|
10
|
+
}
|
|
11
|
+
async record(fact) {
|
|
12
|
+
validateMemoryFact(fact);
|
|
13
|
+
const factHash = createHash("sha256").update(JSON.stringify({ kind: fact.kind, subject: fact.subject, observation: fact.observation, operationId: fact.operationId, createdAt: fact.createdAt })).digest("hex");
|
|
14
|
+
const existing = this.store.db.prepare("SELECT fact_hash, blob_id, status FROM memory_jobs WHERE operation_id = ?").get(fact.operationId);
|
|
15
|
+
if (existing && existing.fact_hash !== factHash)
|
|
16
|
+
fail("MEMORY_OPERATION_CONFLICT", "memory operation ID already identifies another fact");
|
|
17
|
+
if (existing?.status === "complete" && existing.blob_id)
|
|
18
|
+
return existing.blob_id;
|
|
19
|
+
if (existing)
|
|
20
|
+
fail(existing.status === "unresolved" ? "MEMORY_JOB_UNRESOLVED" : "MEMORY_JOB_PENDING", "memory write cannot be repeated without reconciling its prior job");
|
|
21
|
+
const inserted = this.store.db.prepare("INSERT OR IGNORE INTO memory_jobs(operation_id, kind, fact_hash, blob_id, status, created_at) VALUES (?, ?, ?, NULL, 'pending', ?)").run(fact.operationId, fact.kind, factHash, Date.now());
|
|
22
|
+
if (inserted.changes !== 1)
|
|
23
|
+
fail("MEMORY_JOB_PENDING", "another process started this memory write");
|
|
24
|
+
try {
|
|
25
|
+
const blobId = await this.memory.remember(fact);
|
|
26
|
+
if (!blobId)
|
|
27
|
+
fail("MEMORY_BLOB_MISSING", "completed memory write returned no blob ID");
|
|
28
|
+
this.store.db.prepare("UPDATE memory_jobs SET blob_id = ?, status = 'complete' WHERE operation_id = ? AND status = 'pending'").run(blobId, fact.operationId);
|
|
29
|
+
return blobId;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
this.store.db.prepare("UPDATE memory_jobs SET status = 'unresolved' WHERE operation_id = ? AND status = 'pending'").run(fact.operationId);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
completed() {
|
|
37
|
+
return this.store.db.prepare("SELECT operation_id, blob_id FROM memory_jobs WHERE status = 'complete' ORDER BY created_at").all()
|
|
38
|
+
.map((row) => { const value = row; return { operationId: value.operation_id, blobId: value.blob_id }; });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function policyFromRecall(rules) {
|
|
42
|
+
if (!rules.length)
|
|
43
|
+
return { hiddenAmountRequired: false, recalledRulesAvailable: false, contradictory: false };
|
|
44
|
+
const privacy = rules.map((rule) => rule.hiddenAmountRequired).filter((value) => value !== undefined);
|
|
45
|
+
return { hiddenAmountRequired: privacy.includes(true), recalledRulesAvailable: true, contradictory: privacy.includes(true) && privacy.includes(false) };
|
|
46
|
+
}
|
|
47
|
+
export function decideRailFromRecall(rules, requested) {
|
|
48
|
+
return selectRail(policyFromRecall(rules), requested);
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import type { RoleStore } from "./store.js";
|
|
3
|
+
export interface X402ResourceServiceConfig {
|
|
4
|
+
origin: string;
|
|
5
|
+
pathPrefix?: string;
|
|
6
|
+
facilitatorUrl: string;
|
|
7
|
+
/** Owner-only credential for the facilitator. Never put this in a manifest or buyer profile. */
|
|
8
|
+
facilitatorKey: string;
|
|
9
|
+
network: "sui:testnet";
|
|
10
|
+
asset: string;
|
|
11
|
+
payTo: string;
|
|
12
|
+
amount: string;
|
|
13
|
+
maxTimeoutSeconds?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Seller-owned standard x402 v2 exact resource with additional request-binding replay protection. */
|
|
16
|
+
export declare class X402ResourceService {
|
|
17
|
+
#private;
|
|
18
|
+
readonly config: X402ResourceServiceConfig;
|
|
19
|
+
private readonly http;
|
|
20
|
+
private readonly persist?;
|
|
21
|
+
private readonly claimExternal?;
|
|
22
|
+
constructor(store: RoleStore, config: X402ResourceServiceConfig, http?: typeof fetch, persist?: (() => Promise<void>) | undefined, claimExternal?: ((digest: string, requestId: string) => Promise<void>) | undefined);
|
|
23
|
+
register(requestId: string, operationId: string, body: Uint8Array): {
|
|
24
|
+
url: string;
|
|
25
|
+
artifactSha256: string;
|
|
26
|
+
};
|
|
27
|
+
private requirements;
|
|
28
|
+
private resourceUrl;
|
|
29
|
+
private challenge;
|
|
30
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
31
|
+
createServer(): Server;
|
|
32
|
+
private facilitator;
|
|
33
|
+
private send;
|
|
34
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { fail, SupercutsError } from "./errors.js";
|
|
4
|
+
import { paymentBindingBytes, PaymentConsumptionLedger, requirementsHash, verifyBinding, verifyExactPayment } from "./x402.js";
|
|
5
|
+
import { isValidPersonalMessageSignature } from "@mysten/sui/verify";
|
|
6
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64");
|
|
7
|
+
function decode(value, code) {
|
|
8
|
+
const encoded = value ?? fail(code, "required x402 header is missing");
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(Buffer.from(encoded, "base64").toString());
|
|
11
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
12
|
+
fail(code, "x402 header must encode an object");
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
return fail(code, "x402 header is not valid base64 JSON", error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function safeUrl(value) { try {
|
|
20
|
+
return new URL(value);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return fail("X402_CONFIG_INVALID", "x402 service URL is invalid", error);
|
|
24
|
+
} }
|
|
25
|
+
/** Seller-owned standard x402 v2 exact resource with additional request-binding replay protection. */
|
|
26
|
+
export class X402ResourceService {
|
|
27
|
+
config;
|
|
28
|
+
http;
|
|
29
|
+
persist;
|
|
30
|
+
claimExternal;
|
|
31
|
+
#ledger;
|
|
32
|
+
#artifacts = new Map();
|
|
33
|
+
#origin;
|
|
34
|
+
#facilitator;
|
|
35
|
+
#prefix;
|
|
36
|
+
constructor(store, config, http = fetch, persist, claimExternal) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.http = http;
|
|
39
|
+
this.persist = persist;
|
|
40
|
+
this.claimExternal = claimExternal;
|
|
41
|
+
this.#ledger = new PaymentConsumptionLedger(store);
|
|
42
|
+
this.#origin = safeUrl(config.origin);
|
|
43
|
+
this.#facilitator = safeUrl(config.facilitatorUrl);
|
|
44
|
+
this.#prefix = (config.pathPrefix ?? "/capability").replace(/\/$/, "");
|
|
45
|
+
if (this.#origin.protocol !== "https:" && !(this.#origin.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(this.#origin.hostname)))
|
|
46
|
+
fail("X402_CONFIG_INVALID", "resource origin must use HTTPS except on loopback");
|
|
47
|
+
if (this.#facilitator.protocol !== "https:" && !(this.#facilitator.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(this.#facilitator.hostname)))
|
|
48
|
+
fail("X402_CONFIG_INVALID", "facilitator must use HTTPS except on loopback");
|
|
49
|
+
if (!/^0x[0-9a-f]{64}$/i.test(config.payTo) || !/^0x[0-9a-f]+::[A-Za-z0-9_]+::[A-Za-z0-9_]+$/.test(config.asset) || !/^[1-9][0-9]*$/.test(config.amount))
|
|
50
|
+
fail("X402_CONFIG_INVALID", "payTo, asset, or amount is invalid");
|
|
51
|
+
if (!config.facilitatorKey || config.facilitatorKey.length < 24 || config.facilitatorKey.length > 1024)
|
|
52
|
+
fail("X402_CONFIG_INVALID", "facilitatorKey must be an owner-only secret");
|
|
53
|
+
}
|
|
54
|
+
register(requestId, operationId, body) {
|
|
55
|
+
if (!requestId || !operationId || requestId.length > 256 || operationId.length > 256 || !body.length || body.length > 10 * 1024 * 1024)
|
|
56
|
+
fail("RESOURCE_INVALID", "artifact registration is invalid");
|
|
57
|
+
const existing = this.#artifacts.get(requestId), next = Buffer.from(body);
|
|
58
|
+
if (existing && (existing.operationId !== operationId || !existing.body.equals(next)))
|
|
59
|
+
fail("RESOURCE_REQUEST_CONFLICT", "request already identifies another artifact or operation");
|
|
60
|
+
this.#artifacts.set(requestId, { requestId, operationId, body: next });
|
|
61
|
+
return { url: new URL(`${this.#prefix}/${encodeURIComponent(requestId)}`, this.#origin).toString(), artifactSha256: (awaitHash(next)) };
|
|
62
|
+
}
|
|
63
|
+
requirements(artifact) {
|
|
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
|
+
}
|
|
66
|
+
resourceUrl(requestId) { return new URL(`${this.#prefix}/${encodeURIComponent(requestId)}`, this.#origin).toString(); }
|
|
67
|
+
challenge(artifact) {
|
|
68
|
+
// The live Sui exact facilitator requires the x402 nonce to be exactly
|
|
69
|
+
// 32 random bytes (hex encoded), not merely an application nonce.
|
|
70
|
+
const requirements = this.requirements(artifact), nonce = this.#ledger.challenge(artifact.requestId, requirementsHash(requirements), () => randomBytes(32).toString("hex"));
|
|
71
|
+
requirements.extra = { ...requirements.extra, nonce };
|
|
72
|
+
// The nonce changes the signed requirement set and is persisted per request.
|
|
73
|
+
const finalHash = requirementsHash(requirements);
|
|
74
|
+
return { x402Version: 2, resource: { url: this.resourceUrl(artifact.requestId), description: "Supercuts seller capability artifact", mimeType: "application/json" }, accepts: [{ ...requirements, extra: { ...requirements.extra, requirementsHash: finalHash } }] };
|
|
75
|
+
}
|
|
76
|
+
async handle(request, response) {
|
|
77
|
+
try {
|
|
78
|
+
if (request.method !== "GET")
|
|
79
|
+
return this.send(response, 405, Buffer.from("Method Not Allowed"), { allow: "GET" });
|
|
80
|
+
const url = new URL(request.url ?? "/", this.#origin), prefix = `${this.#prefix}/`;
|
|
81
|
+
if (!url.pathname.startsWith(prefix))
|
|
82
|
+
return this.send(response, 404, Buffer.from("Not Found"));
|
|
83
|
+
const requestId = decodeURIComponent(url.pathname.slice(prefix.length)), artifact = this.#artifacts.get(requestId);
|
|
84
|
+
if (!artifact)
|
|
85
|
+
return this.send(response, 404, Buffer.from("Not Found"));
|
|
86
|
+
const challenge = this.challenge(artifact), requirements = challenge.accepts instanceof Array ? challenge.accepts[0] : fail("X402_STATE_INVALID", "challenge omitted requirements");
|
|
87
|
+
const paymentHeader = Array.isArray(request.headers["payment-signature"]) ? request.headers["payment-signature"][0] : request.headers["payment-signature"];
|
|
88
|
+
if (!paymentHeader) {
|
|
89
|
+
await this.persist?.();
|
|
90
|
+
return this.send(response, 402, Buffer.from("Payment Required"), { "payment-required": encode(challenge) });
|
|
91
|
+
}
|
|
92
|
+
const stored = this.#ledger.getChallenge(artifact.requestId, requirementsHash(this.requirements(artifact))) ?? fail("X402_CHALLENGE_MISSING", "resource challenge was not persisted");
|
|
93
|
+
if (!this.#ledger.isDeliveryComplete(artifact.requestId) && Date.now() > stored.createdAt + requirements.maxTimeoutSeconds * 1_000)
|
|
94
|
+
fail("X402_CHALLENGE_EXPIRED", "payment challenge has expired");
|
|
95
|
+
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");
|
|
96
|
+
const offered = payload.accepted;
|
|
97
|
+
if (!offered || requirementsHash(offered) !== requirementsHash(requirements))
|
|
98
|
+
fail("X402_REQUIREMENTS_MISMATCH", "payment payload changed the challenged requirements");
|
|
99
|
+
const extra = requirements.extra, nonce = String(extra.nonce ?? ""), declaredHash = String(extra.requirementsHash ?? "");
|
|
100
|
+
const unsignedRequirements = { ...requirements, extra: { requestId: extra.requestId, operationId: extra.operationId, nonce } };
|
|
101
|
+
if (declaredHash !== requirementsHash(unsignedRequirements))
|
|
102
|
+
fail("X402_REQUIREMENTS_MISMATCH", "challenge requirements hash is invalid");
|
|
103
|
+
verifyBinding(binding, artifact.requestId, nonce, unsignedRequirements, binding.transactionDigest);
|
|
104
|
+
const { payerSignature: _signature, ...unsignedBinding } = binding;
|
|
105
|
+
if (!(await isValidPersonalMessageSignature(paymentBindingBytes(unsignedBinding), binding.payerSignature, { address: binding.payer })))
|
|
106
|
+
fail("X402_PAYER_SIGNATURE_INVALID", "payer did not bind payment to this resource request");
|
|
107
|
+
const body = { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements };
|
|
108
|
+
const verified = await this.facilitator("verify", body);
|
|
109
|
+
if (verified.isValid !== true)
|
|
110
|
+
fail("X402_FACILITATOR_REJECTED", "facilitator rejected the signed payment");
|
|
111
|
+
const settled = await this.facilitator("settle", body), digest = String(settled.transaction ?? "");
|
|
112
|
+
if (settled.success !== true || settled.network !== this.config.network || digest !== binding.transactionDigest)
|
|
113
|
+
fail("X402_SETTLEMENT_INVALID", "facilitator returned mismatched settlement evidence");
|
|
114
|
+
const payment = { digest, network: this.config.network, asset: this.config.asset, amount: BigInt(this.config.amount), payTo: this.config.payTo, finalized: true };
|
|
115
|
+
verifyExactPayment(payment, requirements);
|
|
116
|
+
await this.claimExternal?.(digest, artifact.requestId);
|
|
117
|
+
this.#ledger.claim(payment, artifact.requestId);
|
|
118
|
+
let delivery = this.#ledger.getDelivery(artifact.requestId);
|
|
119
|
+
if (!this.#ledger.isDeliveryComplete(artifact.requestId)) {
|
|
120
|
+
if (!this.#ledger.acquireDelivery(artifact.requestId))
|
|
121
|
+
fail("DELIVERY_IN_PROGRESS", "resource delivery is already being produced");
|
|
122
|
+
this.#ledger.completeDelivery(artifact.requestId, artifact.body);
|
|
123
|
+
delivery = artifact.body;
|
|
124
|
+
}
|
|
125
|
+
await this.persist?.();
|
|
126
|
+
return this.send(response, 200, delivery ?? fail("DELIVERY_STATE_INVALID", "completed delivery has no bytes"), { "content-type": "application/json", "payment-response": encode({ success: true, transaction: digest, network: this.config.network }) });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const known = error instanceof SupercutsError ? error : new SupercutsError("INTERNAL_ERROR", "resource service failed");
|
|
130
|
+
return this.send(response, known.code === "PAYMENT_CONSUMED" ? 409 : 400, Buffer.from(JSON.stringify({ error: known.code, message: known.message })), { "content-type": "application/json" });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
createServer() { return createServer((request, response) => void this.handle(request, response)); }
|
|
134
|
+
async facilitator(path, body) {
|
|
135
|
+
const endpoint = new URL(path, `${this.#facilitator.toString().replace(/\/$/, "")}/`);
|
|
136
|
+
const response = await this.http(endpoint, { method: "POST", headers: { "content-type": "application/json", "x-supercuts-facilitator-key": this.config.facilitatorKey }, body: JSON.stringify(body), signal: AbortSignal.timeout(60_000) });
|
|
137
|
+
let value;
|
|
138
|
+
try {
|
|
139
|
+
value = await response.json();
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return fail("X402_FACILITATOR_INVALID", "facilitator returned invalid JSON", error);
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok || !value || typeof value !== "object" || Array.isArray(value)) {
|
|
145
|
+
const message = value && typeof value === "object" && !Array.isArray(value) && typeof value.message === "string"
|
|
146
|
+
? `: ${value.message}` : "";
|
|
147
|
+
fail("X402_FACILITATOR_INVALID", `facilitator ${path} failed with ${response.status}${message}`);
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
send(response, status, body, headers = {}) { response.writeHead(status, { "content-length": String(body.length), ...headers }); response.end(body); }
|
|
152
|
+
}
|
|
153
|
+
import { createHash } from "node:crypto";
|
|
154
|
+
function awaitHash(value) { return createHash("sha256").update(value).digest("hex"); }
|
package/dist/x402.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RoleStore } from "./store.js";
|
|
2
|
+
export interface ExactRequirements {
|
|
3
|
+
scheme: "exact";
|
|
4
|
+
network: string;
|
|
5
|
+
amount: string;
|
|
6
|
+
asset: string;
|
|
7
|
+
payTo: string;
|
|
8
|
+
maxTimeoutSeconds: number;
|
|
9
|
+
extra: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface PaymentBinding {
|
|
12
|
+
requestId: string;
|
|
13
|
+
nonce: string;
|
|
14
|
+
requirementsHash: string;
|
|
15
|
+
transactionDigest: string;
|
|
16
|
+
payer: string;
|
|
17
|
+
payerSignature: string;
|
|
18
|
+
}
|
|
19
|
+
export interface FinalPayment {
|
|
20
|
+
digest: string;
|
|
21
|
+
network: string;
|
|
22
|
+
asset: string;
|
|
23
|
+
amount: bigint;
|
|
24
|
+
payTo: string;
|
|
25
|
+
finalized: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function requirementsHash(requirements: ExactRequirements): string;
|
|
28
|
+
export declare function paymentBindingBytes(binding: Omit<PaymentBinding, "payerSignature">): Uint8Array;
|
|
29
|
+
export declare class PaymentConsumptionLedger {
|
|
30
|
+
private readonly store;
|
|
31
|
+
constructor(store: RoleStore);
|
|
32
|
+
claim(payment: FinalPayment, requestId: string): void;
|
|
33
|
+
recordDelivery(requestId: string, response: Uint8Array): void;
|
|
34
|
+
getDelivery(requestId: string): Buffer | undefined;
|
|
35
|
+
isDeliveryComplete(requestId: string): boolean;
|
|
36
|
+
acquireDelivery(requestId: string, now?: number, leaseMs?: number): boolean;
|
|
37
|
+
releaseDelivery(requestId: string): void;
|
|
38
|
+
completeDelivery(requestId: string, response: Uint8Array): void;
|
|
39
|
+
challenge(requestId: string, requirementsHashValue: string, createNonce: () => string): string;
|
|
40
|
+
getChallenge(requestId: string, requirementsHashValue: string): {
|
|
41
|
+
nonce: string;
|
|
42
|
+
createdAt: number;
|
|
43
|
+
} | undefined;
|
|
44
|
+
}
|
|
45
|
+
export declare function verifyBinding(binding: PaymentBinding, requestId: string, nonce: string, requirements: ExactRequirements, transactionDigest: string): void;
|
|
46
|
+
export declare function verifyExactPayment(payment: FinalPayment, requirements: ExactRequirements): void;
|