@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.
Files changed (43) hide show
  1. package/README.md +28 -5
  2. package/config/phase1.testnet.json +20 -0
  3. package/config/proving-manifest.json +11 -0
  4. package/dist/buyer-setup.js +6 -6
  5. package/dist/cli.js +9 -3
  6. package/dist/deal-adapter.js +0 -2
  7. package/dist/deal-engine.js +5 -5
  8. package/dist/deal.js +2 -2
  9. package/dist/demo.js +70 -8
  10. package/dist/funding.d.ts +38 -0
  11. package/dist/funding.js +78 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/live-deal-ports.js +8 -1
  15. package/dist/live-runtime.d.ts +4 -1
  16. package/dist/live-runtime.js +83 -11
  17. package/dist/live-seller-transport.js +5 -1
  18. package/dist/mandate.js +10 -9
  19. package/dist/mcp-bootstrap.js +24 -2
  20. package/dist/mcp.js +39 -19
  21. package/dist/onboarding-doctor.js +21 -5
  22. package/dist/phase3.js +38 -6
  23. package/dist/remote-buyer.d.ts +18 -0
  24. package/dist/remote-buyer.js +59 -5
  25. package/dist/role-lock.d.ts +8 -0
  26. package/dist/role-lock.js +54 -0
  27. package/dist/seller-catalog-generate.js +4 -4
  28. package/dist/seller-directory-cli.js +2 -1
  29. package/dist/seller-live-cli.js +10 -1
  30. package/dist/seller-package.d.ts +9 -0
  31. package/dist/seller-package.js +50 -0
  32. package/dist/seller-provision.js +15 -1
  33. package/dist/seller-research.d.ts +50 -0
  34. package/dist/seller-research.js +210 -0
  35. package/dist/seller-scaffold.d.ts +1 -0
  36. package/dist/seller-scaffold.js +35 -0
  37. package/dist/seller-service.d.ts +1 -1
  38. package/dist/seller-service.js +14 -21
  39. package/dist/seller-setup.d.ts +3 -0
  40. package/dist/seller-setup.js +107 -0
  41. package/dist/x402-resource-service.d.ts +2 -0
  42. package/dist/x402-resource-service.js +36 -17
  43. 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
+ }
@@ -18,7 +18,7 @@ export declare class SellerService {
18
18
  facilitatorUrl: string;
19
19
  facilitatorKey: string;
20
20
  allowedBuyers: ReadonlySet<string>;
21
- claudeKey: string;
21
+ claudeKey?: string;
22
22
  directoryAggregator?: string;
23
23
  }): Promise<SellerService>;
24
24
  private artifact;
@@ -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, claudeKey, directoryAggregator) {
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.claudeKey, config.directoryAggregator);
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, model: decision.model } });
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, model: decision.model } });
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, model: decision.model, dealCommitment: signed.dealCommitment } });
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(required("SELLER_ALLOWED_BUYERS").split(",")), claudeKey: required("CLAUDE_API_KEY"), ...(process.env.DIRECTORY_WALRUS_AGGREGATOR ? { directoryAggregator: process.env.DIRECTORY_WALRUS_AGGREGATOR } : {}) })
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,3 @@
1
+ export declare function createSellerSession(home: string): Record<string, unknown>;
2
+ export declare function setupSeller(home: string, configPath: string): Record<string, unknown>;
3
+ export declare function promptSellerSetup(home: string): Promise<Record<string, unknown>>;
@@ -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, requirementsHash(requirements), () => randomBytes(32).toString("hex"));
71
- requirements.extra = { ...requirements.extra, nonce };
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 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)) {
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
- fail("X402_FACILITATOR_INVALID", `facilitator ${path} failed with ${response.status}${message}`);
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 value;
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.0",
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",