@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,570 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
|
|
3
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import { assertSuiFinality, assertTatumFinality } from "./finality.js";
|
|
6
|
+
import { LivePoolIndex } from "./live-index.js";
|
|
7
|
+
import { ProtocolEngine } from "./protocol-engine.js";
|
|
8
|
+
import { Groth16Prover } from "./prover.js";
|
|
9
|
+
import { buildDeposit, buildPrivateTransfer, buildWithdraw, writeClient } from "./sui.js";
|
|
10
|
+
import { V2ExitQueue } from "./v2-exits.js";
|
|
11
|
+
import { TatumLedger } from "./tatum.js";
|
|
12
|
+
import { paymentBindingBytes, requirementsHash } from "./x402.js";
|
|
13
|
+
const hex = (value) => value.toString(16).padStart(64, "0");
|
|
14
|
+
const bytes32 = (value) => Buffer.from(hex(value), "hex");
|
|
15
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
|
+
const serializeOpening = (opening) => Buffer.from(JSON.stringify(opening, (_key, value) => typeof value === "bigint" ? value.toString() : value));
|
|
17
|
+
const parseOpening = (value) => JSON.parse(value.toString(), (_key, item) => typeof item === "string" && /^[0-9]+$/.test(item) ? BigInt(item) : item);
|
|
18
|
+
export class LiveRuntime {
|
|
19
|
+
profile;
|
|
20
|
+
store;
|
|
21
|
+
tatum;
|
|
22
|
+
signer;
|
|
23
|
+
client;
|
|
24
|
+
engine;
|
|
25
|
+
index;
|
|
26
|
+
prover;
|
|
27
|
+
constructor(profile, store, tatum, signer, engine) {
|
|
28
|
+
this.profile = profile;
|
|
29
|
+
this.store = store;
|
|
30
|
+
this.tatum = tatum;
|
|
31
|
+
this.signer = signer;
|
|
32
|
+
this.engine = engine;
|
|
33
|
+
this.client = writeClient(profile.live.writeGrpcEndpoint);
|
|
34
|
+
this.index = new LivePoolIndex(store, this.client, engine, profile.packageId, profile.poolId, BigInt(profile.live.deploymentCheckpoint), profile.verifierVersion === 2 ? "pool_v2" : "pool");
|
|
35
|
+
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 });
|
|
36
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS live_signed_transactions (
|
|
37
|
+
operation_id TEXT PRIMARY KEY, intent_hash TEXT NOT NULL, transaction_bytes BLOB NOT NULL,
|
|
38
|
+
signature TEXT NOT NULL, digest TEXT NOT NULL UNIQUE, status TEXT NOT NULL, updated_at INTEGER NOT NULL
|
|
39
|
+
); CREATE TABLE IF NOT EXISTS live_x402_deliveries (
|
|
40
|
+
operation_id TEXT PRIMARY KEY, resource_url TEXT NOT NULL, receipt TEXT NOT NULL, response BLOB NOT NULL, updated_at INTEGER NOT NULL
|
|
41
|
+
); CREATE TABLE IF NOT EXISTS live_agreements (
|
|
42
|
+
session_id TEXT PRIMARY KEY, agreement TEXT NOT NULL, deal_commitment TEXT NOT NULL, accepted_at INTEGER NOT NULL
|
|
43
|
+
); CREATE TABLE IF NOT EXISTS live_seller_agreements (
|
|
44
|
+
session_id TEXT PRIMARY KEY, agreement TEXT NOT NULL, payment_salt TEXT NOT NULL, amount TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
45
|
+
); CREATE TABLE IF NOT EXISTS live_operation_context (
|
|
46
|
+
operation_id TEXT PRIMARY KEY, kind TEXT NOT NULL, subject TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
47
|
+
); CREATE TABLE IF NOT EXISTS live_transfer_inputs (
|
|
48
|
+
operation_id TEXT PRIMARY KEY, input_commitment TEXT NOT NULL, input_nullifier TEXT NOT NULL
|
|
49
|
+
)`);
|
|
50
|
+
}
|
|
51
|
+
static async create(profile, store) {
|
|
52
|
+
const encoded = store.getSecret("live:sui-private-key")?.toString() ?? fail("LIVE_CREDENTIAL_MISSING", "encrypted Sui signer is missing");
|
|
53
|
+
const decoded = decodeSuiPrivateKey(encoded);
|
|
54
|
+
if (decoded.scheme !== "ED25519")
|
|
55
|
+
fail("SIGNER_UNSUPPORTED", "the initial live runtime requires an ED25519 Sui key");
|
|
56
|
+
const tatumKey = store.getSecret("live:tatum-api-key")?.toString() ?? fail("LIVE_CREDENTIAL_MISSING", "encrypted Tatum key is missing");
|
|
57
|
+
const engine = await ProtocolEngine.create({ deploymentDomain: profile.live.deploymentDomain, poolId: profile.poolId, coinTypeHash: profile.live.coinTypeHash, verifierVersion: profile.verifierVersion, initialRoot: profile.live.initialRoot });
|
|
58
|
+
return new LiveRuntime(profile, store, new TatumLedger(profile.live.tatumGrpcEndpoint, tatumKey), Ed25519Keypair.fromSecretKey(decoded.secretKey), engine);
|
|
59
|
+
}
|
|
60
|
+
async doctor() {
|
|
61
|
+
const artifacts = this.prover.doctor();
|
|
62
|
+
const [service, suiObject] = await Promise.all([this.tatum.getServiceInfo(), this.client.getObject({ objectId: this.profile.poolId, include: { json: true } })]);
|
|
63
|
+
if (!suiObject.object)
|
|
64
|
+
fail("POOL_OBJECT_MISSING", "pinned Testnet pool object is unavailable; check the Sui write RPC and deployment config");
|
|
65
|
+
if (!service)
|
|
66
|
+
fail("TATUM_UNAVAILABLE", "Tatum read RPC did not return service information; check its endpoint and owner-only API key");
|
|
67
|
+
const babySeed = this.store.getSecret("identity:babyjub-seed") ?? fail("IDENTITY_MISSING", "BabyJub identity is missing");
|
|
68
|
+
const identity = this.engine.identity(babySeed);
|
|
69
|
+
const fixes = [];
|
|
70
|
+
if (!artifacts.rapidSnark)
|
|
71
|
+
fixes.push("Set an executable RapidSnark path in the role profile for v2 proofs.");
|
|
72
|
+
if (!this.profile.live.walrusPublisherUrl)
|
|
73
|
+
fixes.push("Configure the Walrus publisher URL for capability and transcript writes.");
|
|
74
|
+
if (!this.profile.live.walrusAggregatorUrl)
|
|
75
|
+
fixes.push("Configure the Walrus aggregator URL for capability and transcript reads.");
|
|
76
|
+
for (const [name, endpoint] of [["publisher", this.profile.live.walrusPublisherUrl], ["aggregator", this.profile.live.walrusAggregatorUrl]]) {
|
|
77
|
+
if (!endpoint)
|
|
78
|
+
continue;
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(endpoint, { method: "HEAD", signal: AbortSignal.timeout(8_000) });
|
|
81
|
+
if (response.status >= 500)
|
|
82
|
+
fixes.push(`Walrus ${name} returned HTTP ${response.status}; check the configured service.`);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
fixes.push(`Walrus ${name} is unreachable; check the configured URL and network access.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (this.profile.role === "buyer" && !this.store.getSecret("live:memory-config"))
|
|
89
|
+
fixes.push("Configure an owner-only Walrus Memory file using the local 1536-dimensional embedding adapter.");
|
|
90
|
+
return { healthy: true, readyForDemo: fixes.length === 0, fixes, runtime: "testnet-live", signer: this.signer.toSuiAddress(), babyJubPublicKey: identity.publicKey.map(String), x25519PublicKey: this.store.getSecret("identity:x25519-public")?.toString("base64"), rapidSnark: artifacts.rapidSnark, artifacts: artifacts.artifacts, poolObjectVerified: true, tatumServiceVerified: true };
|
|
91
|
+
}
|
|
92
|
+
async balance() {
|
|
93
|
+
const state = await this.index.sync();
|
|
94
|
+
let available = 0n, notes = 0;
|
|
95
|
+
for (const row of this.store.db.prepare("SELECT commitment FROM notes WHERE spent=0 ORDER BY commitment").all()) {
|
|
96
|
+
const secret = this.store.getSecret(`note:${row.commitment}`);
|
|
97
|
+
if (!secret)
|
|
98
|
+
continue;
|
|
99
|
+
const opening = parseOpening(secret);
|
|
100
|
+
if (state.leaves[Number(opening.leafIndex)] === opening.commitment) {
|
|
101
|
+
available += opening.amount;
|
|
102
|
+
notes++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { available: available.toString(), notes, root: hex(state.root), nextLeaf: state.nextLeaf.toString(), checkpoint: state.lastCheckpoint.toString(), evidence: "event-derived-and-pool-object-verified",
|
|
106
|
+
...(state.activity ? { poolActivity: state.activity, privacyWarning: state.activity.reducedPrivacy ? "Sparse observed pool use reduces privacy; distinct depositing addresses do not prove independent users" : undefined } : {}) };
|
|
107
|
+
}
|
|
108
|
+
async signed(operationId, intentHash, tx, legacyIntentHash) {
|
|
109
|
+
const existing = this.store.db.prepare("SELECT intent_hash,transaction_bytes,signature,digest FROM live_signed_transactions WHERE operation_id=?").get(operationId);
|
|
110
|
+
if (existing) {
|
|
111
|
+
if (existing.intent_hash !== intentHash && existing.intent_hash !== legacyIntentHash)
|
|
112
|
+
fail("OPERATION_ID_CONFLICT", "operation ID identifies different transaction intent");
|
|
113
|
+
return { digest: existing.digest, transaction: existing.transaction_bytes, signature: existing.signature };
|
|
114
|
+
}
|
|
115
|
+
tx.setSender(this.signer.toSuiAddress());
|
|
116
|
+
const simulation = await this.client.simulateTransaction({ transaction: tx, include: { effects: true } });
|
|
117
|
+
const simulated = simulation.Transaction ?? simulation.FailedTransaction;
|
|
118
|
+
if (!simulated?.status.success)
|
|
119
|
+
fail("TRANSACTION_SIMULATION_FAILED", "Sui rejected the transaction during simulation");
|
|
120
|
+
const transaction = await tx.build({ client: this.client }), digest = await tx.getDigest({ client: this.client }), signature = (await this.signer.signTransaction(transaction)).signature;
|
|
121
|
+
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());
|
|
122
|
+
return { transaction, signature, digest };
|
|
123
|
+
}
|
|
124
|
+
operationContext(operationId, kind, subject) {
|
|
125
|
+
const existing = this.store.db.prepare("SELECT kind,subject FROM live_operation_context WHERE operation_id=?").get(operationId);
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (existing.kind !== kind || existing.subject !== subject)
|
|
128
|
+
fail("OPERATION_ID_CONFLICT", "operation ID identifies another live operation");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.store.db.prepare("INSERT INTO live_operation_context(operation_id,kind,subject,created_at) VALUES(?,?,?,?)").run(operationId, kind, subject, Date.now());
|
|
132
|
+
}
|
|
133
|
+
existingSigned(operationId) {
|
|
134
|
+
const row = this.store.db.prepare("SELECT transaction_bytes,signature,digest FROM live_signed_transactions WHERE operation_id=?").get(operationId);
|
|
135
|
+
return row ? { digest: row.digest, transaction: row.transaction_bytes, signature: row.signature } : undefined;
|
|
136
|
+
}
|
|
137
|
+
async submit(operationId, signed, finalizeState) {
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = await this.client.executeTransaction({ transaction: signed.transaction, signatures: [signed.signature], include: { effects: true, events: true } });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
try {
|
|
144
|
+
await this.tatum.getTransaction(signed.digest);
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
fail("BROADCAST_UNRESOLVED", "broadcast outcome is ambiguous; exact signed bytes remain journaled for retry", cause);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const finalized = await this.client.waitForTransaction({ digest: signed.digest, timeout: 60_000, include: { effects: true, events: true } });
|
|
151
|
+
const checkpoint = assertSuiFinality(finalized, signed.digest);
|
|
152
|
+
await this.confirmTatum(signed.digest, checkpoint);
|
|
153
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
154
|
+
try {
|
|
155
|
+
finalizeState?.();
|
|
156
|
+
this.store.db.prepare("UPDATE live_signed_transactions SET status='finalized',updated_at=? WHERE operation_id=?").run(Date.now(), operationId);
|
|
157
|
+
this.store.db.exec("COMMIT");
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.store.db.exec("ROLLBACK");
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return { transactionDigest: signed.digest, checkpoint, finalized: true, evidence: { sui: "effects-and-checkpoint", tatum: true, broadcastResult: Boolean(result) } };
|
|
164
|
+
}
|
|
165
|
+
async confirmTatum(digest, checkpoint) {
|
|
166
|
+
assertTatumFinality(await this.tatum.getTransaction(digest), digest, checkpoint);
|
|
167
|
+
}
|
|
168
|
+
prepareOpening(opening, role, operationId, leaves) {
|
|
169
|
+
const commitment = hex(opening.commitment), key = `note:${commitment}`;
|
|
170
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
171
|
+
try {
|
|
172
|
+
const prior = this.store.getSecret(key);
|
|
173
|
+
if (prior) {
|
|
174
|
+
const old = parseOpening(prior);
|
|
175
|
+
const row = this.store.db.prepare("SELECT role,operation_id,leaf_index,spent FROM notes WHERE commitment=?").get(commitment);
|
|
176
|
+
if (!row || row.role !== role || row.operation_id !== operationId || row.spent ||
|
|
177
|
+
serializeOpening({ ...old, leafIndex: opening.leafIndex }).toString() !== serializeOpening(opening).toString())
|
|
178
|
+
fail("NOTE_ID_CONFLICT", "prepared note opening differs from this operation");
|
|
179
|
+
if (old.leafIndex !== opening.leafIndex) {
|
|
180
|
+
if (leaves[Number(old.leafIndex)] === opening.commitment)
|
|
181
|
+
fail("NOTE_ID_CONFLICT", "an indexed note cannot move to another leaf");
|
|
182
|
+
this.store.putSecret(key, serializeOpening(opening));
|
|
183
|
+
this.store.db.prepare("UPDATE notes SET leaf_index=? WHERE commitment=?").run(opening.leafIndex.toString(), commitment);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.store.putSecretOnce(key, serializeOpening(opening));
|
|
188
|
+
this.store.putNote(commitment, role, operationId, opening.leafIndex);
|
|
189
|
+
}
|
|
190
|
+
this.store.db.exec("COMMIT");
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
this.store.db.exec("ROLLBACK");
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async deposit(amount, operationId) {
|
|
198
|
+
if (this.profile.role !== "buyer")
|
|
199
|
+
fail("ROLE_FORBIDDEN", "only a buyer role can deposit");
|
|
200
|
+
this.operationContext(operationId, "deposit", amount.toString());
|
|
201
|
+
const prepared = this.store.db.prepare("SELECT commitment FROM notes WHERE operation_id=? AND role='input'").get(operationId), prior = this.existingSigned(operationId);
|
|
202
|
+
if (prior) {
|
|
203
|
+
const evidence = await this.submit(operationId, prior);
|
|
204
|
+
return { commitment: prepared?.commitment, amount: amount.toString(), resumed: true, ...evidence };
|
|
205
|
+
}
|
|
206
|
+
const state = await this.index.sync(), identity = this.engine.identity(this.store.getSecret("identity:babyjub-seed") ?? fail("IDENTITY_MISSING", "BabyJub identity is missing"));
|
|
207
|
+
const priorOpening = prepared ? parseOpening(this.store.getSecret(`note:${prepared.commitment}`) ?? fail("NOTE_RECOVERY_MISMATCH", "prepared deposit opening is missing")) : undefined;
|
|
208
|
+
const witness = this.engine.buildDeposit(state.leaves, identity, amount, priorOpening?.spendSecret, priorOpening?.salt), commitment = hex(witness.opening.commitment), intentHash = hash(`deposit:${amount}:${state.root}:${commitment}`);
|
|
209
|
+
this.prepareOpening(witness.opening, "input", operationId, state.leaves);
|
|
210
|
+
const proof = await this.prover.prove("deposit", witness.input);
|
|
211
|
+
const tx = buildDeposit({ packageId: this.profile.packageId, poolId: this.profile.poolId, coinType: this.profile.coinType, poolModule: this.profile.verifierVersion === 2 ? "pool_v2" : "pool", amount, noteCommitment: bytes32(witness.opening.commitment), newRoot: bytes32(witness.newRoot), newIndex: witness.newIndex, proof: proof.proof });
|
|
212
|
+
const evidence = await this.submit(operationId, await this.signed(operationId, intentHash, tx));
|
|
213
|
+
await this.index.sync();
|
|
214
|
+
return { commitment, amount: amount.toString(), prover: proof.engine, proveMs: proof.proveMs, ...evidence };
|
|
215
|
+
}
|
|
216
|
+
async recover() {
|
|
217
|
+
const state = await this.index.sync();
|
|
218
|
+
let recovered = 0;
|
|
219
|
+
if (this.profile.role === "seller")
|
|
220
|
+
for (const row of this.store.db.prepare("SELECT session_id,agreement,payment_salt,amount FROM live_seller_agreements").all()) {
|
|
221
|
+
const secret = this.store.getSecret(`seller-spend:${row.session_id}`);
|
|
222
|
+
if (!secret)
|
|
223
|
+
continue;
|
|
224
|
+
const spendSecret = BigInt(secret.toString()), raw = JSON.parse(row.agreement), owner = raw.sellerPublicKey.map((value) => BigInt(value)), salt = BigInt(row.payment_salt), amount = BigInt(row.amount), commitment = this.engine.noteCommitment(amount, owner, spendSecret, salt), leafIndex = state.leaves.findIndex((leaf) => leaf === commitment);
|
|
225
|
+
if (leafIndex < 0)
|
|
226
|
+
continue;
|
|
227
|
+
const opening = { deployment: this.engine.deploymentField, amount, ownerAx: owner[0], ownerAy: owner[1], spendSecret, salt, commitment, leafIndex: BigInt(leafIndex) }, id = hex(commitment);
|
|
228
|
+
this.store.putSecretOnce(`note:${id}`, serializeOpening(opening));
|
|
229
|
+
this.store.putNote(id, "payment", row.session_id, BigInt(leafIndex));
|
|
230
|
+
recovered++;
|
|
231
|
+
}
|
|
232
|
+
return { recovered, indexedLeaves: state.leaves.length, root: hex(state.root), nextLeaf: state.nextLeaf.toString(), checkpoint: state.lastCheckpoint.toString(), source: "pinned-chain-events" };
|
|
233
|
+
}
|
|
234
|
+
async withdraw(commitment, recipient, operationId) {
|
|
235
|
+
if (this.profile.verifierVersion === 2)
|
|
236
|
+
fail("V2_EXIT_RELAY_REQUIRED", "v2 exits require an owner-approved scheduled request and a separate relayer");
|
|
237
|
+
this.operationContext(operationId, "withdraw", `${commitment}:${recipient}`);
|
|
238
|
+
const secret = this.store.getSecret(`note:${commitment}`) ?? fail("NOTE_NOT_FOUND", "encrypted note opening was not found");
|
|
239
|
+
const note = parseOpening(secret), prior = this.existingSigned(operationId);
|
|
240
|
+
if (prior) {
|
|
241
|
+
const evidence = await this.submit(operationId, prior, () => { this.store.db.prepare("UPDATE notes SET spent=1 WHERE commitment=?").run(commitment); });
|
|
242
|
+
return { commitment, amount: note.amount.toString(), recipient, resumed: true, ...evidence };
|
|
243
|
+
}
|
|
244
|
+
const state = await this.index.sync();
|
|
245
|
+
const witness = this.engine.buildWithdraw(state.leaves, note, recipient), nullifier = hex(witness.noteNullifier), proof = await this.prover.prove("withdraw", witness.input), intentHash = hash(`withdraw:${commitment}:${recipient}:${state.root}`);
|
|
246
|
+
const tx = buildWithdraw({ packageId: this.profile.packageId, poolId: this.profile.poolId, coinType: this.profile.coinType, root: bytes32(state.root), noteNullifier: bytes32(witness.noteNullifier), recipient, amount: note.amount, proof: proof.proof });
|
|
247
|
+
const evidence = await this.submit(operationId, await this.signed(operationId, intentHash, tx), () => { this.store.db.prepare("UPDATE notes SET spent=1,nullifier=? WHERE commitment=?").run(nullifier, commitment); });
|
|
248
|
+
return { commitment, amount: note.amount.toString(), recipient, nullifier, prover: proof.engine, ...evidence };
|
|
249
|
+
}
|
|
250
|
+
async queueV2Exit(commitment, operationId, amount, exactExit, windowStart, windowEnd, ownerApproved) {
|
|
251
|
+
if (this.profile.verifierVersion !== 2 || this.profile.role === "auditor")
|
|
252
|
+
fail("V2_EXIT_UNAVAILABLE", "v2 exits require a buyer or seller v2 role");
|
|
253
|
+
this.operationContext(operationId, "exit-v2", `${commitment}:${amount}:${exactExit}:${windowStart}:${windowEnd}`);
|
|
254
|
+
const row = this.store.db.prepare("SELECT spent FROM notes WHERE commitment=?").get(commitment);
|
|
255
|
+
if (!row || row.spent)
|
|
256
|
+
fail("NOTE_NOT_FOUND", "unspent recovered note was not found");
|
|
257
|
+
const secret = this.store.getSecret(`note:${commitment}`) ?? fail("NOTE_NOT_FOUND", "encrypted note opening was not found");
|
|
258
|
+
const note = parseOpening(secret), queue = new V2ExitQueue(this.store);
|
|
259
|
+
const scheduled = queue.queue({ operationId, commitment, amount, exactExit, windowStart, windowEnd }, ownerApproved);
|
|
260
|
+
if (this.store.getSecret(`v2-exit-request:${operationId}`))
|
|
261
|
+
return scheduled;
|
|
262
|
+
const state = await this.index.sync();
|
|
263
|
+
const witness = this.engine.buildWithdrawV2(state.leaves, note, scheduled.payoutAddress, amount, exactExit);
|
|
264
|
+
const proof = await this.prover.prove("withdraw", witness.input);
|
|
265
|
+
const request = { operationId, root: hex(state.root), noteNullifier: hex(witness.noteNullifier), recipient: scheduled.payoutAddress,
|
|
266
|
+
amount: amount.toString(), exactExit, changeCommitment: hex(witness.changeOpening.commitment), newRoot: hex(witness.newRoot),
|
|
267
|
+
newIndex: witness.newIndex.toString(), proof: proof.proof.toString("base64") };
|
|
268
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
269
|
+
try {
|
|
270
|
+
if (witness.changeOpening.amount > 0n) {
|
|
271
|
+
const changeCommitment = hex(witness.changeOpening.commitment);
|
|
272
|
+
this.store.putSecretOnce(`note:${changeCommitment}`, serializeOpening(witness.changeOpening));
|
|
273
|
+
this.store.putNote(changeCommitment, "change", operationId, witness.changeOpening.leafIndex);
|
|
274
|
+
}
|
|
275
|
+
this.store.putSecretOnce(`v2-exit-request:${operationId}`, Buffer.from(JSON.stringify(request)));
|
|
276
|
+
this.store.db.exec("COMMIT");
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
this.store.db.exec("ROLLBACK");
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
return scheduled;
|
|
283
|
+
}
|
|
284
|
+
async refreshV2Exit(operationId) {
|
|
285
|
+
if (this.profile.verifierVersion !== 2)
|
|
286
|
+
fail("V2_EXIT_UNAVAILABLE", "v2 exit refresh requires a v2 profile");
|
|
287
|
+
const queue = new V2ExitQueue(this.store), scheduled = queue.get(operationId) ?? fail("EXIT_REQUEST_INVALID", "exit operation is missing");
|
|
288
|
+
if (scheduled.status !== "queued" || Date.now() >= scheduled.windowEnd)
|
|
289
|
+
fail("EXIT_WINDOW_CLOSED", "only an unsubmitted exit inside its owner window can be refreshed");
|
|
290
|
+
const encoded = this.store.getSecret(`v2-exit-request:${operationId}`) ?? fail("EXIT_REQUEST_INVALID", "persisted v2 exit proof is missing");
|
|
291
|
+
const previous = JSON.parse(encoded.toString());
|
|
292
|
+
const state = await this.index.sync();
|
|
293
|
+
if (previous.root === hex(state.root))
|
|
294
|
+
return { refreshed: false, requestOperationId: previous.operationId };
|
|
295
|
+
const secret = this.store.getSecret(`note:${scheduled.commitment}`) ?? fail("NOTE_NOT_FOUND", "encrypted note opening was not found");
|
|
296
|
+
const note = parseOpening(secret), witness = this.engine.buildWithdrawV2(state.leaves, note, scheduled.payoutAddress, scheduled.amount, scheduled.exactExit);
|
|
297
|
+
const proof = await this.prover.prove("withdraw", witness.input);
|
|
298
|
+
const requestOperationId = `${operationId.slice(0, 200)}:root:${hex(state.root).slice(0, 16)}`;
|
|
299
|
+
const request = { operationId: requestOperationId, root: hex(state.root), noteNullifier: hex(witness.noteNullifier), recipient: scheduled.payoutAddress,
|
|
300
|
+
amount: scheduled.amount.toString(), exactExit: scheduled.exactExit, changeCommitment: hex(witness.changeOpening.commitment),
|
|
301
|
+
newRoot: hex(witness.newRoot), newIndex: witness.newIndex.toString(), proof: proof.proof.toString("base64") };
|
|
302
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
303
|
+
try {
|
|
304
|
+
this.store.db.prepare("DELETE FROM notes WHERE commitment=? AND operation_id=? AND role='change' AND spent=0").run(previous.changeCommitment, operationId);
|
|
305
|
+
this.store.db.prepare("DELETE FROM secrets WHERE name=?").run(`note:${previous.changeCommitment}`);
|
|
306
|
+
if (witness.changeOpening.amount > 0n) {
|
|
307
|
+
const changeCommitment = hex(witness.changeOpening.commitment);
|
|
308
|
+
this.store.putSecretOnce(`note:${changeCommitment}`, serializeOpening(witness.changeOpening));
|
|
309
|
+
this.store.putNote(changeCommitment, "change", operationId, witness.changeOpening.leafIndex);
|
|
310
|
+
}
|
|
311
|
+
this.store.putSecret(`v2-exit-request:${operationId}`, Buffer.from(JSON.stringify(request)));
|
|
312
|
+
this.store.db.exec("COMMIT");
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
this.store.db.exec("ROLLBACK");
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
return { refreshed: true, requestOperationId };
|
|
319
|
+
}
|
|
320
|
+
async finalizeV2Exit(operationId, digest, relayerAddress) {
|
|
321
|
+
if (this.profile.verifierVersion !== 2)
|
|
322
|
+
fail("V2_EXIT_UNAVAILABLE", "v2 exit finality requires a v2 profile");
|
|
323
|
+
const queue = new V2ExitQueue(this.store), scheduled = queue.get(operationId) ?? fail("EXIT_REQUEST_INVALID", "exit operation is missing");
|
|
324
|
+
const encoded = this.store.getSecret(`v2-exit-request:${operationId}`) ?? fail("EXIT_REQUEST_INVALID", "persisted v2 exit proof is missing");
|
|
325
|
+
const request = JSON.parse(encoded.toString());
|
|
326
|
+
const observed = await this.client.getTransaction({ digest, include: { events: true, effects: true, transaction: true } });
|
|
327
|
+
const checkpoint = assertSuiFinality(observed, digest);
|
|
328
|
+
const tx = observed.Transaction ?? observed.FailedTransaction;
|
|
329
|
+
if (tx.transaction?.sender?.toLowerCase() !== relayerAddress.toLowerCase())
|
|
330
|
+
fail("EXIT_FINALITY_INVALID", "exit was not finalized by the pinned relayer");
|
|
331
|
+
const vector = (value) => {
|
|
332
|
+
if (typeof value !== "string")
|
|
333
|
+
fail("EXIT_EVENT_INVALID", "exit event field is missing");
|
|
334
|
+
return /^(?:0x)?[0-9a-f]{64}$/i.test(value) ? value.replace(/^0x/, "").toLowerCase() : Buffer.from(value, "base64").toString("hex");
|
|
335
|
+
};
|
|
336
|
+
const event = tx.events?.find((item) => item.packageId.toLowerCase() === this.profile.packageId.toLowerCase() && item.module === "pool_v2" && item.eventType.endsWith("::WithdrawEvent"));
|
|
337
|
+
const value = event?.json;
|
|
338
|
+
if (!value || vector(value.old_root) !== request.root || vector(value.new_root) !== request.newRoot ||
|
|
339
|
+
vector(value.note_nullifier) !== request.noteNullifier || vector(value.change_commitment) !== request.changeCommitment ||
|
|
340
|
+
String(value.recipient).toLowerCase() !== String(request.recipient).toLowerCase() || String(value.amount) !== request.amount ||
|
|
341
|
+
value.exact_exit !== request.exactExit || BigInt(String(value.leaf_index)) + 1n !== BigInt(String(request.newIndex)))
|
|
342
|
+
fail("EXIT_EVENT_INVALID", "finalized exit event differs from the locally proved request");
|
|
343
|
+
await this.confirmTatum(digest, checkpoint);
|
|
344
|
+
await this.index.sync();
|
|
345
|
+
this.store.db.exec("BEGIN IMMEDIATE");
|
|
346
|
+
try {
|
|
347
|
+
queue.recordSubmission(operationId, digest);
|
|
348
|
+
queue.recordFinalized(operationId, digest);
|
|
349
|
+
this.store.db.prepare("UPDATE notes SET spent=1,nullifier=? WHERE commitment=?").run(String(request.noteNullifier), scheduled.commitment);
|
|
350
|
+
this.store.db.exec("COMMIT");
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
this.store.db.exec("ROLLBACK");
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
return { operationId, digest, checkpoint, payoutAddress: scheduled.payoutAddress, amount: scheduled.amount.toString(), exactExit: scheduled.exactExit, relayer: relayerAddress, tatumVerified: true, finalized: true };
|
|
357
|
+
}
|
|
358
|
+
acceptAgreement(sessionId, encoded) {
|
|
359
|
+
if (this.profile.role !== "buyer")
|
|
360
|
+
fail("ROLE_FORBIDDEN", "only the buyer can accept a settlement agreement");
|
|
361
|
+
let raw;
|
|
362
|
+
try {
|
|
363
|
+
raw = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
return fail("AGREEMENT_INVALID", "agreement must be base64url JSON", error);
|
|
367
|
+
}
|
|
368
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.sessionId !== sessionId || !sessionId || sessionId.length > 256 ||
|
|
369
|
+
!["shielded", "x402"].includes(String(raw.rail)))
|
|
370
|
+
fail("AGREEMENT_INVALID", "agreement session or rail is invalid");
|
|
371
|
+
const number = (name) => { const value = raw[name]; if (typeof value !== "string" || !/^[0-9]+$/.test(value))
|
|
372
|
+
return fail("AGREEMENT_INVALID", `${name} must be a decimal integer`); return BigInt(value); };
|
|
373
|
+
const point = (name) => { const value = raw[name]; if (!Array.isArray(value) || value.length !== 2 || value.some((item) => typeof item !== "string" || !/^[0-9]+$/.test(item)))
|
|
374
|
+
return fail("AGREEMENT_INVALID", `${name} must be a decimal BabyJub point`); return [BigInt(value[0]), BigInt(value[1])]; };
|
|
375
|
+
const buyer = this.engine.identity(this.store.getSecret("identity:babyjub-seed") ?? fail("IDENTITY_MISSING", "BabyJub identity is missing")), suppliedBuyer = point("buyerPublicKey");
|
|
376
|
+
if (suppliedBuyer[0] !== buyer.publicKey[0] || suppliedBuyer[1] !== buyer.publicKey[1])
|
|
377
|
+
fail("AGREEMENT_BUYER_MISMATCH", "agreement is bound to another buyer identity");
|
|
378
|
+
const signatureRaw = raw.sellerSignature ?? fail("AGREEMENT_INVALID", "sellerSignature is missing");
|
|
379
|
+
if (!signatureRaw || typeof signatureRaw !== "object" || ["R8x", "R8y", "S"].some((key) => typeof signatureRaw[key] !== "string" || !/^[0-9]+$/.test(signatureRaw[key])))
|
|
380
|
+
fail("AGREEMENT_INVALID", "sellerSignature is malformed");
|
|
381
|
+
const signature = { R8x: BigInt(signatureRaw.R8x), R8y: BigInt(signatureRaw.R8y), S: BigInt(signatureRaw.S) };
|
|
382
|
+
const unsigned = { dealId: number("dealId"), revision: number("revision"), transcriptRootLo: number("transcriptRootLo"), transcriptRootHi: number("transcriptRootHi"), serviceDigestLo: number("serviceDigestLo"), serviceDigestHi: number("serviceDigestHi"), settlementNonceLo: number("settlementNonceLo"), settlementNonceHi: number("settlementNonceHi"), paymentAmount: number("paymentAmount"), blinding: number("blinding"), expiry: number("expiry"), buyer, sellerPublicKey: point("sellerPublicKey"), recipientSpendTag: number("recipientSpendTag") };
|
|
383
|
+
if (unsigned.paymentAmount <= 0n || unsigned.expiry <= BigInt(Date.now()))
|
|
384
|
+
fail("AGREEMENT_INVALID", "agreement amount or expiry is invalid");
|
|
385
|
+
if (Date.now() > this.profile.policy.expiresAt)
|
|
386
|
+
fail("POLICY_EXPIRED", "payment policy has expired");
|
|
387
|
+
const counterparty = String(raw.counterparty ?? "");
|
|
388
|
+
if (!counterparty || !this.profile.policy.allowedCounterparties.includes(counterparty))
|
|
389
|
+
fail("POLICY_COUNTERPARTY_FORBIDDEN", "agreement counterparty is not allowed");
|
|
390
|
+
if (unsigned.paymentAmount > BigInt(this.profile.policy.maxAmount))
|
|
391
|
+
fail("POLICY_AMOUNT_EXCEEDED", "agreement exceeds the configured cap");
|
|
392
|
+
if (!this.profile.policy.allowedAssets.includes(this.profile.coinType))
|
|
393
|
+
fail("POLICY_ASSET_FORBIDDEN", "pool asset is not allowed");
|
|
394
|
+
const values = this.engine.agreementValues(unsigned);
|
|
395
|
+
if (!this.engine.verify(unsigned.sellerPublicKey, 2005n, values.dealCommitment, signature))
|
|
396
|
+
fail("AGREEMENT_SIGNATURE_INVALID", "seller did not sign the pinned agreement commitment");
|
|
397
|
+
const serialized = JSON.stringify({ ...raw, sessionId });
|
|
398
|
+
const existing = this.store.db.prepare("SELECT agreement FROM live_agreements WHERE session_id=?").get(sessionId);
|
|
399
|
+
if (existing && existing.agreement !== serialized)
|
|
400
|
+
fail("AGREEMENT_SESSION_CONFLICT", "session already contains another accepted agreement");
|
|
401
|
+
this.store.db.prepare("INSERT OR IGNORE INTO live_agreements(session_id,agreement,deal_commitment,accepted_at) VALUES(?,?,?,?)").run(sessionId, serialized, hex(values.dealCommitment), Date.now());
|
|
402
|
+
return { sessionId, revision: unsigned.revision.toString(), state: "accepted", dealCommitment: hex(values.dealCommitment), expiry: unsigned.expiry.toString(), paymentAmount: unsigned.paymentAmount.toString(), counterparty, ...(typeof raw.capabilityId === "string" ? { capabilityId: raw.capabilityId } : {}), ...(typeof raw.manifestHash === "string" ? { manifestHash: raw.manifestHash } : {}) };
|
|
403
|
+
}
|
|
404
|
+
prepareSellerAgreement(sessionId, encoded) {
|
|
405
|
+
if (this.profile.role !== "seller")
|
|
406
|
+
fail("ROLE_FORBIDDEN", "only the seller can sign a settlement agreement");
|
|
407
|
+
let raw;
|
|
408
|
+
try {
|
|
409
|
+
raw = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
return fail("AGREEMENT_INVALID", "agreement draft must be base64url JSON", error);
|
|
413
|
+
}
|
|
414
|
+
const persisted = this.store.db.prepare("SELECT agreement FROM live_seller_agreements WHERE session_id=?").get(sessionId);
|
|
415
|
+
if (persisted) {
|
|
416
|
+
const value = JSON.parse(persisted.agreement);
|
|
417
|
+
for (const [key, item] of Object.entries(raw))
|
|
418
|
+
if (JSON.stringify(value[key]) !== JSON.stringify(item))
|
|
419
|
+
fail("AGREEMENT_SESSION_CONFLICT", "seller session already signed another agreement draft");
|
|
420
|
+
return { sessionId, revision: String(value.revision), state: "counter", dealCommitment: hex(this.engine.agreementValues({ dealId: BigInt(value.dealId), revision: BigInt(value.revision), transcriptRootLo: BigInt(value.transcriptRootLo), transcriptRootHi: BigInt(value.transcriptRootHi), serviceDigestLo: BigInt(value.serviceDigestLo), serviceDigestHi: BigInt(value.serviceDigestHi), settlementNonceLo: BigInt(value.settlementNonceLo), settlementNonceHi: BigInt(value.settlementNonceHi), paymentAmount: BigInt(value.paymentAmount), blinding: BigInt(value.blinding), expiry: BigInt(value.expiry), buyer: { seed: new Uint8Array(32), publicKey: value.buyerPublicKey.map((item) => BigInt(item)) }, sellerPublicKey: value.sellerPublicKey.map((item) => BigInt(item)), recipientSpendTag: BigInt(value.recipientSpendTag) }).dealCommitment), agreement: Buffer.from(persisted.agreement).toString("base64url"), replayed: true };
|
|
421
|
+
}
|
|
422
|
+
const decimal = (name) => { if (typeof raw[name] !== "string" || !/^[0-9]+$/.test(raw[name]))
|
|
423
|
+
return fail("AGREEMENT_INVALID", `${name} must be a decimal integer`); return BigInt(raw[name]); }, buyerPoint = raw.buyerPublicKey;
|
|
424
|
+
if (!Array.isArray(buyerPoint) || buyerPoint.length !== 2)
|
|
425
|
+
fail("AGREEMENT_INVALID", "buyerPublicKey is missing");
|
|
426
|
+
const seller = this.engine.identity(this.store.getSecret("identity:babyjub-seed") ?? fail("IDENTITY_MISSING", "BabyJub identity is missing")), spendSecret = this.engine.randomScalar(), unsigned = { dealId: decimal("dealId"), revision: decimal("revision"), transcriptRootLo: decimal("transcriptRootLo"), transcriptRootHi: decimal("transcriptRootHi"), serviceDigestLo: decimal("serviceDigestLo"), serviceDigestHi: decimal("serviceDigestHi"), settlementNonceLo: decimal("settlementNonceLo"), settlementNonceHi: decimal("settlementNonceHi"), paymentAmount: decimal("paymentAmount"), blinding: decimal("blinding"), expiry: decimal("expiry"), buyer: { seed: new Uint8Array(32), publicKey: buyerPoint.map((value) => BigInt(value)) }, sellerPublicKey: seller.publicKey, recipientSpendTag: this.engine.spendTag(spendSecret) };
|
|
427
|
+
if (unsigned.expiry <= BigInt(Date.now()) || unsigned.paymentAmount <= 0n)
|
|
428
|
+
fail("AGREEMENT_INVALID", "draft amount or expiry is invalid");
|
|
429
|
+
const values = this.engine.agreementValues(unsigned), finalized = { ...raw, sellerPublicKey: seller.publicKey.map(String), recipientSpendTag: unsigned.recipientSpendTag.toString(), sellerSignature: Object.fromEntries(Object.entries(this.engine.sign(seller, 2005n, values.dealCommitment)).map(([key, value]) => [key, value.toString()])) };
|
|
430
|
+
const agreement = JSON.stringify(finalized);
|
|
431
|
+
this.store.putSecretOnce(`seller-spend:${sessionId}`, Buffer.from(spendSecret.toString()));
|
|
432
|
+
this.store.db.prepare("INSERT OR IGNORE INTO live_seller_agreements(session_id,agreement,payment_salt,amount,created_at) VALUES(?,?,?,?,?)").run(sessionId, agreement, values.paymentSalt.toString(), unsigned.paymentAmount.toString(), Date.now());
|
|
433
|
+
return { sessionId, revision: unsigned.revision.toString(), state: "counter", dealCommitment: hex(values.dealCommitment), agreement: Buffer.from(agreement).toString("base64url") };
|
|
434
|
+
}
|
|
435
|
+
async settleShielded(sessionId, operationId, bind) {
|
|
436
|
+
if (this.profile.role !== "buyer")
|
|
437
|
+
fail("ROLE_FORBIDDEN", "only the buyer can settle a shielded agreement");
|
|
438
|
+
this.operationContext(operationId, "shielded", sessionId);
|
|
439
|
+
const row = this.store.db.prepare("SELECT agreement FROM live_agreements WHERE session_id=?").get(sessionId) ?? fail("AGREEMENT_NOT_ACCEPTED", "session has no accepted cryptographic agreement");
|
|
440
|
+
const raw = JSON.parse(row.agreement), bigint = (name) => BigInt(String(raw[name]));
|
|
441
|
+
if (raw.sessionId !== sessionId || raw.rail !== "shielded")
|
|
442
|
+
fail("AGREEMENT_BINDING_MISMATCH", "accepted agreement does not authorize shielded settlement for this session");
|
|
443
|
+
bind?.({ amount: String(raw.paymentAmount), counterparty: String(raw.counterparty), asset: this.profile.coinType, requestId: sessionId, session: sessionId });
|
|
444
|
+
const prior = this.existingSigned(operationId);
|
|
445
|
+
if (prior) {
|
|
446
|
+
const input = this.store.db.prepare("SELECT input_commitment,input_nullifier FROM live_transfer_inputs WHERE operation_id=?").get(operationId) ?? fail("NOTE_RECOVERY_MISMATCH", "signed shielded payment has no persisted input note");
|
|
447
|
+
const evidence = await this.submit(operationId, prior, () => { this.store.db.prepare("UPDATE notes SET spent=1,nullifier=? WHERE commitment=?").run(input.input_nullifier, input.input_commitment); });
|
|
448
|
+
return { operationId, status: "finalized", rail: "shielded", sessionId, requestId: sessionId, amount: String(raw.paymentAmount), counterparty: String(raw.counterparty), asset: this.profile.coinType, policyDecision: "allowed", tatumVerified: true, visibility: "no-direct-public-payment; funding-deposits-withdrawals-gas-and-pool-activity-visible", evidence: "sui-and-tatum-finalized", resumed: true, ...evidence };
|
|
449
|
+
}
|
|
450
|
+
if (Date.now() >= Number(raw.expiry))
|
|
451
|
+
fail("AGREEMENT_EXPIRED", "accepted agreement expired before a payment was signed");
|
|
452
|
+
if (Date.now() > this.profile.policy.expiresAt)
|
|
453
|
+
fail("POLICY_EXPIRED", "payment policy has expired");
|
|
454
|
+
if (BigInt(String(raw.paymentAmount)) > BigInt(this.profile.policy.maxAmount) || !this.profile.policy.allowedAssets.includes(this.profile.coinType) || !this.profile.policy.allowedCounterparties.includes(String(raw.counterparty)))
|
|
455
|
+
fail("POLICY_PAYMENT_DENIED", "accepted agreement no longer fits the buyer payment policy");
|
|
456
|
+
const buyer = this.engine.identity(this.store.getSecret("identity:babyjub-seed") ?? fail("IDENTITY_MISSING", "BabyJub identity is missing"));
|
|
457
|
+
const agreement = { dealId: bigint("dealId"), revision: bigint("revision"), transcriptRootLo: bigint("transcriptRootLo"), transcriptRootHi: bigint("transcriptRootHi"), serviceDigestLo: bigint("serviceDigestLo"), serviceDigestHi: bigint("serviceDigestHi"), settlementNonceLo: bigint("settlementNonceLo"), settlementNonceHi: bigint("settlementNonceHi"), paymentAmount: bigint("paymentAmount"), blinding: bigint("blinding"), expiry: bigint("expiry"), buyer, sellerPublicKey: raw.sellerPublicKey.map(BigInt), recipientSpendTag: bigint("recipientSpendTag"), sellerSignature: { R8x: BigInt(raw.sellerSignature.R8x), R8y: BigInt(raw.sellerSignature.R8y), S: BigInt(raw.sellerSignature.S) } };
|
|
458
|
+
const state = await this.index.sync();
|
|
459
|
+
let selected;
|
|
460
|
+
const reservedInput = this.store.db.prepare("SELECT input_commitment,input_nullifier FROM live_transfer_inputs WHERE operation_id=?").get(operationId);
|
|
461
|
+
for (const note of this.store.db.prepare("SELECT commitment FROM notes WHERE spent=0 ORDER BY commitment").all()) {
|
|
462
|
+
if (reservedInput && reservedInput.input_commitment !== note.commitment)
|
|
463
|
+
continue;
|
|
464
|
+
const secret = this.store.getSecret(`note:${note.commitment}`);
|
|
465
|
+
if (!secret)
|
|
466
|
+
continue;
|
|
467
|
+
const opening = parseOpening(secret);
|
|
468
|
+
if (state.leaves[Number(opening.leafIndex)] !== opening.commitment)
|
|
469
|
+
continue;
|
|
470
|
+
if (opening.amount > agreement.paymentAmount && (!selected || opening.amount < selected.amount))
|
|
471
|
+
selected = opening;
|
|
472
|
+
}
|
|
473
|
+
const inputNote = selected ?? fail("INSUFFICIENT_SHIELDED_BALANCE", "no unspent note can pay the agreement while leaving positive change");
|
|
474
|
+
const preparedChange = this.store.db.prepare("SELECT commitment FROM notes WHERE operation_id=? AND role='change'").get(operationId), priorChange = preparedChange ? parseOpening(this.store.getSecret(`note:${preparedChange.commitment}`) ?? fail("NOTE_RECOVERY_MISMATCH", "prepared change opening is missing")) : undefined;
|
|
475
|
+
const witness = this.engine.buildTransfer(state.leaves, inputNote, agreement, priorChange?.spendSecret, priorChange?.salt), changeCommitment = hex(witness.changeCommitment), inputNullifier = hex(witness.inputNullifier), proof = await this.prover.prove("transfer", witness.input), intentHash = hash(`shielded:${sessionId}:${witness.dealCommitment}:${state.root}`);
|
|
476
|
+
if (reservedInput && (reservedInput.input_commitment !== hex(inputNote.commitment) || reservedInput.input_nullifier !== inputNullifier))
|
|
477
|
+
fail("OPERATION_ID_CONFLICT", "shielded operation identifies a different input note");
|
|
478
|
+
this.store.db.prepare("INSERT OR IGNORE INTO live_transfer_inputs(operation_id,input_commitment,input_nullifier) VALUES(?,?,?)").run(operationId, hex(inputNote.commitment), inputNullifier);
|
|
479
|
+
this.prepareOpening(witness.changeOpening, "change", operationId, state.leaves);
|
|
480
|
+
const tx = buildPrivateTransfer({ packageId: this.profile.packageId, poolId: this.profile.poolId, coinType: this.profile.coinType, poolModule: this.profile.verifierVersion === 2 ? "pool_v2" : "pool", expiryMs: agreement.expiry, newIndex: witness.newIndex, dealCommitment: bytes32(witness.dealCommitment), dealNullifier: bytes32(witness.dealNullifier), inputNullifier: bytes32(witness.inputNullifier), paymentCommitment: bytes32(witness.paymentCommitment), changeCommitment: bytes32(witness.changeCommitment), newRoot: bytes32(witness.newRoot), proof: proof.proof });
|
|
481
|
+
const evidence = await this.submit(operationId, await this.signed(operationId, intentHash, tx), () => { this.store.db.prepare("UPDATE notes SET spent=1,nullifier=? WHERE commitment=?").run(inputNullifier, hex(inputNote.commitment)); });
|
|
482
|
+
await this.index.sync();
|
|
483
|
+
return { operationId, status: "finalized", rail: "shielded", sessionId, requestId: sessionId, amount: agreement.paymentAmount.toString(), counterparty: String(raw.counterparty), asset: this.profile.coinType, transactionDigest: evidence.transactionDigest, finalized: true, policyDecision: "allowed", tatumVerified: true, prover: proof.engine, proveMs: proof.proveMs, dealCommitment: hex(witness.dealCommitment), paymentCommitment: hex(witness.paymentCommitment), changeCommitment, visibility: "no-direct-public-payment; funding-deposits-withdrawals-gas-and-pool-activity-visible", evidence: "sui-and-tatum-finalized" };
|
|
484
|
+
}
|
|
485
|
+
async payX402Resource(operationId, resourceUrl, bind) {
|
|
486
|
+
this.operationContext(operationId, "x402", resourceUrl);
|
|
487
|
+
const existing = this.store.db.prepare("SELECT resource_url,receipt,response FROM live_x402_deliveries WHERE operation_id=?").get(operationId);
|
|
488
|
+
if (existing) {
|
|
489
|
+
if (existing.resource_url !== resourceUrl)
|
|
490
|
+
fail("OPERATION_ID_CONFLICT", "operation ID identifies another x402 resource");
|
|
491
|
+
const receipt = JSON.parse(existing.receipt);
|
|
492
|
+
bind?.({ amount: String(receipt.amount), counterparty: String(receipt.counterparty), asset: String(receipt.asset), endpoint: resourceUrl, requestId: String(receipt.requestId), requirementsHash: String(receipt.requirementsHash) });
|
|
493
|
+
return { ...receipt, response: existing.response.toString("base64"), replayed: true };
|
|
494
|
+
}
|
|
495
|
+
let requested;
|
|
496
|
+
try {
|
|
497
|
+
requested = new URL(resourceUrl);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
return fail("X402_RESOURCE_INVALID", "resource URL is invalid", error);
|
|
501
|
+
}
|
|
502
|
+
if (requested.protocol !== "https:" && !(requested.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(requested.hostname)))
|
|
503
|
+
fail("X402_RESOURCE_INVALID", "resource must use HTTPS except on loopback");
|
|
504
|
+
const challenged = await fetch(requested, { redirect: "error", signal: AbortSignal.timeout(30_000) });
|
|
505
|
+
if (challenged.status !== 402)
|
|
506
|
+
fail("X402_CHALLENGE_REQUIRED", `resource returned ${challenged.status} instead of 402`);
|
|
507
|
+
const header = challenged.headers.get("payment-required");
|
|
508
|
+
await challenged.arrayBuffer();
|
|
509
|
+
const encodedChallenge = header ?? fail("X402_CHALLENGE_INVALID", "payment-required header is missing");
|
|
510
|
+
let challenge;
|
|
511
|
+
try {
|
|
512
|
+
challenge = JSON.parse(Buffer.from(encodedChallenge, "base64").toString());
|
|
513
|
+
}
|
|
514
|
+
catch (error) {
|
|
515
|
+
return fail("X402_CHALLENGE_INVALID", "payment-required is not base64 JSON", error);
|
|
516
|
+
}
|
|
517
|
+
const accepted = Array.isArray(challenge.accepts) ? challenge.accepts[0] : undefined, resource = challenge.resource;
|
|
518
|
+
if (challenge.x402Version !== 2 || !accepted || accepted.scheme !== "exact" || accepted.network !== "sui:testnet")
|
|
519
|
+
fail("X402_CHALLENGE_INVALID", "resource does not offer x402 v2 exact payment on Sui Testnet");
|
|
520
|
+
const requirements = accepted ?? fail("X402_CHALLENGE_INVALID", "challenge has no accepted payment requirements");
|
|
521
|
+
const paymentUrl = new URL(String(resource?.url ?? resourceUrl));
|
|
522
|
+
if (paymentUrl.toString() !== requested.toString())
|
|
523
|
+
fail("X402_CHALLENGE_INVALID", "challenge redirected payment away from the requested resource");
|
|
524
|
+
const payTo = String(requirements.payTo), asset = String(requirements.asset), amount = BigInt(String(requirements.amount));
|
|
525
|
+
if (!/^0x[0-9a-f]{64}$/i.test(payTo) || !/^0x[0-9a-f]+::[A-Za-z0-9_]+::[A-Za-z0-9_]+$/.test(asset) || amount <= 0n)
|
|
526
|
+
fail("X402_CHALLENGE_INVALID", "challenge payment requirements are malformed");
|
|
527
|
+
if (Date.now() > this.profile.policy.expiresAt)
|
|
528
|
+
fail("POLICY_EXPIRED", "payment policy has expired");
|
|
529
|
+
if (this.profile.policy.requiredPrivacy === "shielded")
|
|
530
|
+
fail("POLICY_PRIVACY_REQUIRED", "policy requires shielded settlement");
|
|
531
|
+
if (amount > BigInt(this.profile.policy.maxAmount))
|
|
532
|
+
fail("POLICY_AMOUNT_EXCEEDED", "challenge exceeds the configured cap");
|
|
533
|
+
if (!this.profile.policy.allowedAssets.includes(asset))
|
|
534
|
+
fail("POLICY_ASSET_FORBIDDEN", "challenged asset is not allowed");
|
|
535
|
+
if (!this.profile.policy.allowedCounterparties.includes(payTo))
|
|
536
|
+
fail("POLICY_COUNTERPARTY_FORBIDDEN", "challenged payee is not allowed");
|
|
537
|
+
const extra = requirements.extra, requestId = String(extra?.requestId ?? operationId), challengeOperationId = String(extra?.operationId ?? operationId), nonce = String(extra?.nonce ?? "");
|
|
538
|
+
if (challengeOperationId !== operationId || !nonce || String(extra?.requirementsHash ?? "") !== requirementsHash({ ...requirements, extra: { requestId, operationId: challengeOperationId, nonce } }))
|
|
539
|
+
fail("X402_CHALLENGE_INVALID", "challenge does not bind this operation and request");
|
|
540
|
+
bind?.({ amount: amount.toString(), counterparty: payTo, asset, endpoint: resourceUrl, requestId, requirementsHash: String(extra?.requirementsHash) });
|
|
541
|
+
const tx = new (await import("@mysten/sui/transactions")).Transaction();
|
|
542
|
+
tx.transferObjects([tx.coin({ balance: amount, type: asset })], payTo);
|
|
543
|
+
const legacyIntentHash = hash(JSON.stringify({ domain: "supercuts-x402-v2", operationId, resourceUrl, payTo, asset, amount: amount.toString() }));
|
|
544
|
+
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);
|
|
545
|
+
const unsignedBinding = { requestId, nonce, requirementsHash: requirementsHash({ ...requirements, extra: { requestId, operationId: challengeOperationId, nonce } }), transactionDigest: signed.digest, payer: this.signer.toSuiAddress() };
|
|
546
|
+
const payerSignature = (await this.signer.signPersonalMessage(paymentBindingBytes(unsignedBinding))).signature;
|
|
547
|
+
const paymentPayload = { x402Version: 2, resource, accepted: requirements, payload: { signature: signed.signature, transaction: Buffer.from(signed.transaction).toString("base64") } };
|
|
548
|
+
const paid = await fetch(paymentUrl, { redirect: "error", headers: { "payment-signature": Buffer.from(JSON.stringify(paymentPayload)).toString("base64"), "x-supercuts-binding": Buffer.from(JSON.stringify({ ...unsignedBinding, payerSignature })).toString("base64") }, signal: AbortSignal.timeout(60_000) });
|
|
549
|
+
const response = Buffer.from(await paid.arrayBuffer());
|
|
550
|
+
if (!paid.ok)
|
|
551
|
+
fail("X402_DELIVERY_FAILED", `paid resource returned ${paid.status}`);
|
|
552
|
+
const settlementHeader = paid.headers.get("payment-response") ?? fail("X402_SETTLEMENT_INVALID", "payment-response header is missing");
|
|
553
|
+
let settlement;
|
|
554
|
+
try {
|
|
555
|
+
settlement = JSON.parse(Buffer.from(settlementHeader, "base64").toString());
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return fail("X402_SETTLEMENT_INVALID", "payment-response is not base64 JSON", error);
|
|
559
|
+
}
|
|
560
|
+
if (settlement.success !== true || settlement.transaction !== signed.digest || settlement.network !== "sui:testnet")
|
|
561
|
+
fail("X402_SETTLEMENT_INVALID", "resource reported a different or unsuccessful settlement");
|
|
562
|
+
const finalized = await this.client.waitForTransaction({ digest: signed.digest, timeout: 60_000, include: { effects: true } });
|
|
563
|
+
const checkpoint = assertSuiFinality(finalized, signed.digest);
|
|
564
|
+
await this.confirmTatum(signed.digest, checkpoint);
|
|
565
|
+
const receipt = { operationId, status: "finalized", rail: "x402", amount: amount.toString(), counterparty: payTo, asset, requestId, requirementsHash: String(extra?.requirementsHash), transactionDigest: signed.digest, policyDecision: "allowed", tatumVerified: true, visibility: "public-transfer", finalized: true, checkpoint, responseSha256: hash(response) };
|
|
566
|
+
this.store.db.prepare("INSERT INTO live_x402_deliveries(operation_id,resource_url,receipt,response,updated_at) VALUES(?,?,?,?,?)").run(operationId, resourceUrl, JSON.stringify(receipt), response, Date.now());
|
|
567
|
+
this.store.db.prepare("UPDATE live_signed_transactions SET status='finalized',updated_at=? WHERE operation_id=?").run(Date.now(), operationId);
|
|
568
|
+
return { ...receipt, response: response.toString("base64") };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { type SignedCapabilityRegistration } from "./capability.js";
|
|
3
|
+
import { LiveRuntime } from "./live-runtime.js";
|
|
4
|
+
import type { Phase3App } from "./phase3.js";
|
|
5
|
+
import { SellerRuntime, type ServiceHandler } from "./seller-runtime.js";
|
|
6
|
+
export declare class LiveSellerTransport {
|
|
7
|
+
#private;
|
|
8
|
+
private readonly app;
|
|
9
|
+
private readonly config;
|
|
10
|
+
readonly runtime: SellerRuntime;
|
|
11
|
+
constructor(app: Phase3App, config: {
|
|
12
|
+
origin: string;
|
|
13
|
+
facilitatorUrl?: string;
|
|
14
|
+
facilitatorKey?: string;
|
|
15
|
+
allowedBuyers?: string[];
|
|
16
|
+
persist?: () => Promise<void>;
|
|
17
|
+
claimPayment?: (digest: string, requestId: string) => Promise<void>;
|
|
18
|
+
}, runtimeFactory?: () => Promise<LiveRuntime>);
|
|
19
|
+
private prepared;
|
|
20
|
+
register(registration: SignedCapabilityRegistration, handler: ServiceHandler): Promise<void>;
|
|
21
|
+
private signer;
|
|
22
|
+
private body;
|
|
23
|
+
private respond;
|
|
24
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<void>;
|
|
25
|
+
createServer(): Server;
|
|
26
|
+
prepareRequest(body: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
27
|
+
fulfillRequest(body: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
28
|
+
private prepare;
|
|
29
|
+
private fulfill;
|
|
30
|
+
}
|