@supercuts-walrus/backend 0.1.0 → 0.1.2
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/README.md +28 -5
- package/config/phase1.testnet.json +20 -0
- package/config/proving-manifest.json +11 -0
- package/dist/buyer-setup.js +6 -6
- package/dist/cli.js +9 -3
- package/dist/deal-adapter.js +0 -2
- package/dist/deal-engine.js +5 -5
- package/dist/deal.js +2 -2
- package/dist/demo.js +70 -8
- package/dist/funding.d.ts +38 -0
- package/dist/funding.js +78 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/live-deal-ports.js +8 -1
- package/dist/live-runtime.d.ts +4 -1
- package/dist/live-runtime.js +83 -11
- package/dist/live-seller-transport.js +5 -1
- package/dist/mandate.js +10 -9
- package/dist/mcp-bootstrap.js +24 -2
- package/dist/mcp.js +39 -19
- package/dist/onboarding-doctor.js +21 -5
- package/dist/phase3.js +38 -6
- package/dist/remote-buyer.d.ts +18 -0
- package/dist/remote-buyer.js +59 -5
- package/dist/role-lock.d.ts +8 -0
- package/dist/role-lock.js +54 -0
- package/dist/seller-catalog-generate.js +4 -4
- package/dist/seller-directory-cli.js +2 -1
- package/dist/seller-live-cli.js +10 -1
- package/dist/seller-package.d.ts +9 -0
- package/dist/seller-package.js +50 -0
- package/dist/seller-provision.js +15 -1
- package/dist/seller-research.d.ts +50 -0
- package/dist/seller-research.js +210 -0
- package/dist/seller-scaffold.d.ts +1 -0
- package/dist/seller-scaffold.js +35 -0
- package/dist/seller-service.d.ts +1 -1
- package/dist/seller-service.js +14 -21
- package/dist/seller-setup.d.ts +3 -0
- package/dist/seller-setup.js +107 -0
- package/dist/x402-resource-service.d.ts +2 -0
- package/dist/x402-resource-service.js +36 -17
- package/package.json +2 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fail } from "./errors.js";
|
|
4
|
+
const idPattern = /^[a-z0-9][a-z0-9._-]{1,127}$/;
|
|
5
|
+
export function scaffoldSeller(input) {
|
|
6
|
+
const home = typeof input.home === "string" ? resolve(input.home) : fail("ARGUMENT_REQUIRED", "seller scaffold requires --home");
|
|
7
|
+
const output = typeof input.output === "string" ? resolve(input.output) : fail("ARGUMENT_REQUIRED", "seller scaffold requires --output");
|
|
8
|
+
const capabilityId = String(input["capability-id"] ?? ""), capability = String(input.capability ?? ""), description = String(input.description ?? ""), origin = String(input.origin ?? ""), price = String(input.price ?? "");
|
|
9
|
+
if (!idPattern.test(capabilityId) || !idPattern.test(capability))
|
|
10
|
+
fail("SELLER_SCAFFOLD_INVALID", "capability identifiers must use 2..128 lowercase letters, digits, dot, underscore, or dash");
|
|
11
|
+
if (!description || description.length > 512 || !/^[1-9][0-9]*$/.test(price))
|
|
12
|
+
fail("SELLER_SCAFFOLD_INVALID", "description and positive MIST --price are required");
|
|
13
|
+
const url = new URL(origin);
|
|
14
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname)) || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
15
|
+
fail("SELLER_SCAFFOLD_INVALID", "origin must be an HTTPS or loopback HTTP origin without path or credentials");
|
|
16
|
+
const rails = String(input.rails ?? "x402").split(",").map((rail) => rail.trim()).filter(Boolean);
|
|
17
|
+
if (!rails.length || rails.some((rail) => rail !== "x402" && rail !== "shielded") || new Set(rails).size !== rails.length)
|
|
18
|
+
fail("SELLER_SCAFFOLD_INVALID", "--rails must be x402, shielded, or x402,shielded");
|
|
19
|
+
const days = Number(input["expires-in-days"] ?? "30");
|
|
20
|
+
if (!Number.isSafeInteger(days) || days < 1 || days > 365)
|
|
21
|
+
fail("SELLER_SCAFFOLD_INVALID", "--expires-in-days must be 1..365");
|
|
22
|
+
const listenPort = Number(input["listen-port"] ?? (url.protocol === "http:" && url.port ? url.port : "8788"));
|
|
23
|
+
if (!Number.isSafeInteger(listenPort) || listenPort < 1 || listenPort > 65535)
|
|
24
|
+
fail("SELLER_SCAFFOLD_INVALID", "--listen-port must be 1..65535");
|
|
25
|
+
mkdirSync(output, { recursive: false, mode: 0o700 });
|
|
26
|
+
chmodSync(output, 0o700);
|
|
27
|
+
const specPath = resolve(output, "capability.json"), handlerPath = resolve(output, "handler.mjs"), registrationPath = resolve(output, "registration.json"), servicePath = resolve(output, "seller-service.json");
|
|
28
|
+
const spec = { capabilityId, capability, description, inputSchema: { type: "object", required: ["task"], properties: { task: { type: "string", minLength: 1, maxLength: 4096 } }, additionalProperties: false }, outputSchema: { type: "object", required: ["kind"], properties: { kind: { const: capability } }, additionalProperties: false }, supportedRails: rails, priceBand: { minimum: price, maximum: price }, asset: "0x2::sui::SUI", endpoint: `${url.origin}/v2/resource/${capabilityId}`, expiresAt: Date.now() + days * 86_400_000 };
|
|
29
|
+
const handler = `export async function handle(request) {\n const result = { kind: ${JSON.stringify(capability)}, task: request.input.task, message: "Replace this template with your seller implementation." };\n return { artifact: Buffer.from(JSON.stringify(result)), contentType: "application/json", metadata: { kind: ${JSON.stringify(capability)} } };\n}\n`;
|
|
30
|
+
const service = { origin: url.origin, listenHost: "127.0.0.1", listenPort, ...(rails.includes("x402") ? { facilitatorUrlEnv: "X402_FACILITATOR_URL", facilitatorKeyEnv: "FACILITATOR_SHARED_KEY" } : {}), capabilities: [{ registrationFile: registrationPath, handler: handlerPath }] };
|
|
31
|
+
writeFileSync(specPath, `${JSON.stringify(spec, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
32
|
+
writeFileSync(handlerPath, handler, { mode: 0o600, flag: "wx" });
|
|
33
|
+
writeFileSync(servicePath, `${JSON.stringify(service, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
34
|
+
return { home, output, specFile: specPath, handlerFile: handlerPath, registrationFile: registrationPath, serviceFile: servicePath, expiresAt: spec.expiresAt, nextCommands: [`supercuts-seller-register ${JSON.stringify(home)} ${JSON.stringify(specPath)} ${JSON.stringify(registrationPath)}`, `supercuts-seller-live ${JSON.stringify(home)} ${JSON.stringify(servicePath)}`], note: "Review and replace the generated handler before serving production work." };
|
|
35
|
+
}
|
package/dist/seller-service.d.ts
CHANGED
package/dist/seller-service.js
CHANGED
|
@@ -4,7 +4,6 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { BoundedClaude } from "./claude.js";
|
|
8
7
|
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
|
|
9
8
|
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
10
9
|
import { isValidPersonalMessageSignature } from "@mysten/sui/verify";
|
|
@@ -35,10 +34,9 @@ export class SellerService {
|
|
|
35
34
|
#v2 = new Map();
|
|
36
35
|
#v2BySeller = new Map();
|
|
37
36
|
#root = mkdtempSync(join(tmpdir(), "supercuts-sellers-"));
|
|
38
|
-
#claude;
|
|
39
37
|
#queue = Promise.resolve();
|
|
40
38
|
#healthy = true;
|
|
41
|
-
constructor(db, snapshotKey, origin, facilitatorUrl, facilitatorKey, allowedBuyers,
|
|
39
|
+
constructor(db, snapshotKey, origin, facilitatorUrl, facilitatorKey, allowedBuyers, directoryAggregator) {
|
|
42
40
|
this.db = db;
|
|
43
41
|
this.snapshotKey = snapshotKey;
|
|
44
42
|
this.origin = origin;
|
|
@@ -46,10 +44,9 @@ export class SellerService {
|
|
|
46
44
|
this.facilitatorKey = facilitatorKey;
|
|
47
45
|
this.allowedBuyers = allowedBuyers;
|
|
48
46
|
this.directoryAggregator = directoryAggregator;
|
|
49
|
-
this.#claude = new BoundedClaude({ apiKey: claudeKey });
|
|
50
47
|
}
|
|
51
48
|
static async create(config) {
|
|
52
|
-
const service = new SellerService(config.db, config.snapshotKey, config.origin, config.facilitatorUrl, config.facilitatorKey, config.allowedBuyers, config.
|
|
49
|
+
const service = new SellerService(config.db, config.snapshotKey, config.origin, config.facilitatorUrl, config.facilitatorKey, config.allowedBuyers, config.directoryAggregator);
|
|
53
50
|
const rows = (await config.db.listCatalog()).filter((row) => !(isV2(row) && row.manifest.expiresAt <= Date.now()));
|
|
54
51
|
for (const row of rows) {
|
|
55
52
|
validateCapabilityManifest(row.manifest);
|
|
@@ -182,7 +179,13 @@ export class SellerService {
|
|
|
182
179
|
try {
|
|
183
180
|
const url = new URL(request.url ?? "/", this.origin);
|
|
184
181
|
if (request.method === "GET" && url.pathname === "/health")
|
|
185
|
-
return this.reply(response, this.#healthy ? 200 : 503, { ok: this.#healthy, service: "supercuts-sellers" });
|
|
182
|
+
return this.reply(response, this.#healthy ? 200 : 503, { ok: this.#healthy, service: "supercuts-sellers", buyerAccess: this.allowedBuyers.size ? "restricted" : "open" });
|
|
183
|
+
if (request.method === "GET" && url.pathname === "/v1/access") {
|
|
184
|
+
const buyerAddress = String(url.searchParams.get("buyerAddress") ?? "").toLowerCase();
|
|
185
|
+
if (!/^0x[0-9a-f]{64}$/.test(buyerAddress))
|
|
186
|
+
fail("SELLER_REQUEST_INVALID", "buyerAddress is invalid");
|
|
187
|
+
return this.reply(response, 200, { buyerAddress, allowed: this.allowedBuyers.size === 0 || this.allowedBuyers.has(buyerAddress), mode: this.allowedBuyers.size ? "restricted" : "open" });
|
|
188
|
+
}
|
|
186
189
|
if (request.method === "GET" && url.pathname === "/v2/directory/health") {
|
|
187
190
|
if (!this.directoryAggregator)
|
|
188
191
|
return this.reply(response, 503, { ok: false, service: "supercuts-directory", reason: "aggregator not configured" });
|
|
@@ -241,7 +244,7 @@ export class SellerService {
|
|
|
241
244
|
const sessionId = validId(input.sessionId, "sessionId"), runId = validId(input.runId, "runId"), buyerAddress = String(input.buyerAddress ?? ""), buyerPublicKey = String(input.buyerPublicKey ?? ""), ciphertext = String(input.ciphertext ?? ""), buyerSignature = String(input.buyerSignature ?? "");
|
|
242
245
|
if (!/^0x[0-9a-f]{64}$/.test(buyerAddress) || buyerPublicKey.length > 1024 || ciphertext.length > 16384 || !ciphertext || !buyerSignature)
|
|
243
246
|
fail("SELLER_REQUEST_INVALID", "buyer identity or encrypted offer is invalid");
|
|
244
|
-
if (!this.allowedBuyers.has(buyerAddress))
|
|
247
|
+
if (this.allowedBuyers.size > 0 && !this.allowedBuyers.has(buyerAddress))
|
|
245
248
|
fail("SELLER_BUYER_FORBIDDEN", "buyer is outside the seller service allowlist");
|
|
246
249
|
const signedBytes = Buffer.from(`${sessionId}:${capabilityId}:${buyerPublicKey}:${sha256(Buffer.from(ciphertext, "base64"))}`);
|
|
247
250
|
if (!(await isValidPersonalMessageSignature(signedBytes, buyerSignature, { address: buyerAddress })))
|
|
@@ -265,10 +268,6 @@ export class SellerService {
|
|
|
265
268
|
const opening = BigInt(String(offer.opening ?? "0")), maximum = BigInt(String(offer.maximum ?? "0")), minimum = BigInt(row.manifest.priceBand.minimum), briefHash = typeof offer.briefHash === "string" ? offer.briefHash : undefined;
|
|
266
269
|
if (opening <= 0n || opening > maximum || maximum < minimum || maximum > BigInt(row.manifest.priceBand.maximum) || briefHash !== undefined && !/^[0-9a-f]{64}$/.test(briefHash))
|
|
267
270
|
fail("NEGOTIATION_BOUNDS_INVALID", "buyer offer exceeds approved price bounds");
|
|
268
|
-
const task = String(offer.task ?? "").slice(0, 512);
|
|
269
|
-
const decision = await this.#claude.decide({ system: "You are a bounded research seller. Present only the fixed price and service terms approved by Supercuts.", task, candidates: [{ action: "counteroffer", capabilityId, amount: minimum.toString() }], schema: { name: "respond_to_private_offer", description: "Present the approved exact price.", properties: { action: { type: "string", enum: ["counteroffer"] } }, required: ["action"] } });
|
|
270
|
-
if (decision.action.action !== "counteroffer")
|
|
271
|
-
fail("SELLER_DECISION_INVALID", "seller did not choose the approved response");
|
|
272
271
|
const reply = { capabilityId, amount: minimum.toString(), asset: row.manifest.asset, railOptions: row.manifest.supportedRails, accepted: true };
|
|
273
272
|
const replyCiphertext = encryptSessionMessage(key, sessionId, `${sessionId}:seller:2`, Buffer.from(JSON.stringify(reply)));
|
|
274
273
|
const requestBlobId = await this.publishCiphertext(slot, ciphertext), replyBlobId = await this.publishCiphertext(slot, replyCiphertext);
|
|
@@ -285,7 +284,7 @@ export class SellerService {
|
|
|
285
284
|
this.#healthy = false;
|
|
286
285
|
throw error;
|
|
287
286
|
}
|
|
288
|
-
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "negotiation", status: "complete", details: { capabilityId, sessionId, requestBlobId, replyBlobId, transcriptHash,
|
|
287
|
+
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "negotiation", status: "complete", details: { capabilityId, sessionId, requestBlobId, replyBlobId, transcriptHash, decision: "policy-validated-fixed-price" } });
|
|
289
288
|
return this.reply(response, 200, { sessionId, capabilityId, ciphertext: replyCiphertext, signature, requestBlobId, replyBlobId, transcriptHash });
|
|
290
289
|
});
|
|
291
290
|
}
|
|
@@ -310,9 +309,6 @@ export class SellerService {
|
|
|
310
309
|
const prior = slot.app.store.db.prepare("SELECT operation_id AS operationId,capability_id AS capabilityId,amount,artifact_key AS artifactKey,run_id AS runId FROM remote_quotes WHERE request_id=?").get(requestId);
|
|
311
310
|
if (prior && JSON.stringify(prior) !== JSON.stringify({ operationId, capabilityId, amount: quote.amount, artifactKey: quote.artifactKey, runId }))
|
|
312
311
|
fail("SELLER_QUOTE_CONFLICT", "request ID identifies another quote");
|
|
313
|
-
const decision = await this.#claude.decide({ system: "You are a bounded seller agent. You may present only the fixed approved quote.", task: String(input.task ?? "Sui market brief").slice(0, 512), candidates: [{ action: "present_quote", capabilityId, amount: quote.amount }], schema: { name: "respond_to_buyer", description: "Present the approved exact price.", properties: { action: { type: "string", enum: ["present_quote"] } }, required: ["action"] } });
|
|
314
|
-
if (decision.action.action !== "present_quote")
|
|
315
|
-
fail("SELLER_DECISION_INVALID", "seller did not choose the approved quote");
|
|
316
312
|
slot.app.store.db.prepare("INSERT OR IGNORE INTO remote_quotes(request_id,operation_id,capability_id,amount,artifact_key,run_id) VALUES(?,?,?,?,?,?)").run(requestId, operationId, capabilityId, quote.amount, quote.artifactKey, runId);
|
|
317
313
|
const artifact = this.artifact(slot, quote.artifactKey), registration = this.resource(slot, row).register(requestId, operationId, artifact);
|
|
318
314
|
try {
|
|
@@ -322,7 +318,7 @@ export class SellerService {
|
|
|
322
318
|
this.#healthy = false;
|
|
323
319
|
throw error;
|
|
324
320
|
}
|
|
325
|
-
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "quote", status: "complete", details: { capabilityId, amount: quote.amount,
|
|
321
|
+
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "quote", status: "complete", details: { capabilityId, amount: quote.amount, decision: "manifest-fixed-price" } });
|
|
326
322
|
return this.reply(response, 200, { capabilityId, sellerId: slot.sellerId, amount: quote.amount, asset: row.manifest.asset, requestId, operationId, resourceUrl: registration.url, artifactSha256: registration.artifactSha256 });
|
|
327
323
|
});
|
|
328
324
|
}
|
|
@@ -350,9 +346,6 @@ export class SellerService {
|
|
|
350
346
|
const prior = slot.app.store.db.prepare("SELECT capability_id AS capabilityId,artifact_key AS artifactKey,delivery_public_key AS deliveryPublicKey,run_id AS runId FROM remote_deliveries WHERE session_id=?").get(sessionId);
|
|
351
347
|
if (prior && JSON.stringify(prior) !== JSON.stringify({ capabilityId, artifactKey, deliveryPublicKey, runId }))
|
|
352
348
|
fail("AGREEMENT_SESSION_CONFLICT", "session identifies another delivery");
|
|
353
|
-
const decision = await this.#claude.decide({ system: "You are a bounded seller agent. Sign only the backend-verified agreement.", task: `Respond to accepted competitor analysis request for ${capabilityId}.`, candidates: [{ action: "sign_approved_agreement", capabilityId }], schema: { name: "respond_to_private_offer", description: "Approve the backend-verified agreement.", properties: { action: { type: "string", enum: ["sign_approved_agreement"] } }, required: ["action"] } });
|
|
354
|
-
if (decision.action.action !== "sign_approved_agreement")
|
|
355
|
-
fail("SELLER_DECISION_INVALID", "seller did not choose the approved agreement");
|
|
356
349
|
const signed = await executeMandated(slot.app, loadMandate(slot.home), "offer.counter", { "operation-id": `remote-counter-${sessionId}`, session: sessionId, agreement: encoded });
|
|
357
350
|
slot.app.store.db.prepare("INSERT OR IGNORE INTO remote_deliveries(session_id,capability_id,artifact_key,delivery_public_key,run_id) VALUES(?,?,?,?,?)").run(sessionId, capabilityId, artifactKey, deliveryPublicKey, runId);
|
|
358
351
|
try {
|
|
@@ -362,7 +355,7 @@ export class SellerService {
|
|
|
362
355
|
this.#healthy = false;
|
|
363
356
|
throw error;
|
|
364
357
|
}
|
|
365
|
-
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "agreement", status: "complete", details: { capabilityId, sessionId,
|
|
358
|
+
await this.db.addEvent({ run_id: runId, seller_id: slot.sellerId, stage: "agreement", status: "complete", details: { capabilityId, sessionId, decision: "backend-verified-agreement", dealCommitment: signed.dealCommitment } });
|
|
366
359
|
return this.reply(response, 200, { sessionId, capabilityId, agreement: signed.agreement, dealCommitment: signed.dealCommitment, artifactSha256 });
|
|
367
360
|
});
|
|
368
361
|
}
|
|
@@ -410,7 +403,7 @@ if (process.argv[1] && import.meta.url.endsWith(process.argv[1])) {
|
|
|
410
403
|
const required = (key) => process.env[key] ?? fail("SELLER_CONFIG_MISSING", `${key} is required`);
|
|
411
404
|
const port = Number(process.env.PORT ?? "3000");
|
|
412
405
|
const origin = required("SELLER_ORIGIN");
|
|
413
|
-
SellerService.create({ db: new SellerDatabase(required("SUPABASE_URL"), required("SUPABASE_SERVICE_ROLE_KEY")), snapshotKey: required("SELLER_STATE_KEY"), origin, facilitatorUrl: required("FACILITATOR_URL"), facilitatorKey: required("FACILITATOR_SHARED_KEY"), allowedBuyers: new Set(
|
|
406
|
+
SellerService.create({ db: new SellerDatabase(required("SUPABASE_URL"), required("SUPABASE_SERVICE_ROLE_KEY")), snapshotKey: required("SELLER_STATE_KEY"), origin, facilitatorUrl: required("FACILITATOR_URL"), facilitatorKey: required("FACILITATOR_SHARED_KEY"), allowedBuyers: new Set((process.env.SELLER_ALLOWED_BUYERS ?? "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean)), ...(process.env.DIRECTORY_WALRUS_AGGREGATOR ? { directoryAggregator: process.env.DIRECTORY_WALRUS_AGGREGATOR } : {}) })
|
|
414
407
|
.then((service) => { const server = service.createServer(); server.listen(port, "0.0.0.0"); const close = () => server.close(() => { service.close(); process.exit(0); }); process.on("SIGINT", close); process.on("SIGTERM", close); })
|
|
415
408
|
.catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : "seller service failed"}\n`); process.exitCode = 1; });
|
|
416
409
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
|
|
6
|
+
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
|
|
7
|
+
import { fail } from "./errors.js";
|
|
8
|
+
import { initialize } from "./phase3.js";
|
|
9
|
+
function ownerFile(path) {
|
|
10
|
+
if ((statSync(path).mode & 0o077) !== 0)
|
|
11
|
+
fail("SETUP_PERMISSIONS", `${path} must be owner-only`);
|
|
12
|
+
}
|
|
13
|
+
function validateConfig(config) {
|
|
14
|
+
config.deploymentConfig ||= join(dirname(fileURLToPath(import.meta.url)), "../config/phase1.testnet.json");
|
|
15
|
+
for (const key of ["deploymentConfig", "tatumKeyFile", "writeGrpc", "tatumGrpc", "walrusPublisher", "walrusAggregator"]) {
|
|
16
|
+
if (typeof config[key] !== "string" || !config[key])
|
|
17
|
+
fail("SETUP_INVALID", `${key} is required`);
|
|
18
|
+
}
|
|
19
|
+
ownerFile(resolve(config.tatumKeyFile));
|
|
20
|
+
if (config.suiKeyFile)
|
|
21
|
+
ownerFile(resolve(config.suiKeyFile));
|
|
22
|
+
const deployment = JSON.parse(readFileSync(resolve(config.deploymentConfig), "utf8"));
|
|
23
|
+
if (deployment.network !== "testnet" || deployment.testOnly !== true || deployment.verifierVersion !== 2)
|
|
24
|
+
fail("SETUP_INVALID", "seller setup requires the pinned experimental v2 Testnet deployment");
|
|
25
|
+
return config;
|
|
26
|
+
}
|
|
27
|
+
function readConfig(path) {
|
|
28
|
+
ownerFile(path);
|
|
29
|
+
return validateConfig(JSON.parse(readFileSync(path, "utf8")));
|
|
30
|
+
}
|
|
31
|
+
export function createSellerSession(home) {
|
|
32
|
+
if (!existsSync(join(home, "profile.json")))
|
|
33
|
+
fail("SETUP_REQUIRED", "initialize the seller before creating an agent session");
|
|
34
|
+
const mcpScript = join(dirname(fileURLToPath(import.meta.url)), "mcp-bootstrap.js");
|
|
35
|
+
const mcp = `[mcp_servers.supercuts-seller]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([mcpScript])}\n[mcp_servers.supercuts-seller.env]\nSUPERCUTS_HOME = ${JSON.stringify(home)}\n`;
|
|
36
|
+
const mcpConfig = join(home, "seller-codex.toml");
|
|
37
|
+
writeFileSync(mcpConfig, mcp, { mode: 0o600 });
|
|
38
|
+
chmodSync(mcpConfig, 0o600);
|
|
39
|
+
return { mcpConfig };
|
|
40
|
+
}
|
|
41
|
+
function setupSellerConfig(home, config) {
|
|
42
|
+
if (existsSync(join(home, "profile.json")))
|
|
43
|
+
fail("PROFILE_EXISTS", "seller home already exists; create a separate home for another seller identity");
|
|
44
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
45
|
+
chmodSync(home, 0o700);
|
|
46
|
+
const keyPath = config.suiKeyFile ? resolve(config.suiKeyFile) : join(home, "seller.sui.key");
|
|
47
|
+
if (!config.suiKeyFile)
|
|
48
|
+
writeFileSync(keyPath, Ed25519Keypair.generate().getSecretKey(), { mode: 0o600, flag: "wx" });
|
|
49
|
+
ownerFile(keyPath);
|
|
50
|
+
initialize(home, {
|
|
51
|
+
role: "seller",
|
|
52
|
+
"deployment-config": resolve(config.deploymentConfig),
|
|
53
|
+
"sui-key-file": keyPath,
|
|
54
|
+
"tatum-key-file": resolve(config.tatumKeyFile),
|
|
55
|
+
"write-grpc": config.writeGrpc,
|
|
56
|
+
"tatum-grpc": config.tatumGrpc,
|
|
57
|
+
"walrus-publisher": config.walrusPublisher,
|
|
58
|
+
"walrus-aggregator": config.walrusAggregator,
|
|
59
|
+
...(config.snarkjs ? { snarkjs: resolve(config.snarkjs) } : {}),
|
|
60
|
+
...(config.converter ? { converter: resolve(config.converter) } : {}),
|
|
61
|
+
...(config.rapidsnark ? { rapidsnark: resolve(config.rapidsnark) } : {}),
|
|
62
|
+
});
|
|
63
|
+
const decoded = decodeSuiPrivateKey(readFileSync(keyPath, "utf8").trim());
|
|
64
|
+
if (decoded.scheme !== "ED25519")
|
|
65
|
+
fail("SETUP_INVALID", "seller key must use ED25519");
|
|
66
|
+
const address = Ed25519Keypair.fromSecretKey(decoded.secretKey).toSuiAddress();
|
|
67
|
+
return {
|
|
68
|
+
home,
|
|
69
|
+
address,
|
|
70
|
+
fundingRequired: false,
|
|
71
|
+
warning: "Do not faucet-fund the seller. Only a generated buyer address is funded for live purchases.",
|
|
72
|
+
...createSellerSession(home),
|
|
73
|
+
nextStep: `Run supercuts seller scaffold --home ${JSON.stringify(home)} --output <offer-dir> with the capability, origin, price, rails, and expiry options. Review the handler, then run the printed registration and service commands.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function setupSeller(home, configPath) {
|
|
77
|
+
return setupSellerConfig(home, readConfig(resolve(configPath)));
|
|
78
|
+
}
|
|
79
|
+
export async function promptSellerSetup(home) {
|
|
80
|
+
if (!process.stdin.isTTY)
|
|
81
|
+
fail("SETUP_INPUT_REQUIRED", "interactive seller setup requires a terminal; use --config with an owner-only JSON file");
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
83
|
+
try {
|
|
84
|
+
const ask = async (label) => (await rl.question(`${label}: `)).trim();
|
|
85
|
+
const config = {
|
|
86
|
+
deploymentConfig: await ask("Pinned v2 Testnet deployment config path (blank for bundled config)"),
|
|
87
|
+
tatumKeyFile: await ask("Owner-only Tatum key file path"),
|
|
88
|
+
writeGrpc: await ask("Sui write gRPC URL"),
|
|
89
|
+
tatumGrpc: await ask("Tatum read gRPC URL"),
|
|
90
|
+
walrusPublisher: await ask("Walrus publisher URL"),
|
|
91
|
+
walrusAggregator: await ask("Walrus aggregator URL"),
|
|
92
|
+
};
|
|
93
|
+
const snarkjs = await ask("SnarkJS executable path (blank for deployment-relative default)");
|
|
94
|
+
const converter = await ask("Groth16 converter path (blank for deployment-relative default)");
|
|
95
|
+
const rapidsnark = await ask("RapidSnark executable path (blank to use SnarkJS)");
|
|
96
|
+
if (snarkjs)
|
|
97
|
+
config.snarkjs = snarkjs;
|
|
98
|
+
if (converter)
|
|
99
|
+
config.converter = converter;
|
|
100
|
+
if (rapidsnark)
|
|
101
|
+
config.rapidsnark = rapidsnark;
|
|
102
|
+
return setupSellerConfig(home, validateConfig(config));
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rl.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -11,6 +11,8 @@ export interface X402ResourceServiceConfig {
|
|
|
11
11
|
payTo: string;
|
|
12
12
|
amount: string;
|
|
13
13
|
maxTimeoutSeconds?: number;
|
|
14
|
+
/** Bounded retry schedule for transient facilitator cold starts. */
|
|
15
|
+
facilitatorRetryDelaysMs?: number[];
|
|
14
16
|
}
|
|
15
17
|
/** Seller-owned standard x402 v2 exact resource with additional request-binding replay protection. */
|
|
16
18
|
export declare class X402ResourceService {
|
|
@@ -67,8 +67,10 @@ export class X402ResourceService {
|
|
|
67
67
|
challenge(artifact) {
|
|
68
68
|
// The live Sui exact facilitator requires the x402 nonce to be exactly
|
|
69
69
|
// 32 random bytes (hex encoded), not merely an application nonce.
|
|
70
|
-
const requirements = this.requirements(artifact), nonce = this.#ledger.challenge(artifact.requestId,
|
|
71
|
-
|
|
70
|
+
const requirements = this.requirements(artifact), baseHash = requirementsHash(requirements), nonce = this.#ledger.challenge(artifact.requestId, baseHash, () => randomBytes(32).toString("hex"));
|
|
71
|
+
const stored = this.#ledger.getChallenge(artifact.requestId, baseHash) ?? fail("X402_CHALLENGE_MISSING", "resource challenge was not persisted");
|
|
72
|
+
const expiresAt = stored.createdAt + requirements.maxTimeoutSeconds * 1_000;
|
|
73
|
+
requirements.extra = { ...requirements.extra, nonce, expiresAt };
|
|
72
74
|
// The nonce changes the signed requirement set and is persisted per request.
|
|
73
75
|
const finalHash = requirementsHash(requirements);
|
|
74
76
|
return { x402Version: 2, resource: { url: this.resourceUrl(artifact.requestId), description: "Supercuts seller capability artifact", mimeType: "application/json" }, accepts: [{ ...requirements, extra: { ...requirements.extra, requirementsHash: finalHash } }] };
|
|
@@ -96,10 +98,10 @@ export class X402ResourceService {
|
|
|
96
98
|
const offered = payload.accepted;
|
|
97
99
|
if (!offered || requirementsHash(offered) !== requirementsHash(requirements))
|
|
98
100
|
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");
|
|
101
|
+
const extra = requirements.extra, nonce = String(extra.nonce ?? ""), declaredHash = String(extra.requirementsHash ?? ""), expiresAt = Number(extra.expiresAt);
|
|
102
|
+
const unsignedRequirements = { ...requirements, extra: { requestId: extra.requestId, operationId: extra.operationId, nonce, expiresAt } };
|
|
103
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt !== stored.createdAt + requirements.maxTimeoutSeconds * 1_000 || declaredHash !== requirementsHash(unsignedRequirements))
|
|
104
|
+
fail("X402_REQUIREMENTS_MISMATCH", "challenge requirements hash or expiry is invalid");
|
|
103
105
|
verifyBinding(binding, artifact.requestId, nonce, unsignedRequirements, binding.transactionDigest);
|
|
104
106
|
const { payerSignature: _signature, ...unsignedBinding } = binding;
|
|
105
107
|
if (!(await isValidPersonalMessageSignature(paymentBindingBytes(unsignedBinding), binding.payerSignature, { address: binding.payer })))
|
|
@@ -133,20 +135,37 @@ export class X402ResourceService {
|
|
|
133
135
|
createServer() { return createServer((request, response) => void this.handle(request, response)); }
|
|
134
136
|
async facilitator(path, body) {
|
|
135
137
|
const endpoint = new URL(path, `${this.#facilitator.toString().replace(/\/$/, "")}/`);
|
|
136
|
-
const
|
|
137
|
-
let
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
138
|
+
const delays = this.config.facilitatorRetryDelaysMs ?? [0, 2_000, 5_000, 10_000];
|
|
139
|
+
let lastCause;
|
|
140
|
+
for (const delay of delays) {
|
|
141
|
+
if (delay > 0)
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
143
|
+
let response;
|
|
144
|
+
try {
|
|
145
|
+
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) });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
lastCause = error;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const raw = Buffer.from(await response.arrayBuffer());
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = JSON.parse(raw.toString("utf8"));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
lastCause = error;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (response.ok && value && typeof value === "object" && !Array.isArray(value))
|
|
161
|
+
return value;
|
|
145
162
|
const message = value && typeof value === "object" && !Array.isArray(value) && typeof value.message === "string"
|
|
146
163
|
? `: ${value.message}` : "";
|
|
147
|
-
|
|
164
|
+
if (response.status < 500)
|
|
165
|
+
fail("X402_FACILITATOR_INVALID", `facilitator ${path} failed with ${response.status}${message}`);
|
|
166
|
+
lastCause = new Error(`facilitator ${path} returned ${response.status}${message}`);
|
|
148
167
|
}
|
|
149
|
-
return
|
|
168
|
+
return fail("X402_FACILITATOR_UNAVAILABLE", `facilitator ${path} remained unavailable after bounded retries`, lastCause);
|
|
150
169
|
}
|
|
151
170
|
send(response, status, body, headers = {}) { response.writeHead(status, { "content-length": String(body.length), ...headers }); response.end(body); }
|
|
152
171
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supercuts-walrus/backend",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
+
"config",
|
|
32
33
|
"README.md"
|
|
33
34
|
],
|
|
34
35
|
"license": "Apache-2.0",
|