From e011a4c2f65158367fb848257aae2908b2f03fe2 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Tue, 28 Apr 2026 11:43:24 +0800 Subject: [PATCH 01/11] Add service job provider runtime --- bin/acp.ts | 2 + serve/ARCHITECTURE.md | 12 + serve/runtime/loader.ts | 28 ++ serve/runtime/sandbox-worker.ts | 30 ++ serve/runtime/sandbox.ts | 29 ++ serve/scaffold/budget.ts.template | 9 + serve/scaffold/handler.ts.template | 19 + serve/scaffold/offering.json.template | 9 + serve/scaffold/serve.json.template | 5 + serve/server/index.ts | 219 +++++++++ serve/server/relay.ts | 82 ++++ serve/types.ts | 47 ++ src/commands/serve.ts | 609 ++++++++++++++++++++++++++ src/lib/api/auth.ts | 37 ++ src/lib/api/client.ts | 11 +- src/lib/config.ts | 22 + 16 files changed, 1168 insertions(+), 2 deletions(-) create mode 100644 serve/ARCHITECTURE.md create mode 100644 serve/runtime/loader.ts create mode 100644 serve/runtime/sandbox-worker.ts create mode 100644 serve/runtime/sandbox.ts create mode 100644 serve/scaffold/budget.ts.template create mode 100644 serve/scaffold/handler.ts.template create mode 100644 serve/scaffold/offering.json.template create mode 100644 serve/scaffold/serve.json.template create mode 100644 serve/server/index.ts create mode 100644 serve/server/relay.ts create mode 100644 serve/types.ts create mode 100644 src/commands/serve.ts diff --git a/bin/acp.ts b/bin/acp.ts index ebf2f71..019f3b9 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -15,6 +15,7 @@ import { registerResourceCommands } from "../src/commands/resource"; import { registerChainCommands } from "../src/commands/chain"; import { registerEmailCommands } from "../src/commands/email"; import { registerCardCommands } from "../src/commands/card"; +import { registerServeCommands } from "../src/commands/serve"; program .name("acp") @@ -40,5 +41,6 @@ registerResourceCommands(program); registerChainCommands(program); registerEmailCommands(program); registerCardCommands(program); +registerServeCommands(program); program.parse(); diff --git a/serve/ARCHITECTURE.md b/serve/ARCHITECTURE.md new file mode 100644 index 0000000..d176459 --- /dev/null +++ b/serve/ARCHITECTURE.md @@ -0,0 +1,12 @@ +# ACP Serve Architecture + +ACP Serve runs provider service code from `handler.ts`. + +For v1, x402 and MPP payment endpoints live on `agentic-commerce-be`, not in the CLI runtime. The CLI runtime connects outbound to BE on `/service-jobs`, receives paid jobs, runs the handler, and returns the deliverable as a socket ack. + +Canonical client endpoints: + +- `/x402/:providerAddress/jobs/:offeringName` +- `/mpp/:providerAddress/jobs/:offeringName` + +The provider runtime does not need facilitator credentials. BE verifies and settles direct x402/MPP payments to the provider wallet. `--settle-8183` is reserved but disabled until ERC-8183 supports the needed flow. diff --git a/serve/runtime/loader.ts b/serve/runtime/loader.ts new file mode 100644 index 0000000..8d16f8d --- /dev/null +++ b/serve/runtime/loader.ts @@ -0,0 +1,28 @@ +import { existsSync } from "fs"; +import { resolve } from "path"; +import type { BudgetHandler, Handler } from "../types"; + +export interface LoadedHandlers { + handler: Handler; + budgetHandler?: BudgetHandler; +} + +export async function loadHandlers(dir: string): Promise { + const handlerPath = resolve(dir, "handler.ts"); + if (!existsSync(handlerPath)) { + throw new Error(`handler.ts not found in ${dir}. This file is required.`); + } + + const handlerModule = await import(handlerPath); + const handler = handlerModule.default as Handler; + if (typeof handler !== "function") { + throw new Error(`handler.ts must export a default handler function.`); + } + + const budgetPath = resolve(dir, "budget.ts"); + const budgetHandler = existsSync(budgetPath) + ? ((await import(budgetPath)).default as BudgetHandler) + : undefined; + + return { handler, budgetHandler }; +} diff --git a/serve/runtime/sandbox-worker.ts b/serve/runtime/sandbox-worker.ts new file mode 100644 index 0000000..27cce46 --- /dev/null +++ b/serve/runtime/sandbox-worker.ts @@ -0,0 +1,30 @@ +import { parentPort, workerData } from "worker_threads"; + +async function main() { + const { handlerPath, input } = workerData as { + handlerPath: string; + input: unknown; + }; + const originalEnv = process.env; + process.env = {}; + + try { + const mod = await import(handlerPath); + const result = await mod.default(input); + parentPort?.postMessage({ ok: true, result }); + } catch (err) { + parentPort?.postMessage({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } finally { + process.env = originalEnv; + } +} + +main().catch((err) => { + parentPort?.postMessage({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }); +}); diff --git a/serve/runtime/sandbox.ts b/serve/runtime/sandbox.ts new file mode 100644 index 0000000..1568678 --- /dev/null +++ b/serve/runtime/sandbox.ts @@ -0,0 +1,29 @@ +import { Worker } from "worker_threads"; +import type { HandlerInput, HandlerOutput } from "../types"; + +export function runInSandbox( + handlerPath: string, + input: HandlerInput, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./sandbox-worker.ts", import.meta.url), { + workerData: { handlerPath, input }, + }); + + const timeout = setTimeout(() => { + worker.terminate().catch(() => {}); + reject(new Error(`Handler timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.on("message", (message: { ok: boolean; result?: HandlerOutput; error?: string }) => { + clearTimeout(timeout); + if (message.ok) resolve(message.result!); + else reject(new Error(message.error ?? "Handler failed")); + }); + worker.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} diff --git a/serve/scaffold/budget.ts.template b/serve/scaffold/budget.ts.template new file mode 100644 index 0000000..6b19542 --- /dev/null +++ b/serve/scaffold/budget.ts.template @@ -0,0 +1,9 @@ +import type { BudgetHandler } from "acp-cli/serve/types"; + +const budget: BudgetHandler = async () => { + return { + amount: 1, + }; +}; + +export default budget; diff --git a/serve/scaffold/handler.ts.template b/serve/scaffold/handler.ts.template new file mode 100644 index 0000000..8abb396 --- /dev/null +++ b/serve/scaffold/handler.ts.template @@ -0,0 +1,19 @@ +import type { Handler } from "acp-cli/serve/types"; + +const handler: Handler = async (input) => { + const { requirements } = input; + + // TODO: implement your service logic here. + // Example: + // const result = await myService.process(requirements); + // return { deliverable: result.url }; + + return { + deliverable: { + message: "TODO: replace with actual deliverable", + requirements, + }, + }; +}; + +export default handler; diff --git a/serve/scaffold/offering.json.template b/serve/scaffold/offering.json.template new file mode 100644 index 0000000..38548af --- /dev/null +++ b/serve/scaffold/offering.json.template @@ -0,0 +1,9 @@ +{ + "name": "{{NAME}}", + "description": "Describe what this service does", + "priceType": "fixed", + "priceValue": 1, + "slaMinutes": 5, + "requirements": {}, + "deliverable": {} +} diff --git a/serve/scaffold/serve.json.template b/serve/scaffold/serve.json.template new file mode 100644 index 0000000..07fcfe6 --- /dev/null +++ b/serve/scaffold/serve.json.template @@ -0,0 +1,5 @@ +{ + "agents": {}, + "evaluator": "self", + "port": 3000 +} diff --git a/serve/server/index.ts b/serve/server/index.ts new file mode 100644 index 0000000..0b32525 --- /dev/null +++ b/serve/server/index.ts @@ -0,0 +1,219 @@ +import { createServer } from "http"; +import { dirname, resolve as resolvePath } from "path"; +import { homedir } from "os"; +import { mkdirSync, unlinkSync, writeFileSync } from "fs"; +import { loadHandlers, type LoadedHandlers } from "../runtime/loader"; +import type { DeployedOffering, HandlerInput, ServeProtocol } from "../types"; +import { serviceJobEndpoint, startServiceJobRelay } from "./relay"; + +export interface ServerOptions { + dir: string; + port?: number; + agentSlug: string; + providerWallet: string; + offering: DeployedOffering["offering"]; + protocols?: ServeProtocol[]; + settle8183?: boolean; + apiUrl?: string; + agentToken?: string; + sandbox?: boolean; +} + +export async function startOfferingServer(options: ServerOptions): Promise { + const { dir, providerWallet, offering } = options; + const protocols = options.protocols ?? ["x402", "mpp", "acp"]; + const port = options.port ?? 3000; + const apiUrl = options.apiUrl ?? process.env.ACP_API_URL; + const settle8183 = options.settle8183 ?? false; + + let handlers = await loadHandlers(dir); + if (options.sandbox) { + const { runInSandbox } = await import("../runtime/sandbox"); + const handlerPath = resolvePath(dir, "handler.ts"); + const timeoutMs = Math.max(offering.slaMinutes, 1) * 60_000; + handlers = { + ...handlers, + handler: (input) => runInSandbox(handlerPath, input, timeoutMs), + }; + } + + const deployed: DeployedOffering = { + offeringId: offering.id, + agentSlug: options.agentSlug, + providerWallet, + offering, + hasBudgetHandler: Boolean(handlers.budgetHandler), + protocols, + evaluator: "self", + settle8183, + }; + + const usesRelay = protocols.includes("x402") || protocols.includes("mpp"); + if (usesRelay && settle8183) { + throw new Error( + "--settle-8183 is reserved but not supported until ERC-8183 supports this flow." + ); + } + if (usesRelay && (!apiUrl || !options.agentToken)) { + throw new Error("ACP API URL and agent auth token are required for x402/MPP."); + } + + const relay = + usesRelay && apiUrl && options.agentToken + ? startServiceJobRelay(deployed, handlers, { + apiUrl, + agentToken: options.agentToken, + }) + : undefined; + + if (protocols.includes("acp")) { + startACPListener(deployed, handlers).catch((err) => { + console.error(`[ACP] Native listener failed: ${err.message ?? err}`); + }); + } + + const pidFile = getPidFilePath(offering.id); + mkdirSync(dirname(pidFile), { recursive: true }); + writeFileSync(pidFile, String(process.pid)); + + const server = createServer((req, res) => { + if (req.url === "/health") { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + status: "ok", + offering: { id: offering.id, name: offering.name }, + protocols, + relay: usesRelay ? "enabled" : "disabled", + pid: process.pid, + }) + ); + return; + } + res.statusCode = 404; + res.end("Not found"); + }); + + server.listen(port, () => { + const offeringSlug = offering.slug || offering.id; + console.log(`\nACP Serve runtime running on port ${port}\n`); + console.log(`Offering: ${offering.name} (${offering.id})`); + console.log(`Provider: ${providerWallet}`); + console.log("Mode: BE-mediated service-job runtime"); + console.log(`Settlement: ${settle8183 ? "ERC-8183" : "direct x402/MPP"}`); + console.log("\nEndpoints:"); + if (apiUrl && protocols.includes("x402")) { + console.log( + ` x402: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "x402")}` + ); + } + if (apiUrl && protocols.includes("mpp")) { + console.log( + ` MPP: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "mpp")}` + ); + } + if (protocols.includes("acp")) console.log(" ACP: native listener"); + console.log(`\nHealth: http://localhost:${port}/health`); + }); + + const shutdown = () => { + relay?.disconnect(); + server.close(); + try { + unlinkSync(pidFile); + } catch {} + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +async function startACPListener( + offering: DeployedOffering, + handlers: LoadedHandlers +): Promise { + const { createAgentFromConfig } = await import("../../src/lib/agentFactory"); + const { AssetToken } = await import("@virtuals-protocol/acp-node-v2"); + const agent = await createAgentFromConfig(); + const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); + const jobRequirements = new Map | string>(); + + agent.on("entry", async (session: any, entry: any) => { + const jobId = session.jobId; + const status = session.status; + + if (entry.contentType === "requirement" && entry.content) { + try { + jobRequirements.set(jobId, JSON.parse(entry.content)); + } catch { + jobRequirements.set(jobId, entry.content); + } + } + + if (status === "open" && jobRequirements.has(jobId)) { + const requirements = jobRequirements.get(jobId)!; + const input = buildHandlerInput( + offering, + requirements, + entry.from || "unknown", + "acp", + jobId + ); + if (handlers.budgetHandler) { + const budget = await handlers.budgetHandler(input); + if (budget.fundRequest) { + await session.setBudgetWithFundRequest( + AssetToken.usdc(budget.amount, chainId), + AssetToken.usdc(budget.fundRequest.transferAmount, chainId), + budget.fundRequest.destination + ); + } else { + await session.setBudget(AssetToken.usdc(budget.amount, chainId)); + } + } else { + await session.setBudget( + AssetToken.usdc(offering.offering.priceValue, chainId) + ); + } + } + + if (status === "funded" && jobRequirements.has(jobId)) { + const result = await handlers.handler( + buildHandlerInput( + offering, + jobRequirements.get(jobId)!, + entry.from || "unknown", + "acp", + jobId + ) + ); + await session.submit(result.deliverable); + } + + if (["completed", "rejected", "expired"].includes(status)) { + jobRequirements.delete(jobId); + } + }); + + await agent.start(); +} + +function buildHandlerInput( + offering: DeployedOffering, + requirements: Record | string, + clientAddress: string, + protocol: HandlerInput["protocol"], + jobId?: string +): HandlerInput { + return { + requirements, + offering: offering.offering, + jobId, + client: { address: clientAddress }, + protocol, + }; +} + +export function getPidFilePath(offeringId: string): string { + return resolvePath(homedir(), ".acp", "serve", `${offeringId}.pid`); +} diff --git a/serve/server/relay.ts b/serve/server/relay.ts new file mode 100644 index 0000000..a55919b --- /dev/null +++ b/serve/server/relay.ts @@ -0,0 +1,82 @@ +import { io, type Socket } from "socket.io-client"; +import type { LoadedHandlers } from "../runtime/loader"; +import type { DeployedOffering, HandlerInput } from "../types"; + +export interface ServiceJobRelayOptions { + apiUrl: string; + agentToken: string; +} + +interface ServiceJobRequest { + jobId: string; + protocol: "x402" | "mpp"; + clientAddress: string; + requirements: Record; + payment: { + txHash: string | null; + paymentKey: string; + }; +} + +interface ServiceJobAck { + status: "completed" | "failed"; + deliverable?: unknown; + error?: string; +} + +export function serviceJobEndpoint( + apiUrl: string, + providerAddress: string, + offeringSlug: string, + protocol: "x402" | "mpp" +): string { + return new URL( + `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, + apiUrl + ).toString(); +} + +export function startServiceJobRelay( + offering: DeployedOffering, + handlers: LoadedHandlers, + options: ServiceJobRelayOptions +): Socket { + const socket = io(new URL("/service-jobs", options.apiUrl).toString(), { + auth: { token: options.agentToken }, + transports: ["websocket"], + }); + + socket.on("connect", () => { + console.log(`[Relay] Connected to ACP service jobs (${socket.id})`); + }); + socket.on("connect_error", (err) => { + console.error(`[Relay] Connection failed: ${err.message}`); + }); + socket.on("disconnect", (reason) => { + console.log(`[Relay] Disconnected from ACP service jobs: ${reason}`); + }); + + socket.on( + "service-job:request", + async (request: ServiceJobRequest, ack: (response: ServiceJobAck) => void) => { + try { + const input: HandlerInput = { + requirements: request.requirements, + offering: offering.offering, + jobId: request.jobId, + client: { address: request.clientAddress }, + protocol: request.protocol, + }; + const result = await handlers.handler(input); + ack({ status: "completed", deliverable: result.deliverable }); + } catch (err) { + ack({ + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + } + ); + + return socket; +} diff --git a/serve/types.ts b/serve/types.ts new file mode 100644 index 0000000..3006a54 --- /dev/null +++ b/serve/types.ts @@ -0,0 +1,47 @@ +export type ServeProtocol = "x402" | "mpp" | "acp"; + +export interface HandlerInput { + requirements: Record | string; + offering: { + id: string; + slug: string; + name: string; + description: string; + priceType: string; + priceValue: number; + slaMinutes: number; + requirements: Record | string; + deliverable: Record | string; + }; + jobId?: string; + client: { + address: string; + }; + protocol: ServeProtocol; +} + +export interface HandlerOutput { + deliverable: unknown; +} + +export interface BudgetOutput { + amount: number; + fundRequest?: { + transferAmount: number; + destination: string; + }; +} + +export type Handler = (input: HandlerInput) => Promise; +export type BudgetHandler = (input: HandlerInput) => Promise; + +export interface DeployedOffering { + offeringId: string; + agentSlug: string; + providerWallet: string; + offering: HandlerInput["offering"]; + hasBudgetHandler: boolean; + protocols: ServeProtocol[]; + evaluator: string; + settle8183: boolean; +} diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 0000000..5ced3af --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,609 @@ +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + watchFile, + writeFileSync, +} from "fs"; +import type { Command } from "commander"; +import type { Agent, AgentOffering } from "../lib/api/agent"; +import { AuthApi } from "../lib/api/auth"; +import { getApiUrl, getClient } from "../lib/api/client"; +import { + getActiveWallet, + getAgentId, + getAgentToken, +} from "../lib/config"; +import { isJson, outputError, outputResult } from "../lib/output"; +import type { ServeProtocol } from "../../serve/types"; + +type LocalOfferingConfig = { + slug: string; + dir: string; + protocols: ServeProtocol[]; + offeringJson: Record; +}; + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function readJsonFile(path: string): Record { + return JSON.parse(readFileSync(path, "utf8")) as Record; +} + +function getServeConfigPath(rootDir: string): string { + return resolve(rootDir, "serve.json"); +} + +function requireActiveAgent(json: boolean): { + wallet: string; + agentId: string; +} | null { + const wallet = getActiveWallet(); + if (!wallet) { + outputError(json, "No active agent set. Run `acp agent use` first."); + return null; + } + + const agentId = getAgentId(wallet); + if (!agentId) { + outputError( + json, + "Agent ID not found. Run `acp agent list` or `acp agent use` first." + ); + return null; + } + + return { wallet, agentId }; +} + +function loadLocalOfferings( + rootDir: string, + agentId: string +): LocalOfferingConfig[] { + const serveConfigPath = getServeConfigPath(rootDir); + if (!existsSync(serveConfigPath)) { + throw new Error(`serve.json not found in ${rootDir}. Run \`acp serve init\` first.`); + } + + const serveConfig = readJsonFile(serveConfigPath); + const agents = (serveConfig.agents ?? {}) as Record< + string, + { offerings?: Record } + >; + const agentConfig = agents[agentId]; + if (!agentConfig?.offerings) return []; + + return Object.entries(agentConfig.offerings).map(([slug, entry]) => { + const dir = resolve(rootDir, entry.dir); + const offeringJsonPath = resolve(dir, "offering.json"); + return { + slug, + dir, + protocols: entry.protocols ?? ["x402", "mpp", "acp"], + offeringJson: existsSync(offeringJsonPath) + ? readJsonFile(offeringJsonPath) + : {}, + }; + }); +} + +function selectLocalOfferings( + offerings: LocalOfferingConfig[], + selector?: string +): LocalOfferingConfig[] { + if (!selector) return offerings; + const normalized = selector.trim().toLowerCase(); + return offerings.filter((entry) => { + const id = typeof entry.offeringJson.id === "string" ? entry.offeringJson.id : ""; + const name = + typeof entry.offeringJson.name === "string" ? entry.offeringJson.name : ""; + return ( + entry.slug.toLowerCase() === normalized || + id.toLowerCase() === normalized || + name.toLowerCase() === normalized + ); + }); +} + +function findRemoteOffering( + local: LocalOfferingConfig, + agent: Agent +): AgentOffering | undefined { + const localId = + typeof local.offeringJson.id === "string" ? local.offeringJson.id : undefined; + if (localId) return agent.offerings.find((offering) => offering.id === localId); + + const localName = + typeof local.offeringJson.name === "string" + ? local.offeringJson.name + : undefined; + if (!localName) return undefined; + + const matches = agent.offerings.filter((offering) => offering.name === localName); + return matches.length === 1 ? matches[0] : undefined; +} + +function materializeOffering( + local: LocalOfferingConfig, + remote: AgentOffering | undefined +) { + const localName = + typeof local.offeringJson.name === "string" ? local.offeringJson.name : local.slug; + const localDescription = + typeof local.offeringJson.description === "string" + ? local.offeringJson.description + : ""; + const localPriceValue = + typeof local.offeringJson.priceValue === "number" + ? local.offeringJson.priceValue + : Number(local.offeringJson.priceValue ?? 0); + + return { + id: + remote?.id ?? + (typeof local.offeringJson.id === "string" ? local.offeringJson.id : local.slug), + slug: local.slug, + name: remote?.name ?? localName, + description: remote?.description ?? localDescription, + priceType: + remote?.priceType ?? + (typeof local.offeringJson.priceType === "string" + ? local.offeringJson.priceType + : "fixed"), + priceValue: remote ? Number(remote.priceValue) : localPriceValue, + slaMinutes: + remote?.slaMinutes ?? + (typeof local.offeringJson.slaMinutes === "number" + ? local.offeringJson.slaMinutes + : Number(local.offeringJson.slaMinutes ?? 5)), + requirements: + remote?.requirements ?? + ((local.offeringJson.requirements as Record | string) ?? {}), + deliverable: + remote?.deliverable ?? + ((local.offeringJson.deliverable as Record | string) ?? {}), + }; +} + +function serviceJobEndpoint( + apiUrl: string, + providerAddress: string, + offeringSlug: string, + protocol: "x402" | "mpp" +): string { + return new URL( + `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, + apiUrl + ).toString(); +} + +async function getOrCreateAgentToken(wallet: string): Promise { + const existing = getAgentToken(wallet); + if (existing) return existing; + + const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); + return AuthApi.fetchAndStoreAgentToken(wallet, chainId, getApiUrl()); +} + +function getDefaultPort(input: unknown): number { + const port = Number(input ?? 3000); + return Number.isFinite(port) && port > 0 ? port : 3000; +} + +function copyRuntimeBundle( + rootDir: string, + bundleDir: string, + active: { wallet: string; agentId: string }, + agentName: string, + local: LocalOfferingConfig, + apiUrl: string, + serviceName: string +): Record { + rmSync(bundleDir, { recursive: true, force: true }); + mkdirSync(bundleDir, { recursive: true }); + + for (const relativePath of [ + "bin", + "src", + "serve", + "package.json", + "package-lock.json", + "tsconfig.json", + ]) { + const source = resolve(process.cwd(), relativePath); + if (existsSync(source)) { + cpSync(source, resolve(bundleDir, relativePath), { recursive: true }); + } + } + + const agentSlug = slugify(agentName); + const destination = resolve( + bundleDir, + "agents", + agentSlug, + "offerings", + local.slug + ); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(local.dir, destination, { recursive: true }); + + writeFileSync( + resolve(bundleDir, "serve.json"), + JSON.stringify( + { + agents: { + [active.agentId]: { + name: agentName, + offerings: { + [local.slug]: { + dir: `agents/${agentSlug}/offerings/${local.slug}`, + protocols: local.protocols, + registered: true, + }, + }, + }, + }, + evaluator: "self", + port: 3000, + }, + null, + 2 + ) + "\n" + ); + + writeFileSync( + resolve(bundleDir, ".env.example"), + [ + `ACP_ACTIVE_WALLET=${active.wallet}`, + `ACP_AGENT_ID=${active.agentId}`, + `ACP_API_URL=${apiUrl}`, + `ACP_SERVE_OFFERING=${local.slug}`, + "ACP_AGENT_TOKEN=", + "IS_TESTNET=", + "", + ].join("\n") + ); + + writeFileSync( + resolve(bundleDir, "Dockerfile"), + [ + "FROM node:20-alpine", + "WORKDIR /app", + "COPY package*.json ./", + "RUN npm ci", + "COPY . .", + 'CMD ["sh", "-c", "npx tsx bin/acp.ts serve start --dir . --offering ${ACP_SERVE_OFFERING} --port ${PORT:-3000}"]', + "", + ].join("\n") + ); + + return { + x402: serviceJobEndpoint(apiUrl, active.wallet, local.slug, "x402"), + mpp: serviceJobEndpoint(apiUrl, active.wallet, local.slug, "mpp"), + health: `https://${serviceName}.example/health`, + }; +} + +export function registerServeCommands(program: Command): void { + const serve = program + .command("serve") + .description("Scaffold and run ACP service-job provider runtimes"); + + serve + .command("init") + .description("Scaffold a local offering runtime") + .requiredOption("--name ", "Offering name") + .option("--output ", "Project root directory", ".") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.output); + const { agentApi } = await getClient(); + const agent = await agentApi.getById(active.agentId); + const agentSlug = slugify(agent.name); + const offeringSlug = slugify(opts.name); + const offeringDir = resolve( + rootDir, + "agents", + agentSlug, + "offerings", + offeringSlug + ); + + if (existsSync(resolve(offeringDir, "handler.ts"))) { + throw new Error(`Handler already exists at ${offeringDir}.`); + } + + mkdirSync(offeringDir, { recursive: true }); + const scaffoldDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../serve/scaffold" + ); + const offeringTemplate = readFileSync( + resolve(scaffoldDir, "offering.json.template"), + "utf8" + ); + + writeFileSync( + resolve(offeringDir, "offering.json"), + offeringTemplate.replace("{{NAME}}", opts.name) + ); + writeFileSync( + resolve(offeringDir, "handler.ts"), + readFileSync(resolve(scaffoldDir, "handler.ts.template"), "utf8") + ); + writeFileSync( + resolve(offeringDir, "budget.ts"), + readFileSync(resolve(scaffoldDir, "budget.ts.template"), "utf8") + ); + + const serveConfigPath = getServeConfigPath(rootDir); + const serveConfig = existsSync(serveConfigPath) + ? readJsonFile(serveConfigPath) + : { agents: {}, evaluator: "self", port: 3000 }; + const agents = (serveConfig.agents ?? {}) as Record; + const agentConfig = (agents[active.agentId] ?? { + name: agent.name, + offerings: {}, + }) as Record; + agentConfig.offerings ??= {}; + agentConfig.offerings[offeringSlug] = { + dir: `agents/${agentSlug}/offerings/${offeringSlug}`, + protocols: ["x402", "mpp", "acp"], + registered: false, + }; + agents[active.agentId] = agentConfig; + serveConfig.agents = agents; + writeFileSync(serveConfigPath, JSON.stringify(serveConfig, null, 2) + "\n"); + + outputResult(json, { + success: true, + offering: opts.name, + directory: offeringDir, + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("start") + .description("Start the provider runtime for a single offering") + .option("--dir ", "Project root directory", ".") + .option("--offering ", "Offering slug, ID, or name") + .option("--port ", "Local health-check port") + .option("--settle-8183", "Reserved for future ERC-8183 settlement") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.dir); + const selected = selectLocalOfferings( + loadLocalOfferings(rootDir, active.agentId), + opts.offering + ); + if (selected.length === 0) throw new Error("No matching offerings found."); + if (selected.length > 1) { + throw new Error("Multiple offerings matched. Use --offering."); + } + + const { agentApi } = await getClient(); + const agent = await agentApi.getById(active.agentId); + const local = selected[0]; + const offering = materializeOffering(local, findRemoteOffering(local, agent)); + const { startOfferingServer } = await import("../../serve/server/index"); + + await startOfferingServer({ + dir: local.dir, + port: opts.port ? Number(opts.port) : getDefaultPort(undefined), + agentSlug: slugify(agent.name), + providerWallet: active.wallet, + offering, + protocols: local.protocols, + settle8183: opts.settle8183 === true, + apiUrl: getApiUrl(), + agentToken: await getOrCreateAgentToken(active.wallet), + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("endpoints") + .description("Show canonical BE x402/MPP endpoints") + .option("--dir ", "Project root directory", ".") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const apiUrl = getApiUrl(); + const payload: Record> = {}; + for (const offering of loadLocalOfferings(resolve(opts.dir), active.agentId)) { + payload[offering.slug] = {}; + if (offering.protocols.includes("x402")) { + payload[offering.slug].x402 = serviceJobEndpoint( + apiUrl, + active.wallet, + offering.slug, + "x402" + ); + } + if (offering.protocols.includes("mpp")) { + payload[offering.slug].mpp = serviceJobEndpoint( + apiUrl, + active.wallet, + offering.slug, + "mpp" + ); + } + if (offering.protocols.includes("acp")) { + payload[offering.slug].acp = "native ACP listener"; + } + } + outputResult(json, { endpoints: payload }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("deploy") + .description("Build a deployable provider runtime bundle") + .option("--dir ", "Project root directory", ".") + .option("--offering ", "Offering slug, ID, or name") + .option("--provider ", "Deployment provider label", "railway") + .option("--service ", "Service name override") + .option("--bundle-only", "Only create the deploy bundle", true) + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.dir); + const serveConfig = readJsonFile(getServeConfigPath(rootDir)); + const selected = selectLocalOfferings( + loadLocalOfferings(rootDir, active.agentId), + opts.offering + ); + if (selected.length === 0) throw new Error("No matching offerings found."); + if (selected.length > 1) { + throw new Error("Multiple offerings matched. Use --offering."); + } + + const agentConfig = (serveConfig.agents as any)?.[active.agentId]; + const agentName = agentConfig?.name ?? "agent"; + const local = selected[0]; + const serviceName = opts.service ?? `${slugify(agentName)}-${local.slug}`; + const provider = String(opts.provider ?? "railway"); + const bundleDir = resolve( + rootDir, + ".acp", + "serve", + "deploy", + provider, + serviceName + ); + const endpoints = copyRuntimeBundle( + rootDir, + bundleDir, + active, + agentName, + local, + getApiUrl(), + serviceName + ); + + outputResult(json, { + provider, + serviceName, + bundleDir, + executed: false, + endpoints, + nextSteps: [ + `Set ACP_AGENT_TOKEN in your hosting provider secrets.`, + `Deploy ${bundleDir} with Docker or your provider CLI.`, + `The public x402/MPP endpoints are the BE endpoints above; the deployment only needs outbound access to BE.`, + ], + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("stop") + .description("Stop a locally running offering runtime") + .option("--dir ", "Project root directory", ".") + .option("--offering ", "Offering slug, ID, or name") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + const { getPidFilePath } = await import("../../serve/server/index"); + let stopped = 0; + for (const local of selectLocalOfferings( + loadLocalOfferings(resolve(opts.dir), active.agentId), + opts.offering + )) { + const offeringId = + typeof local.offeringJson.id === "string" + ? local.offeringJson.id + : local.slug; + const pidFile = getPidFilePath(offeringId); + if (!existsSync(pidFile)) continue; + const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + try { + process.kill(pid, "SIGTERM"); + stopped += 1; + } catch {} + } + outputResult(json, { success: true, stopped }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("logs") + .description("Read recent serve logs") + .option("--offering ", "Offering slug or ID") + .option("--follow", "Tail logs in real time") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const logDir = resolve(process.env.HOME ?? ".", ".acp", "serve", "logs"); + if (!existsSync(logDir)) { + outputResult(json, { logs: [] }); + return; + } + + const files = readdirSync(logDir) + .filter((name) => name.endsWith(".log")) + .filter((name) => !opts.offering || name === `${opts.offering}.log`) + .map((name) => resolve(logDir, name)); + const logs = files.flatMap((file) => + readFileSync(file, "utf8") + .trim() + .split("\n") + .filter(Boolean) + ); + outputResult(json, { logs: logs.slice(-50) }); + + if (opts.follow && files.length > 0 && !json) { + const offsets = new Map(files.map((file) => [file, statSync(file).size])); + for (const file of files) { + watchFile(file, { interval: 1000 }, () => { + const currentSize = statSync(file).size; + const previousSize = offsets.get(file) ?? 0; + if (currentSize <= previousSize) return; + process.stdout.write(readFileSync(file, "utf8").slice(previousSize)); + offsets.set(file, currentSize); + }); + } + } + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); +} diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index 69ed892..71c842c 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,4 +1,6 @@ import { ApiClient } from "./client"; +import { createProviderAdapter } from "../agentFactory"; +import { setAgentToken } from "../config"; interface CliUrlResponse { data: { url: string; requestId: string }; @@ -8,6 +10,17 @@ interface CliTokenResponse { data: { token: string; refreshToken: string; walletAddress: string }; } +interface AgentTokenResponse { + data: { token: string }; +} + +interface RequestAgentToken { + walletAddress: string; + signature: string; + message: string; + chainId: number; +} + export class AuthApi { constructor(private readonly client: ApiClient) {} @@ -49,4 +62,28 @@ export class AuthApi { return null; } } + + async getAgentToken(data: RequestAgentToken): Promise { + const res = await this.client.post("/auth/agent", data); + const token = res.data.token; + setAgentToken(data.walletAddress, token); + return token; + } + + static async fetchAndStoreAgentToken( + walletAddress: string, + chainId: number, + baseUrl: string + ): Promise { + const message = `acp-auth:${Date.now()}`; + const provider = await createProviderAdapter(); + const signature = await provider.signMessage(chainId, message); + const authApi = new AuthApi(new ApiClient(baseUrl)); + return authApi.getAgentToken({ + walletAddress, + signature, + message, + chainId, + }); + } } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 805b98c..bd2276c 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -90,6 +90,14 @@ export class ApiClient { } } +export function getApiUrl(): string { + const isTestnet = process.env.IS_TESTNET === "true"; + return ( + process.env.ACP_API_URL ?? + (isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL) + ); +} + async function resolveToken(apiUrl: string): Promise { const ownerWallet = getCurrentOwnerWallet(); const token = await getToken(ownerWallet); @@ -132,8 +140,7 @@ export async function getClient(unauthenticated?: boolean): Promise<{ agentApi: AgentApi; authApi: AuthApi; }> { - const isTestnet = process.env.IS_TESTNET === "true"; - const apiUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; + const apiUrl = getApiUrl(); const token = unauthenticated ? undefined : await resolveToken(apiUrl); const httpClient = new ApiClient(apiUrl, token); return { diff --git a/src/lib/config.ts b/src/lib/config.ts index 1233fdf..0c4cd0b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -8,6 +8,7 @@ const CONFIG_PATH = resolve(process.cwd(), "config.json"); interface AgentConfig { publicKey: string; + token?: string; walletId?: string; id?: string; builderCode?: string; @@ -37,6 +38,27 @@ function saveConfig(config: Config): void { writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); } +function getEnv(name: string): string | undefined { + const value = process.env[name]; + return value && value.trim() ? value.trim() : undefined; +} + +export function getAgentToken(walletAddress: string): string | undefined { + return ( + getEnv("ACP_AGENT_TOKEN") ?? + loadConfig().agents?.[walletAddress]?.token ?? + loadConfig().agents?.[walletAddress.toLowerCase()]?.token + ); +} + +export function setAgentToken(walletAddress: string, token: string): void { + const config = loadConfig(); + config.agents ??= {}; + config.agents[walletAddress] ??= { publicKey: "" }; + config.agents[walletAddress].token = token; + saveConfig(config); +} + export async function getToken( walletAddress?: string ): Promise { From f992c5d89bef8bcce1ff89d2dedc136093a13793 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Tue, 28 Apr 2026 13:45:46 +0800 Subject: [PATCH 02/11] Fix serve runtime review findings --- serve/runtime/loader.ts | 13 +++- serve/server/index.ts | 114 +++++++++++++++++-------------- src/commands/serve.ts | 148 ++++++++++++++++++++++------------------ 3 files changed, 156 insertions(+), 119 deletions(-) diff --git a/serve/runtime/loader.ts b/serve/runtime/loader.ts index 8d16f8d..6fb8f03 100644 --- a/serve/runtime/loader.ts +++ b/serve/runtime/loader.ts @@ -20,9 +20,16 @@ export async function loadHandlers(dir: string): Promise { } const budgetPath = resolve(dir, "budget.ts"); - const budgetHandler = existsSync(budgetPath) - ? ((await import(budgetPath)).default as BudgetHandler) - : undefined; + let budgetHandler: BudgetHandler | undefined; + if (existsSync(budgetPath)) { + const budgetModule = await import(budgetPath); + budgetHandler = budgetModule.default as BudgetHandler; + if (typeof budgetHandler !== "function") { + throw new Error( + `budget.ts must export a default budget handler function.`, + ); + } + } return { handler, budgetHandler }; } diff --git a/serve/server/index.ts b/serve/server/index.ts index 0b32525..c7dd68e 100644 --- a/serve/server/index.ts +++ b/serve/server/index.ts @@ -19,7 +19,9 @@ export interface ServerOptions { sandbox?: boolean; } -export async function startOfferingServer(options: ServerOptions): Promise { +export async function startOfferingServer( + options: ServerOptions, +): Promise { const { dir, providerWallet, offering } = options; const protocols = options.protocols ?? ["x402", "mpp", "acp"]; const port = options.port ?? 3000; @@ -51,11 +53,13 @@ export async function startOfferingServer(options: ServerOptions): Promise const usesRelay = protocols.includes("x402") || protocols.includes("mpp"); if (usesRelay && settle8183) { throw new Error( - "--settle-8183 is reserved but not supported until ERC-8183 supports this flow." + "--settle-8183 is reserved but not supported until ERC-8183 supports this flow.", ); } if (usesRelay && (!apiUrl || !options.agentToken)) { - throw new Error("ACP API URL and agent auth token are required for x402/MPP."); + throw new Error( + "ACP API URL and agent auth token are required for x402/MPP.", + ); } const relay = @@ -86,7 +90,7 @@ export async function startOfferingServer(options: ServerOptions): Promise protocols, relay: usesRelay ? "enabled" : "disabled", pid: process.pid, - }) + }), ); return; } @@ -104,12 +108,12 @@ export async function startOfferingServer(options: ServerOptions): Promise console.log("\nEndpoints:"); if (apiUrl && protocols.includes("x402")) { console.log( - ` x402: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "x402")}` + ` x402: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "x402")}`, ); } if (apiUrl && protocols.includes("mpp")) { console.log( - ` MPP: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "mpp")}` + ` MPP: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "mpp")}`, ); } if (protocols.includes("acp")) console.log(" ACP: native listener"); @@ -130,7 +134,7 @@ export async function startOfferingServer(options: ServerOptions): Promise async function startACPListener( offering: DeployedOffering, - handlers: LoadedHandlers + handlers: LoadedHandlers, ): Promise { const { createAgentFromConfig } = await import("../../src/lib/agentFactory"); const { AssetToken } = await import("@virtuals-protocol/acp-node-v2"); @@ -140,58 +144,66 @@ async function startACPListener( agent.on("entry", async (session: any, entry: any) => { const jobId = session.jobId; - const status = session.status; + try { + const status = session.status; - if (entry.contentType === "requirement" && entry.content) { - try { - jobRequirements.set(jobId, JSON.parse(entry.content)); - } catch { - jobRequirements.set(jobId, entry.content); + if (entry.contentType === "requirement" && entry.content) { + try { + jobRequirements.set(jobId, JSON.parse(entry.content)); + } catch { + jobRequirements.set(jobId, entry.content); + } } - } - if (status === "open" && jobRequirements.has(jobId)) { - const requirements = jobRequirements.get(jobId)!; - const input = buildHandlerInput( - offering, - requirements, - entry.from || "unknown", - "acp", - jobId - ); - if (handlers.budgetHandler) { - const budget = await handlers.budgetHandler(input); - if (budget.fundRequest) { - await session.setBudgetWithFundRequest( - AssetToken.usdc(budget.amount, chainId), - AssetToken.usdc(budget.fundRequest.transferAmount, chainId), - budget.fundRequest.destination - ); + if (status === "open" && jobRequirements.has(jobId)) { + const requirements = jobRequirements.get(jobId)!; + const input = buildHandlerInput( + offering, + requirements, + entry.from || "unknown", + "acp", + jobId, + ); + if (handlers.budgetHandler) { + const budget = await handlers.budgetHandler(input); + if (budget.fundRequest) { + await session.setBudgetWithFundRequest( + AssetToken.usdc(budget.amount, chainId), + AssetToken.usdc(budget.fundRequest.transferAmount, chainId), + budget.fundRequest.destination, + ); + } else { + await session.setBudget(AssetToken.usdc(budget.amount, chainId)); + } } else { - await session.setBudget(AssetToken.usdc(budget.amount, chainId)); + await session.setBudget( + AssetToken.usdc(offering.offering.priceValue, chainId), + ); } - } else { - await session.setBudget( - AssetToken.usdc(offering.offering.priceValue, chainId) + } + + if (status === "funded" && jobRequirements.has(jobId)) { + const result = await handlers.handler( + buildHandlerInput( + offering, + jobRequirements.get(jobId)!, + entry.from || "unknown", + "acp", + jobId, + ), ); + await session.submit(result.deliverable); } - } - if (status === "funded" && jobRequirements.has(jobId)) { - const result = await handlers.handler( - buildHandlerInput( - offering, - jobRequirements.get(jobId)!, - entry.from || "unknown", - "acp", - jobId - ) + if (["completed", "rejected", "expired"].includes(status)) { + jobRequirements.delete(jobId); + } + } catch (err) { + console.error( + `[ACP] Failed to process job ${jobId}: ${ + err instanceof Error ? err.message : String(err) + }`, ); - await session.submit(result.deliverable); - } - - if (["completed", "rejected", "expired"].includes(status)) { - jobRequirements.delete(jobId); } }); @@ -203,7 +215,7 @@ function buildHandlerInput( requirements: Record | string, clientAddress: string, protocol: HandlerInput["protocol"], - jobId?: string + jobId?: string, ): HandlerInput { return { requirements, diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 5ced3af..f9b17b2 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,5 +1,6 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; +import { homedir } from "os"; import { cpSync, existsSync, @@ -15,13 +16,10 @@ import type { Command } from "commander"; import type { Agent, AgentOffering } from "../lib/api/agent"; import { AuthApi } from "../lib/api/auth"; import { getApiUrl, getClient } from "../lib/api/client"; -import { - getActiveWallet, - getAgentId, - getAgentToken, -} from "../lib/config"; +import { getActiveWallet, getAgentId, getAgentToken } from "../lib/config"; import { isJson, outputError, outputResult } from "../lib/output"; import type { ServeProtocol } from "../../serve/types"; +import { serviceJobEndpoint } from "../../serve/server/relay"; type LocalOfferingConfig = { slug: string; @@ -59,7 +57,7 @@ function requireActiveAgent(json: boolean): { if (!agentId) { outputError( json, - "Agent ID not found. Run `acp agent list` or `acp agent use` first." + "Agent ID not found. Run `acp agent list` or `acp agent use` first.", ); return null; } @@ -69,11 +67,13 @@ function requireActiveAgent(json: boolean): { function loadLocalOfferings( rootDir: string, - agentId: string + agentId: string, ): LocalOfferingConfig[] { const serveConfigPath = getServeConfigPath(rootDir); if (!existsSync(serveConfigPath)) { - throw new Error(`serve.json not found in ${rootDir}. Run \`acp serve init\` first.`); + throw new Error( + `serve.json not found in ${rootDir}. Run \`acp serve init\` first.`, + ); } const serveConfig = readJsonFile(serveConfigPath); @@ -100,14 +100,17 @@ function loadLocalOfferings( function selectLocalOfferings( offerings: LocalOfferingConfig[], - selector?: string + selector?: string, ): LocalOfferingConfig[] { if (!selector) return offerings; const normalized = selector.trim().toLowerCase(); return offerings.filter((entry) => { - const id = typeof entry.offeringJson.id === "string" ? entry.offeringJson.id : ""; + const id = + typeof entry.offeringJson.id === "string" ? entry.offeringJson.id : ""; const name = - typeof entry.offeringJson.name === "string" ? entry.offeringJson.name : ""; + typeof entry.offeringJson.name === "string" + ? entry.offeringJson.name + : ""; return ( entry.slug.toLowerCase() === normalized || id.toLowerCase() === normalized || @@ -118,11 +121,14 @@ function selectLocalOfferings( function findRemoteOffering( local: LocalOfferingConfig, - agent: Agent + agent: Agent, ): AgentOffering | undefined { const localId = - typeof local.offeringJson.id === "string" ? local.offeringJson.id : undefined; - if (localId) return agent.offerings.find((offering) => offering.id === localId); + typeof local.offeringJson.id === "string" + ? local.offeringJson.id + : undefined; + if (localId) + return agent.offerings.find((offering) => offering.id === localId); const localName = typeof local.offeringJson.name === "string" @@ -130,16 +136,20 @@ function findRemoteOffering( : undefined; if (!localName) return undefined; - const matches = agent.offerings.filter((offering) => offering.name === localName); + const matches = agent.offerings.filter( + (offering) => offering.name === localName, + ); return matches.length === 1 ? matches[0] : undefined; } function materializeOffering( local: LocalOfferingConfig, - remote: AgentOffering | undefined + remote: AgentOffering | undefined, ) { const localName = - typeof local.offeringJson.name === "string" ? local.offeringJson.name : local.slug; + typeof local.offeringJson.name === "string" + ? local.offeringJson.name + : local.slug; const localDescription = typeof local.offeringJson.description === "string" ? local.offeringJson.description @@ -152,7 +162,9 @@ function materializeOffering( return { id: remote?.id ?? - (typeof local.offeringJson.id === "string" ? local.offeringJson.id : local.slug), + (typeof local.offeringJson.id === "string" + ? local.offeringJson.id + : local.slug), slug: local.slug, name: remote?.name ?? localName, description: remote?.description ?? localDescription, @@ -169,25 +181,15 @@ function materializeOffering( : Number(local.offeringJson.slaMinutes ?? 5)), requirements: remote?.requirements ?? - ((local.offeringJson.requirements as Record | string) ?? {}), + (local.offeringJson.requirements as Record | string) ?? + {}, deliverable: remote?.deliverable ?? - ((local.offeringJson.deliverable as Record | string) ?? {}), + (local.offeringJson.deliverable as Record | string) ?? + {}, }; } -function serviceJobEndpoint( - apiUrl: string, - providerAddress: string, - offeringSlug: string, - protocol: "x402" | "mpp" -): string { - return new URL( - `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, - apiUrl - ).toString(); -} - async function getOrCreateAgentToken(wallet: string): Promise { const existing = getAgentToken(wallet); if (existing) return existing; @@ -208,7 +210,7 @@ function copyRuntimeBundle( agentName: string, local: LocalOfferingConfig, apiUrl: string, - serviceName: string + serviceName: string, ): Record { rmSync(bundleDir, { recursive: true, force: true }); mkdirSync(bundleDir, { recursive: true }); @@ -233,7 +235,7 @@ function copyRuntimeBundle( "agents", agentSlug, "offerings", - local.slug + local.slug, ); mkdirSync(dirname(destination), { recursive: true }); cpSync(local.dir, destination, { recursive: true }); @@ -258,8 +260,8 @@ function copyRuntimeBundle( port: 3000, }, null, - 2 - ) + "\n" + 2, + ) + "\n", ); writeFileSync( @@ -272,7 +274,7 @@ function copyRuntimeBundle( "ACP_AGENT_TOKEN=", "IS_TESTNET=", "", - ].join("\n") + ].join("\n"), ); writeFileSync( @@ -285,7 +287,7 @@ function copyRuntimeBundle( "COPY . .", 'CMD ["sh", "-c", "npx tsx bin/acp.ts serve start --dir . --offering ${ACP_SERVE_OFFERING} --port ${PORT:-3000}"]', "", - ].join("\n") + ].join("\n"), ); return { @@ -321,7 +323,7 @@ export function registerServeCommands(program: Command): void { "agents", agentSlug, "offerings", - offeringSlug + offeringSlug, ); if (existsSync(resolve(offeringDir, "handler.ts"))) { @@ -331,24 +333,24 @@ export function registerServeCommands(program: Command): void { mkdirSync(offeringDir, { recursive: true }); const scaffoldDir = resolve( dirname(fileURLToPath(import.meta.url)), - "../../serve/scaffold" + "../../serve/scaffold", ); const offeringTemplate = readFileSync( resolve(scaffoldDir, "offering.json.template"), - "utf8" + "utf8", ); writeFileSync( resolve(offeringDir, "offering.json"), - offeringTemplate.replace("{{NAME}}", opts.name) + offeringTemplate.replace("{{NAME}}", opts.name), ); writeFileSync( resolve(offeringDir, "handler.ts"), - readFileSync(resolve(scaffoldDir, "handler.ts.template"), "utf8") + readFileSync(resolve(scaffoldDir, "handler.ts.template"), "utf8"), ); writeFileSync( resolve(offeringDir, "budget.ts"), - readFileSync(resolve(scaffoldDir, "budget.ts.template"), "utf8") + readFileSync(resolve(scaffoldDir, "budget.ts.template"), "utf8"), ); const serveConfigPath = getServeConfigPath(rootDir); @@ -368,7 +370,10 @@ export function registerServeCommands(program: Command): void { }; agents[active.agentId] = agentConfig; serveConfig.agents = agents; - writeFileSync(serveConfigPath, JSON.stringify(serveConfig, null, 2) + "\n"); + writeFileSync( + serveConfigPath, + JSON.stringify(serveConfig, null, 2) + "\n", + ); outputResult(json, { success: true, @@ -396,9 +401,10 @@ export function registerServeCommands(program: Command): void { const rootDir = resolve(opts.dir); const selected = selectLocalOfferings( loadLocalOfferings(rootDir, active.agentId), - opts.offering + opts.offering, ); - if (selected.length === 0) throw new Error("No matching offerings found."); + if (selected.length === 0) + throw new Error("No matching offerings found."); if (selected.length > 1) { throw new Error("Multiple offerings matched. Use --offering."); } @@ -406,8 +412,12 @@ export function registerServeCommands(program: Command): void { const { agentApi } = await getClient(); const agent = await agentApi.getById(active.agentId); const local = selected[0]; - const offering = materializeOffering(local, findRemoteOffering(local, agent)); - const { startOfferingServer } = await import("../../serve/server/index"); + const offering = materializeOffering( + local, + findRemoteOffering(local, agent), + ); + const { startOfferingServer } = + await import("../../serve/server/index"); await startOfferingServer({ dir: local.dir, @@ -437,14 +447,17 @@ export function registerServeCommands(program: Command): void { const apiUrl = getApiUrl(); const payload: Record> = {}; - for (const offering of loadLocalOfferings(resolve(opts.dir), active.agentId)) { + for (const offering of loadLocalOfferings( + resolve(opts.dir), + active.agentId, + )) { payload[offering.slug] = {}; if (offering.protocols.includes("x402")) { payload[offering.slug].x402 = serviceJobEndpoint( apiUrl, active.wallet, offering.slug, - "x402" + "x402", ); } if (offering.protocols.includes("mpp")) { @@ -452,7 +465,7 @@ export function registerServeCommands(program: Command): void { apiUrl, active.wallet, offering.slug, - "mpp" + "mpp", ); } if (offering.protocols.includes("acp")) { @@ -483,9 +496,10 @@ export function registerServeCommands(program: Command): void { const serveConfig = readJsonFile(getServeConfigPath(rootDir)); const selected = selectLocalOfferings( loadLocalOfferings(rootDir, active.agentId), - opts.offering + opts.offering, ); - if (selected.length === 0) throw new Error("No matching offerings found."); + if (selected.length === 0) + throw new Error("No matching offerings found."); if (selected.length > 1) { throw new Error("Multiple offerings matched. Use --offering."); } @@ -493,7 +507,8 @@ export function registerServeCommands(program: Command): void { const agentConfig = (serveConfig.agents as any)?.[active.agentId]; const agentName = agentConfig?.name ?? "agent"; const local = selected[0]; - const serviceName = opts.service ?? `${slugify(agentName)}-${local.slug}`; + const serviceName = + opts.service ?? `${slugify(agentName)}-${local.slug}`; const provider = String(opts.provider ?? "railway"); const bundleDir = resolve( rootDir, @@ -501,7 +516,7 @@ export function registerServeCommands(program: Command): void { "serve", "deploy", provider, - serviceName + serviceName, ); const endpoints = copyRuntimeBundle( rootDir, @@ -510,7 +525,7 @@ export function registerServeCommands(program: Command): void { agentName, local, getApiUrl(), - serviceName + serviceName, ); outputResult(json, { @@ -544,7 +559,7 @@ export function registerServeCommands(program: Command): void { let stopped = 0; for (const local of selectLocalOfferings( loadLocalOfferings(resolve(opts.dir), active.agentId), - opts.offering + opts.offering, )) { const offeringId = typeof local.offeringJson.id === "string" @@ -572,7 +587,7 @@ export function registerServeCommands(program: Command): void { .action(async (opts, cmd) => { const json = isJson(cmd); try { - const logDir = resolve(process.env.HOME ?? ".", ".acp", "serve", "logs"); + const logDir = resolve(homedir(), ".acp", "serve", "logs"); if (!existsSync(logDir)) { outputResult(json, { logs: [] }); return; @@ -583,21 +598,24 @@ export function registerServeCommands(program: Command): void { .filter((name) => !opts.offering || name === `${opts.offering}.log`) .map((name) => resolve(logDir, name)); const logs = files.flatMap((file) => - readFileSync(file, "utf8") - .trim() - .split("\n") - .filter(Boolean) + readFileSync(file, "utf8").trim().split("\n").filter(Boolean), ); outputResult(json, { logs: logs.slice(-50) }); if (opts.follow && files.length > 0 && !json) { - const offsets = new Map(files.map((file) => [file, statSync(file).size])); + const offsets = new Map( + files.map((file) => [file, statSync(file).size]), + ); for (const file of files) { watchFile(file, { interval: 1000 }, () => { const currentSize = statSync(file).size; const previousSize = offsets.get(file) ?? 0; if (currentSize <= previousSize) return; - process.stdout.write(readFileSync(file, "utf8").slice(previousSize)); + const chunk = readFileSync(file).subarray( + previousSize, + currentSize, + ); + process.stdout.write(chunk.toString("utf8")); offsets.set(file, currentSize); }); } From d984cf9cffc9f65e5bc9b81e7ad7642bbb8d9a30 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Wed, 29 Apr 2026 15:07:18 +0800 Subject: [PATCH 03/11] Add Railway deploy execution path --- src/commands/serve.ts | 149 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 3 deletions(-) diff --git a/src/commands/serve.ts b/src/commands/serve.ts index f9b17b2..3c5b12b 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import { homedir } from "os"; +import { spawnSync } from "child_process"; import { cpSync, existsSync, @@ -28,6 +29,15 @@ type LocalOfferingConfig = { offeringJson: Record; }; +type RailwayDeployOptions = { + bundleDir: string; + serviceName: string; + project?: string; + environment?: string; + variables: Record; + agentToken: string; +}; + function slugify(name: string): string { return name .toLowerCase() @@ -203,6 +213,101 @@ function getDefaultPort(input: unknown): number { return Number.isFinite(port) && port > 0 ? port : 3000; } +function railwayServiceArgs( + args: string[], + opts: Pick, +): string[] { + const result = [...args, "--service", opts.serviceName]; + if (opts.environment) result.push("--environment", opts.environment); + return result; +} + +function railwayDeployArgs( + args: string[], + opts: Pick, +): string[] { + const result = railwayServiceArgs(args, opts); + if (opts.project) result.push("--project", opts.project); + return result; +} + +function runRailway( + args: string[], + cwd: string, + input?: string, +): { command: string; stdout: string; stderr: string } { + const command = `railway ${args.join(" ")}`; + const result = spawnSync("railway", args, { + cwd, + encoding: "utf8", + input, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (result.error) { + throw new Error( + `Failed to run Railway CLI. Install and login with \`railway login\` first. ${result.error.message}`, + ); + } + + if (result.status !== 0) { + const output = [result.stderr, result.stdout].filter(Boolean).join("\n"); + throw new Error(`${command} failed.\n${output}`); + } + + return { + command, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function deployRailway(opts: RailwayDeployOptions): { + commands: string[]; + deploymentOutput: string; +} { + const commands: string[] = []; + const base = { ...opts, serviceName: opts.serviceName }; + const envVars = Object.entries(opts.variables) + .filter(([, value]) => value !== undefined && value !== "") + .map(([key, value]) => `${key}=${value}`); + + if (envVars.length > 0) { + const result = runRailway( + railwayServiceArgs( + ["variable", "set", "--skip-deploys", ...envVars], + base, + ), + opts.bundleDir, + ); + commands.push(result.command); + } + + const tokenResult = runRailway( + railwayServiceArgs( + ["variable", "set", "--skip-deploys", "--stdin", "ACP_AGENT_TOKEN"], + base, + ), + opts.bundleDir, + opts.agentToken, + ); + commands.push(tokenResult.command); + + const deployResult = runRailway( + railwayDeployArgs(["up", ".", "--detach", "--path-as-root"], base), + opts.bundleDir, + ); + commands.push(deployResult.command); + + return { + commands, + deploymentOutput: [deployResult.stdout, deployResult.stderr] + .filter(Boolean) + .join("\n") + .trim(), + }; +} + function copyRuntimeBundle( rootDir: string, bundleDir: string, @@ -485,6 +590,12 @@ export function registerServeCommands(program: Command): void { .option("--offering ", "Offering slug, ID, or name") .option("--provider ", "Deployment provider label", "railway") .option("--service ", "Service name override") + .option( + "--execute", + "Run the provider deployment after building the bundle", + ) + .option("--railway-project ", "Railway project ID for --execute") + .option("--railway-environment ", "Railway environment for --execute") .option("--bundle-only", "Only create the deploy bundle", true) .action(async (opts, cmd) => { const json = isJson(cmd); @@ -528,15 +639,47 @@ export function registerServeCommands(program: Command): void { serviceName, ); + if (opts.execute && provider !== "railway") { + throw new Error( + `Provider ${provider} does not support --execute yet.`, + ); + } + + let execution: + | { + commands: string[]; + deploymentOutput: string; + } + | undefined; + if (opts.execute) { + execution = deployRailway({ + bundleDir, + serviceName, + project: opts.railwayProject, + environment: opts.railwayEnvironment, + agentToken: await getOrCreateAgentToken(active.wallet), + variables: { + ACP_ACTIVE_WALLET: active.wallet, + ACP_AGENT_ID: active.agentId, + ACP_API_URL: getApiUrl(), + ACP_SERVE_OFFERING: local.slug, + ACP_CHAIN_ID: process.env.ACP_CHAIN_ID, + IS_TESTNET: process.env.IS_TESTNET, + }, + }); + } + outputResult(json, { provider, serviceName, bundleDir, - executed: false, + executed: Boolean(execution), endpoints, + execution, nextSteps: [ - `Set ACP_AGENT_TOKEN in your hosting provider secrets.`, - `Deploy ${bundleDir} with Docker or your provider CLI.`, + execution + ? `Railway deployment started for service ${serviceName}.` + : `Run with --execute to deploy to Railway, or deploy ${bundleDir} with Docker/provider CLI.`, `The public x402/MPP endpoints are the BE endpoints above; the deployment only needs outbound access to BE.`, ], }); From e29ec7da6a6dd25a8457c9e5377eb3aea71e55f3 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Wed, 29 Apr 2026 16:32:49 +0800 Subject: [PATCH 04/11] Fix hosted serve Railway runtime --- src/commands/serve.ts | 46 ++++++++++++++++++++++++++++++++++++++----- src/lib/config.ts | 34 ++++++++++++++++++-------------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 3c5b12b..6e9782b 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -17,7 +17,13 @@ import type { Command } from "commander"; import type { Agent, AgentOffering } from "../lib/api/agent"; import { AuthApi } from "../lib/api/auth"; import { getApiUrl, getClient } from "../lib/api/client"; -import { getActiveWallet, getAgentId, getAgentToken } from "../lib/config"; +import { + getActiveWallet, + getAgentId, + getAgentToken, + getPublicKey, + getWalletId, +} from "../lib/config"; import { isJson, outputError, outputResult } from "../lib/output"; import type { ServeProtocol } from "../../serve/types"; import { serviceJobEndpoint } from "../../serve/server/relay"; @@ -53,6 +59,15 @@ function getServeConfigPath(rootDir: string): string { return resolve(rootDir, "serve.json"); } +function getLocalAgentName(rootDir: string, agentId: string): string { + const serveConfig = readJsonFile(getServeConfigPath(rootDir)); + const agents = (serveConfig.agents ?? {}) as Record< + string, + { name?: string } + >; + return agents[agentId]?.name ?? "agent"; +} + function requireActiveAgent(json: boolean): { wallet: string; agentId: string; @@ -272,6 +287,19 @@ function deployRailway(opts: RailwayDeployOptions): { .filter(([, value]) => value !== undefined && value !== "") .map(([key, value]) => `${key}=${value}`); + if (opts.project) { + const linkArgs = [ + "link", + "--project", + opts.project, + "--service", + opts.serviceName, + ]; + if (opts.environment) linkArgs.push("--environment", opts.environment); + const result = runRailway(linkArgs, opts.bundleDir); + commands.push(result.command); + } + if (envVars.length > 0) { const result = runRailway( railwayServiceArgs( @@ -514,12 +542,18 @@ export function registerServeCommands(program: Command): void { throw new Error("Multiple offerings matched. Use --offering."); } - const { agentApi } = await getClient(); - const agent = await agentApi.getById(active.agentId); + const agentName = getLocalAgentName(rootDir, active.agentId); + let agent: Agent | undefined; + try { + const { agentApi } = await getClient(); + agent = await agentApi.getById(active.agentId); + } catch { + agent = undefined; + } const local = selected[0]; const offering = materializeOffering( local, - findRemoteOffering(local, agent), + agent ? findRemoteOffering(local, agent) : undefined, ); const { startOfferingServer } = await import("../../serve/server/index"); @@ -527,7 +561,7 @@ export function registerServeCommands(program: Command): void { await startOfferingServer({ dir: local.dir, port: opts.port ? Number(opts.port) : getDefaultPort(undefined), - agentSlug: slugify(agent.name), + agentSlug: slugify(agent?.name ?? agentName), providerWallet: active.wallet, offering, protocols: local.protocols, @@ -662,7 +696,9 @@ export function registerServeCommands(program: Command): void { ACP_ACTIVE_WALLET: active.wallet, ACP_AGENT_ID: active.agentId, ACP_API_URL: getApiUrl(), + ACP_PUBLIC_KEY: getPublicKey(active.wallet), ACP_SERVE_OFFERING: local.slug, + ACP_WALLET_ID: getWalletId(active.wallet), ACP_CHAIN_ID: process.env.ACP_CHAIN_ID, IS_TESTNET: process.env.IS_TESTNET, }, diff --git a/src/lib/config.ts b/src/lib/config.ts index 0c4cd0b..f1e1eeb 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -60,23 +60,23 @@ export function setAgentToken(walletAddress: string, token: string): void { } export async function getToken( - walletAddress?: string + walletAddress?: string, ): Promise { return ( (await getPassword( AUTH_KEYCHAIN_SERVICE, - `access-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}` + `access-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}`, )) ?? undefined ); } export async function getRefreshToken( - walletAddress?: string + walletAddress?: string, ): Promise { return ( (await getPassword( AUTH_KEYCHAIN_SERVICE, - `refresh-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}` + `refresh-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}`, )) ?? undefined ); } @@ -84,22 +84,24 @@ export async function getRefreshToken( export async function setTokens( accessToken: string, refreshToken: string, - walletAddress?: string + walletAddress?: string, ): Promise { await setPassword( AUTH_KEYCHAIN_SERVICE, `access-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}`, - accessToken + accessToken, ); await setPassword( AUTH_KEYCHAIN_SERVICE, `refresh-token${walletAddress ? `-${walletAddress.toLowerCase()}` : ""}`, - refreshToken + refreshToken, ); } export function getWalletId(walletAddress: string): string | undefined { - return loadConfig().agents?.[walletAddress]?.walletId; + return ( + getEnv("ACP_WALLET_ID") ?? loadConfig().agents?.[walletAddress]?.walletId + ); } export function setWalletId(walletAddress: string, walletId: string): void { @@ -111,7 +113,9 @@ export function setWalletId(walletAddress: string, walletId: string): void { } export function getPublicKey(agentAddress: string): string | undefined { - return loadConfig().agents?.[agentAddress]?.publicKey; + return ( + getEnv("ACP_PUBLIC_KEY") ?? loadConfig().agents?.[agentAddress]?.publicKey + ); } export function setPublicKey(agentAddress: string, publicKey: string): void { @@ -123,7 +127,7 @@ export function setPublicKey(agentAddress: string, publicKey: string): void { } export function getAgentId(walletAddress: string): string | undefined { - return loadConfig().agents?.[walletAddress]?.id; + return getEnv("ACP_AGENT_ID") ?? loadConfig().agents?.[walletAddress]?.id; } export function setAgentId(walletAddress: string, id: string): void { @@ -135,7 +139,7 @@ export function setAgentId(walletAddress: string, id: string): void { } export function getActiveWallet(): string | undefined { - return loadConfig().activeWallet; + return getEnv("ACP_ACTIVE_WALLET") ?? loadConfig().activeWallet; } export function getCurrentOwnerWallet(): string | undefined { @@ -157,7 +161,7 @@ export function setActiveWallet(walletAddress: string): void { export function registerJob( jobId: string, legacy: boolean, - chainId: number + chainId: number, ): void { const config = loadConfig(); config.jobRegistry ??= {}; @@ -166,7 +170,7 @@ export function registerJob( } export function getJobRegistryEntry( - jobId: string + jobId: string, ): JobRegistryEntry | undefined { return loadConfig().jobRegistry?.[jobId]; } @@ -183,7 +187,7 @@ export function getLegacyJobChainId(jobId: string): number | undefined { export function isTokenExpired(token: string): boolean { try { const payload = JSON.parse( - Buffer.from(token.split(".")[1], "base64url").toString() + Buffer.from(token.split(".")[1], "base64url").toString(), ); const bufferMs = 5 * 60 * 1000; return ( @@ -197,7 +201,7 @@ export function isTokenExpired(token: string): boolean { export function setBuilderCode( walletAddress: string, - builderCode: string + builderCode: string, ): void { const config = loadConfig(); config.agents ??= {}; From f18fd450e851b3f3e2c0325402f3b7fac4372ad9 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Mon, 4 May 2026 16:11:32 +0800 Subject: [PATCH 05/11] Move service job settlement into provider runtime --- package-lock.json | 204 ++++++++++++++++++- package.json | 3 + serve/ARCHITECTURE.md | 73 ++++++- serve/server/payment/chain.ts | 138 +++++++++++++ serve/server/payment/mpp.ts | 213 +++++++++++++++++++ serve/server/payment/x402.ts | 371 ++++++++++++++++++++++++++++++++++ serve/server/relay.ts | 171 +++++++++++++++- 7 files changed, 1152 insertions(+), 21 deletions(-) create mode 100644 serve/server/payment/chain.ts create mode 100644 serve/server/payment/mpp.ts create mode 100644 serve/server/payment/x402.ts diff --git a/package-lock.json b/package-lock.json index efa68f3..071fe6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,13 @@ "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.0.6", + "@x402/core": "^2.9.0", + "@x402/evm": "^2.9.0", "ajv": "^8.18.0", "commander": "^13.0.0", "cross-keychain": "^1.1.0", "dotenv": "^17.0.0", + "mppx": "^0.5.5", "picocolors": "^1.1.1", "qrcode-terminal": "^0.12.0", "socket.io-client": "^4.8.3", @@ -257,6 +260,12 @@ "node": ">=6.9.0" } }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1824,6 +1833,35 @@ "node": ">=8" } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-alpha.2.tgz", + "integrity": "sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==", + "license": "MIT", + "dependencies": { + "zod": "^4.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@napi-rs/keyring": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.2.0.tgz", @@ -2688,6 +2726,12 @@ "tslib": "^2.8.0" } }, + "node_modules/@toon-format/toon": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.1.0.tgz", + "integrity": "sha512-JwWptdF5eOA0HaQxbKAzkpQtR4wSWTEfDlEy/y3/4okmOAX1qwnpLZMmtEWr+ncAhTTY1raCKH0kteHhSXnQqg==", + "license": "MIT" + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2863,6 +2907,26 @@ } } }, + "node_modules/@x402/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.11.0.tgz", + "integrity": "sha512-aqTfZc/BULrlWnd3I0lsqRQaH4gjJd8CsPcL16XqK2Lx5c6QDm+zCljgUVS1yj9BGJoZeQWTzI5hE+SVFkqMTw==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.11.0.tgz", + "integrity": "sha512-F8uU1txDZA+wc/sEnmaHAyYvoTi/w39r7K3a44MmQHSxECDTEuB3A0FwbxOxUPLN1eyCxTAFKEiqlGe3bwybKA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.11.0", + "viem": "^2.39.3", + "zod": "^3.24.2" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -4128,6 +4192,36 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/incur": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/incur/-/incur-0.3.25.tgz", + "integrity": "sha512-jrSkzauM42ilbQJ6THVkAY6dTulkyVW0sZpVHdA8gfiBwrLrLnLUf8U3bAOegAKBIMSOFgk1idchgu9xm9HMng==", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.1.1", + "@modelcontextprotocol/server": "^2.0.0-alpha.2", + "@toon-format/toon": "^2.1.0", + "tokenx": "^1.3.0", + "yaml": "^2.8.2", + "zod": "^4.3.6" + }, + "bin": { + "incur": "dist/bin.js", + "incur.src": "src/bin.ts" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/incur/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4678,6 +4772,51 @@ "ufo": "^1.6.3" } }, + "node_modules/mppx": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/mppx/-/mppx-0.5.17.tgz", + "integrity": "sha512-4iZwc9XZclCsv8nzQyw32rdaWYg5eLRj4gNjq9l5d+6NuArazZSvjHsI5SQmtzDF6WsssI6E5hSIecCQ9LDA+w==", + "license": "MIT", + "dependencies": { + "incur": "^0.3.25", + "ox": "0.14.15", + "zod": "^4.3.6" + }, + "bin": { + "mppx": "dist/bin.js", + "mppx.src": "src/bin.ts" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.25.0", + "elysia": ">=1", + "express": ">=5", + "hono": ">=4.12.14", + "viem": ">=2.47.5" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "elysia": { + "optional": true + }, + "express": { + "optional": true + }, + "hono": { + "optional": true + } + } + }, + "node_modules/mppx/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4825,9 +4964,9 @@ } }, "node_modules/ox": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.0.tgz", - "integrity": "sha512-WLOB7IKnmI3Ol6RAqY7CJdZKl8QaI44LN91OGF1061YIeN6bL5IsFcdp7+oQShRyamE/8fW/CBRWhJAOzI35Dw==", + "version": "0.14.15", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.15.tgz", + "integrity": "sha512-3TubCmbKen/cuZQzX0qDbOS5lojjdSZ90lqKxWIDWd5siuJ0IJBaTXMYs8eMPLcraqnOwGZazz3apHPGiRCkGQ==", "funding": [ { "type": "github", @@ -5511,6 +5650,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tokenx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.3.0.tgz", + "integrity": "sha512-NLdXTEZkKiO0gZuLtMoZKjCXTREXeZZt8nnnNeyoXtNZAfG/GKGSbQtLU5STspc0rMSwcA+UJfWZkbNU01iKmQ==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -5693,9 +5838,9 @@ } }, "node_modules/viem": { - "version": "2.47.0", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.0.tgz", - "integrity": "sha512-jU5e1E1s5E5M1y+YrELDnNar/34U8NXfVcRfxtVETigs2gS1vvW2ngnBoQUGBwLnNr0kNv+NUu4m10OqHByoFw==", + "version": "2.48.8", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.48.8.tgz", + "integrity": "sha512-Xj3Nrt66SKtn06kczU91ELn9Difr84ZM5A62BTlaisT5lpgt058i2mBkfMZCXHGb1ocOLjzC2ztPhD0Lvky7uQ==", "funding": [ { "type": "github", @@ -5710,7 +5855,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.0", + "ox": "0.14.20", "ws": "8.18.3" }, "peerDependencies": { @@ -5737,6 +5882,36 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/viem/node_modules/ox": { + "version": "0.14.20", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.20.tgz", + "integrity": "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/webauthn-p256": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/webauthn-p256/-/webauthn-p256-0.0.10.tgz", @@ -5930,6 +6105,21 @@ "node": ">=0.10.32" } }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/package.json b/package.json index 089bc1f..64b135a 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,13 @@ "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.0.6", + "@x402/core": "^2.9.0", + "@x402/evm": "^2.9.0", "ajv": "^8.18.0", "commander": "^13.0.0", "cross-keychain": "^1.1.0", "dotenv": "^17.0.0", + "mppx": "^0.5.5", "picocolors": "^1.1.1", "qrcode-terminal": "^0.12.0", "socket.io-client": "^4.8.3", diff --git a/serve/ARCHITECTURE.md b/serve/ARCHITECTURE.md index d176459..a520566 100644 --- a/serve/ARCHITECTURE.md +++ b/serve/ARCHITECTURE.md @@ -1,12 +1,73 @@ # ACP Serve Architecture -ACP Serve runs provider service code from `handler.ts`. +ACP Serve turns a developer's `handler.ts` into one provider runtime that can serve: -For v1, x402 and MPP payment endpoints live on `agentic-commerce-be`, not in the CLI runtime. The CLI runtime connects outbound to BE on `/service-jobs`, receives paid jobs, runs the handler, and returns the deliverable as a socket ack. +- direct ACP jobs from the ACP registry +- direct x402 jobs through `agentic-commerce-be` +- direct MPP jobs through `agentic-commerce-be` -Canonical client endpoints: +For v1, x402 and MPP do not settle through ERC-8183. The `--settle-8183` flag is reserved, but disabled until the contract supports the needed flow. -- `/x402/:providerAddress/jobs/:offeringName` -- `/mpp/:providerAddress/jobs/:offeringName` +## Runtime Model -The provider runtime does not need facilitator credentials. BE verifies and settles direct x402/MPP payments to the provider wallet. `--settle-8183` is reserved but disabled until ERC-8183 supports the needed flow. +The provider runtime does not expose public x402 or MPP payment endpoints. + +Instead: + +1. The provider runs `acp serve start` locally or in a hosted deployment. +2. The runtime authenticates as the provider agent and opens an outbound Socket.IO connection to `agentic-commerce-be` namespace `/service-jobs`. +3. Clients call canonical BE endpoints: + - `/x402/:providerAddress/jobs/:offeringName` + - `/mpp/:providerAddress/jobs/:offeringName` +4. BE asks the provider runtime to build the protocol-specific 402 challenge. +5. The client retries the same BE endpoint with the x402 payment header or MPP authorization header. +6. BE creates/idempotently claims the service job and relays the raw payment credential over the provider's outbound socket. +7. The provider runtime verifies and settles the payment with the protocol SDK, then calls the developer's `handler.ts`. +8. The runtime returns the deliverable, protocol response headers, and settlement metadata as the socket ack. +9. BE stores the result and returns the deliverable to the client in the same paid HTTP request. + +This keeps provider infrastructure private and self-hostable while preserving stable public x402/MPP endpoints. + +## Payment Roles + +The provider runtime is the x402/MPP resource server and settlement actor. + +- x402: the client signs an EIP-3009 authorization. The provider runtime verifies it and broadcasts `transferWithAuthorization` with the provider deployment signer as the gas sponsor. Funds move client -> provider. +- MPP: the client submits a tempo credential. The provider runtime verifies/settles it with `mppx` and the provider deployment signer as fee payer. Funds move client -> provider. +- BE: owns public URLs, job idempotency, socket routing, and persistence. It does not hold payment credentials, facilitator credentials, or an ops settlement wallet. + +No separate external facilitator service is required. + +## CLI Responsibilities + +`acp serve init` scaffolds the local service files: + +- `handler.ts`: required service implementation +- `budget.ts`: optional ACP-native budget hook +- `offering.json`: registry offering metadata +- `serve.json`: local runtime config + +`acp serve start`: + +- loads the selected offering handler +- authenticates as the active provider agent +- connects to BE `/service-jobs` +- listens for `service-job:payment-challenge` +- listens for `service-job:request` +- verifies/settles x402 or MPP credentials before running the handler +- returns `{ status: "completed", deliverable, headers, settlement }` or `{ status: "failed", error }` +- optionally runs the native ACP listener for ACP registry jobs + +`acp serve endpoints` prints canonical BE x402/MPP endpoints, not localhost payment endpoints. + +`acp serve deploy` packages the same runtime for a hosted deployment. The deployed runtime still connects outbound to BE. + +## Signers + +The BE does not need the provider's signer or wallet private key. + +The provider runtime needs provider signing capability to authenticate as the agent, settle x402/MPP payments, and for ACP-native jobs interact with ACP. Hosted deployments can use a scoped deploy signer for that runtime. That signer is never sent to BE. + +## Future 8183 Settlement + +`--settle-8183` and `SETTLE_8183_ACP` remain reserved. Once the ERC-8183 contract supports the missing functions, the provider runtime can settle x402/MPP-backed ACP jobs without changing the developer's `handler.ts` contract. diff --git a/serve/server/payment/chain.ts b/serve/server/payment/chain.ts new file mode 100644 index 0000000..5172d1e --- /dev/null +++ b/serve/server/payment/chain.ts @@ -0,0 +1,138 @@ +import { + createPublicClient, + createWalletClient, + erc20Abi, + formatUnits, + http, + verifyTypedData, + type Address, + type Chain, + type Hex, +} from "viem"; +import { privateKeyToAccount, nonceManager } from "viem/accounts"; +import { + base, + baseSepolia, + bsc, + bscTestnet, + mainnet, + sepolia, + xLayer, + xLayerTestnet, +} from "viem/chains"; + +const SUPPORTED_CHAINS = [ + mainnet, + sepolia, + base, + baseSepolia, + bsc, + bscTestnet, + xLayer, + xLayerTestnet, +]; + +const USDC_ADDRESSES: Record = { + [baseSepolia.id]: "0xB270EDc833056001f11a7828DFdAC9D4ac2b8344", + [base.id]: "0x833589fCD6E08f4c7C32D4f71b54bdA02913", + [bsc.id]: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + [bscTestnet.id]: "0x64544969ed7EBf5f083679233325356EbE738930", + [xLayer.id]: "0x74b7f16337b8972027f6196a17a631ac6de26d22", + [xLayerTestnet.id]: "0xcb8bf24c6ce16ad21d707c9505421a17f2bec79d", +}; + +const USDC_DECIMALS: Record = { + [baseSepolia.id]: 6, + [base.id]: 6, + [bsc.id]: 18, + [bscTestnet.id]: 18, + [xLayer.id]: 6, + [xLayerTestnet.id]: 6, +}; + +export function getDefaultChainId(): number { + return Number(process.env.ACP_CHAIN_ID || "84532"); +} + +export function getChain(chainId = getDefaultChainId()): Chain { + const chain = SUPPORTED_CHAINS.find((candidate) => candidate.id === chainId); + if (!chain) throw new Error(`Unsupported chain ${chainId}`); + return chain; +} + +export function getRpcUrl(chainId = getDefaultChainId()): string | undefined { + return ( + process.env[`CUSTOM_RPC_URL_${chainId}`] || + process.env[`RPC_URL_${chainId}`] || + process.env.GATEWAY_RPC_URL + ); +} + +export function getPublicClient(chainId = getDefaultChainId()) { + return createPublicClient({ + chain: getChain(chainId), + transport: http(getRpcUrl(chainId)), + }); +} + +export function getSettlementAccount() { + const privateKey = + process.env.ACP_SIGNER_PRIVATE_KEY || + process.env.DEPLOY_SIGNER_KEY || + process.env.GATEWAY_PRIVATE_KEY; + if (!privateKey) { + throw new Error( + "Provider settlement signer is not configured. Set ACP_SIGNER_PRIVATE_KEY.", + ); + } + return privateKeyToAccount(privateKey as Hex, { nonceManager }); +} + +export function getWalletClient(chainId = getDefaultChainId()) { + return createWalletClient({ + account: getSettlementAccount(), + chain: getChain(chainId), + transport: http(getRpcUrl(chainId)), + }); +} + +export function getUsdcAddress(chainId = getDefaultChainId()): Address { + const configured = process.env[`USDC_ADDRESS_${chainId}`]; + const address = configured || USDC_ADDRESSES[chainId]; + if (!address) + throw new Error(`USDC address is not configured for ${chainId}`); + return address as Address; +} + +export async function getTokenDecimals( + chainId = getDefaultChainId(), + tokenAddress = getUsdcAddress(chainId), +): Promise { + const configured = process.env[`USDC_DECIMALS_${chainId}`]; + if (configured) return Number(configured); + if (USDC_DECIMALS[chainId]) return USDC_DECIMALS[chainId]; + const decimals = await getPublicClient(chainId).readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "decimals", + }); + return Number(decimals); +} + +export function toAtomicAmount(amount: number, decimals: number): string { + if (!Number.isFinite(amount) || amount < 0) { + throw new Error(`Invalid payment amount: ${amount}`); + } + const [whole, fraction = ""] = String(amount).split("."); + const paddedFraction = fraction.padEnd(decimals, "0").slice(0, decimals); + return ( + BigInt(whole || "0") * 10n ** BigInt(decimals) + + BigInt(paddedFraction || "0") + ).toString(); +} + +export function fromAtomicAmount(raw: bigint, decimals: number): number { + return Number(formatUnits(raw, decimals)); +} + +export { verifyTypedData }; diff --git a/serve/server/payment/mpp.ts b/serve/server/payment/mpp.ts new file mode 100644 index 0000000..17b2320 --- /dev/null +++ b/serve/server/payment/mpp.ts @@ -0,0 +1,213 @@ +import { getAddress, type Hex } from "viem"; +import type { DeployedOffering } from "../../types"; +import { + getDefaultChainId, + getPublicClient, + getSettlementAccount, + getTokenDecimals, + getUsdcAddress, + toAtomicAmount, +} from "./chain"; + +type TempoHashPayload = { + type: "hash"; + hash: `0x${string}`; +}; + +type TempoTransactionPayload = { + type: "transaction"; + signature: `0x${string}`; +}; + +type TempoProofPayload = { + type: "proof"; + signature: `0x${string}`; +}; + +type TempoPayload = + | TempoHashPayload + | TempoTransactionPayload + | TempoProofPayload; + +export interface MppSettlementResult { + clientAddress: string; + paymentKey: string; + txHash: string | null; + receiptReference: string; +} + +export async function buildMppPaymentChallenge( + offering: DeployedOffering, + nonce: string, +): Promise { + const handler = await createChargeHandler(offering, nonce); + const result = await handler(buildRequest()); + if (result.status !== 402) { + throw new Error("Unable to build MPP challenge"); + } + + const header = result.challenge.headers.get("WWW-Authenticate"); + if (!header) throw new Error("MPP challenge missing header"); + return header; +} + +export async function verifyAndSettleMppPayment( + authHeader: string, + offering: DeployedOffering, +): Promise { + const { Credential, Receipt } = await import("mppx"); + const credential = deserializeCredential(Credential, authHeader); + const challenge = credential.challenge; + const nonce = challenge.opaque?.nonce; + if (!nonce) throw new Error("MPP challenge missing nonce"); + + const source = parseDidPkh(credential.source); + const request = challenge.request as any; + const chainId = getChallengeChainId(request); + if (source && source.chainId !== chainId) { + throw new Error("MPP source chain does not match challenge"); + } + + const handler = await createChargeHandler(offering, nonce); + const result = await handler(buildRequest({ Authorization: authHeader })); + if (result.status === 402) { + throw new Error(await result.challenge.text()); + } + + const response = result.withReceipt(new Response(null, { status: 200 })); + const receiptHeader = response.headers.get("Payment-Receipt"); + if (!receiptHeader) throw new Error("MPP receipt missing"); + const receipt = Receipt.deserialize(receiptHeader); + const txHash = isTxHash(receipt.reference) ? receipt.reference : null; + + return { + clientAddress: + source?.address || (await findReceiptPayer(chainId, receipt)), + paymentKey: `mpp:${chainId}:${receipt.reference}`, + receiptReference: receipt.reference, + txHash, + }; +} + +export function buildMppReceipt(result: MppSettlementResult): string { + return Buffer.from( + JSON.stringify({ + method: "tempo", + reference: result.receiptReference || result.txHash || result.paymentKey, + timestamp: new Date().toISOString(), + status: "success", + }), + ).toString("base64url"); +} + +async function createChargeHandler(offering: DeployedOffering, nonce: string) { + const chainId = getDefaultChainId(); + const asset = getUsdcAddress(chainId); + const decimals = await getTokenDecimals(chainId, asset); + const amount = toAtomicAmount(offering.offering.priceValue, decimals); + const { Mppx, tempo } = await import("mppx/server"); + const payment = Mppx.create({ + secretKey: getSecretKey(), + realm: getRealm(), + methods: [ + tempo.charge({ + currency: getAddress(asset), + decimals, + feePayer: getSettlementAccount(), + getClient: ({ chainId: requestedChainId }) => + getPublicClient(requestedChainId || chainId), + recipient: getAddress(offering.providerWallet), + waitForConfirmation: process.env.MPP_WAIT_FOR_CONFIRMATION !== "false", + }), + ], + }); + + return payment.tempo.charge({ + amount, + chainId, + description: offering.offering.description, + expires: new Date( + Date.now() + Math.max(offering.offering.slaMinutes, 1) * 60_000, + ).toISOString(), + feePayer: true, + meta: { + nonce, + offeringId: String(offering.offering.id), + offeringName: offering.offering.name, + }, + }); +} + +function deserializeCredential( + Credential: { + deserialize(value: string): { + challenge: any; + source?: string; + }; + }, + authHeader: string, +) { + try { + return Credential.deserialize(authHeader); + } catch { + throw new Error("MPP credential is invalid"); + } +} + +function buildRequest(headers: Record = {}): Request { + return new Request(`${getRealm().replace(/\/$/, "")}/mpp/service-job`, { + headers, + method: "POST", + }); +} + +function getSecretKey(): string { + return ( + process.env.MPP_SECRET_KEY || + process.env.JWT_SECRET || + process.env.ACP_SIGNER_PRIVATE_KEY || + "acp-local-mpp-secret" + ); +} + +function getRealm(): string { + return ( + process.env.MPP_REALM || + process.env.ACP_API_URL || + "https://acp-service-jobs.local" + ); +} + +function getChallengeChainId(request: any): number { + const chainId = Number(request?.methodDetails?.chainId); + if (!Number.isInteger(chainId) || chainId <= 0) { + throw new Error("MPP challenge chainId is invalid"); + } + return chainId; +} + +async function findReceiptPayer( + chainId: number, + receipt: { reference: string }, +): Promise { + if (!isTxHash(receipt.reference)) { + throw new Error("MPP credential source is required"); + } + const txReceipt = await getPublicClient(chainId).getTransactionReceipt({ + hash: receipt.reference as Hex, + }); + return getAddress(txReceipt.from); +} + +function isTxHash(value: string): value is Hex { + return /^0x[a-fA-F0-9]{64}$/.test(value); +} + +function parseDidPkh( + source?: string, +): { chainId: number; address: string } | null { + if (!source) return null; + const match = /^did:pkh:eip155:(\d+):(0x[a-fA-F0-9]{40})$/.exec(source); + if (!match) return null; + return { chainId: Number(match[1]), address: getAddress(match[2]) }; +} diff --git a/serve/server/payment/x402.ts b/serve/server/payment/x402.ts new file mode 100644 index 0000000..2a800e0 --- /dev/null +++ b/serve/server/payment/x402.ts @@ -0,0 +1,371 @@ +import { + decodePaymentSignatureHeader, + encodePaymentRequiredHeader, + encodePaymentResponseHeader, +} from "@x402/core/http"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer } from "@x402/core/server"; +import type { FacilitatorClient } from "@x402/core/server"; +import type { + Network, + PaymentPayload, + PaymentRequired, + PaymentRequirements, + SettleResponse, + SupportedResponse, + VerifyResponse, +} from "@x402/core/types"; +import { + authorizationTypes, + eip3009ABI, + isEIP3009Payload, + isPermit2Payload, +} from "@x402/evm"; +import type { FacilitatorEvmSigner } from "@x402/evm"; +import { registerExactEvmScheme as registerExactEvmFacilitatorScheme } from "@x402/evm/exact/facilitator"; +import { registerExactEvmScheme as registerExactEvmServerScheme } from "@x402/evm/exact/server"; +import { encodeFunctionData, getAddress, type Address, type Hex } from "viem"; +import type { DeployedOffering } from "../../types"; +import { + getDefaultChainId, + getPublicClient, + getTokenDecimals, + getUsdcAddress, + getWalletClient, + toAtomicAmount, + verifyTypedData, +} from "./chain"; + +export interface X402SettlementResult { + clientAddress: string; + paymentKey: string; + txHash: string | null; + alreadySettled?: boolean; +} + +type X402Runtime = { + network: Network; + resourceServer: x402ResourceServer; +}; + +class LocalX402FacilitatorClient implements FacilitatorClient { + constructor(private readonly facilitator: x402Facilitator) {} + + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + async getSupported(): Promise { + return this.facilitator.getSupported() as SupportedResponse; + } +} + +const runtimes = new Map>(); + +export async function buildX402PaymentChallenge( + offering: DeployedOffering, + resourceUrl: string, +): Promise<{ header: string; body: PaymentRequired }> { + const requirements = await buildRequirements(offering); + const runtime = await getRuntimeForNetwork(requirements.network); + const body = await runtime.resourceServer.createPaymentRequiredResponse( + [requirements], + { + url: resourceUrl, + description: offering.offering.description, + mimeType: "application/json", + }, + "Payment required", + ); + + return { + body, + header: encodePaymentRequiredHeader(body), + }; +} + +export async function verifyAndSettleX402Payment( + paymentHeader: string, + offering: DeployedOffering, +): Promise { + const payload = decodePaymentPayload(paymentHeader); + const expected = await buildRequirements(offering); + const runtime = await getRuntimeForNetwork(expected.network); + const matched = runtime.resourceServer.findMatchingRequirements( + [expected], + payload, + ); + if (!matched) { + throw new Error("x402 payment requirements mismatch"); + } + + if (await isEip3009AuthorizationUsed(payload, matched)) { + await assertRecoverableEip3009Payment(payload, matched); + return { + clientAddress: getAddress(extractPayer(payload)), + paymentKey: buildPaymentKey(payload, matched), + txHash: null, + alreadySettled: true, + }; + } + + const verifyResult = await runtime.resourceServer.verifyPayment( + payload, + matched, + ); + if (!verifyResult.isValid) { + throw new Error( + verifyResult.invalidMessage || + verifyResult.invalidReason || + "Invalid x402 payment signature", + ); + } + + const settleResult = await runtime.resourceServer.settlePayment( + payload, + matched, + ); + if (!settleResult.success) { + throw new Error( + settleResult.errorMessage || + settleResult.errorReason || + "x402 payment settlement failed", + ); + } + + return { + clientAddress: getAddress(settleResult.payer || extractPayer(payload)), + paymentKey: buildPaymentKey(payload, matched, settleResult), + txHash: (settleResult.transaction as Hex | undefined) || null, + }; +} + +export function buildX402PaymentResponse(result: X402SettlementResult): string { + return encodePaymentResponseHeader({ + success: true, + payer: result.clientAddress, + transaction: result.txHash || "", + network: `eip155:${getDefaultChainId()}`, + }); +} + +async function buildRequirements( + offering: DeployedOffering, +): Promise { + const chainId = getDefaultChainId(); + const network = `eip155:${chainId}` as Network; + const asset = getUsdcAddress(chainId); + const decimals = await getTokenDecimals(chainId, asset); + const runtime = await getRuntime(chainId); + const [requirements] = + await runtime.resourceServer.buildPaymentRequirementsFromOptions( + [ + { + scheme: "exact", + network, + payTo: getAddress(offering.providerWallet), + price: { + asset: getAddress(asset), + amount: toAtomicAmount(offering.offering.priceValue, decimals), + extra: { + name: process.env.X402_ASSET_NAME || "USDC", + version: process.env.X402_ASSET_VERSION || "2", + assetTransferMethod: "eip3009", + }, + }, + maxTimeoutSeconds: Math.max(offering.offering.slaMinutes, 1) * 60, + }, + ], + undefined, + ); + + if (!requirements) { + throw new Error("Unable to build x402 requirements"); + } + return requirements; +} + +function decodePaymentPayload(paymentHeader: string): PaymentPayload { + try { + return decodePaymentSignatureHeader(paymentHeader); + } catch { + throw new Error("Invalid x402 payment header"); + } +} + +function getRuntimeForNetwork(network: Network): Promise { + return getRuntime(Number(network.replace("eip155:", ""))); +} + +function getRuntime(chainId: number): Promise { + let runtime = runtimes.get(chainId); + if (!runtime) { + runtime = createRuntime(chainId).catch((error) => { + if (runtimes.get(chainId) === runtime) { + runtimes.delete(chainId); + } + throw error; + }); + runtimes.set(chainId, runtime); + } + return runtime; +} + +async function createRuntime(chainId: number): Promise { + const network = `eip155:${chainId}` as Network; + const facilitator = new x402Facilitator(); + registerExactEvmFacilitatorScheme(facilitator, { + signer: buildFacilitatorSigner(chainId), + networks: network, + }); + + const resourceServer = new x402ResourceServer( + new LocalX402FacilitatorClient(facilitator), + ); + registerExactEvmServerScheme(resourceServer, { networks: [network] }); + await resourceServer.initialize(); + + return { network, resourceServer }; +} + +function buildFacilitatorSigner(chainId: number): FacilitatorEvmSigner { + const publicClient = getPublicClient(chainId); + const wallet = getWalletClient(chainId); + const address = getAddress(wallet.account!.address); + + return { + getAddresses: () => [address], + readContract: (args) => publicClient.readContract(args as any), + verifyTypedData: (args) => verifyTypedData(args as any), + writeContract: async (args) => { + const data = encodeFunctionData({ + abi: args.abi, + functionName: args.functionName, + args: args.args, + }); + return wallet.sendTransaction({ + to: args.address, + data, + gas: args.gas, + }); + }, + sendTransaction: (args) => wallet.sendTransaction(args), + waitForTransactionReceipt: (args) => + publicClient.waitForTransactionReceipt(args), + getCode: (args) => publicClient.getCode(args), + }; +} + +function extractPayer(payload: PaymentPayload): Address { + const schemePayload = payload.payload as any; + if (isEIP3009Payload(schemePayload)) { + return getAddress(schemePayload.authorization.from); + } + if (isPermit2Payload(schemePayload)) { + return getAddress(schemePayload.permit2Authorization.from); + } + throw new Error("Unsupported x402 EVM payload"); +} + +async function isEip3009AuthorizationUsed( + payload: PaymentPayload, + requirements: PaymentRequirements, +): Promise { + const schemePayload = payload.payload as any; + if (!isEIP3009Payload(schemePayload)) { + return false; + } + + const chainId = Number(requirements.network.replace("eip155:", "")); + const publicClient = getPublicClient(chainId); + return Boolean( + await publicClient.readContract({ + address: getAddress(requirements.asset), + abi: eip3009ABI, + functionName: "authorizationState", + args: [ + getAddress(schemePayload.authorization.from), + schemePayload.authorization.nonce, + ], + }), + ); +} + +async function assertRecoverableEip3009Payment( + payload: PaymentPayload, + requirements: PaymentRequirements, +): Promise { + const schemePayload = payload.payload as any; + if (!isEIP3009Payload(schemePayload)) { + throw new Error("Unsupported x402 replay payload"); + } + + const authorization = schemePayload.authorization; + const extra = requirements.extra as + | { name?: string; version?: string } + | undefined; + const signature = schemePayload.signature as Hex | undefined; + if (!extra?.name || !extra.version || !signature) { + throw new Error("Invalid x402 payment signature"); + } + + if ( + getAddress(authorization.to) !== getAddress(requirements.payTo) || + BigInt(authorization.value) !== BigInt(requirements.amount) + ) { + throw new Error("x402 payment requirements mismatch"); + } + + const isValid = await verifyTypedData({ + address: getAddress(authorization.from), + domain: { + name: extra.name, + version: extra.version, + chainId: Number(requirements.network.replace("eip155:", "")), + verifyingContract: getAddress(requirements.asset), + }, + types: authorizationTypes, + primaryType: "TransferWithAuthorization", + message: { + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }, + signature, + }); + + if (!isValid) { + throw new Error("Invalid x402 payment signature"); + } +} + +function buildPaymentKey( + payload: PaymentPayload, + requirements: PaymentRequirements, + settleResult?: SettleResponse, +): string { + const schemePayload = payload.payload as any; + if (isEIP3009Payload(schemePayload)) { + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${schemePayload.authorization.nonce}`; + } + if (isPermit2Payload(schemePayload)) { + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${schemePayload.permit2Authorization.nonce}`; + } + if (!settleResult?.transaction) { + throw new Error("Unsupported x402 payment payload"); + } + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${settleResult.transaction}`; +} diff --git a/serve/server/relay.ts b/serve/server/relay.ts index a55919b..cd600c6 100644 --- a/serve/server/relay.ts +++ b/serve/server/relay.ts @@ -1,26 +1,73 @@ import { io, type Socket } from "socket.io-client"; import type { LoadedHandlers } from "../runtime/loader"; import type { DeployedOffering, HandlerInput } from "../types"; +import { + buildX402PaymentChallenge, + buildX402PaymentResponse, + verifyAndSettleX402Payment, +} from "./payment/x402"; +import { + buildMppPaymentChallenge, + buildMppReceipt, + verifyAndSettleMppPayment, +} from "./payment/mpp"; export interface ServiceJobRelayOptions { apiUrl: string; agentToken: string; } +interface ServiceJobOffering { + id: string; + name: string; + description: string; + priceUsd: number; + requirements: Record | string; + deliverable: Record | string; + slaMinutes: number; +} + +interface PaymentChallengeRequest { + protocol: "x402" | "mpp"; + providerAddress: string; + offering: ServiceJobOffering; + resourceUrl?: string; + nonce?: string; +} + +interface PaymentChallengeAck { + status: "completed" | "failed"; + headers?: Record; + body?: unknown; + error?: string; +} + interface ServiceJobRequest { jobId: string; protocol: "x402" | "mpp"; - clientAddress: string; + providerAddress: string; + clientAddress: string | null; + offering: ServiceJobOffering; requirements: Record; payment: { + credential: string; txHash: string | null; paymentKey: string; }; } +interface PaymentSettlementAck { + clientAddress?: string | null; + paymentKey?: string; + txHash?: string | null; + receiptReference?: string; +} + interface ServiceJobAck { status: "completed" | "failed"; deliverable?: unknown; + headers?: Record; + settlement?: PaymentSettlementAck; error?: string; } @@ -28,18 +75,18 @@ export function serviceJobEndpoint( apiUrl: string, providerAddress: string, offeringSlug: string, - protocol: "x402" | "mpp" + protocol: "x402" | "mpp", ): string { return new URL( `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, - apiUrl + apiUrl, ).toString(); } export function startServiceJobRelay( offering: DeployedOffering, handlers: LoadedHandlers, - options: ServiceJobRelayOptions + options: ServiceJobRelayOptions, ): Socket { const socket = io(new URL("/service-jobs", options.apiUrl).toString(), { auth: { token: options.agentToken }, @@ -56,27 +103,135 @@ export function startServiceJobRelay( console.log(`[Relay] Disconnected from ACP service jobs: ${reason}`); }); + socket.on( + "service-job:payment-challenge", + async ( + request: PaymentChallengeRequest, + ack: (response: PaymentChallengeAck) => void, + ) => { + try { + assertRelayRequestMatchesOffering(offering, request); + if (request.protocol === "x402") { + const challenge = await buildX402PaymentChallenge( + offering, + request.resourceUrl || + serviceJobEndpoint( + options.apiUrl, + offering.providerWallet, + offering.offering.slug || offering.offering.id, + "x402", + ), + ); + ack({ + status: "completed", + headers: { "Payment-Required": challenge.header }, + body: challenge.body, + }); + return; + } + + const header = await buildMppPaymentChallenge( + offering, + request.nonce || `${Date.now()}`, + ); + ack({ + status: "completed", + headers: { + "WWW-Authenticate": header, + "Cache-Control": "no-store", + }, + body: { error: "Payment required" }, + }); + } catch (err) { + ack({ + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + socket.on( "service-job:request", - async (request: ServiceJobRequest, ack: (response: ServiceJobAck) => void) => { + async ( + request: ServiceJobRequest, + ack: (response: ServiceJobAck) => void, + ) => { try { + assertRelayRequestMatchesOffering(offering, request); + const payment = await verifyAndSettlePayment(offering, request); const input: HandlerInput = { requirements: request.requirements, offering: offering.offering, jobId: request.jobId, - client: { address: request.clientAddress }, + client: { address: payment.clientAddress }, protocol: request.protocol, }; const result = await handlers.handler(input); - ack({ status: "completed", deliverable: result.deliverable }); + ack({ + status: "completed", + deliverable: result.deliverable, + headers: payment.headers, + settlement: payment.settlement, + }); } catch (err) { ack({ status: "failed", error: err instanceof Error ? err.message : String(err), }); } - } + }, ); return socket; } + +function assertRelayRequestMatchesOffering( + offering: DeployedOffering, + request: { providerAddress: string; offering: ServiceJobOffering }, +): void { + if ( + request.providerAddress.toLowerCase() !== + offering.providerWallet.toLowerCase() + ) { + throw new Error("Relay request provider does not match this runtime"); + } + if ( + request.offering.id !== offering.offering.id && + request.offering.name !== offering.offering.name && + request.offering.name !== offering.offering.slug + ) { + throw new Error("Relay request offering does not match this runtime"); + } +} + +async function verifyAndSettlePayment( + offering: DeployedOffering, + request: ServiceJobRequest, +): Promise<{ + clientAddress: string; + headers: Record; + settlement: PaymentSettlementAck; +}> { + if (request.protocol === "x402") { + const settlement = await verifyAndSettleX402Payment( + request.payment.credential, + offering, + ); + return { + clientAddress: settlement.clientAddress, + headers: { "Payment-Response": buildX402PaymentResponse(settlement) }, + settlement, + }; + } + + const settlement = await verifyAndSettleMppPayment( + request.payment.credential, + offering, + ); + return { + clientAddress: settlement.clientAddress, + headers: { "Payment-Receipt": buildMppReceipt(settlement) }, + settlement, + }; +} From c172a7f6119f36fe828c0ad2048199de7f77862e Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Mon, 4 May 2026 16:17:38 +0800 Subject: [PATCH 06/11] Address serve runtime review issues --- serve/runtime/sandbox.ts | 43 ++++++++++++++++++++++++++++++---------- serve/server/index.ts | 18 +++++++++++++++++ src/commands/serve.ts | 38 ++++++++++++++++++++++++++--------- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/serve/runtime/sandbox.ts b/serve/runtime/sandbox.ts index 1568678..882089b 100644 --- a/serve/runtime/sandbox.ts +++ b/serve/runtime/sandbox.ts @@ -4,26 +4,49 @@ import type { HandlerInput, HandlerOutput } from "../types"; export function runInSandbox( handlerPath: string, input: HandlerInput, - timeoutMs: number + timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { + let settled = false; + let timeout: NodeJS.Timeout; const worker = new Worker(new URL("./sandbox-worker.ts", import.meta.url), { workerData: { handlerPath, input }, }); - const timeout = setTimeout(() => { + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(); + }; + + timeout = setTimeout(() => { worker.terminate().catch(() => {}); - reject(new Error(`Handler timed out after ${timeoutMs}ms`)); + finish(() => reject(new Error(`Handler timed out after ${timeoutMs}ms`))); }, timeoutMs); - worker.on("message", (message: { ok: boolean; result?: HandlerOutput; error?: string }) => { - clearTimeout(timeout); - if (message.ok) resolve(message.result!); - else reject(new Error(message.error ?? "Handler failed")); + worker.once( + "message", + (message: { ok: boolean; result?: HandlerOutput; error?: string }) => { + finish(() => { + if (message.ok) resolve(message.result!); + else reject(new Error(message.error ?? "Handler failed")); + }); + }, + ); + worker.once("error", (err) => { + finish(() => reject(err)); }); - worker.on("error", (err) => { - clearTimeout(timeout); - reject(err); + worker.once("exit", (code) => { + if (code === 0) { + finish(() => + reject(new Error("Handler exited before returning a result")), + ); + return; + } + finish(() => + reject(new Error(`Handler worker exited with code ${code}`)), + ); }); }); } diff --git a/serve/server/index.ts b/serve/server/index.ts index c7dd68e..2797317 100644 --- a/serve/server/index.ts +++ b/serve/server/index.ts @@ -145,6 +145,8 @@ async function startACPListener( agent.on("entry", async (session: any, entry: any) => { const jobId = session.jobId; try { + if (!(await isSessionForOffering(offering, session))) return; + const status = session.status; if (entry.contentType === "requirement" && entry.content) { @@ -210,6 +212,22 @@ async function startACPListener( await agent.start(); } +async function isSessionForOffering( + offering: DeployedOffering, + session: any, +): Promise { + const job = session.job ?? (await session.fetchJob()); + const providerAddress = String(job.providerAddress ?? "").toLowerCase(); + if (providerAddress !== offering.providerWallet.toLowerCase()) return false; + + const description = String(job.description ?? ""); + return ( + description === offering.offering.name || + description === offering.offering.id || + description === offering.offering.slug + ); +} + function buildHandlerInput( offering: DeployedOffering, requirements: Record | string, diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 6e9782b..506615a 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -735,22 +735,42 @@ export function registerServeCommands(program: Command): void { const active = requireActiveAgent(json); if (!active) return; const { getPidFilePath } = await import("../../serve/server/index"); + const rootDir = resolve(opts.dir); + let agent: Agent | undefined; + try { + const { agentApi } = await getClient(); + agent = await agentApi.getById(active.agentId); + } catch { + agent = undefined; + } let stopped = 0; for (const local of selectLocalOfferings( - loadLocalOfferings(resolve(opts.dir), active.agentId), + loadLocalOfferings(rootDir, active.agentId), opts.offering, )) { - const offeringId = + const offering = materializeOffering( + local, + agent ? findRemoteOffering(local, agent) : undefined, + ); + const legacyOfferingId = typeof local.offeringJson.id === "string" ? local.offeringJson.id : local.slug; - const pidFile = getPidFilePath(offeringId); - if (!existsSync(pidFile)) continue; - const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); - try { - process.kill(pid, "SIGTERM"); - stopped += 1; - } catch {} + const pidFiles = [ + getPidFilePath(offering.id), + ...(offering.id === legacyOfferingId + ? [] + : [getPidFilePath(legacyOfferingId)]), + ]; + for (const pidFile of pidFiles) { + if (!existsSync(pidFile)) continue; + const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + try { + process.kill(pid, "SIGTERM"); + stopped += 1; + } catch {} + break; + } } outputResult(json, { success: true, stopped }); } catch (err) { From cd7fef8e950e9bd79142c3aa4e2fdfed06d12c37 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Mon, 4 May 2026 16:45:42 +0800 Subject: [PATCH 07/11] Address service job payment review issues --- serve/server/payment/chain.ts | 2 +- serve/server/payment/mpp.ts | 7 +++++-- src/commands/serve.ts | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/serve/server/payment/chain.ts b/serve/server/payment/chain.ts index 5172d1e..af64ce4 100644 --- a/serve/server/payment/chain.ts +++ b/serve/server/payment/chain.ts @@ -34,7 +34,7 @@ const SUPPORTED_CHAINS = [ const USDC_ADDRESSES: Record = { [baseSepolia.id]: "0xB270EDc833056001f11a7828DFdAC9D4ac2b8344", - [base.id]: "0x833589fCD6E08f4c7C32D4f71b54bdA02913", + [base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", [bsc.id]: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", [bscTestnet.id]: "0x64544969ed7EBf5f083679233325356EbE738930", [xLayer.id]: "0x74b7f16337b8972027f6196a17a631ac6de26d22", diff --git a/serve/server/payment/mpp.ts b/serve/server/payment/mpp.ts index 17b2320..8feaad9 100644 --- a/serve/server/payment/mpp.ts +++ b/serve/server/payment/mpp.ts @@ -1,3 +1,4 @@ +import { randomBytes } from "crypto"; import { getAddress, type Hex } from "viem"; import type { DeployedOffering } from "../../types"; import { @@ -29,6 +30,8 @@ type TempoPayload = | TempoTransactionPayload | TempoProofPayload; +const localMppSecretKey = randomBytes(32).toString("hex"); + export interface MppSettlementResult { clientAddress: string; paymentKey: string; @@ -164,9 +167,9 @@ function buildRequest(headers: Record = {}): Request { function getSecretKey(): string { return ( process.env.MPP_SECRET_KEY || + process.env.ACP_MPP_SECRET_KEY || process.env.JWT_SECRET || - process.env.ACP_SIGNER_PRIVATE_KEY || - "acp-local-mpp-secret" + localMppSecretKey ); } diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 506615a..04de610 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,4 +1,5 @@ import { dirname, resolve } from "path"; +import { randomBytes } from "crypto"; import { fileURLToPath } from "url"; import { homedir } from "os"; import { spawnSync } from "child_process"; @@ -701,6 +702,8 @@ export function registerServeCommands(program: Command): void { ACP_WALLET_ID: getWalletId(active.wallet), ACP_CHAIN_ID: process.env.ACP_CHAIN_ID, IS_TESTNET: process.env.IS_TESTNET, + MPP_SECRET_KEY: + process.env.MPP_SECRET_KEY || randomBytes(32).toString("hex"), }, }); } From a25e0ce6a60a39f608b9ac10a36524af1552126d Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Mon, 4 May 2026 22:03:56 +0800 Subject: [PATCH 08/11] Clean up serve deployment review issues --- serve/server/payment/chain.ts | 5 ----- src/commands/serve.ts | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/serve/server/payment/chain.ts b/serve/server/payment/chain.ts index af64ce4..502047e 100644 --- a/serve/server/payment/chain.ts +++ b/serve/server/payment/chain.ts @@ -2,7 +2,6 @@ import { createPublicClient, createWalletClient, erc20Abi, - formatUnits, http, verifyTypedData, type Address, @@ -131,8 +130,4 @@ export function toAtomicAmount(amount: number, decimals: number): string { ).toString(); } -export function fromAtomicAmount(raw: bigint, decimals: number): number { - return Number(formatUnits(raw, decimals)); -} - export { verifyTypedData }; diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 04de610..3cfd9b3 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -218,12 +218,27 @@ function materializeOffering( async function getOrCreateAgentToken(wallet: string): Promise { const existing = getAgentToken(wallet); - if (existing) return existing; + if (existing && !isExpiredJwt(existing)) return existing; const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); return AuthApi.fetchAndStoreAgentToken(wallet, chainId, getApiUrl()); } +function isExpiredJwt(token: string, skewSeconds = 60): boolean { + const [, payload] = token.split("."); + if (!payload) return true; + + try { + const decoded = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8"), + ) as { exp?: unknown }; + if (typeof decoded.exp !== "number") return true; + return decoded.exp <= Math.floor(Date.now() / 1000) + skewSeconds; + } catch { + return true; + } +} + function getDefaultPort(input: unknown): number { const port = Number(input ?? 3000); return Number.isFinite(port) && port > 0 ? port : 3000; @@ -631,7 +646,6 @@ export function registerServeCommands(program: Command): void { ) .option("--railway-project ", "Railway project ID for --execute") .option("--railway-environment ", "Railway environment for --execute") - .option("--bundle-only", "Only create the deploy bundle", true) .action(async (opts, cmd) => { const json = isJson(cmd); try { From 6eccf1ea82f700f2f58d5e15706b2f5ee9acab5b Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Mon, 4 May 2026 23:30:04 +0800 Subject: [PATCH 09/11] Address serve runtime review cleanup --- serve/server/payment/chain.ts | 24 +++++++++++++++++++++++- src/commands/serve.ts | 23 ++++++----------------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/serve/server/payment/chain.ts b/serve/server/payment/chain.ts index 502047e..441bbcf 100644 --- a/serve/server/payment/chain.ts +++ b/serve/server/payment/chain.ts @@ -122,7 +122,7 @@ export function toAtomicAmount(amount: number, decimals: number): string { if (!Number.isFinite(amount) || amount < 0) { throw new Error(`Invalid payment amount: ${amount}`); } - const [whole, fraction = ""] = String(amount).split("."); + const [whole, fraction = ""] = toDecimalString(amount).split("."); const paddedFraction = fraction.padEnd(decimals, "0").slice(0, decimals); return ( BigInt(whole || "0") * 10n ** BigInt(decimals) + @@ -130,4 +130,26 @@ export function toAtomicAmount(amount: number, decimals: number): string { ).toString(); } +function toDecimalString(amount: number): string { + const value = String(amount); + if (!value.toLowerCase().includes("e")) return value; + + const [coefficient, exponentValue] = value.toLowerCase().split("e"); + const exponent = Number(exponentValue); + if (!Number.isInteger(exponent)) { + throw new Error(`Invalid payment amount: ${amount}`); + } + + const [whole, fraction = ""] = coefficient.split("."); + const digits = `${whole}${fraction}`; + const decimalIndex = whole.length + exponent; + if (decimalIndex <= 0) { + return `0.${"0".repeat(Math.abs(decimalIndex))}${digits}`; + } + if (decimalIndex >= digits.length) { + return `${digits}${"0".repeat(decimalIndex - digits.length)}`; + } + return `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; +} + export { verifyTypedData }; diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 3cfd9b3..2367812 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -22,6 +22,7 @@ import { getActiveWallet, getAgentId, getAgentToken, + isTokenExpired, getPublicKey, getWalletId, } from "../lib/config"; @@ -218,27 +219,12 @@ function materializeOffering( async function getOrCreateAgentToken(wallet: string): Promise { const existing = getAgentToken(wallet); - if (existing && !isExpiredJwt(existing)) return existing; + if (existing && !isTokenExpired(existing)) return existing; const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); return AuthApi.fetchAndStoreAgentToken(wallet, chainId, getApiUrl()); } -function isExpiredJwt(token: string, skewSeconds = 60): boolean { - const [, payload] = token.split("."); - if (!payload) return true; - - try { - const decoded = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8"), - ) as { exp?: unknown }; - if (typeof decoded.exp !== "number") return true; - return decoded.exp <= Math.floor(Date.now() / 1000) + skewSeconds; - } catch { - return true; - } -} - function getDefaultPort(input: unknown): number { const port = Number(input ?? 3000); return Number.isFinite(port) && port > 0 ? port : 3000; @@ -548,6 +534,7 @@ export function registerServeCommands(program: Command): void { if (!active) return; const rootDir = resolve(opts.dir); + const serveConfig = readJsonFile(getServeConfigPath(rootDir)); const selected = selectLocalOfferings( loadLocalOfferings(rootDir, active.agentId), opts.offering, @@ -576,7 +563,9 @@ export function registerServeCommands(program: Command): void { await startOfferingServer({ dir: local.dir, - port: opts.port ? Number(opts.port) : getDefaultPort(undefined), + port: opts.port + ? Number(opts.port) + : getDefaultPort(serveConfig.port), agentSlug: slugify(agent?.name ?? agentName), providerWallet: active.wallet, offering, From 3ceb483bfe0e5a3fe4820f1a3818bfd9ce6e9167 Mon Sep 17 00:00:00 2001 From: ai-virtual-b Date: Tue, 5 May 2026 09:29:35 +0800 Subject: [PATCH 10/11] Fix serve stop fallback and bundle source root --- src/commands/serve.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 2367812..a133fea 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -338,6 +338,21 @@ function deployRailway(opts: RailwayDeployOptions): { }; } +function getCliPackageRoot(): string { + let current = dirname(fileURLToPath(import.meta.url)); + while (current !== dirname(current)) { + if ( + existsSync(resolve(current, "package.json")) && + existsSync(resolve(current, "serve")) + ) { + return current; + } + current = dirname(current); + } + + throw new Error("Unable to locate acp-cli package root."); +} + function copyRuntimeBundle( rootDir: string, bundleDir: string, @@ -349,6 +364,7 @@ function copyRuntimeBundle( ): Record { rmSync(bundleDir, { recursive: true, force: true }); mkdirSync(bundleDir, { recursive: true }); + const cliRoot = getCliPackageRoot(); for (const relativePath of [ "bin", @@ -358,7 +374,7 @@ function copyRuntimeBundle( "package-lock.json", "tsconfig.json", ]) { - const source = resolve(process.cwd(), relativePath); + const source = resolve(cliRoot, relativePath); if (existsSync(source)) { cpSync(source, resolve(bundleDir, relativePath), { recursive: true }); } @@ -774,8 +790,8 @@ export function registerServeCommands(program: Command): void { try { process.kill(pid, "SIGTERM"); stopped += 1; + break; } catch {} - break; } } outputResult(json, { success: true, stopped }); From 7338dc24fb298745c31a99c277a1a057695e39d3 Mon Sep 17 00:00:00 2001 From: Andrew Khor Date: Wed, 6 May 2026 18:39:30 +0800 Subject: [PATCH 11/11] feat: enhanced init & serve offerings flow --- .gitignore | 4 +- package-lock.json | 41 ------ serve/server/index.ts | 239 ++++++++++++++++++++++------------ serve/server/payment/chain.ts | 117 +---------------- serve/server/payment/mpp.ts | 31 +++-- serve/server/payment/x402.ts | 154 ++++++++++++++++------ serve/server/relay.ts | 52 ++++---- src/commands/serve.ts | 185 +++++++++++++++----------- 8 files changed, 424 insertions(+), 399 deletions(-) diff --git a/.gitignore b/.gitignore index 6a874e7..024aa32 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist/ *.js.map *config*.json acp-cli-signer/acp-cli-signer -.DS_Store \ No newline at end of file +.DS_Store +agents/ +serve.json \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b903d47..da6c4dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4578,21 +4578,6 @@ "license": "MIT", "optional": true }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -6241,7 +6226,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6258,7 +6242,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6275,7 +6258,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6292,7 +6274,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6309,7 +6290,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6326,7 +6306,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6343,7 +6322,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6360,7 +6338,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6377,7 +6354,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6394,7 +6370,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6411,7 +6386,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6428,7 +6402,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6445,7 +6418,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6462,7 +6434,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6479,7 +6450,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6496,7 +6466,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6513,7 +6482,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6530,7 +6498,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6547,7 +6514,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6564,7 +6530,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6581,7 +6546,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6598,7 +6562,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6615,7 +6578,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6632,7 +6594,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6649,7 +6610,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6666,7 +6626,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/serve/server/index.ts b/serve/server/index.ts index 2797317..783ee1d 100644 --- a/serve/server/index.ts +++ b/serve/server/index.ts @@ -4,79 +4,118 @@ import { homedir } from "os"; import { mkdirSync, unlinkSync, writeFileSync } from "fs"; import { loadHandlers, type LoadedHandlers } from "../runtime/loader"; import type { DeployedOffering, HandlerInput, ServeProtocol } from "../types"; -import { serviceJobEndpoint, startServiceJobRelay } from "./relay"; +import { + serviceJobEndpoint, + startServiceJobRelay, + type ServiceJobRelayOptions, +} from "./relay"; +import type { Socket } from "socket.io-client"; +import { base } from "viem/chains"; -export interface ServerOptions { +export interface RuntimeOffering { dir: string; + offering: DeployedOffering["offering"]; + protocols?: ServeProtocol[]; +} + +export interface ServerOptions { port?: number; agentSlug: string; providerWallet: string; - offering: DeployedOffering["offering"]; - protocols?: ServeProtocol[]; + offerings: RuntimeOffering[]; + resolveOffering: (offeringId: string) => Promise; settle8183?: boolean; apiUrl?: string; agentToken?: string; sandbox?: boolean; } -export async function startOfferingServer( +interface PreparedOffering { + deployed: DeployedOffering; + handlers: LoadedHandlers; + protocols: ServeProtocol[]; +} + +export async function startOfferingsServer( options: ServerOptions, ): Promise { - const { dir, providerWallet, offering } = options; - const protocols = options.protocols ?? ["x402", "mpp", "acp"]; + const { providerWallet, agentSlug } = options; const port = options.port ?? 3000; const apiUrl = options.apiUrl ?? process.env.ACP_API_URL; const settle8183 = options.settle8183 ?? false; - let handlers = await loadHandlers(dir); - if (options.sandbox) { - const { runInSandbox } = await import("../runtime/sandbox"); - const handlerPath = resolvePath(dir, "handler.ts"); - const timeoutMs = Math.max(offering.slaMinutes, 1) * 60_000; - handlers = { - ...handlers, - handler: (input) => runInSandbox(handlerPath, input, timeoutMs), - }; + if (options.offerings.length === 0) { + throw new Error("No offerings to serve."); } - const deployed: DeployedOffering = { - offeringId: offering.id, - agentSlug: options.agentSlug, - providerWallet, - offering, - hasBudgetHandler: Boolean(handlers.budgetHandler), - protocols, - evaluator: "self", - settle8183, - }; + const prepared: PreparedOffering[] = []; + for (const entry of options.offerings) { + const protocols = entry.protocols ?? ["x402", "mpp", "acp"]; + let handlers = await loadHandlers(entry.dir); + if (options.sandbox) { + const { runInSandbox } = await import("../runtime/sandbox"); + const handlerPath = resolvePath(entry.dir, "handler.ts"); + const timeoutMs = Math.max(entry.offering.slaMinutes, 1) * 60_000; + handlers = { + ...handlers, + handler: (input) => runInSandbox(handlerPath, input, timeoutMs), + }; + } - const usesRelay = protocols.includes("x402") || protocols.includes("mpp"); - if (usesRelay && settle8183) { + prepared.push({ + deployed: { + offeringId: entry.offering.id, + agentSlug, + providerWallet, + offering: entry.offering, + hasBudgetHandler: Boolean(handlers.budgetHandler), + protocols, + evaluator: "self", + settle8183, + }, + handlers, + protocols, + }); + } + + const anyUsesRelay = prepared.some( + (p) => p.protocols.includes("x402") || p.protocols.includes("mpp"), + ); + if (anyUsesRelay && settle8183) { throw new Error( "--settle-8183 is reserved but not supported until ERC-8183 supports this flow.", ); } - if (usesRelay && (!apiUrl || !options.agentToken)) { + if (anyUsesRelay && (!apiUrl || !options.agentToken)) { throw new Error( "ACP API URL and agent auth token are required for x402/MPP.", ); } - const relay = - usesRelay && apiUrl && options.agentToken - ? startServiceJobRelay(deployed, handlers, { - apiUrl, - agentToken: options.agentToken, - }) - : undefined; - - if (protocols.includes("acp")) { - startACPListener(deployed, handlers).catch((err) => { - console.error(`[ACP] Native listener failed: ${err.message ?? err}`); - }); + const relays: Socket[] = []; + if (apiUrl && options.agentToken) { + const relayOptions: ServiceJobRelayOptions = { + apiUrl, + agentToken: options.agentToken, + resolveOffering: options.resolveOffering, + }; + for (const p of prepared) { + if (p.protocols.includes("x402") || p.protocols.includes("mpp")) { + relays.push(startServiceJobRelay(p.deployed, p.handlers, relayOptions)); + } + } + } + + const acpOfferings = prepared.filter((p) => p.protocols.includes("acp")); + if (acpOfferings.length > 0) { + startSharedACPListener(acpOfferings, options.resolveOffering).catch( + (err) => { + console.error(`[ACP] Native listener failed: ${err.message ?? err}`); + }, + ); } - const pidFile = getPidFilePath(offering.id); + const pidFile = getRuntimePidFilePath(providerWallet); mkdirSync(dirname(pidFile), { recursive: true }); writeFileSync(pidFile, String(process.pid)); @@ -86,10 +125,17 @@ export async function startOfferingServer( res.end( JSON.stringify({ status: "ok", - offering: { id: offering.id, name: offering.name }, - protocols, - relay: usesRelay ? "enabled" : "disabled", + provider: providerWallet, pid: process.pid, + offerings: prepared.map((p) => ({ + id: p.deployed.offering.id, + name: p.deployed.offering.name, + protocols: p.protocols, + relay: + p.protocols.includes("x402") || p.protocols.includes("mpp") + ? "enabled" + : "disabled", + })), }), ); return; @@ -99,29 +145,41 @@ export async function startOfferingServer( }); server.listen(port, () => { - const offeringSlug = offering.slug || offering.id; console.log(`\nACP Serve runtime running on port ${port}\n`); - console.log(`Offering: ${offering.name} (${offering.id})`); console.log(`Provider: ${providerWallet}`); console.log("Mode: BE-mediated service-job runtime"); console.log(`Settlement: ${settle8183 ? "ERC-8183" : "direct x402/MPP"}`); - console.log("\nEndpoints:"); - if (apiUrl && protocols.includes("x402")) { - console.log( - ` x402: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "x402")}`, - ); - } - if (apiUrl && protocols.includes("mpp")) { - console.log( - ` MPP: ${serviceJobEndpoint(apiUrl, providerWallet, offeringSlug, "mpp")}`, - ); + console.log(`\nServing ${prepared.length} offering(s):`); + for (const p of prepared) { + const offeringSlug = p.deployed.offering.slug || p.deployed.offering.id; + console.log(`\n ${p.deployed.offering.name} (${p.deployed.offering.id})`); + if (apiUrl && p.protocols.includes("x402")) { + console.log( + ` x402: ${serviceJobEndpoint( + apiUrl, + providerWallet, + offeringSlug, + "x402", + )}`, + ); + } + if (apiUrl && p.protocols.includes("mpp")) { + console.log( + ` MPP: ${serviceJobEndpoint( + apiUrl, + providerWallet, + offeringSlug, + "mpp", + )}`, + ); + } + if (p.protocols.includes("acp")) console.log(" ACP: native listener"); } - if (protocols.includes("acp")) console.log(" ACP: native listener"); console.log(`\nHealth: http://localhost:${port}/health`); }); const shutdown = () => { - relay?.disconnect(); + for (const relay of relays) relay.disconnect(); server.close(); try { unlinkSync(pidFile); @@ -132,20 +190,27 @@ export async function startOfferingServer( process.on("SIGTERM", shutdown); } -async function startACPListener( - offering: DeployedOffering, - handlers: LoadedHandlers, +async function startSharedACPListener( + offerings: PreparedOffering[], + resolveOffering: (offeringId: string) => Promise, ): Promise { const { createAgentFromConfig } = await import("../../src/lib/agentFactory"); const { AssetToken } = await import("@virtuals-protocol/acp-node-v2"); const agent = await createAgentFromConfig(); - const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); + const chainId = Number(process.env.ACP_CHAIN_ID || base.id); const jobRequirements = new Map | string>(); agent.on("entry", async (session: any, entry: any) => { const jobId = session.jobId; try { - if (!(await isSessionForOffering(offering, session))) return; + const match = await findOfferingForSession(offerings, session); + if (!match) return; + const liveOffering = await resolveOffering(match.deployed.offeringId); + const liveDeployed: DeployedOffering = { + ...match.deployed, + offering: liveOffering, + offeringId: liveOffering.id, + }; const status = session.status; @@ -160,14 +225,14 @@ async function startACPListener( if (status === "open" && jobRequirements.has(jobId)) { const requirements = jobRequirements.get(jobId)!; const input = buildHandlerInput( - offering, + liveDeployed, requirements, entry.from || "unknown", "acp", jobId, ); - if (handlers.budgetHandler) { - const budget = await handlers.budgetHandler(input); + if (match.handlers.budgetHandler) { + const budget = await match.handlers.budgetHandler(input); if (budget.fundRequest) { await session.setBudgetWithFundRequest( AssetToken.usdc(budget.amount, chainId), @@ -179,15 +244,15 @@ async function startACPListener( } } else { await session.setBudget( - AssetToken.usdc(offering.offering.priceValue, chainId), + AssetToken.usdc(liveOffering.priceValue, chainId), ); } } if (status === "funded" && jobRequirements.has(jobId)) { - const result = await handlers.handler( + const result = await match.handlers.handler( buildHandlerInput( - offering, + liveDeployed, jobRequirements.get(jobId)!, entry.from || "unknown", "acp", @@ -212,20 +277,25 @@ async function startACPListener( await agent.start(); } -async function isSessionForOffering( - offering: DeployedOffering, +async function findOfferingForSession( + offerings: PreparedOffering[], session: any, -): Promise { +): Promise { const job = session.job ?? (await session.fetchJob()); const providerAddress = String(job.providerAddress ?? "").toLowerCase(); - if (providerAddress !== offering.providerWallet.toLowerCase()) return false; - const description = String(job.description ?? ""); - return ( - description === offering.offering.name || - description === offering.offering.id || - description === offering.offering.slug - ); + for (const o of offerings) { + if (providerAddress !== o.deployed.providerWallet.toLowerCase()) continue; + const off = o.deployed.offering; + if ( + description === off.name || + description === off.id || + description === off.slug + ) { + return o; + } + } + return undefined; } function buildHandlerInput( @@ -244,6 +314,11 @@ function buildHandlerInput( }; } -export function getPidFilePath(offeringId: string): string { - return resolvePath(homedir(), ".acp", "serve", `${offeringId}.pid`); +export function getRuntimePidFilePath(providerWallet: string): string { + return resolvePath( + homedir(), + ".acp", + "serve", + `${providerWallet.toLowerCase()}.pid`, + ); } diff --git a/serve/server/payment/chain.ts b/serve/server/payment/chain.ts index 441bbcf..697c7e4 100644 --- a/serve/server/payment/chain.ts +++ b/serve/server/payment/chain.ts @@ -1,7 +1,5 @@ import { createPublicClient, - createWalletClient, - erc20Abi, http, verifyTypedData, type Address, @@ -9,48 +7,12 @@ import { type Hex, } from "viem"; import { privateKeyToAccount, nonceManager } from "viem/accounts"; -import { - base, - baseSepolia, - bsc, - bscTestnet, - mainnet, - sepolia, - xLayer, - xLayerTestnet, -} from "viem/chains"; - -const SUPPORTED_CHAINS = [ - mainnet, - sepolia, - base, - baseSepolia, - bsc, - bscTestnet, - xLayer, - xLayerTestnet, -]; - -const USDC_ADDRESSES: Record = { - [baseSepolia.id]: "0xB270EDc833056001f11a7828DFdAC9D4ac2b8344", - [base.id]: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - [bsc.id]: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", - [bscTestnet.id]: "0x64544969ed7EBf5f083679233325356EbE738930", - [xLayer.id]: "0x74b7f16337b8972027f6196a17a631ac6de26d22", - [xLayerTestnet.id]: "0xcb8bf24c6ce16ad21d707c9505421a17f2bec79d", -}; +import { base, baseSepolia } from "viem/chains"; -const USDC_DECIMALS: Record = { - [baseSepolia.id]: 6, - [base.id]: 6, - [bsc.id]: 18, - [bscTestnet.id]: 18, - [xLayer.id]: 6, - [xLayerTestnet.id]: 6, -}; +const SUPPORTED_CHAINS = [base, baseSepolia]; export function getDefaultChainId(): number { - return Number(process.env.ACP_CHAIN_ID || "84532"); + return Number(process.env.ACP_CHAIN_ID || base.id); } export function getChain(chainId = getDefaultChainId()): Chain { @@ -60,11 +22,7 @@ export function getChain(chainId = getDefaultChainId()): Chain { } export function getRpcUrl(chainId = getDefaultChainId()): string | undefined { - return ( - process.env[`CUSTOM_RPC_URL_${chainId}`] || - process.env[`RPC_URL_${chainId}`] || - process.env.GATEWAY_RPC_URL - ); + return process.env[`CUSTOM_RPC_URL_${chainId}`]; } export function getPublicClient(chainId = getDefaultChainId()) { @@ -81,75 +39,10 @@ export function getSettlementAccount() { process.env.GATEWAY_PRIVATE_KEY; if (!privateKey) { throw new Error( - "Provider settlement signer is not configured. Set ACP_SIGNER_PRIVATE_KEY.", + "Provider settlement signer is not configured. Set ACP_SIGNER_PRIVATE_KEY." ); } return privateKeyToAccount(privateKey as Hex, { nonceManager }); } -export function getWalletClient(chainId = getDefaultChainId()) { - return createWalletClient({ - account: getSettlementAccount(), - chain: getChain(chainId), - transport: http(getRpcUrl(chainId)), - }); -} - -export function getUsdcAddress(chainId = getDefaultChainId()): Address { - const configured = process.env[`USDC_ADDRESS_${chainId}`]; - const address = configured || USDC_ADDRESSES[chainId]; - if (!address) - throw new Error(`USDC address is not configured for ${chainId}`); - return address as Address; -} - -export async function getTokenDecimals( - chainId = getDefaultChainId(), - tokenAddress = getUsdcAddress(chainId), -): Promise { - const configured = process.env[`USDC_DECIMALS_${chainId}`]; - if (configured) return Number(configured); - if (USDC_DECIMALS[chainId]) return USDC_DECIMALS[chainId]; - const decimals = await getPublicClient(chainId).readContract({ - address: tokenAddress, - abi: erc20Abi, - functionName: "decimals", - }); - return Number(decimals); -} - -export function toAtomicAmount(amount: number, decimals: number): string { - if (!Number.isFinite(amount) || amount < 0) { - throw new Error(`Invalid payment amount: ${amount}`); - } - const [whole, fraction = ""] = toDecimalString(amount).split("."); - const paddedFraction = fraction.padEnd(decimals, "0").slice(0, decimals); - return ( - BigInt(whole || "0") * 10n ** BigInt(decimals) + - BigInt(paddedFraction || "0") - ).toString(); -} - -function toDecimalString(amount: number): string { - const value = String(amount); - if (!value.toLowerCase().includes("e")) return value; - - const [coefficient, exponentValue] = value.toLowerCase().split("e"); - const exponent = Number(exponentValue); - if (!Number.isInteger(exponent)) { - throw new Error(`Invalid payment amount: ${amount}`); - } - - const [whole, fraction = ""] = coefficient.split("."); - const digits = `${whole}${fraction}`; - const decimalIndex = whole.length + exponent; - if (decimalIndex <= 0) { - return `0.${"0".repeat(Math.abs(decimalIndex))}${digits}`; - } - if (decimalIndex >= digits.length) { - return `${digits}${"0".repeat(decimalIndex - digits.length)}`; - } - return `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; -} - export { verifyTypedData }; diff --git a/serve/server/payment/mpp.ts b/serve/server/payment/mpp.ts index 8feaad9..626c2e8 100644 --- a/serve/server/payment/mpp.ts +++ b/serve/server/payment/mpp.ts @@ -1,14 +1,13 @@ import { randomBytes } from "crypto"; -import { getAddress, type Hex } from "viem"; +import { getAddress, parseUnits, type Hex } from "viem"; import type { DeployedOffering } from "../../types"; import { getDefaultChainId, getPublicClient, getSettlementAccount, - getTokenDecimals, - getUsdcAddress, - toAtomicAmount, } from "./chain"; +import { DEFAULT_STABLECOINS } from "@x402/evm"; +import { Network } from "@x402/core/types"; type TempoHashPayload = { type: "hash"; @@ -41,7 +40,7 @@ export interface MppSettlementResult { export async function buildMppPaymentChallenge( offering: DeployedOffering, - nonce: string, + nonce: string ): Promise { const handler = await createChargeHandler(offering, nonce); const result = await handler(buildRequest()); @@ -56,7 +55,7 @@ export async function buildMppPaymentChallenge( export async function verifyAndSettleMppPayment( authHeader: string, - offering: DeployedOffering, + offering: DeployedOffering ): Promise { const { Credential, Receipt } = await import("mppx"); const credential = deserializeCredential(Credential, authHeader); @@ -99,15 +98,19 @@ export function buildMppReceipt(result: MppSettlementResult): string { reference: result.receiptReference || result.txHash || result.paymentKey, timestamp: new Date().toISOString(), status: "success", - }), + }) ).toString("base64url"); } async function createChargeHandler(offering: DeployedOffering, nonce: string) { const chainId = getDefaultChainId(); - const asset = getUsdcAddress(chainId); - const decimals = await getTokenDecimals(chainId, asset); - const amount = toAtomicAmount(offering.offering.priceValue, decimals); + const network = `eip155:${chainId}` as Network; + const asset = DEFAULT_STABLECOINS[network].address; + const decimals = DEFAULT_STABLECOINS[network].decimals; + const amount = parseUnits( + String(offering.offering.priceValue), + decimals + ).toString(); const { Mppx, tempo } = await import("mppx/server"); const payment = Mppx.create({ secretKey: getSecretKey(), @@ -130,7 +133,7 @@ async function createChargeHandler(offering: DeployedOffering, nonce: string) { chainId, description: offering.offering.description, expires: new Date( - Date.now() + Math.max(offering.offering.slaMinutes, 1) * 60_000, + Date.now() + Math.max(offering.offering.slaMinutes, 1) * 60_000 ).toISOString(), feePayer: true, meta: { @@ -148,7 +151,7 @@ function deserializeCredential( source?: string; }; }, - authHeader: string, + authHeader: string ) { try { return Credential.deserialize(authHeader); @@ -191,7 +194,7 @@ function getChallengeChainId(request: any): number { async function findReceiptPayer( chainId: number, - receipt: { reference: string }, + receipt: { reference: string } ): Promise { if (!isTxHash(receipt.reference)) { throw new Error("MPP credential source is required"); @@ -207,7 +210,7 @@ function isTxHash(value: string): value is Hex { } function parseDidPkh( - source?: string, + source?: string ): { chainId: number; address: string } | null { if (!source) return null; const match = /^did:pkh:eip155:(\d+):(0x[a-fA-F0-9]{40})$/.exec(source); diff --git a/serve/server/payment/x402.ts b/serve/server/payment/x402.ts index 2a800e0..9a198a8 100644 --- a/serve/server/payment/x402.ts +++ b/serve/server/payment/x402.ts @@ -22,19 +22,23 @@ import { isPermit2Payload, } from "@x402/evm"; import type { FacilitatorEvmSigner } from "@x402/evm"; +import { DEFAULT_STABLECOINS } from "@x402/evm"; import { registerExactEvmScheme as registerExactEvmFacilitatorScheme } from "@x402/evm/exact/facilitator"; import { registerExactEvmScheme as registerExactEvmServerScheme } from "@x402/evm/exact/server"; -import { encodeFunctionData, getAddress, type Address, type Hex } from "viem"; +import { + encodeFunctionData, + getAddress, + parseUnits, + type Address, + type Hex, +} from "viem"; +import { type IEvmProviderAdapter } from "@virtuals-protocol/acp-node-v2"; import type { DeployedOffering } from "../../types"; import { - getDefaultChainId, - getPublicClient, - getTokenDecimals, - getUsdcAddress, - getWalletClient, - toAtomicAmount, - verifyTypedData, -} from "./chain"; + createProviderAdapter, + getWalletAddress, +} from "../../../src/lib/agentFactory"; +import { getDefaultChainId, getPublicClient, verifyTypedData } from "./chain"; export interface X402SettlementResult { clientAddress: string; @@ -53,14 +57,14 @@ class LocalX402FacilitatorClient implements FacilitatorClient { verify( paymentPayload: PaymentPayload, - paymentRequirements: PaymentRequirements, + paymentRequirements: PaymentRequirements ): Promise { return this.facilitator.verify(paymentPayload, paymentRequirements); } settle( paymentPayload: PaymentPayload, - paymentRequirements: PaymentRequirements, + paymentRequirements: PaymentRequirements ): Promise { return this.facilitator.settle(paymentPayload, paymentRequirements); } @@ -74,7 +78,7 @@ const runtimes = new Map>(); export async function buildX402PaymentChallenge( offering: DeployedOffering, - resourceUrl: string, + resourceUrl: string ): Promise<{ header: string; body: PaymentRequired }> { const requirements = await buildRequirements(offering); const runtime = await getRuntimeForNetwork(requirements.network); @@ -86,6 +90,7 @@ export async function buildX402PaymentChallenge( mimeType: "application/json", }, "Payment required", + { bazaar: buildBazaarDiscovery(offering) } ); return { @@ -96,14 +101,14 @@ export async function buildX402PaymentChallenge( export async function verifyAndSettleX402Payment( paymentHeader: string, - offering: DeployedOffering, + offering: DeployedOffering ): Promise { const payload = decodePaymentPayload(paymentHeader); const expected = await buildRequirements(offering); const runtime = await getRuntimeForNetwork(expected.network); const matched = runtime.resourceServer.findMatchingRequirements( [expected], - payload, + payload ); if (!matched) { throw new Error("x402 payment requirements mismatch"); @@ -121,25 +126,25 @@ export async function verifyAndSettleX402Payment( const verifyResult = await runtime.resourceServer.verifyPayment( payload, - matched, + matched ); if (!verifyResult.isValid) { throw new Error( verifyResult.invalidMessage || verifyResult.invalidReason || - "Invalid x402 payment signature", + "Invalid x402 payment signature" ); } const settleResult = await runtime.resourceServer.settlePayment( payload, - matched, + matched ); if (!settleResult.success) { throw new Error( settleResult.errorMessage || settleResult.errorReason || - "x402 payment settlement failed", + "x402 payment settlement failed" ); } @@ -160,13 +165,18 @@ export function buildX402PaymentResponse(result: X402SettlementResult): string { } async function buildRequirements( - offering: DeployedOffering, + offering: DeployedOffering ): Promise { const chainId = getDefaultChainId(); const network = `eip155:${chainId}` as Network; - const asset = getUsdcAddress(chainId); - const decimals = await getTokenDecimals(chainId, asset); + const asset = DEFAULT_STABLECOINS[network]; + if (!asset) { + throw new Error(`Unsupported chain ${chainId}`); + } + const assetAddress = asset.address; + const decimals = asset.decimals; const runtime = await getRuntime(chainId); + const [requirements] = await runtime.resourceServer.buildPaymentRequirementsFromOptions( [ @@ -175,18 +185,20 @@ async function buildRequirements( network, payTo: getAddress(offering.providerWallet), price: { - asset: getAddress(asset), - amount: toAtomicAmount(offering.offering.priceValue, decimals), + asset: getAddress(assetAddress), + amount: parseUnits( + String(offering.offering.priceValue), + decimals + ).toString(), extra: { name: process.env.X402_ASSET_NAME || "USDC", version: process.env.X402_ASSET_VERSION || "2", - assetTransferMethod: "eip3009", }, }, maxTimeoutSeconds: Math.max(offering.offering.slaMinutes, 1) * 60, }, ], - undefined, + undefined ); if (!requirements) { @@ -195,6 +207,42 @@ async function buildRequirements( return requirements; } +function buildBazaarDiscovery( + offering: DeployedOffering +): Record { + const toBodySchema = ( + value: Record | string + ): Record => { + if (typeof value === "string") { + return { + type: "object", + description: value, + additionalProperties: true, + }; + } + return value.type ? value : { type: "object", ...value }; + }; + + const toOutput = ( + value: Record | string + ): Record => + typeof value === "string" + ? { type: "json", example: value } + : { type: "json", schema: value }; + + return { + info: { + input: { + type: "http", + method: "POST", + bodyType: "json", + body: toBodySchema(offering.offering.requirements), + }, + output: toOutput(offering.offering.deliverable), + }, + }; +} + function decodePaymentPayload(paymentHeader: string): PaymentPayload { try { return decodePaymentSignatureHeader(paymentHeader); @@ -225,12 +273,12 @@ async function createRuntime(chainId: number): Promise { const network = `eip155:${chainId}` as Network; const facilitator = new x402Facilitator(); registerExactEvmFacilitatorScheme(facilitator, { - signer: buildFacilitatorSigner(chainId), + signer: await buildFacilitatorSigner(chainId), networks: network, }); const resourceServer = new x402ResourceServer( - new LocalX402FacilitatorClient(facilitator), + new LocalX402FacilitatorClient(facilitator) ); registerExactEvmServerScheme(resourceServer, { networks: [network] }); await resourceServer.initialize(); @@ -238,10 +286,28 @@ async function createRuntime(chainId: number): Promise { return { network, resourceServer }; } -function buildFacilitatorSigner(chainId: number): FacilitatorEvmSigner { +let providerAdapterPromise: Promise | null = null; + +function getProviderAdapter(): Promise { + if (!providerAdapterPromise) { + providerAdapterPromise = createProviderAdapter(); + } + return providerAdapterPromise; +} + +async function buildFacilitatorSigner( + chainId: number +): Promise { const publicClient = getPublicClient(chainId); - const wallet = getWalletClient(chainId); - const address = getAddress(wallet.account!.address); + const provider = await getProviderAdapter(); + const address = getAddress(getWalletAddress() as Address); + + const send = (args: { to: Address; data?: Hex; value?: bigint }) => + provider.sendTransaction(chainId, { + to: args.to, + ...(args.data !== undefined ? { data: args.data } : {}), + ...(args.value !== undefined ? { value: args.value } : {}), + }) as Promise; return { getAddresses: () => [address], @@ -253,13 +319,9 @@ function buildFacilitatorSigner(chainId: number): FacilitatorEvmSigner { functionName: args.functionName, args: args.args, }); - return wallet.sendTransaction({ - to: args.address, - data, - gas: args.gas, - }); + return send({ to: args.address, data }); }, - sendTransaction: (args) => wallet.sendTransaction(args), + sendTransaction: (args) => send({ to: args.to, data: args.data }), waitForTransactionReceipt: (args) => publicClient.waitForTransactionReceipt(args), getCode: (args) => publicClient.getCode(args), @@ -279,7 +341,7 @@ function extractPayer(payload: PaymentPayload): Address { async function isEip3009AuthorizationUsed( payload: PaymentPayload, - requirements: PaymentRequirements, + requirements: PaymentRequirements ): Promise { const schemePayload = payload.payload as any; if (!isEIP3009Payload(schemePayload)) { @@ -297,13 +359,13 @@ async function isEip3009AuthorizationUsed( getAddress(schemePayload.authorization.from), schemePayload.authorization.nonce, ], - }), + }) ); } async function assertRecoverableEip3009Payment( payload: PaymentPayload, - requirements: PaymentRequirements, + requirements: PaymentRequirements ): Promise { const schemePayload = payload.payload as any; if (!isEIP3009Payload(schemePayload)) { @@ -355,17 +417,23 @@ async function assertRecoverableEip3009Payment( function buildPaymentKey( payload: PaymentPayload, requirements: PaymentRequirements, - settleResult?: SettleResponse, + settleResult?: SettleResponse ): string { const schemePayload = payload.payload as any; if (isEIP3009Payload(schemePayload)) { - return `x402:${requirements.network}:${getAddress(requirements.asset)}:${schemePayload.authorization.nonce}`; + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${ + schemePayload.authorization.nonce + }`; } if (isPermit2Payload(schemePayload)) { - return `x402:${requirements.network}:${getAddress(requirements.asset)}:${schemePayload.permit2Authorization.nonce}`; + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${ + schemePayload.permit2Authorization.nonce + }`; } if (!settleResult?.transaction) { throw new Error("Unsupported x402 payment payload"); } - return `x402:${requirements.network}:${getAddress(requirements.asset)}:${settleResult.transaction}`; + return `x402:${requirements.network}:${getAddress(requirements.asset)}:${ + settleResult.transaction + }`; } diff --git a/serve/server/relay.ts b/serve/server/relay.ts index cd600c6..73416fa 100644 --- a/serve/server/relay.ts +++ b/serve/server/relay.ts @@ -15,6 +15,7 @@ import { export interface ServiceJobRelayOptions { apiUrl: string; agentToken: string; + resolveOffering: (offeringId: string) => Promise; } interface ServiceJobOffering { @@ -103,6 +104,22 @@ export function startServiceJobRelay( console.log(`[Relay] Disconnected from ACP service jobs: ${reason}`); }); + const resolveLiveOffering = async ( + request: PaymentChallengeRequest | ServiceJobRequest, + ): Promise => { + if ( + request.providerAddress.toLowerCase() !== + offering.providerWallet.toLowerCase() + ) { + throw new Error("Relay request provider does not match this runtime"); + } + if (request.offering.id !== offering.offeringId) { + throw new Error("Relay request offering does not match this runtime"); + } + const fresh = await options.resolveOffering(request.offering.id); + return { ...offering, offering: fresh, offeringId: fresh.id }; + }; + socket.on( "service-job:payment-challenge", async ( @@ -110,15 +127,15 @@ export function startServiceJobRelay( ack: (response: PaymentChallengeAck) => void, ) => { try { - assertRelayRequestMatchesOffering(offering, request); + const live = await resolveLiveOffering(request); if (request.protocol === "x402") { const challenge = await buildX402PaymentChallenge( - offering, + live, request.resourceUrl || serviceJobEndpoint( options.apiUrl, - offering.providerWallet, - offering.offering.slug || offering.offering.id, + live.providerWallet, + live.offering.slug || live.offering.id, "x402", ), ); @@ -131,7 +148,7 @@ export function startServiceJobRelay( } const header = await buildMppPaymentChallenge( - offering, + live, request.nonce || `${Date.now()}`, ); ack({ @@ -158,11 +175,11 @@ export function startServiceJobRelay( ack: (response: ServiceJobAck) => void, ) => { try { - assertRelayRequestMatchesOffering(offering, request); - const payment = await verifyAndSettlePayment(offering, request); + const live = await resolveLiveOffering(request); + const payment = await verifyAndSettlePayment(live, request); const input: HandlerInput = { requirements: request.requirements, - offering: offering.offering, + offering: live.offering, jobId: request.jobId, client: { address: payment.clientAddress }, protocol: request.protocol, @@ -186,25 +203,6 @@ export function startServiceJobRelay( return socket; } -function assertRelayRequestMatchesOffering( - offering: DeployedOffering, - request: { providerAddress: string; offering: ServiceJobOffering }, -): void { - if ( - request.providerAddress.toLowerCase() !== - offering.providerWallet.toLowerCase() - ) { - throw new Error("Relay request provider does not match this runtime"); - } - if ( - request.offering.id !== offering.offering.id && - request.offering.name !== offering.offering.name && - request.offering.name !== offering.offering.slug - ) { - throw new Error("Relay request offering does not match this runtime"); - } -} - async function verifyAndSettlePayment( offering: DeployedOffering, request: ServiceJobRequest, diff --git a/src/commands/serve.ts b/src/commands/serve.ts index a133fea..b0ef2db 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -27,6 +27,7 @@ import { getWalletId, } from "../lib/config"; import { isJson, outputError, outputResult } from "../lib/output"; +import { selectOption } from "../lib/prompt"; import type { ServeProtocol } from "../../serve/types"; import { serviceJobEndpoint } from "../../serve/server/relay"; @@ -455,8 +456,11 @@ export function registerServeCommands(program: Command): void { serve .command("init") - .description("Scaffold a local offering runtime") - .requiredOption("--name ", "Offering name") + .description("Scaffold a handler.ts for an existing agent offering") + .option( + "--name ", + "Offering name (selected interactively if omitted)", + ) .option("--output ", "Project root directory", ".") .action(async (opts, cmd) => { const json = isJson(cmd); @@ -467,14 +471,47 @@ export function registerServeCommands(program: Command): void { const rootDir = resolve(opts.output); const { agentApi } = await getClient(); const agent = await agentApi.getById(active.agentId); - const agentSlug = slugify(agent.name); - const offeringSlug = slugify(opts.name); + + if (!agent.offerings || agent.offerings.length === 0) { + outputError( + json, + "Agent has no offerings. Create one with `acp offering create` first.", + ); + return; + } + + let offering: AgentOffering; + if (opts.name) { + const requested = String(opts.name).trim().toLowerCase(); + const matches = agent.offerings.filter( + (o) => o.name.toLowerCase() === requested, + ); + if (matches.length === 0) { + outputError(json, `No offering found with name: ${opts.name}`); + return; + } + if (matches.length > 1) { + outputError( + json, + `Multiple offerings match name "${opts.name}". Refine selection.`, + ); + return; + } + offering = matches[0]; + } else { + offering = await selectOption( + "Choose an offering to init:", + agent.offerings, + (o) => `${o.name} (${o.id})`, + ); + } + const offeringDir = resolve( rootDir, "agents", - agentSlug, + agent.walletAddress, "offerings", - offeringSlug, + offering.id, ); if (existsSync(resolve(offeringDir, "handler.ts"))) { @@ -486,23 +523,10 @@ export function registerServeCommands(program: Command): void { dirname(fileURLToPath(import.meta.url)), "../../serve/scaffold", ); - const offeringTemplate = readFileSync( - resolve(scaffoldDir, "offering.json.template"), - "utf8", - ); - - writeFileSync( - resolve(offeringDir, "offering.json"), - offeringTemplate.replace("{{NAME}}", opts.name), - ); writeFileSync( resolve(offeringDir, "handler.ts"), readFileSync(resolve(scaffoldDir, "handler.ts.template"), "utf8"), ); - writeFileSync( - resolve(offeringDir, "budget.ts"), - readFileSync(resolve(scaffoldDir, "budget.ts.template"), "utf8"), - ); const serveConfigPath = getServeConfigPath(rootDir); const serveConfig = existsSync(serveConfigPath) @@ -514,10 +538,10 @@ export function registerServeCommands(program: Command): void { offerings: {}, }) as Record; agentConfig.offerings ??= {}; - agentConfig.offerings[offeringSlug] = { - dir: `agents/${agentSlug}/offerings/${offeringSlug}`, + agentConfig.offerings[offering.id] = { + dir: `agents/${agent.walletAddress}/offerings/${offering.id}`, protocols: ["x402", "mpp", "acp"], - registered: false, + registered: true, }; agents[active.agentId] = agentConfig; serveConfig.agents = agents; @@ -528,7 +552,7 @@ export function registerServeCommands(program: Command): void { outputResult(json, { success: true, - offering: opts.name, + offering: { id: offering.id, name: offering.name }, directory: offeringDir, }); } catch (err) { @@ -538,9 +562,9 @@ export function registerServeCommands(program: Command): void { serve .command("start") - .description("Start the provider runtime for a single offering") + .description("Start the provider runtime for all registered offerings") .option("--dir ", "Project root directory", ".") - .option("--offering ", "Offering slug, ID, or name") + .option("--offering ", "Filter to a single offering") .option("--port ", "Local health-check port") .option("--settle-8183", "Reserved for future ERC-8183 settlement") .action(async (opts, cmd) => { @@ -555,10 +579,10 @@ export function registerServeCommands(program: Command): void { loadLocalOfferings(rootDir, active.agentId), opts.offering, ); - if (selected.length === 0) - throw new Error("No matching offerings found."); - if (selected.length > 1) { - throw new Error("Multiple offerings matched. Use --offering."); + if (selected.length === 0) { + throw new Error( + "No offerings found in serve.json. Run `acp serve init` first.", + ); } const agentName = getLocalAgentName(rootDir, active.agentId); @@ -569,23 +593,52 @@ export function registerServeCommands(program: Command): void { } catch { agent = undefined; } - const local = selected[0]; - const offering = materializeOffering( - local, - agent ? findRemoteOffering(local, agent) : undefined, - ); - const { startOfferingServer } = + + const runtimeOfferings = selected.map((local) => { + const remote = agent + ? (agent.offerings.find((o) => o.id === local.slug) ?? + findRemoteOffering(local, agent)) + : undefined; + return { + dir: local.dir, + offering: materializeOffering(local, remote), + protocols: local.protocols, + }; + }); + + const { agentApi } = await getClient(); + const resolveOffering = async (offeringId: string) => { + const live = await agentApi.getById(active.agentId); + const found = live.offerings.find((o) => o.id === offeringId); + if (!found) { + throw new Error( + `Offering ${offeringId} not found on agent ${active.agentId}.`, + ); + } + return { + id: found.id, + slug: found.id, + name: found.name, + description: found.description, + priceType: found.priceType, + priceValue: Number(found.priceValue), + slaMinutes: found.slaMinutes, + requirements: found.requirements, + deliverable: found.deliverable, + }; + }; + + const { startOfferingsServer } = await import("../../serve/server/index"); - await startOfferingServer({ - dir: local.dir, + await startOfferingsServer({ port: opts.port ? Number(opts.port) : getDefaultPort(serveConfig.port), agentSlug: slugify(agent?.name ?? agentName), providerWallet: active.wallet, - offering, - protocols: local.protocols, + offerings: runtimeOfferings, + resolveOffering, settle8183: opts.settle8183 === true, apiUrl: getApiUrl(), agentToken: await getOrCreateAgentToken(active.wallet), @@ -748,52 +801,26 @@ export function registerServeCommands(program: Command): void { serve .command("stop") - .description("Stop a locally running offering runtime") - .option("--dir ", "Project root directory", ".") - .option("--offering ", "Offering slug, ID, or name") - .action(async (opts, cmd) => { + .description("Stop the locally running provider runtime") + .action(async (_opts, cmd) => { const json = isJson(cmd); try { const active = requireActiveAgent(json); if (!active) return; - const { getPidFilePath } = await import("../../serve/server/index"); - const rootDir = resolve(opts.dir); - let agent: Agent | undefined; - try { - const { agentApi } = await getClient(); - agent = await agentApi.getById(active.agentId); - } catch { - agent = undefined; + const { getRuntimePidFilePath } = await import( + "../../serve/server/index" + ); + const pidFile = getRuntimePidFilePath(active.wallet); + if (!existsSync(pidFile)) { + outputResult(json, { success: true, stopped: 0 }); + return; } + const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); let stopped = 0; - for (const local of selectLocalOfferings( - loadLocalOfferings(rootDir, active.agentId), - opts.offering, - )) { - const offering = materializeOffering( - local, - agent ? findRemoteOffering(local, agent) : undefined, - ); - const legacyOfferingId = - typeof local.offeringJson.id === "string" - ? local.offeringJson.id - : local.slug; - const pidFiles = [ - getPidFilePath(offering.id), - ...(offering.id === legacyOfferingId - ? [] - : [getPidFilePath(legacyOfferingId)]), - ]; - for (const pidFile of pidFiles) { - if (!existsSync(pidFile)) continue; - const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); - try { - process.kill(pid, "SIGTERM"); - stopped += 1; - break; - } catch {} - } - } + try { + process.kill(pid, "SIGTERM"); + stopped = 1; + } catch {} outputResult(json, { success: true, stopped }); } catch (err) { outputError(json, err instanceof Error ? err.message : String(err));