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/bin/acp.ts b/bin/acp.ts index d742e87..0c2803b 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -16,6 +16,7 @@ import { registerSubscriptionCommands } from "../src/commands/subscription"; 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") @@ -42,5 +43,6 @@ registerSubscriptionCommands(program); registerChainCommands(program); registerEmailCommands(program); registerCardCommands(program); +registerServeCommands(program); program.parse(); diff --git a/package-lock.json b/package-lock.json index 4b28221..da6c4dd 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.1.2", + "@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", @@ -261,6 +264,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.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -1828,6 +1837,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", @@ -2692,6 +2730,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", @@ -2868,6 +2912,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", @@ -4150,6 +4214,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", @@ -4484,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", @@ -4700,6 +4779,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", @@ -4847,9 +4971,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", @@ -5533,6 +5657,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", @@ -6096,7 +6226,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6113,7 +6242,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6130,7 +6258,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6147,7 +6274,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6164,7 +6290,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6181,7 +6306,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6198,7 +6322,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6215,7 +6338,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6232,7 +6354,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6249,7 +6370,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6266,7 +6386,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6283,7 +6402,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6300,7 +6418,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6317,7 +6434,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6334,7 +6450,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6351,7 +6466,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6368,7 +6482,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6385,7 +6498,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6402,7 +6514,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6419,7 +6530,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6436,7 +6546,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6453,7 +6562,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6470,7 +6578,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6487,7 +6594,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6504,7 +6610,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6521,7 +6626,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6656,9 +6760,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", @@ -6673,7 +6777,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": { @@ -6700,6 +6804,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", @@ -6893,6 +7027,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 8426f38..48c377f 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,13 @@ "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.1.2", + "@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 new file mode 100644 index 0000000..a520566 --- /dev/null +++ b/serve/ARCHITECTURE.md @@ -0,0 +1,73 @@ +# ACP Serve Architecture + +ACP Serve turns a developer's `handler.ts` into one provider runtime that can serve: + +- direct ACP jobs from the ACP registry +- direct x402 jobs through `agentic-commerce-be` +- direct MPP jobs through `agentic-commerce-be` + +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. + +## Runtime Model + +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/runtime/loader.ts b/serve/runtime/loader.ts new file mode 100644 index 0000000..6fb8f03 --- /dev/null +++ b/serve/runtime/loader.ts @@ -0,0 +1,35 @@ +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"); + 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/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..882089b --- /dev/null +++ b/serve/runtime/sandbox.ts @@ -0,0 +1,52 @@ +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) => { + let settled = false; + let timeout: NodeJS.Timeout; + const worker = new Worker(new URL("./sandbox-worker.ts", import.meta.url), { + workerData: { handlerPath, input }, + }); + + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(); + }; + + timeout = setTimeout(() => { + worker.terminate().catch(() => {}); + finish(() => reject(new Error(`Handler timed out after ${timeoutMs}ms`))); + }, timeoutMs); + + 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.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/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..783ee1d --- /dev/null +++ b/serve/server/index.ts @@ -0,0 +1,324 @@ +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, + type ServiceJobRelayOptions, +} from "./relay"; +import type { Socket } from "socket.io-client"; +import { base } from "viem/chains"; + +export interface RuntimeOffering { + dir: string; + offering: DeployedOffering["offering"]; + protocols?: ServeProtocol[]; +} + +export interface ServerOptions { + port?: number; + agentSlug: string; + providerWallet: string; + offerings: RuntimeOffering[]; + resolveOffering: (offeringId: string) => Promise; + settle8183?: boolean; + apiUrl?: string; + agentToken?: string; + sandbox?: boolean; +} + +interface PreparedOffering { + deployed: DeployedOffering; + handlers: LoadedHandlers; + protocols: ServeProtocol[]; +} + +export async function startOfferingsServer( + options: ServerOptions, +): Promise { + const { providerWallet, agentSlug } = options; + const port = options.port ?? 3000; + const apiUrl = options.apiUrl ?? process.env.ACP_API_URL; + const settle8183 = options.settle8183 ?? false; + + if (options.offerings.length === 0) { + throw new Error("No offerings to serve."); + } + + 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), + }; + } + + 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 (anyUsesRelay && (!apiUrl || !options.agentToken)) { + throw new Error( + "ACP API URL and agent auth token are required for x402/MPP.", + ); + } + + 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 = getRuntimePidFilePath(providerWallet); + 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", + 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; + } + res.statusCode = 404; + res.end("Not found"); + }); + + server.listen(port, () => { + console.log(`\nACP Serve runtime running on port ${port}\n`); + console.log(`Provider: ${providerWallet}`); + console.log("Mode: BE-mediated service-job runtime"); + console.log(`Settlement: ${settle8183 ? "ERC-8183" : "direct x402/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"); + } + console.log(`\nHealth: http://localhost:${port}/health`); + }); + + const shutdown = () => { + for (const relay of relays) relay.disconnect(); + server.close(); + try { + unlinkSync(pidFile); + } catch {} + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +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 || base.id); + const jobRequirements = new Map | string>(); + + agent.on("entry", async (session: any, entry: any) => { + const jobId = session.jobId; + try { + 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; + + 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( + liveDeployed, + requirements, + entry.from || "unknown", + "acp", + jobId, + ); + if (match.handlers.budgetHandler) { + const budget = await match.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(liveOffering.priceValue, chainId), + ); + } + } + + if (status === "funded" && jobRequirements.has(jobId)) { + const result = await match.handlers.handler( + buildHandlerInput( + liveDeployed, + jobRequirements.get(jobId)!, + entry.from || "unknown", + "acp", + jobId, + ), + ); + await session.submit(result.deliverable); + } + + 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 agent.start(); +} + +async function findOfferingForSession( + offerings: PreparedOffering[], + session: any, +): Promise { + const job = session.job ?? (await session.fetchJob()); + const providerAddress = String(job.providerAddress ?? "").toLowerCase(); + const description = String(job.description ?? ""); + 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( + 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 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 new file mode 100644 index 0000000..697c7e4 --- /dev/null +++ b/serve/server/payment/chain.ts @@ -0,0 +1,48 @@ +import { + createPublicClient, + http, + verifyTypedData, + type Address, + type Chain, + type Hex, +} from "viem"; +import { privateKeyToAccount, nonceManager } from "viem/accounts"; +import { base, baseSepolia } from "viem/chains"; + +const SUPPORTED_CHAINS = [base, baseSepolia]; + +export function getDefaultChainId(): number { + return Number(process.env.ACP_CHAIN_ID || base.id); +} + +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}`]; +} + +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 { verifyTypedData }; diff --git a/serve/server/payment/mpp.ts b/serve/server/payment/mpp.ts new file mode 100644 index 0000000..626c2e8 --- /dev/null +++ b/serve/server/payment/mpp.ts @@ -0,0 +1,219 @@ +import { randomBytes } from "crypto"; +import { getAddress, parseUnits, type Hex } from "viem"; +import type { DeployedOffering } from "../../types"; +import { + getDefaultChainId, + getPublicClient, + getSettlementAccount, +} from "./chain"; +import { DEFAULT_STABLECOINS } from "@x402/evm"; +import { Network } from "@x402/core/types"; + +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; + +const localMppSecretKey = randomBytes(32).toString("hex"); + +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 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(), + 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.ACP_MPP_SECRET_KEY || + process.env.JWT_SECRET || + localMppSecretKey + ); +} + +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..9a198a8 --- /dev/null +++ b/serve/server/payment/x402.ts @@ -0,0 +1,439 @@ +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 { 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, + parseUnits, + type Address, + type Hex, +} from "viem"; +import { type IEvmProviderAdapter } from "@virtuals-protocol/acp-node-v2"; +import type { DeployedOffering } from "../../types"; +import { + createProviderAdapter, + getWalletAddress, +} from "../../../src/lib/agentFactory"; +import { getDefaultChainId, getPublicClient, 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", + { bazaar: buildBazaarDiscovery(offering) } + ); + + 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 = 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( + [ + { + scheme: "exact", + network, + payTo: getAddress(offering.providerWallet), + price: { + 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", + }, + }, + maxTimeoutSeconds: Math.max(offering.offering.slaMinutes, 1) * 60, + }, + ], + undefined + ); + + if (!requirements) { + throw new Error("Unable to build x402 requirements"); + } + 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); + } 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: await buildFacilitatorSigner(chainId), + networks: network, + }); + + const resourceServer = new x402ResourceServer( + new LocalX402FacilitatorClient(facilitator) + ); + registerExactEvmServerScheme(resourceServer, { networks: [network] }); + await resourceServer.initialize(); + + return { network, resourceServer }; +} + +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 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], + 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 send({ to: args.address, data }); + }, + sendTransaction: (args) => send({ to: args.to, data: args.data }), + 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 new file mode 100644 index 0000000..73416fa --- /dev/null +++ b/serve/server/relay.ts @@ -0,0 +1,235 @@ +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; + resolveOffering: (offeringId: string) => Promise; +} + +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"; + 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; +} + +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}`); + }); + + 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 ( + request: PaymentChallengeRequest, + ack: (response: PaymentChallengeAck) => void, + ) => { + try { + const live = await resolveLiveOffering(request); + if (request.protocol === "x402") { + const challenge = await buildX402PaymentChallenge( + live, + request.resourceUrl || + serviceJobEndpoint( + options.apiUrl, + live.providerWallet, + live.offering.slug || live.offering.id, + "x402", + ), + ); + ack({ + status: "completed", + headers: { "Payment-Required": challenge.header }, + body: challenge.body, + }); + return; + } + + const header = await buildMppPaymentChallenge( + live, + 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, + ) => { + try { + const live = await resolveLiveOffering(request); + const payment = await verifyAndSettlePayment(live, request); + const input: HandlerInput = { + requirements: request.requirements, + offering: live.offering, + jobId: request.jobId, + client: { address: payment.clientAddress }, + protocol: request.protocol, + }; + const result = await handlers.handler(input); + 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; +} + +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, + }; +} 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..b0ef2db --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,875 @@ +import { dirname, resolve } from "path"; +import { randomBytes } from "crypto"; +import { fileURLToPath } from "url"; +import { homedir } from "os"; +import { spawnSync } from "child_process"; +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, + isTokenExpired, + getPublicKey, + 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"; + +type LocalOfferingConfig = { + slug: string; + dir: string; + protocols: ServeProtocol[]; + offeringJson: Record; +}; + +type RailwayDeployOptions = { + bundleDir: string; + serviceName: string; + project?: string; + environment?: string; + variables: Record; + agentToken: string; +}; + +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 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; +} | 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) ?? + {}, + }; +} + +async function getOrCreateAgentToken(wallet: string): Promise { + const existing = getAgentToken(wallet); + if (existing && !isTokenExpired(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 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 (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( + ["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 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, + active: { wallet: string; agentId: string }, + agentName: string, + local: LocalOfferingConfig, + apiUrl: string, + serviceName: string, +): Record { + rmSync(bundleDir, { recursive: true, force: true }); + mkdirSync(bundleDir, { recursive: true }); + const cliRoot = getCliPackageRoot(); + + for (const relativePath of [ + "bin", + "src", + "serve", + "package.json", + "package-lock.json", + "tsconfig.json", + ]) { + const source = resolve(cliRoot, 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 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); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.output); + const { agentApi } = await getClient(); + const agent = await agentApi.getById(active.agentId); + + 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", + agent.walletAddress, + "offerings", + offering.id, + ); + + 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", + ); + writeFileSync( + resolve(offeringDir, "handler.ts"), + readFileSync(resolve(scaffoldDir, "handler.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[offering.id] = { + dir: `agents/${agent.walletAddress}/offerings/${offering.id}`, + protocols: ["x402", "mpp", "acp"], + registered: true, + }; + agents[active.agentId] = agentConfig; + serveConfig.agents = agents; + writeFileSync( + serveConfigPath, + JSON.stringify(serveConfig, null, 2) + "\n", + ); + + outputResult(json, { + success: true, + offering: { id: offering.id, name: offering.name }, + directory: offeringDir, + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("start") + .description("Start the provider runtime for all registered offerings") + .option("--dir ", "Project root directory", ".") + .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) => { + 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 offerings found in serve.json. Run `acp serve init` first.", + ); + } + + 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 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 startOfferingsServer({ + port: opts.port + ? Number(opts.port) + : getDefaultPort(serveConfig.port), + agentSlug: slugify(agent?.name ?? agentName), + providerWallet: active.wallet, + offerings: runtimeOfferings, + resolveOffering, + 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( + "--execute", + "Run the provider deployment after building the bundle", + ) + .option("--railway-project ", "Railway project ID for --execute") + .option("--railway-environment ", "Railway environment for --execute") + .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, + ); + + 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_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, + MPP_SECRET_KEY: + process.env.MPP_SECRET_KEY || randomBytes(32).toString("hex"), + }, + }); + } + + outputResult(json, { + provider, + serviceName, + bundleDir, + executed: Boolean(execution), + endpoints, + execution, + nextSteps: [ + 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.`, + ], + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("stop") + .description("Stop the locally running provider runtime") + .action(async (_opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + 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; + 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(homedir(), ".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; + const chunk = readFileSync(file).subarray( + previousSize, + currentSize, + ); + process.stdout.write(chunk.toString("utf8")); + 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 952e0a8..329e8db 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -23,6 +23,7 @@ function migrateLegacyConfig(): void { interface AgentConfig { publicKey: string; + token?: string; walletId?: string; id?: string; builderCode?: string; @@ -54,24 +55,45 @@ 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 + 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 ); } @@ -79,22 +101,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 { @@ -106,7 +130,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 { @@ -118,7 +144,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 { @@ -130,7 +156,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 { @@ -152,7 +178,7 @@ export function setActiveWallet(walletAddress: string): void { export function registerJob( jobId: string, legacy: boolean, - chainId: number + chainId: number, ): void { const config = loadConfig(); config.jobRegistry ??= {}; @@ -161,7 +187,7 @@ export function registerJob( } export function getJobRegistryEntry( - jobId: string + jobId: string, ): JobRegistryEntry | undefined { return loadConfig().jobRegistry?.[jobId]; } @@ -178,7 +204,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 ( @@ -192,7 +218,7 @@ export function isTokenExpired(token: string): boolean { export function setBuilderCode( walletAddress: string, - builderCode: string + builderCode: string, ): void { const config = loadConfig(); config.agents ??= {};