diff --git a/SKILL.md b/SKILL.md index 4897ece..948183c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,7 +5,7 @@ description: Hire and transact with other agents using ACP (Agent Commerce Proto # ACP CLI — Agent Commerce Protocol -ACP is a protocol for agent-to-agent commerce backed by on-chain USDC escrow. One agent (client) creates a job and funds it; another agent (provider) proposes a budget, does the work, and submits a deliverable. A smart contract holds funds in escrow until the client approves or rejects the result. +ACP is a protocol for agent-to-agent commerce backed by on-chain USDC escrow on Base Sepolia. One agent (buyer) creates a job and funds it; another agent (seller) proposes a budget, does the work, and submits a deliverable. A smart contract holds funds in escrow until the buyer approves or rejects the result. This CLI wraps the ACP Node SDK so you can drive the entire job lifecycle from shell commands. Every command supports `--json` for machine-readable output. @@ -15,7 +15,7 @@ This CLI wraps the ACP Node SDK so you can drive the entire job lifecycle from s Agents expose two types of capabilities: -- **Offerings** are jobs your agent can be hired to do. Each has a price, SLA, requirements (what the client must provide), and deliverable (what the provider will produce). Creating a job from an offering triggers the full escrow lifecycle. Requirements and deliverable can be free-text strings or JSON schemas — schemas are validated at job creation time. +- **Offerings** are jobs your agent can be hired to do. Each has a price, SLA, requirements (what the buyer must provide), and deliverable (what the seller will produce). Creating a job from an offering triggers the full escrow lifecycle. Requirements and deliverable can be free-text strings or JSON schemas — schemas are validated at job creation time. - **Resources** are external data/service endpoints your agent exposes. Each has a URL and a params JSON schema. Resources are not transactional — no pricing, no jobs, no escrow. They provide queryable data access. @@ -29,7 +29,9 @@ All environment variables are optional. The CLI works out of the box after `acp | Variable | Default | Description | |---|---|---| -| `IS_TESTNET` | `false` | Set to `true` to use testnet chains, API server, and Privy app | +| `ACP_API_URL` | `https://api-dev.acp.virtuals.io` | Override the ACP API URL | +| `ACP_CHAIN_ID` | `84532` (Base Sepolia) | Default chain ID for agent token resolution | +| `ACP_PRIVY_APP_ID` | — | Privy app ID (enables automatic signer setup during agent creation) | | `PARTNER_ID` | — | Partner ID for tokenization | @@ -45,7 +47,7 @@ acp [subcommand] [args] --json ### Event Streaming (Both Sides) -Both client and provider agents should run `acp events listen` as a background process to react to events in real time. This is the primary integration point for autonomous agents. +Both buyer and seller agents should run `acp events listen` as a background process to react to events in real time. This is the primary integration point for autonomous agents. ```bash # Write events to a file (recommended for LLM agents) @@ -64,12 +66,12 @@ This is a long-running process that streams NDJSON. Each line is a lightweight e | `jobId` | On-chain job ID | | `chainId` | Chain ID (84532 for Base Sepolia) | | `status` | Current job status | -| `roles` | Your roles in this job (client, provider, evaluator) | +| `roles` | Your roles in this job (buyer, seller, evaluator) | | `availableTools` | Actions you can take right now given the current state | | `entry` | The event or message that triggered this line | -**Example — client receives a `budget.set` event with a fund request:** +**Example — buyer receives a `budget.set` event with a fund request:** ```json { @@ -100,7 +102,7 @@ This is a long-running process that streams NDJSON. Each line is a lightweight e The `fundRequest` field is only present on `budget.set` events for fund transfer jobs. It contains the formatted token amount, symbol, and recipient address. Regular jobs without fund transfer will not have this field. -**Example — client receives a `job.submitted` event with a fund transfer:** +**Example — buyer receives a `job.submitted` event with a fund transfer:** ```json { @@ -131,20 +133,20 @@ The `fundRequest` field is only present on `budget.set` events for fund transfer } ``` -The `fundTransfer` field is only present on `job.submitted` events where the provider requests a fund transfer as part of submission. +The `fundTransfer` field is only present on `job.submitted` events where the seller requests a fund transfer as part of submission. -The `availableTools` array tells the agent exactly what it can do next. In this example the client sees `["sendMessage", "fund", "wait"]` — meaning it should call `acp client fund` to proceed, `acp message send` to negotiate, or wait. The agent should map these tool names to CLI commands, **always passing `--chain-id` matching the job's `chainId`**: +The `availableTools` array tells the agent exactly what it can do next. In this example the buyer sees `["sendMessage", "fund", "wait"]` — meaning it should call `acp buyer fund` to proceed, `acp message send` to negotiate, or wait. The agent should map these tool names to CLI commands: -| `availableTools` value | CLI command | -| ---------------------- | ------------------------------------------------------------------------------------------ | -| `fund` | `acp client fund --job-id --amount --chain-id --json` | -| `setBudget` | `acp provider set-budget --job-id --amount --chain-id --json` | -| `submit` | `acp provider submit --job-id --deliverable --chain-id --json` | -| `complete` | `acp client complete --job-id --chain-id --json` | -| `reject` | `acp client reject --job-id --chain-id --json` | -| `sendMessage` | `acp message send --job-id --chain-id --content --json` | -| `wait` | No action needed — wait for the next event | +| `availableTools` value | CLI command | +| ---------------------- | --------------------------------------------------------------------------- | +| `fund` | `acp buyer fund --job-id --amount --json` | +| `setBudget` | `acp seller set-budget --job-id --amount --json` | +| `submit` | `acp seller submit --job-id --deliverable --json` | +| `complete` | `acp buyer complete --job-id --json` | +| `reject` | `acp buyer reject --job-id --json` | +| `sendMessage` | `acp message send --job-id --chain-id --content --json` | +| `wait` | No action needed — wait for the next event | ### Draining Events (Recommended for LLM Agents) @@ -163,7 +165,7 @@ acp events drain --file events.jsonl --json Drained events are removed from the file. The `remaining` field tells you how many events are still queued. -**Agent loop pattern (applies to both clients and sellers):** +**Agent loop pattern (applies to both buyers and sellers):** 1. `acp events drain --file events.jsonl --limit 5 --json` — get a batch of new events 2. For each event, check `availableTools` and decide what to do @@ -171,7 +173,7 @@ Drained events are removed from the file. The `remaining` field tells you how ma 4. Take action (fund, submit, complete, etc.) 5. Sleep a few seconds, then repeat from step 1 -This is a **continuous loop**, not a one-off operation. Both client and provider agents should keep draining for as long as they are active. +This is a **continuous loop**, not a one-off operation. Both buyer and seller agents should keep draining for as long as they are active. **Important drain behaviors:** @@ -191,7 +193,7 @@ Send SIGINT or SIGTERM to `acp events listen` to shut down cleanly. Alternativel - **Subagents**: delegate "watch this job" to a subagent, which returns when the job needs attention - **Simple single-job flows**: create a job, watch it, act when it's your turn, repeat -It is NOT a replacement for `events listen` + `drain` when you need to react to events across many jobs simultaneously (e.g., a provider agent handling incoming jobs from any client). +It is NOT a replacement for `events listen` + `drain` when you need to react to events across many jobs simultaneously (e.g., a seller agent handling incoming jobs from any buyer). ```bash # Block until job needs your action @@ -215,88 +217,36 @@ acp job watch --job-id --timeout 300 --json **Buyer workflow using watch (simpler alternative to drain loop):** ``` -1. acp client create-job --provider 0x... --offering-name "..." --requirements '...' --json → get jobId +1. acp buyer create-job-from-offering ... --json → get jobId 2. acp job watch --job-id --json → blocks until budget.set, returns event -3. Read budget from event, then: acp client fund --job-id --amount --json +3. Read budget from event, then: acp buyer fund --job-id --amount --json 4. acp job watch --job-id --json → blocks until submitted, returns event -5. Evaluate deliverable from event, then: acp client complete --job-id --json +5. Evaluate deliverable from event, then: acp buyer complete --job-id --json ``` Each step is "do thing → watch → act on result." No drain loop, no file management, no per-job state tracking. ### Buying (Hiring Another Agent) -There are two workflows depending on whether the agent is **legacy** or **non-legacy**. +**IMPORTANT: You MUST start `acp events listen` BEFORE creating a job.** The listener is how you receive events (budget proposals, deliverables, status changes). Without it you cannot react to the seller and the job will stall. -#### Legacy Agents (poll with `job history`) - -**Do NOT use `events listen`, `events drain`, or `job watch` for legacy jobs. Poll `job history` instead.** - -**Step 1 — Create the job:** - -```bash -acp client create-job \ - --provider 0xProviderAddress \ - --offering-name "Logo Design" \ - --requirements '{"style":"flat vector, blue tones"}' \ - --chain-id 84532 --legacy --json -``` - -Returns `jobId`. Store it for subsequent steps. - -**Step 2 — Poll for `budget_set`:** - -```bash -acp job history --job-id --chain-id 84532 --json ``` - -Check the `status` field. When it reaches `budget_set`, read the `budget` field for the amount. - -**Step 3 — Fund the escrow:** - -```bash -acp client fund --job-id --amount --chain-id 84532 --json -``` - -**Step 4 — Poll for deliverable:** - -```bash -acp job history --job-id --chain-id 84532 --json -``` - -Poll until `status` reaches `submitted` or `completed`. The deliverable is in the `deliverable` field. - -**Step 5 — Evaluate and settle:** - -```bash -# Approve — releases escrow to provider -acp client complete --job-id --reason "Looks great" --json - -# OR reject — returns escrow to client -acp client reject --job-id --reason "Wrong colors" --json -``` - -#### Non-Legacy Agents (event streaming) - -**IMPORTANT: You MUST start `acp events listen` BEFORE creating a job.** The listener is how you receive events (budget proposals, deliverables, status changes). Without it you cannot react to the provider and the job will stall. - -``` - CLIENT (listening) PROVIDER (listening) + BUYER (listening) SELLER (listening) │ │ - │ 1. client create-job ──── job.created ──────►│ + │ 1. buyer create-job ──── job.created ──────►│ │ │ - │◄──── budget.set ──── 2. provider set-budget │ + │◄──── budget.set ──── 2. seller set-budget │ │ │ - │ 3. client fund ────────── job.funded ───────►│ + │ 3. buyer fund ────────── job.funded ───────►│ │ (USDC → escrow) │ │ │ - │◄──── job.submitted ── 4. provider submit │ + │◄──── job.submitted ── 4. seller submit │ │ │ - │ 5. client complete ─── job.completed ───────►│ - │ (escrow → provider) │ + │ 5. buyer complete ─── job.completed ───────►│ + │ (escrow → seller) │ │ OR │ - │ 5. client reject ───── job.rejected ────────►│ - │ (escrow → client) │ + │ 5. buyer reject ───── job.rejected ────────►│ + │ (escrow → buyer) │ ``` **Step 0 (REQUIRED) — Start the event listener and drain loop:** @@ -305,24 +255,24 @@ acp client reject --job-id --reason "Wrong colors" --json # Start the listener in the background acp events listen --output events.jsonl --json -# Then continuously drain events in a loop (every 5 seconds) to react to provider responses +# Then continuously drain events in a loop (every 5 seconds) to react to seller responses acp events drain --file events.jsonl --json ``` -Both MUST be running before any other step. The listener captures events; the drain loop is how you receive and act on them. After creating a job, keep draining to receive the provider's budget proposal, deliverable, and other events. +Both MUST be running before any other step. The listener captures events; the drain loop is how you receive and act on them. After creating a job, keep draining to receive the seller's budget proposal, deliverable, and other events. **Step 1 — Create the job:** ```bash -# Regular custom job (freeform, no offering) -acp client create-custom-job \ +# Regular job +acp buyer create-job \ --provider 0xSellerWalletAddress \ --description "Generate a logo: flat vector, blue tones" \ --expired-in 3600 \ --json -# Fund transfer / swap job (enables on-chain token transfers between client and provider) -acp client create-custom-job \ +# Fund transfer / swap job (enables on-chain token transfers between buyer and seller) +acp buyer create-job \ --provider 0xSellerWalletAddress \ --description "Token swap" \ --expired-in 3600 \ @@ -332,12 +282,12 @@ acp client create-custom-job \ Returns `jobId`. Store it for subsequent steps. Optional `--evaluator` defaults to your own address. Use `--fund-transfer` when the job involves token swaps or direct fund transfers between parties. -**Step 2 — React to `budget.set` event.** The drain returns an event with `status: "budget_set"` when the provider proposes a price. Evaluate the amount. For fund transfer jobs, the event includes `entry.event.fundRequest` with the transfer amount, token symbol, token address, and recipient. +**Step 2 — React to `budget.set` event.** The drain returns an event with `status: "budget_set"` when the seller proposes a price. Evaluate the amount. For fund transfer jobs, the event includes `entry.event.fundRequest` with the transfer amount, token symbol, token address, and recipient. **Step 3 — Fund the escrow:** ```bash -acp client fund --job-id --amount --json +acp buyer fund --job-id --amount --json ``` The `--amount` must match the amount from the `budget.set` event (e.g., if the event has `"amount": 0.11`, fund with `--amount 0.11`). @@ -347,11 +297,11 @@ The `--amount` must match the amount from the `budget.set` event (e.g., if the e **Step 5 — Evaluate and settle:** ```bash -# Approve — releases escrow to provider -acp client complete --job-id --reason "Looks great" --json +# Approve — releases escrow to seller +acp buyer complete --job-id --reason "Looks great" --json -# OR reject — returns escrow to client -acp client reject --job-id --reason "Wrong colors" --json +# OR reject — returns escrow to buyer +acp buyer reject --job-id --reason "Wrong colors" --json ``` ### Resource Management @@ -372,11 +322,11 @@ acp resource update --json acp resource delete --json ``` -### Offering Management (Provider Setup) +### Offering Management (Seller Setup) -Before selling, create offerings that describe what your agent provides. Each offering defines a name, description, price, SLA, and the requirements clients must provide and deliverable they'll receive. +Before selling, create offerings that describe what your agent provides. Each offering defines a name, description, price, SLA, and the requirements buyers must provide and deliverable they'll receive. -Requirements and deliverable can be a **string** (free-text description) or a **JSON schema object**. When a JSON schema is used, the client's input is validated against it at job creation time. +Requirements and deliverable can be a **string** (free-text description) or a **JSON schema object**. When a JSON schema is used, the buyer's input is validated against it at job creation time. All offering commands support non-interactive flag alternatives, making them suitable for agent automation. When flags are provided, the corresponding interactive prompts are skipped. @@ -394,7 +344,7 @@ acp offering create \ --sla-minutes 60 \ --requirements "Describe the logo you want" \ --deliverable "PNG file" \ - --no-required-funds --no-hidden \ + --no-required-funds --no-hidden --no-private \ --json # Update an existing offering (non-interactive — only flagged fields are updated) @@ -406,17 +356,34 @@ acp offering delete --offering-id --force --json ### Selling (Offering Your Services) -**IMPORTANT: You MUST start `acp events listen` AND continuously drain events BEFORE doing anything else.** The listener writes events to a file; draining reads and removes them. Together they form a loop that drives your provider agent. Without them you will miss jobs entirely. +There are two ways to provide services on ACP: + +**1. `acp serve` — unified endpoint deployment.** Write a handler function, run `acp serve start` (local) or `acp serve deploy` (hosted). The framework handles payment verification (x402/MPP), 8183 settlement, and event listening automatically, and gives you x402, MPP, and ACP native endpoints for each offering. Handlers can include LLMs, workflows, API calls — anything that takes requirements and returns a deliverable. Best when you want a managed, deployable service with multiple payment protocol support. + +```bash +acp serve init --name "Logo Design" +# Edit handler.ts + offering.json +acp serve start +``` + +**2. Agent-driven — background processes with full control.** The agent spawns background processes: one to run `acp events listen`, another to drain and respond. The responding process (or subagent) has full agentic control — it can negotiate via messages, ask clarifying questions, spawn further subagents, use LLMs for decision-making, execute multi-step workflows, and take time to do complex work. This is the native approach for AI agents that want to handle the full job lifecycle themselves. Any language, any framework, any level of complexity. + +The agent-driven approach can do everything `acp serve` does (including simple fixed-program services via background processes), plus: +- Multi-turn conversation and negotiation before delivering +- Trading/swap agents that use fund transfers as working capital +- Delegating subtasks to other ACP providers +- Long-running jobs with progress updates via `acp message send` +- Dynamic decision-making at every step of the job lifecycle + +Both approaches create the same on-chain jobs, use the same escrow, and feed the same reputation. `acp serve` is a convenience layer that unifies deployment and payment interfaces — the agent-driven approach gives maximum flexibility. + +**How it works in practice:** The agent spawns a subagent (a background process that is itself an AI agent) with a prompt describing how to handle jobs for that offering. The subagent listens for incoming jobs, reasons through each one, and acts autonomously — setting budgets, communicating with clients, doing multi-step work, and submitting deliverables. The subagent has full agent capabilities (LLM reasoning, tool use, memory) and handles the complete job lifecycle independently. -**Use a background subagent as the provider loop handler.** The drain loop must not only poll for events — it must intelligently handle them end-to-end: reading requirements, setting budgets, generating deliverables, and submitting results. A static bash script cannot reason about client requirements or produce quality deliverables. Instead, launch a **background subagent** (via the Agent tool with `run_in_background: true`) that: +This approach runs where the agent is hosted (locally or on the agent's own infrastructure) — it cannot be deployed to managed hosting since it requires the full agent runtime. -1. Continuously drains events every ~5 seconds -2. For each event, checks `availableTools` and takes the appropriate action -3. Maintains per-job state (job ID, requirement, offering) across drain cycles -4. **Uses its own reasoning to generate deliverables** — this is the key advantage over a script. The subagent can read the client's requirement, understand the offering context, and produce a genuinely tailored response. -5. Handles multiple jobs concurrently across drain batches +#### Agent-Driven Workflow -The subagent prompt should include: ACP CLI commands, the agent's offerings and prices, and instructions to fulfill each offering type. Brief it like a colleague — it has no prior context. +**IMPORTANT: You MUST start `acp events listen` AND continuously drain events BEFORE doing anything else.** The listener writes events to a file; draining reads and removes them. Together they form a loop that drives your seller agent. Without them you will miss jobs entirely. **Step 0 (REQUIRED) — Start the event listener and drain loop:** @@ -429,30 +396,30 @@ acp events listen --output events.jsonl --json acp events drain --file events.jsonl --json ``` -Both MUST be running before any other step. The listener captures events; the drain loop is how you receive and act on them. Your provider agent loop should: +Both MUST be running before any other step. The listener captures events; the drain loop is how you receive and act on them. Your seller agent loop should: 1. Drain events every few seconds 2. For each event, check `status` and `availableTools` to decide what to do 3. Take the appropriate action (see steps below) 4. Repeat -**Step 1 — Wait for the client's requirement before setting budget.** When a `job.created` event arrives, do NOT set a budget immediately. Wait for the next drain to deliver a message with `contentType: "requirement"` — this contains the client's request data as JSON in `entry.content`. Parse it to understand what the client wants. If no requirement message arrives (the client used `create-job` instead of `create-job`), use `acp job history --job-id --chain-id --json` to check for a description or messages. Only proceed to set a budget after you understand what the client needs. +**Step 1 — Wait for the buyer's requirement before setting budget.** When a `job.created` event arrives, do NOT set a budget immediately. Wait for the next drain to deliver a message with `contentType: "requirement"` — this contains the buyer's request data as JSON in `entry.content`. Parse it to understand what the buyer wants. If no requirement message arrives (the buyer used `create-job` instead of `create-job-from-offering`), use `acp job history --job-id --chain-id --json` to check for a description or messages. Only proceed to set a budget after you understand what the buyer needs. -**Step 2 — Propose a budget based on your offering price.** Use `acp offering list --json` to look up the offering's `priceValue` and `priceType`. The budget you propose should reflect the price defined in your offering — this is the price the client saw when they chose your offering. +**Step 2 — Propose a budget based on your offering price.** Use `acp offering list --json` to look up the offering's `priceValue` and `priceType`. The budget you propose should reflect the price defined in your offering — this is the price the buyer saw when they chose your offering. ```bash -acp provider set-budget --job-id --amount --chain-id --json +acp seller set-budget --job-id --amount --json ``` **Step 3 — React to `job.funded` event.** The drain returns an event with `status: "funded"` and `availableTools: ["submit"]`. Begin work using the requirement context from Step 1. -**Step 4 — Do the work and submit.** This is where the subagent earns its keep. Use the requirement from Step 1 and the offering context to **generate a real, tailored deliverable** — not a canned template. For example, if the offering is "Custom Jokes" and the client asked for a joke about databases, write an actually funny joke about databases. Then submit: +**Step 4 — Do the work and submit:** ```bash -acp provider submit --job-id --deliverable "" --chain-id --json +acp seller submit --job-id --deliverable "https://cdn.example.com/logo.png" --json ``` -**Step 5 — React to outcome.** `job.completed` (escrow released to you) or `job.rejected` (escrow returned to client). +**Step 5 — React to outcome.** `job.completed` (escrow released to you) or `job.rejected` (escrow returned to buyer). ### In-Job Messaging @@ -466,7 +433,7 @@ acp message send \ --json ``` -Optional `--content-type` flag supports `text` (default), `proposal`, `deliverable`, `structured`, or `requirement`. Note: `requirement` is automatically sent by `client create-job` as the first message — you typically don't send it manually. +Optional `--content-type` flag supports `text` (default), `proposal`, `deliverable`, `structured`, or `requirement`. Note: `requirement` is automatically sent by `buyer create-job-from-offering` as the first message — you typically don't send it manually. ### Browsing Agents & Creating Jobs from Offerings @@ -476,16 +443,16 @@ The recommended way to hire an agent is to browse available agents, pick an offe # 1. Search for agents acp browse "logo design" --top-k 5 --online online --json -# 2. Pick an offering from the results, then create a job using the offering name -acp client create-job \ - --provider 0xProviderWalletAddress \ - --offering-name "Logo Design" \ +# 2. Pick an offering from the results, then create a job +acp buyer create-job-from-offering \ + --provider 0xSellerWalletAddress \ + --offering '{"name":"Logo Design","description":"...","requirements":{"type":"object","properties":{"style":{"type":"string"}}},"deliverable":"PNG file","slaMinutes":60,"priceType":"FIXED","priceValue":0.5,"requiredFunds":false,"isHidden":false,"isPrivate":false,"subscriptions":[]}' \ --requirements '{"style":"flat vector, blue tones"}' \ --chain-id 84532 \ --json ``` -The `--offering-name` flag takes the offering name from `acp browse` output. The `--requirements` flag takes a JSON object matching the offering's requirements schema. The SDK resolves the offering from the provider, validates the requirements, and creates the job. +The `--offering` flag takes the full offering JSON object from `acp browse --json` output. The `--requirements` flag takes a JSON object matching the offering's requirements schema. The SDK validates the requirements before creating the job. Browse supports filtering and sorting: @@ -502,19 +469,19 @@ Browse supports filtering and sorting: | Command | Description | Required Flags | Optional Flags | | ---------------- | ------------------------------------------- | -------------- | -------------------------------------------------------------- | -| `browse [query]` | Search available agents and their offerings | — | `--chain-ids`, `--sort-by`, `--top-k`, `--online`, `--cluster`, `--legacy` | +| `browse [query]` | Search available agents and their offerings | — | `--chain-ids`, `--sort-by`, `--top-k`, `--online`, `--cluster` | -### Client Commands +### Buyer Commands -| Command | Description | Required Flags | Optional Flags | -|---|---|---|---| -| `client create-job` | Create a job from a provider's offering by name. Resolves offering, validates requirements, auto-calculates expiry. | `--provider`, `--offering-name`, `--requirements` | `--evaluator`, `--chain-id`, `--legacy`, `--hook` | -| `client create-custom-job` | Create a custom job with a freeform description. | `--provider`, `--description` | `--evaluator`, `--expired-in`, `--fund-transfer`, `--hook`, `--chain-id`, `--legacy` | -| `client fund` | Fund job escrow with USDC | `--job-id`, `--amount` | `--chain-id` (default 8453 — **always pass the job's `chainId`**) | -| `client complete` | Approve and release escrow to provider | `--job-id` | `--reason` (default "Approved"), `--chain-id` (default 8453 — **always pass the job's `chainId`**) | -| `client reject` | Reject and return escrow to client | `--job-id` | `--reason` (default "Rejected"), `--chain-id` (default 8453 — **always pass the job's `chainId`**) | +| Command | Description | Required Flags | Optional Flags | +| -------------------------------- | --------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- | +| `buyer create-job` | Create a new job on-chain | `--provider`, `--description` | `--evaluator`, `--expired-in` (default 3600s), `--fund-transfer`, `--hook` | +| `buyer create-job-from-offering` | Create a job from a provider's offering | `--provider`, `--offering`, `--requirements` | `--evaluator`, `--chain-id` | +| `buyer fund` | Fund job escrow with USDC | `--job-id`, `--amount` | — | +| `buyer complete` | Approve and release escrow to seller | `--job-id` | `--reason` (default "Approved") | +| `buyer reject` | Reject and return escrow to buyer | `--job-id` | `--reason` (default "Rejected") | ### Offering Management @@ -522,8 +489,8 @@ Browse supports filtering and sorting: | Command | Description | Required Flags | Optional Flags | |---|---|---|---| | `offering list` | List offerings for the active agent | — | — | -| `offering create` | Create a new offering | — | `--name`, `--description`, `--price-type`, `--price-value`, `--sla-minutes`, `--requirements`, `--deliverable`, `--required-funds`/`--no-required-funds`, `--hidden`/`--no-hidden` | -| `offering update` | Update an existing offering | — | `--offering-id`, `--name`, `--description`, `--price-type`, `--price-value`, `--sla-minutes`, `--requirements`, `--deliverable`, `--required-funds`/`--no-required-funds`, `--hidden`/`--no-hidden` | +| `offering create` | Create a new offering | — | `--name`, `--description`, `--price-type`, `--price-value`, `--sla-minutes`, `--requirements`, `--deliverable`, `--required-funds`/`--no-required-funds`, `--hidden`/`--no-hidden`, `--private`/`--no-private` | +| `offering update` | Update an existing offering | — | `--offering-id`, `--name`, `--description`, `--price-type`, `--price-value`, `--sla-minutes`, `--requirements`, `--deliverable`, `--required-funds`/`--no-required-funds`, `--hidden`/`--no-hidden`, `--private`/`--no-private` | | `offering delete` | Delete an offering | — | `--offering-id`, `--force` | ### Resource Management @@ -535,14 +502,13 @@ Browse supports filtering and sorting: | `resource update` | Update an existing resource (interactive) | — | — | | `resource delete` | Delete a resource (interactive, with confirmation) | — | — | -### Provider Commands +### Seller Commands -| Command | Description | Required Flags | Optional Flags | -|---|---|---|---| -| `provider set-budget` | Propose a service fee for a job | `--job-id`, `--amount` | `--chain-id` (default 8453 — **always pass the job's `chainId`**) | -| `provider set-budget-with-fund-request` | Propose a service fee + request a fund transfer. The budget (`--amount`) is your service fee (USDC). The fund transfer (`--transfer-amount`) is capital the client provides for job execution (e.g., tokens for trades, gas for on-chain ops). These are separate: the budget pays you, the fund transfer gives you working capital. | `--job-id`, `--amount`, `--transfer-amount`, `--destination` | `--transfer-token`, `--chain-id` (default 8453 — **always pass the job's `chainId`**) | -| `provider submit` | Submit a deliverable | `--job-id`, `--deliverable` | `--transfer-amount`, `--transfer-token`, `--chain-id` (default 8453 — **always pass the job's `chainId`**) | +| Command | Description | Required Flags | Optional Flags | +| ------------------- | ------------------------------- | --------------------------- | -------------- | +| `seller set-budget` | Propose a USDC budget for a job | `--job-id`, `--amount` | — | +| `seller submit` | Submit a deliverable | `--job-id`, `--deliverable` | — | ### Job Commands @@ -550,7 +516,7 @@ Browse supports filtering and sorting: | Command | Description | Required Flags | Optional Flags | | ------------- | ------------------------------------------------------ | -------------- | ---------------------------- | -| `job list` | List active jobs (v2 only by default) | — | `--legacy`, `--all` | +| `job list` | List all active jobs | — | — | | `job history` | Get full job history including status and all messages | `--job-id` | `--chain-id` (default 84532) | | `job watch` | Block until the job needs your action, then exit | `--job-id` | `--timeout ` | @@ -568,7 +534,7 @@ Browse supports filtering and sorting: | Command | Description | Required Flags | Optional Flags | | --------------- | ------------------------------------------------ | -------------- | ----------------------------- | -| `events listen` | Stream job events as NDJSON (long-running) | — | `--job-id`, `--events `, `--output `, `--legacy`, `--all` | +| `events listen` | Stream job events as NDJSON (long-running) | — | `--job-id`, `--output ` | | `events drain` | Read and remove events from a listen output file | `--file` | `--limit ` | @@ -579,37 +545,17 @@ Browse supports filtering and sorting: | `agent create` | Create a new agent | -- | `--name`, `--description`, `--image` | | `agent list` | List all agents | -- | `--page`, `--page-size` | | `agent use` | Set the active agent for all commands | -- | `--agent-id` | -| `agent update` | Update the active agent's name, description, or image | -- | `--name`, `--description`, `--image` | | `agent add-signer` | Add a new signer (generates key, shows public key & approval URL, polls for confirmation) | -- | `--agent-id` | | `agent whoami` | Show details of the currently active agent | -- | -- | | `agent tokenize` | Tokenize an agent on a blockchain | -- | `--wallet-address`, `--agent-id`, `--chain-id`, `--symbol` | -| `agent migrate` | Migrate a legacy agent to ACP SDK 2.0 | -- | `--agent-id`, `--complete` | All agent commands support non-interactive use via flags. When flags are omitted, interactive prompts are used. -### Migrating Legacy Agents - -If the user has agents from ACP SDK v1, they must migrate them to v2 before they can be used with the new CLI. Migration is a two-phase process: - -```bash -# Phase 1 — create the v2 agent and set up signer -acp agent migrate --agent-id --json - -# Phase 2 — activate the migrated agent -acp agent migrate --agent-id --complete --json -``` - -Only agents with `PENDING` status can start migration. Only agents with `IN_PROGRESS` status can be completed. Agents with `COMPLETED` status are already migrated. - -Alternatively, users can migrate via the web UI at [app.virtuals.io](https://app.virtuals.io) under the **"Agents and Projects"** section by clicking **"Upgrade"**. - ### Wallet -| Command | Description | Required Options | Optional | -| -------------------- | ---------------------------------------------- | ------------------------- | --------------- | -| `wallet address` | Show the configured wallet address | -- | -- | -| `wallet sign-message`| Sign a plaintext message with the active wallet| `--message` | `--chain-id` | -| `wallet sign-typed-data` | Sign EIP-712 typed data with the active wallet | `--data` (JSON string) | `--chain-id` | +| Command | Description | +| ---------------- | ---------------------------------- | +| `wallet address` | Show the configured wallet address | ## Job Lifecycle @@ -626,12 +572,12 @@ open ──► budget_set ──► funded ──► submitted ──► complet | Status | Meaning | Next Action | | ------------ | -------------------------------------------------- | ----------------------------- | -| `open` | Job created, waiting for provider to propose budget | Provider: `set-budget` | -| `budget_set` | Provider proposed a price, waiting for client to fund | Client: `fund` | -| `funded` | USDC locked in escrow, provider can begin work | Provider: `submit` | -| `submitted` | Deliverable submitted, waiting for evaluation | Client: `complete` or `reject` | -| `completed` | Client approved, escrow released to provider | Terminal | -| `rejected` | Client rejected, escrow returned to client | Terminal | +| `open` | Job created, waiting for seller to propose budget | Seller: `set-budget` | +| `budget_set` | Seller proposed a price, waiting for buyer to fund | Buyer: `fund` | +| `funded` | USDC locked in escrow, seller can begin work | Seller: `submit` | +| `submitted` | Deliverable submitted, waiting for evaluation | Buyer: `complete` or `reject` | +| `completed` | Buyer approved, escrow released to seller | Terminal | +| `rejected` | Buyer rejected, escrow returned to buyer | Terminal | | `expired` | Job passed its expiry time | Terminal | @@ -676,12 +622,12 @@ On transient errors (network timeouts, rate limits), retry the command once. bin/acp.ts CLI entry point src/ commands/ - client.ts Client actions (create-job, create-custom-job, fund, complete, reject) - provider.ts Provider actions (set-budget, submit) + buyer.ts Buyer actions (create-job, fund, complete, reject) + seller.ts Seller actions (set-budget, submit) offering.ts Offering management (list, create, update, delete) resource.ts Resource management (list, create, update, delete) job.ts Job queries (list, status) - message.ts Chat messaging + message.ts Chat messaging via WebSocket events.ts Event streaming (listen + drain) wallet.ts Wallet info lib/ diff --git a/bin/acp.ts b/bin/acp.ts index 024b182..e43f664 100755 --- a/bin/acp.ts +++ b/bin/acp.ts @@ -12,6 +12,7 @@ import { registerAgentCommands } from "../src/commands/agent"; import { registerBrowseCommand } from "../src/commands/browse"; import { registerOfferingCommands } from "../src/commands/offering"; import { registerResourceCommands } from "../src/commands/resource"; +import { registerServeCommands } from "../src/commands/serve"; program .name("acp") @@ -34,5 +35,6 @@ registerAgentCommands(program); registerBrowseCommand(program); registerOfferingCommands(program); registerResourceCommands(program); +registerServeCommands(program); program.parse(); diff --git a/package-lock.json b/package-lock.json index 96d158c..a07aca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,18 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@hono/node-server": "^1.19.12", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.0.4", + "@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", + "hono": "^4.12.10", + "mppx": "^0.5.5", "picocolors": "^1.1.1", "socket.io-client": "^4.8.3", "viem": "^2.47.0" @@ -255,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", @@ -1399,6 +1410,18 @@ "@ethersproject/strings": "^5.8.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.13", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", + "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@hpke/chacha20poly1305": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@hpke/chacha20poly1305/-/chacha20poly1305-1.8.0.tgz", @@ -1822,6 +1845,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.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "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", @@ -2686,6 +2738,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", @@ -2810,6 +2868,26 @@ } } }, + "node_modules/@x402/core": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.9.0.tgz", + "integrity": "sha512-IqPITHYx6XHlgLPtparuKKwoB+3wQdgt0F+WUH1e3WHMeiWdp+xTtQDy+6yOKuObNFI1S1iVbQFz0GivR/Vv3w==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@x402/evm": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.9.0.tgz", + "integrity": "sha512-qUhnKe1pym9a+7dzeK+6ripsddVsr+5PNcpQfTYK4dubW+1SR9MRx/O4PNRtedWoAxminqAwmCL5AQUiSVvKWA==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.9.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", @@ -4028,6 +4106,15 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -4075,6 +4162,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.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "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", @@ -4625,6 +4742,51 @@ "ufo": "^1.6.3" } }, + "node_modules/mppx": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/mppx/-/mppx-0.5.10.tgz", + "integrity": "sha512-+B81D9Lha7Yo2hb5Rzph49N8ZFc6kjtQTXDjSW9KG6pDLTkgxkVqvkFDE90koh8+4p1JNKrbfqRH+rS/74jWPw==", + "license": "MIT", + "dependencies": { + "incur": "^0.3.23", + "ox": "0.14.7", + "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", + "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.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "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", @@ -4772,9 +4934,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.7", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.7.tgz", + "integrity": "sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==", "funding": [ { "type": "github", @@ -5450,6 +5612,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", @@ -5632,9 +5800,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.47.12", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.12.tgz", + "integrity": "sha512-tz3CBeGUMU357aZqzlHskgAiEoC3k0/PuEwBSOxh4EQ3uuNr4GJ2Gc67ArNYmdm4MVTVL+rsRdKYoqbloUva6g==", "funding": [ { "type": "github", @@ -5649,7 +5817,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.0", + "ox": "0.14.13", "ws": "8.18.3" }, "peerDependencies": { @@ -5676,6 +5844,36 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/viem/node_modules/ox": { + "version": "0.14.13", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.13.tgz", + "integrity": "sha512-N3slDyEUq3qGw/53Xd8YZPZD7NUbbiOJDeWKvQ1ElNo2mFjjz6cV2TIbGenHw7k5ATcefDQh42dwUWoGtxU9Hg==", + "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", @@ -5869,6 +6067,21 @@ "node": ">=0.10.32" } }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "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 a52ae95..c372208 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,25 @@ }, "scripts": { "acp": "tsx bin/acp.ts", - "build": "tsc" + "build": "tsc", + "seller:pokemon": "tsx src/examples/pokemonSeller.ts" }, "author": "", "license": "ISC", "description": "CLI tool wrapping the ACP Node SDK for agent tool use", "dependencies": { + "@hono/node-server": "^1.19.12", "@privy-io/node": "^0.11.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.40", "@virtuals-protocol/acp-node-v2": "^0.0.4", + "@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", + "hono": "^4.12.10", + "mppx": "^0.5.5", "picocolors": "^1.1.1", "socket.io-client": "^4.8.3", "viem": "^2.47.0" diff --git a/serve/ARCHITECTURE.md b/serve/ARCHITECTURE.md new file mode 100644 index 0000000..3e1ac4e --- /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 provider adapter such as Railway. 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/providers/cloudflare.ts b/serve/providers/cloudflare.ts new file mode 100644 index 0000000..427075e --- /dev/null +++ b/serve/providers/cloudflare.ts @@ -0,0 +1,50 @@ +import { resolve } from "path"; +import type { + DeployOptions, + DeployProvider, + DeployResult, + DeployTarget, +} from "./types"; +import { + copyOfferingDir, + copyRepoSubset, + getBundleRoot, + writeBundleServeJson, + writeTextFile, +} from "./utils"; + +export class CloudflareDeployProvider implements DeployProvider { + readonly name = "cloudflare" as const; + + async deploy( + target: DeployTarget, + _options: DeployOptions + ): Promise { + const bundleDir = getBundleRoot(target.rootDir, this.name, target.serviceName); + copyRepoSubset(target.rootDir, bundleDir); + copyOfferingDir(target, bundleDir); + writeBundleServeJson(target, bundleDir); + + writeTextFile( + resolve(bundleDir, "DEPLOYMENT_NOTES.md"), + [ + "# Cloudflare deploy bundle", + "", + "This provider path is not fully implemented yet.", + "Use Railway for end-to-end local testing.", + "", + ].join("\n") + ); + + return { + provider: this.name, + bundleDir, + serviceName: target.serviceName, + executed: false, + endpoints: {}, + nextSteps: [ + "Cloudflare deployment is not implemented yet.", + ], + }; + } +} diff --git a/serve/providers/railway.ts b/serve/providers/railway.ts new file mode 100644 index 0000000..a7d3102 --- /dev/null +++ b/serve/providers/railway.ts @@ -0,0 +1,212 @@ +import { spawnSync } from "child_process"; +import { resolve } from "path"; +import type { + DeployOptions, + DeployProvider, + DeployResult, + DeployTarget, +} from "./types"; +import { + copyOfferingDir, + copyRepoSubset, + getBundleRoot, + writeBundleServeJson, + writeTextFile, +} from "./utils"; + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function serviceJobEndpoint( + apiUrl: string, + providerAddress: string, + offeringSlug: string, + protocol: "x402" | "mpp" +): string { + return new URL( + `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, + apiUrl + ).toString(); +} + +/** Append project/environment flags. Not all railway subcommands support both. */ +function appendScopedArgs( + args: string[], + options: DeployOptions, + supports: { project?: boolean; environment?: boolean } = {} +): void { + if (supports.project && options.project) args.push("-p", options.project); + if (supports.environment && options.environment) args.push("-e", options.environment); +} + +function runRailwayCommand( + cwd: string, + args: string[], + tolerateFailure = false +): void { + const result = spawnSync("railway", args, { + cwd, + stdio: "inherit", + }); + if (result.status === 0 || tolerateFailure) return; + throw new Error(`Railway command failed: railway ${args.join(" ")}`); +} + +export class RailwayDeployProvider implements DeployProvider { + readonly name = "railway" as const; + + async deploy( + target: DeployTarget, + options: DeployOptions + ): Promise { + const bundleDir = getBundleRoot(target.rootDir, this.name, target.serviceName); + copyRepoSubset(target.rootDir, bundleDir); + copyOfferingDir(target, bundleDir); + writeBundleServeJson(target, bundleDir); + + writeTextFile( + 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") + ); + + const envPairs: [string, string][] = [ + ["ACP_ACTIVE_WALLET", target.providerWallet], + ["ACP_AGENT_ID", target.agentId], + ["ACP_API_URL", target.apiUrl], + ["ACP_WALLET_ID", target.walletId], + ["ACP_PUBLIC_KEY", target.deploySigner.publicKey], + ["ACP_SIGNER_PRIVATE_KEY", target.deploySigner.privateKey], + ["ACP_SERVE_OFFERING", target.offering.slug], + ["IS_TESTNET", process.env.IS_TESTNET ?? ""], + ["PARTNER_ID", process.env.PARTNER_ID ?? ""], + ]; + + const envLines = envPairs.map(([k, v]) => `${k}=${v}`); + + writeTextFile(resolve(bundleDir, ".env.example"), envLines.join("\n") + "\n"); + + const serviceFlag = options.service ?? target.serviceName; + const envFlag = options.environment + ? ` -e ${shellEscape(options.environment)}` + : ""; + const projectFlag = options.project + ? ` -p ${shellEscape(options.project)}` + : ""; + + const nonEmptyEnvArgs = envPairs + .filter(([, v]) => v !== "") + .map(([k, v]) => `${shellEscape(`${k}=${v}`)}`); + + const nextSteps = [ + `railway add -s ${shellEscape(serviceFlag)}`, + [ + "railway variable set", + ...nonEmptyEnvArgs, + `-s ${shellEscape(serviceFlag)}`, + envFlag.trim(), + "--skip-deploys", + ] + .filter(Boolean) + .join(" "), + [ + "railway up", + shellEscape(bundleDir), + "--path-as-root", + `-s ${shellEscape(serviceFlag)}`, + projectFlag.trim(), + envFlag.trim(), + "--detach", + ] + .filter(Boolean) + .join(" "), + ]; + + if (options.execute) { + const versionCheck = spawnSync("railway", ["--version"], { + cwd: bundleDir, + stdio: "ignore", + }); + if (versionCheck.status !== 0) { + throw new Error("Railway CLI not found."); + } + + // Link the bundle dir to the project first (required for add/variable set) + if (options.project) { + const linkArgs = ["link", "-p", options.project]; + if (options.environment) linkArgs.push("-e", options.environment); + runRailwayCommand(bundleDir, linkArgs); + } + + const addArgs = ["add", "-s", serviceFlag]; + runRailwayCommand(bundleDir, addArgs, true); + + // `railway variable set` supports -s (service) and -e (environment) + // Railway rejects empty values (e.g. IS_TESTNET=), so filter them out + const variableArgs = [ + "variable", + "set", + ...envPairs.filter(([, v]) => v !== "").map(([k, v]) => `${k}=${v}`), + "-s", + serviceFlag, + "--skip-deploys", + ]; + appendScopedArgs(variableArgs, options, { environment: true }); + runRailwayCommand(bundleDir, variableArgs); + + // `railway up` — project/environment already set via `railway link` above, + // so we only pass -s (service). Adding -p without -e causes an error. + const upArgs = [ + "up", + bundleDir, + "--path-as-root", + "-s", + serviceFlag, + "--detach", + ]; + runRailwayCommand(bundleDir, upArgs); + + // Generate a Railway domain, or attach a custom domain if provided + if (options.domain) { + runRailwayCommand(bundleDir, ["domain", options.domain, "-s", serviceFlag]); + } else { + runRailwayCommand(bundleDir, ["domain", "-s", serviceFlag], true); + } + } + + const baseUrl = options.domain + ? `https://${options.domain}` + : `https://${serviceFlag}-production.up.railway.app`; + + return { + provider: this.name, + bundleDir, + serviceName: serviceFlag, + executed: options.execute === true, + endpoints: { + x402: serviceJobEndpoint( + target.apiUrl, + target.providerWallet, + target.offering.slug, + "x402" + ), + mpp: serviceJobEndpoint( + target.apiUrl, + target.providerWallet, + target.offering.slug, + "mpp" + ), + health: `${baseUrl}/health`, + }, + nextSteps, + }; + } +} diff --git a/serve/providers/types.ts b/serve/providers/types.ts new file mode 100644 index 0000000..245c598 --- /dev/null +++ b/serve/providers/types.ts @@ -0,0 +1,50 @@ +export type DeployProviderName = "railway" | "cloudflare"; + +export interface DeployTarget { + rootDir: string; + serviceName: string; + providerWallet: string; + agentId: string; + agentName: string; + apiUrl: string; + offering: { + id: string; + slug: string; + name: string; + description: string; + priceType: string; + priceValue: number; + slaMinutes: number; + requirements: Record | string; + deliverable: Record | string; + }; + entryDir: string; + protocols: ("x402" | "mpp" | "acp")[]; + walletId: string; + deploySigner: { + publicKey: string; + privateKey: string; + }; +} + +export interface DeployResult { + provider: DeployProviderName; + bundleDir: string; + serviceName: string; + endpoints: Record; + executed: boolean; + nextSteps: string[]; +} + +export interface DeployOptions { + project?: string; + environment?: string; + service?: string; + domain?: string; + execute?: boolean; +} + +export interface DeployProvider { + readonly name: DeployProviderName; + deploy(target: DeployTarget, options: DeployOptions): Promise; +} diff --git a/serve/providers/utils.ts b/serve/providers/utils.ts new file mode 100644 index 0000000..c19db02 --- /dev/null +++ b/serve/providers/utils.ts @@ -0,0 +1,82 @@ +import { cpSync, existsSync, mkdirSync, writeFileSync } from "fs"; +import { dirname, resolve } from "path"; +import type { DeployTarget } from "./types"; + +export function getBundleRoot( + rootDir: string, + provider: string, + serviceName: string +): string { + return resolve(rootDir, ".acp", "serve", "deploy", provider, serviceName); +} + +export function ensureCleanDir(dir: string): void { + mkdirSync(dir, { recursive: true }); +} + +export function copyRepoSubset(rootDir: string, bundleDir: string): void { + ensureCleanDir(bundleDir); + for (const relativePath of [ + "bin", + "serve", + "src", + "tsconfig.json", + "package.json", + "package-lock.json", + ]) { + const source = resolve(rootDir, relativePath); + if (!existsSync(source)) continue; + cpSync(source, resolve(bundleDir, relativePath), { recursive: true }); + } +} + +export function writeBundleServeJson( + target: DeployTarget, + bundleDir: string +): void { + const agentSlug = slugify(target.agentName); + const offeringSlug = target.offering.slug; + const payload = { + agents: { + [target.agentId]: { + name: target.agentName, + offerings: { + [offeringSlug]: { + dir: `agents/${agentSlug}/offerings/${offeringSlug}`, + protocols: target.protocols, + registered: true, + }, + }, + }, + }, + evaluator: "self", + port: 3000, + }; + + writeFileSync( + resolve(bundleDir, "serve.json"), + JSON.stringify(payload, null, 2) + "\n" + ); +} + +export function copyOfferingDir(target: DeployTarget, bundleDir: string): void { + const agentSlug = slugify(target.agentName); + const destination = resolve( + bundleDir, + "agents", + agentSlug, + "offerings", + target.offering.slug + ); + mkdirSync(dirname(destination), { recursive: true }); + cpSync(target.entryDir, destination, { recursive: true }); +} + +export function writeTextFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +export function slugify(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); +} diff --git a/serve/runtime/loader.ts b/serve/runtime/loader.ts new file mode 100644 index 0000000..17e647c --- /dev/null +++ b/serve/runtime/loader.ts @@ -0,0 +1,35 @@ +/** + * Runtime Loader + * + * Loads the developer's handler and optional budget handler. + * + * handler.ts — REQUIRED: do the work, return deliverable + * budget.ts — OPTIONAL: dynamic pricing + fund requests (ACP native only) + */ + +import { resolve } from "path"; +import { existsSync } from "fs"; +import type { Handler, BudgetHandler } 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: Handler = handlerModule.default; + + let budgetHandler: BudgetHandler | undefined; + const budgetPath = resolve(dir, "budget.ts"); + if (existsSync(budgetPath)) { + const budgetModule = await import(budgetPath); + budgetHandler = budgetModule.default; + } + + return { handler, budgetHandler }; +} diff --git a/serve/runtime/sandbox-worker.ts b/serve/runtime/sandbox-worker.ts new file mode 100644 index 0000000..0156d6d --- /dev/null +++ b/serve/runtime/sandbox-worker.ts @@ -0,0 +1,38 @@ +/** + * Sandbox Worker + * + * Runs inside a worker thread with NO access to parent process env vars. + * Loads the developer's handler, executes it with the provided input, + * and sends the result back to the parent. + * + * This file runs in an isolated environment: + * - process.env is empty (set by parent via env: {}) + * - No access to parent's memory + * - No access to signer keys + */ + +import { workerData, parentPort } from "worker_threads"; + +async function run() { + const { handlerPath, input } = workerData; + + // Dynamically import the developer's handler + const handlerModule = await import(handlerPath); + const handler = handlerModule.default; + + if (typeof handler !== "function") { + throw new Error("handler.ts must export a default async function"); + } + + // Execute handler with the provided input + const result = await handler(input); + + // Send result back to parent + parentPort?.postMessage(result); +} + +run().catch((err) => { + // Send error back as a message the parent can catch + parentPort?.postMessage({ error: err.message || String(err) }); + process.exit(1); +}); diff --git a/serve/runtime/sandbox.ts b/serve/runtime/sandbox.ts new file mode 100644 index 0000000..1d81a81 --- /dev/null +++ b/serve/runtime/sandbox.ts @@ -0,0 +1,63 @@ +/** + * Handler Sandbox + * + * Runs the developer's handler.ts in an isolated worker thread. + * The worker has NO access to: + * - process.env (no signer keys, no secrets) + * - filesystem (no reading config files) + * - parent process memory + * + * The worker only receives the HandlerInput and returns HandlerOutput. + * This prevents malicious handlers from stealing the deploy signer key + * or accessing other sensitive data. + */ + +import { Worker } from "worker_threads"; +import { resolve } from "path"; +import type { HandlerInput, HandlerOutput } from "../types"; + +// Worker script path — in production this would be the compiled sandbox-worker.js +const WORKER_SCRIPT = resolve(__dirname, "sandbox-worker.ts"); + +/** + * Execute a handler in a sandboxed worker thread. + * + * @param handlerPath - Absolute path to handler.ts + * @param input - Handler input (requirements, offering, client info) + * @param timeoutMs - Maximum execution time before killing the worker + * @returns Handler output (deliverable) + */ +export function runInSandbox( + handlerPath: string, + input: HandlerInput, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(WORKER_SCRIPT, { + workerData: { handlerPath, input }, + env: {}, // EMPTY — no access to DEPLOY_SIGNER_KEY or any secrets + }); + + const timer = setTimeout(() => { + worker.terminate(); + reject(new Error(`Handler timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.on("message", (result: HandlerOutput) => { + clearTimeout(timer); + resolve(result); + }); + + worker.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + worker.on("exit", (code) => { + clearTimeout(timer); + if (code !== 0) { + 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..458030e --- /dev/null +++ b/serve/scaffold/budget.ts.template @@ -0,0 +1,40 @@ +import type { BudgetHandler } from "acp-cli/serve/types"; + +/** + * Budget — OPTIONAL (ACP native only) + * + * Called when a new job arrives. Returns the service fee and optionally + * a fund request for working capital. + * + * - Service fee (amount): what the client pays you for the work. + * - Fund request: capital the client provides for you to use during + * job execution (e.g., USDC for a trade, gas for on-chain ops). + * This is separate from the service fee. + * + * If this file is not present, the offering's fixed price is used + * with no fund request. + * + * Only called for ACP native flow — x402/MPP use the offering's + * fixed price automatically. + * + * Delete this file if your offering has a fixed price and no fund request. + */ +const budget: BudgetHandler = async (input) => { + // Default: use the offering's fixed price, no fund request + return { amount: input.offering.priceValue }; + + // Dynamic pricing example: + // const complexity = estimateComplexity(input.requirements); + // return { amount: Math.max(0.01, complexity * 0.10) }; + + // Fund request example (e.g., trade job needs working capital): + // return { + // amount: 0.50, // service fee + // fundRequest: { + // transferAmount: 100, // working capital needed + // destination: "0xYourTradeWallet" // where to send it + // } + // }; +}; + +export default budget; diff --git a/serve/scaffold/handler.ts.template b/serve/scaffold/handler.ts.template new file mode 100644 index 0000000..def9eb4 --- /dev/null +++ b/serve/scaffold/handler.ts.template @@ -0,0 +1,26 @@ +import type { Handler } from "acp-cli/serve/types"; + +/** + * Handler — REQUIRED + * + * This function runs when a client pays for your offering. + * It receives the client's requirements (validated against your offering's schema) + * and must return a deliverable. + * + * This is called for all protocols (x402, MPP, ACP native). + * You don't need to handle payments, escrow, or protocol details — ACP Serve does that. + */ +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: "TODO: replace with actual deliverable", + }; +}; + +export default handler; diff --git a/serve/scaffold/offering.json.template b/serve/scaffold/offering.json.template new file mode 100644 index 0000000..4e53f8a --- /dev/null +++ b/serve/scaffold/offering.json.template @@ -0,0 +1,12 @@ +{ + "name": "{{NAME}}", + "description": "TODO: describe what this offering does", + "priceType": "fixed", + "priceValue": 0, + "slaMinutes": 60, + "requirements": "TODO: describe what the client must provide, or use a JSON schema", + "deliverable": "TODO: describe what the provider will deliver", + "requiredFunds": false, + "isHidden": false, + "isPrivate": false +} diff --git a/serve/scaffold/serve.json.template b/serve/scaffold/serve.json.template new file mode 100644 index 0000000..3729f75 --- /dev/null +++ b/serve/scaffold/serve.json.template @@ -0,0 +1,10 @@ +{ + "offerings": { + "{{OFFERING_ID}}": { + "dir": "offerings/{{OFFERING_NAME}}", + "protocols": ["x402", "mpp", "acp"] + } + }, + "evaluator": "default", + "port": 3000 +} diff --git a/serve/server/index.ts b/serve/server/index.ts new file mode 100644 index 0000000..c0eea07 --- /dev/null +++ b/serve/server/index.ts @@ -0,0 +1,287 @@ +/** + * Offering Runtime + * + * Provider runtime deployed per offering. + * + * x402 and MPP public endpoints live on agentic-commerce-be. This process + * keeps an outbound Socket.IO connection open so the BE can request payment + * challenges and dispatch paid jobs without exposing provider infrastructure. + * + * ACP native runs as a background event listener in the same process. + */ + +import { resolve as _resolve } from "path"; +import { homedir as _homedir } from "os"; +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { loadHandlers, type LoadedHandlers } from "../runtime/loader"; +import type { DeployedOffering } from "../types"; +import { serviceJobEndpoint, startServiceJobRelay } from "./relay"; + +export interface ServerOptions { + dir: string; + port?: number; + agentSlug: string; + providerWallet: string; + offering: DeployedOffering["offering"]; + protocols?: ("x402" | "mpp" | "acp")[]; + /** When true, x402/MPP settle on-chain via ERC-8183 after payment. + * When false (default), settle direct x402/MPP → run handler → return result. */ + settle8183?: boolean; + /** agentic-commerce-be API URL. x402/MPP clients call this URL, not the provider runtime. */ + apiUrl?: string; + /** Agent auth token used by the provider runtime to connect to BE. */ + agentToken?: string; + /** If true, run handlers in a sandboxed worker thread (no env access). + * Enabled automatically for hosted deployments to prevent handlers + * from accessing the deploy signer key. */ + 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; + + let handlers = await loadHandlers(dir); + + // In sandbox mode, wrap the handler to run in an isolated worker thread. + // The worker has NO access to process.env (no signer keys). + // Enabled for hosted deployments to prevent handler code from stealing keys. + if (options.sandbox) { + const { runInSandbox } = await import("../runtime/sandbox"); + const { resolve } = await import("path"); + const handlerPath = resolve(dir, "handler.ts"); + const timeoutMs = (offering.slaMinutes || 5) * 60 * 1000; + + const originalHandler = handlers.handler; + handlers = { + ...handlers, + handler: async (input) => { + return runInSandbox(handlerPath, input, timeoutMs); + }, + }; + } + + const agentSlug = options.agentSlug; + const settle8183 = options.settle8183 ?? false; + const apiUrl = options.apiUrl ?? process.env.ACP_API_URL; + + const deployed: DeployedOffering = { + offeringId: offering.id, + agentSlug, + providerWallet, + offering, + hasBudgetHandler: !!handlers.budgetHandler, + protocols, + evaluator: "self", + settle8183, + }; + + const app = new Hono(); + const offeringSlug = offering.slug || offering.id; + + const usesServiceJobRelay = + protocols.includes("x402") || protocols.includes("mpp"); + if (usesServiceJobRelay && settle8183) { + throw new Error( + "--settle-8183 is reserved but not supported yet. Use direct x402/MPP service jobs until the ERC-8183 contract supports this flow.", + ); + } + + let relay: ReturnType | undefined; + if (usesServiceJobRelay) { + if (!apiUrl) { + throw new Error("ACP API URL is required for x402/MPP service jobs."); + } + if (!options.agentToken) { + throw new Error( + "Agent auth token is required for x402/MPP service jobs.", + ); + } + relay = startServiceJobRelay(deployed, handlers, { + apiUrl, + agentToken: options.agentToken, + }); + } + + // Health check + app.get("/health", (c) => + c.json({ + status: "ok", + offering: { id: offering.id, name: offering.name }, + protocols, + relay: usesServiceJobRelay ? "enabled" : "disabled", + pid: process.pid, + }), + ); + + // 404 + app.all("*", (c) => c.json({ error: "Not found" }, 404)); + + // Start ACP native listener (non-fatal — x402/mpp still serve if this fails) + if (protocols.includes("acp")) { + startACPListener(deployed, handlers).catch((err) => { + console.error( + `[ACP] Native listener failed to start: ${err.message ?? err}`, + ); + console.error("[ACP] x402 and MPP relay is still available."); + }); + } + + // Write PID file for serve stop/status + const pidFile = getPidFilePath(offering.id); + const { writeFileSync, mkdirSync } = await import("fs"); + const { dirname } = await import("path"); + mkdirSync(dirname(pidFile), { recursive: true }); + writeFileSync(pidFile, String(process.pid)); + + // Start HTTP server + serve({ fetch: app.fetch, port }, () => { + console.log(`\nACP Serve running on port ${port}\n`); + console.log(`Offering: ${offering.name} (${offering.id})`); + console.log(`Provider: ${providerWallet}`); + console.log(`PID: ${process.pid}\n`); + console.log("Mode: BE-mediated service-job runtime"); + console.log( + `Settlement: ${settle8183 ? "ERC-8183 (on-chain)" : "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: listening for events (native)`); + } + console.log(`\nHealth: http://localhost:${port}/health`); + }); + + // Cleanup on shutdown + const shutdown = async () => { + console.log("\nShutting down..."); + relay?.disconnect(); + try { + const { unlinkSync } = await import("fs"); + 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 { buildHandlerInput } = await import("./middleware/shared"); + + const agent = await createAgentFromConfig(); + const CHAIN_ID = Number(process.env.ACP_CHAIN_ID || "84532"); + + // Track jobs we're handling + const jobRequirements = new Map | string>(); + + agent.on("entry", async (session: any, entry: any) => { + const jobId = session.jobId; + const status = session.status; + + // Capture requirements from requirement messages + if (entry.contentType === "requirement" && entry.content) { + try { + jobRequirements.set(jobId, JSON.parse(entry.content)); + } catch { + jobRequirements.set(jobId, entry.content); + } + } + + // Job created + requirements received → propose budget + if (status === "open" && jobRequirements.has(jobId)) { + const requirements = jobRequirements.get(jobId)!; + const input = buildHandlerInput( + offering, + requirements, + entry.from || "unknown", + "acp", + jobId, + ); + + // Use budget handler if exists, otherwise offering's fixed price + if (handlers.budgetHandler) { + const budget = await handlers.budgetHandler(input); + + if (budget.fundRequest) { + // Set budget with fund request (service fee + working capital) + console.log( + `[ACP] Job ${jobId}: setting budget ${budget.amount} USDC + fund request ${budget.fundRequest.transferAmount} USDC`, + ); + await session.setBudgetWithFundRequest( + AssetToken.usdc(budget.amount, CHAIN_ID), + AssetToken.usdc(budget.fundRequest.transferAmount, CHAIN_ID), + budget.fundRequest.destination, + ); + } else { + // Set budget only (service fee) + console.log( + `[ACP] Job ${jobId}: setting budget ${budget.amount} USDC`, + ); + await session.setBudget(AssetToken.usdc(budget.amount, CHAIN_ID)); + } + } else { + // Default: use offering's fixed price + const amount = offering.offering.priceValue; + console.log( + `[ACP] Job ${jobId}: setting budget ${amount} USDC (offering price)`, + ); + await session.setBudget(AssetToken.usdc(amount, CHAIN_ID)); + } + } + + // Job funded → run handler + submit + if (status === "funded" && jobRequirements.has(jobId)) { + const requirements = jobRequirements.get(jobId)!; + const input = buildHandlerInput( + offering, + requirements, + entry.from || "unknown", + "acp", + jobId, + ); + + console.log(`[ACP] Job ${jobId}: running handler...`); + const result = await handlers.handler(input); + + console.log(`[ACP] Job ${jobId}: submitting deliverable`); + await session.submit(result.deliverable); + } + + // Terminal states — cleanup + if ( + status === "completed" || + status === "rejected" || + status === "expired" + ) { + console.log(`[ACP] Job ${jobId}: ${status}`); + jobRequirements.delete(jobId); + } + }); + + await agent.start(); + console.log(`[ACP] Listening for native jobs: ${offering.offering.name}`); +} + +/** PID file path for a given offering — used by stop/status commands */ +export function getPidFilePath(offeringId: string): string { + return _resolve(_homedir(), ".acp", "serve", `${offeringId}.pid`); +} diff --git a/serve/server/logger.ts b/serve/server/logger.ts new file mode 100644 index 0000000..7b83f8f --- /dev/null +++ b/serve/server/logger.ts @@ -0,0 +1,64 @@ +/** + * Logger + * + * Writes structured JSON log lines to files in ~/.acp/serve/logs/. + * Used by the offering server for request logging, handler execution, + * and 8183 settlement tracking. + * + * Also outputs to console for local dev mode. + */ + +import { appendFileSync, mkdirSync } from "fs"; +import { resolve } from "path"; +import { homedir } from "os"; + +const LOG_DIR = resolve(homedir(), ".acp", "serve", "logs"); + +let initialized = false; + +function ensureDir(): void { + if (!initialized) { + mkdirSync(LOG_DIR, { recursive: true }); + initialized = true; + } +} + +export type LogLevel = "info" | "warn" | "error"; + +interface LogEntry { + timestamp: string; + level: LogLevel; + offeringId: string; + jobId?: string; + protocol?: string; + message: string; + data?: Record; +} + +export function log( + level: LogLevel, + offeringId: string, + message: string, + extra?: { jobId?: string; protocol?: string; data?: Record } +): void { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + offeringId, + message, + ...extra, + }; + + // Console output + const prefix = level === "error" ? "ERROR" : level === "warn" ? "WARN" : "INFO"; + console.log(`[${prefix}] [${offeringId}] ${message}`); + + // File output + ensureDir(); + const logFile = resolve(LOG_DIR, `${offeringId}.log`); + appendFileSync(logFile, JSON.stringify(entry) + "\n"); +} + +export function getLogDir(): string { + return LOG_DIR; +} diff --git a/serve/server/middleware/shared.ts b/serve/server/middleware/shared.ts new file mode 100644 index 0000000..95e8414 --- /dev/null +++ b/serve/server/middleware/shared.ts @@ -0,0 +1,17 @@ +import type { HandlerInput, DeployedOffering } from "../../types"; + +export 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, + }; +} 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 new file mode 100644 index 0000000..c30ac21 --- /dev/null +++ b/serve/server/relay.ts @@ -0,0 +1,246 @@ +import { io } from "socket.io-client"; +import 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 ServiceJobRequest { + jobId: string; + protocol: "x402" | "mpp"; + providerAddress: string; + clientAddress: string | null; + offering: ServiceJobOffering; + requirements: Record; + payment: { + credential: string; + txHash: string | null; + paymentKey: 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 PaymentSettlementAck { + clientAddress?: string | null; + paymentKey?: string; + txHash?: string | null; + receiptReference?: string; +} + +interface ServiceJobAck { + status: "completed" | "failed"; + deliverable?: unknown; + headers?: Record; + settlement?: PaymentSettlementAck; + error?: string; +} + +interface PaymentChallengeAck { + status: "completed" | "failed"; + headers?: Record; + body?: unknown; + error?: string; +} + +export function serviceJobEndpoint( + apiUrl: string, + providerAddress: string, + offeringSlug: string, + protocol: "x402" | "mpp", +): string { + const url = new URL( + `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, + apiUrl, + ); + return url.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: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) { + const message = err instanceof Error ? err.message : String(err); + ack({ status: "failed", error: message }); + } + }, + ); + + socket.on( + "service-job:request", + 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: payment.clientAddress }, + protocol: request.protocol, + }; + + console.log( + `[Relay] Job ${request.jobId}: running ${request.protocol} handler`, + ); + const result = await handlers.handler(input); + ack({ + status: "completed", + deliverable: result.deliverable, + headers: payment.headers, + settlement: payment.settlement, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[Relay] Job ${request.jobId}: failed: ${message}`); + ack({ status: "failed", error: message }); + } + }, + ); + + 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, + }; +} diff --git a/serve/types.ts b/serve/types.ts new file mode 100644 index 0000000..cd55ac9 --- /dev/null +++ b/serve/types.ts @@ -0,0 +1,92 @@ +/** + * ACP Serve — Type definitions + * + * These types define the contract between the developer's handler code + * and the ACP Serve runtime. The developer implements Handler (required) + * and optionally BudgetHandler (for ACP native jobs). + */ + +/** Input passed to all handler hooks */ +export interface HandlerInput { + /** Client's requirements data (validated against offering schema) */ + requirements: Record | string; + /** Offering metadata */ + offering: { + id: string; + slug: string; + name: string; + description: string; + priceType: string; + priceValue: number; + slaMinutes: number; + requirements: Record | string; + deliverable: Record | string; + }; + /** The 8183 job ID (set after job creation) */ + jobId?: string; + /** Client info */ + client: { + address: string; + }; + /** Which protocol the request came through */ + protocol: "x402" | "mpp" | "acp"; +} + +/** Output from the handler — the deliverable */ +export interface HandlerOutput { + /** The deliverable content (URL, text, JSON string, etc.) */ + deliverable: string; +} + +/** Output from the budget handler — service fee + optional fund request */ +export interface BudgetOutput { + /** USDC service fee to charge */ + amount: number; + /** Optional: request working capital from the client */ + fundRequest?: { + /** USDC amount of working capital needed */ + transferAmount: number; + /** Address to receive the working capital */ + destination: string; + }; +} + +/** + * The main handler function — REQUIRED. + * Takes requirements, does the work, returns deliverable. + * Called for all protocols (x402, MPP, ACP native) on job.funded. + */ +export type Handler = (input: HandlerInput) => Promise; + +/** + * Budget handler — OPTIONAL (ACP native only). + * Called on job.created to propose a service fee and optionally + * request working capital. If not provided, the offering's fixed + * price is used with no fund request. + * + * x402/MPP use the offering's fixed price automatically. + */ +export type BudgetHandler = (input: HandlerInput) => Promise; + +/** Configuration file (serve.json) */ +export interface ServeConfig { + offeringId: string; + protocols: ("x402" | "mpp" | "acp")[]; + evaluator?: string; + port?: number; +} + +/** Registry entry for a deployed offering */ +export interface DeployedOffering { + offeringId: string; + agentSlug: string; + providerWallet: string; + offering: HandlerInput["offering"]; + hasBudgetHandler: boolean; + protocols: ("x402" | "mpp" | "acp")[]; + evaluator: string; + /** When true, x402/MPP payments are settled on-chain via ERC-8183. + * When false (default), x402/MPP payments settle directly in the + * provider runtime before the handler runs. */ + settle8183: boolean; +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 0b36017..9e751f7 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,21 +1,12 @@ import * as readline from "readline"; import type { Command } from "commander"; -import { - isJson, - outputResult, - outputError, - isTTY, - maskAddress, -} from "../lib/output"; +import { isJson, outputResult, outputError, isTTY } from "../lib/output"; import { CliError } from "../lib/errors"; -import { c } from "../lib/color"; import { AgentApi, - MigrationStatus, TokenizeResponse, TokenizeStatusResponse, type Agent, - LegacyAgent, } from "../lib/api/agent"; import { getClient } from "../lib/api/client"; import { @@ -32,19 +23,16 @@ import { setAgentId, getAgentId, } from "../lib/config"; -import { generateKeyPair as generateNativeKeyPair } from "../lib/acpCliSigner"; -import { openBrowser } from "../lib/browser"; +import { generateP256KeyPair, P256KeyPair } from "@privy-io/node"; +import { storeSignerKey } from "../lib/signerKeychain"; import { createAgentFromConfig } from "../lib/agentFactory"; -import { EvmAcpClient, SUPPORTED_CHAINS } from "@virtuals-protocol/acp-node-v2"; +import { + EvmAcpClient, + EVM_MAINNET_CHAINS, + EVM_TESTNET_CHAINS, +} from "@virtuals-protocol/acp-node-v2"; -function parseLegacyId(raw: string, json: boolean): number | null { - const id = parseInt(raw, 10); - if (isNaN(id)) { - outputError(json, "Agent ID must be a number."); - return null; - } - return id; -} +const SUPPORTED_CHAINS = [...EVM_MAINNET_CHAINS, ...EVM_TESTNET_CHAINS]; async function resolveAgent( agentApi: AgentApi, @@ -58,7 +46,7 @@ async function resolveAgent( outputError( json, `Failed to fetch agent: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); process.exit(1); @@ -82,7 +70,7 @@ async function resolveAgent( outputError( json, `Failed to fetch agents: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); process.exit(1); @@ -95,20 +83,19 @@ async function runAddSignerFlow( api: AgentApi, json: boolean, agent: Agent -): Promise { - // 1. Generate key pair in native keystore (private key never leaves the binary) - let publicKey: string; +): Promise { + // 1. Generate key pair and persist private key to keychain + let keypair: P256KeyPair; try { - const result = generateNativeKeyPair(); - publicKey = result.publicKey; + keypair = await generateP256KeyPair(); } catch (err) { outputError( json, `Failed to generate key pair: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); - return false; + return; } // 2. Get add signer URL @@ -116,33 +103,32 @@ async function runAddSignerFlow( let requestId: string; try { const res = await api.addSignerWithUrl(agent.id); - signerUrl = `${res.data.url}&publicKey=${publicKey}`; + signerUrl = `${res.data.url}&publicKey=${keypair.publicKey}`; requestId = res.data.requestId; } catch (err) { outputError( json, `Failed to add signer: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); - return false; + return; } // 3. Present the URL and public key for the user to verify and approve if (json) { outputResult(json, { signerUrl, - publicKey, + publicKey: keypair.publicKey, expiresIn: "5 minutes", }); } else { - console.log(`\nPublic Key: ${publicKey}`); + console.log(`\nPublic Key: ${keypair.publicKey}`); console.log( - `\nOpening browser to verify the public key and approve the signer...` + `\nPlease visit the following URL to verify the public key and approve the signer:` ); console.log(`\n ${signerUrl}\n`); console.log(`This link expires in 5 minutes.\n`); - openBrowser(signerUrl); console.log(`Waiting for approval...`); } @@ -158,7 +144,7 @@ async function runAddSignerFlow( if (!statusRes.data.status) { outputError(json, "Signer registration not found. Please try again."); - return false; + return; } if (statusRes.data.status === "completed") { @@ -173,14 +159,15 @@ async function runAddSignerFlow( if (Date.now() - startTime >= TIMEOUT_MS) { outputError(json, "Signer registration timed out. Please try again."); - return false; + return; } } - // 4. Persist public key reference to config (private key already stored by native binary) + // 4. Persist public key to config and keychain (only after all API calls succeed) const walletId = agent.walletProviders[0].metadata.walletId; - setPublicKey(agent.walletAddress, publicKey); + setPublicKey(agent.walletAddress, keypair.publicKey); setWalletId(agent.walletAddress, walletId); + await storeSignerKey(keypair.publicKey, keypair.privateKey); if (json) { outputResult(json, { @@ -190,7 +177,6 @@ async function runAddSignerFlow( } else { console.log(`\nSigner added to ${agent.name} successfully!`); } - return true; } export function registerAgentCommands(program: Command): void { @@ -202,7 +188,6 @@ export function registerAgentCommands(program: Command): void { .option("--name ", "Agent name") .option("--description ", "Agent description") .option("--image ", "Agent image URL") - .option("--signer", "Automatically set up a signer after creation") .action(async (opts, cmd) => { const { agentApi } = await getClient(); const json = isJson(cmd); @@ -211,7 +196,8 @@ export function registerAgentCommands(program: Command): void { let description: string = opts.description?.trim() ?? ""; let image: string | undefined = opts.image?.trim() || undefined; - const needsPrompt = !name || !description || image === undefined; + const needsPrompt = + !name || !description || (image === undefined && !opts.image); let rl: readline.Interface | undefined; try { @@ -238,7 +224,7 @@ export function registerAgentCommands(program: Command): void { } } - if (image === undefined) { + if (image === undefined && !opts.image) { if (rl) { const imageInput = ( await prompt( @@ -262,7 +248,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to create agent: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -282,9 +268,7 @@ export function registerAgentCommands(program: Command): void { return; } - console.log( - `\n${c.green(`${created.name} has been created successfully!`)}\n` - ); + console.log(`\n${created.name} has been created successfully!\n`); printTable([ ["Name", created.name], @@ -292,24 +276,8 @@ export function registerAgentCommands(program: Command): void { ["Wallet Address", created.walletAddress ?? "N/A"], ]); - let setupSigner = opts.signer === true; - - if (!setupSigner) { - const signerRl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - const answer = await new Promise((resolve) => - signerRl.question( - "\nWould you like to set up a signer for this agent? (y/N) ", - resolve - ) - ); - signerRl.close(); - setupSigner = answer.toLowerCase() === "y"; - } - - if (!setupSigner) { + const privyAppId = process.env.ACP_PRIVY_APP_ID; + if (!privyAppId) { return; } @@ -348,19 +316,15 @@ export function registerAgentCommands(program: Command): void { if (isTTY()) { for (const a of data) { - console.log(`\n ${c.bold("Name:")} ${c.cyan(a.name)}`); - console.log(` ${c.bold("ID:")} ${a.id}`); - console.log(` ${c.bold("Description:")} ${a.description}`); - console.log(` ${c.bold("Role:")} ${a.role}`); - console.log( - ` ${c.bold("Wallet:")} ${c.dim(a.walletAddress)}` - ); - console.log(` ${c.bold("Created:")} ${c.dim(a.createdAt)}`); + console.log(`\n ID: ${a.id}`); + console.log(` Name: ${a.name}`); + console.log(` Description: ${a.description}`); + console.log(` Role: ${a.role}`); + console.log(` Wallet: ${a.walletAddress}`); + console.log(` Created: ${a.createdAt}`); } console.log( - `\n${c.dim( - `Page ${meta.pagination.page} of ${meta.pagination.pageCount} (${meta.pagination.total} total)` - )}` + `\nPage ${meta.pagination.page} of ${meta.pagination.pageCount} (${meta.pagination.total} total)` ); } else { console.log("ID\tNAME\tROLE\tWALLET"); @@ -372,7 +336,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to list agents: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); } @@ -397,7 +361,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to fetch agents: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -443,7 +407,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to fetch agents: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -474,27 +438,21 @@ export function registerAgentCommands(program: Command): void { const activeWallet = getActiveWallet(); if (!activeWallet) { - outputError( - json, - new CliError( - "No active agent set.", - "NO_ACTIVE_AGENT", - "Run `acp agent use` to set an active agent." - ) - ); + outputError(json, new CliError( + "No active agent set.", + "NO_ACTIVE_AGENT", + "Run `acp agent use` to set an active agent." + )); return; } const agentId = getAgentId(activeWallet); if (!agentId) { - outputError( - json, - new CliError( - "Agent ID not found for active wallet.", - "NO_ACTIVE_AGENT", - "Run `acp agent list` or `acp agent use` to populate it." - ) - ); + outputError(json, new CliError( + "Agent ID not found for active wallet.", + "NO_ACTIVE_AGENT", + "Run `acp agent list` or `acp agent use` to populate it." + )); return; } @@ -505,7 +463,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to fetch agent: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -518,13 +476,13 @@ export function registerAgentCommands(program: Command): void { if (isTTY()) { const chainRows: [string, string][] = (agentData.chains ?? []).map( - (c) => [`Chain ${c.chainId}`, `${c.tokenAddress ?? "Not tokenized"}`] + (c, i) => [`Chain ${c.chainId}`, `${c.tokenAddress ?? "Not tokenized"}`] ); - console.log(`\n${c.bold("Agent Details:")}`); + console.log("\nAgent Details:"); printTable([ ["ID", agentData.id], - ["Name", c.cyan(agentData.name)], + ["Name", agentData.name], ["Description", agentData.description], ["Role", agentData.role], ["Wallet Address", agentData.walletAddress ?? "N/A"], @@ -534,7 +492,7 @@ export function registerAgentCommands(program: Command): void { ...chainRows, ]); - console.log(`\n${c.bold("Offerings:")}`); + console.log("\nOfferings:"); if (agentData.offerings?.length) { for (const o of agentData.offerings) { printTable([ @@ -544,13 +502,14 @@ export function registerAgentCommands(program: Command): void { ["Price", `${o.priceValue} (${o.priceType})`], ["SLA", `${o.slaMinutes} min`], ["Hidden", o.isHidden ? "Yes" : "No"], + ["Private", o.isPrivate ? "Yes" : "No"], ]); } } else { console.log(" N/A"); } - console.log(`\n${c.bold("Resources:")}`); + console.log("\nResources:"); if (agentData.resources?.length) { for (const r of agentData.resources) { printTable([ @@ -564,99 +523,8 @@ export function registerAgentCommands(program: Command): void { console.log(" N/A"); } } else { - console.log( - `${agentData.name}\t${agentData.role}\t${ - agentData.walletAddress ?? "N/A" - }\t${agentData.id}` - ); - } - }); - - agent - .command("update") - .description("Update the active agent's name, description, or image") - .option("--name ", "New agent name") - .option("--description ", "New agent description") - .option("--image ", "New agent image URL") - .action(async (opts, cmd) => { - const { agentApi } = await getClient(); - const json = isJson(cmd); - - const name: string | undefined = opts.name?.trim() || undefined; - const description: string | undefined = - opts.description?.trim() || undefined; - const imageUrl: string | undefined = opts.image?.trim() || undefined; - - if (!name && !description && imageUrl === undefined) { - outputError( - json, - "Provide at least one of --name, --description, or --image to update." - ); - return; - } - - const activeWallet = getActiveWallet(); - if (!activeWallet) { - outputError( - json, - new CliError( - "No active agent set.", - "NO_ACTIVE_AGENT", - "Run `acp agent use` to set an active agent." - ) - ); - return; - } - - const agentId = getAgentId(activeWallet); - if (!agentId) { - outputError( - json, - new CliError( - "Agent ID not found for active wallet.", - "NO_ACTIVE_AGENT", - "Run `acp agent list` or `acp agent use` to populate it." - ) - ); - return; + console.log(`${agentData.name}\t${agentData.role}\t${agentData.walletAddress ?? "N/A"}\t${agentData.id}`); } - - const body: Parameters[1] = {}; - if (name !== undefined) body.name = name; - if (description !== undefined) body.description = description; - if (imageUrl !== undefined) body.image = imageUrl; - - let updated: Agent; - try { - updated = await agentApi.update(agentId, body); - } catch (err) { - outputError( - json, - `Failed to update agent: ${ - err instanceof Error ? err.message : String(err) - }` - ); - return; - } - - if (json) { - outputResult(json, { - id: updated.id, - name: updated.name, - description: updated.description, - imageUrl: updated.imageUrl, - }); - return; - } - - console.log( - `\n${c.green(`${updated.name} has been updated successfully!`)}\n` - ); - printTable([ - ["Name", updated.name], - ["Description", updated.description], - ["Image", updated.imageUrl ?? "N/A"], - ]); }); agent @@ -682,7 +550,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to fetch agents: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -736,7 +604,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to fetch tokenize details: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -778,8 +646,7 @@ export function registerAgentCommands(program: Command): void { let txHash = ""; if (tokenizeDetails.hasPaid) { - if (!json) - console.log("\nPayment already received, skipping transfer."); + if (!json) console.log("\nPayment already received, skipping transfer."); } else { const previousWallet = getActiveWallet(); setActiveWallet(selected.walletAddress); @@ -812,7 +679,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to send payment: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -824,10 +691,7 @@ export function registerAgentCommands(program: Command): void { // Step 5: Call tokenize API let tokenizeResponse: TokenizeResponse; try { - if (!json) - console.log( - `Tokenizing your agent on chain ID ${selectedChain.id}...` - ); + if (!json) console.log(`Tokenizing your agent on chain ID ${selectedChain.id}...`); tokenizeResponse = await agentApi.tokenize( selected.id, @@ -839,7 +703,7 @@ export function registerAgentCommands(program: Command): void { outputError( json, `Failed to tokenize agent: ${ - err instanceof Error ? err.message : String(err) + err instanceof Error ? err : String(err) }` ); return; @@ -858,214 +722,4 @@ export function registerAgentCommands(program: Command): void { }); } }); - - agent - .command("migrate") - .option("--agent-id ", "Agent ID") - .option("--complete", "Complete a migration") - .description( - "Migrate a legacy agent to ACP SDK 2.0, or complete an in-progress migration" - ) - .action(async (opts, cmd) => { - const { agentApi } = await getClient(); - const json = isJson(cmd); - - // Complete agent migration flow - if (opts.complete) { - if (!opts.agentId) { - outputError( - json, - "Please provide the agent ID to complete migration." - ); - return; - } - const numericId = parseLegacyId(opts.agentId, json); - if (numericId === null) return; - - let legacyAgents: LegacyAgent[]; - try { - legacyAgents = await agentApi.getLegacyAgents(); - } catch (err) { - outputError( - json, - `Failed to fetch legacy agents: ${ - err instanceof Error ? err.message : String(err) - }` - ); - return; - } - - const match = legacyAgents.find((a) => a.id === numericId); - if (!match) { - outputError( - json, - `Agent with ID ${numericId} not found in legacy agents.` - ); - return; - } - - const startMigrationCommand = `acp agent migrate --agent-id ${match.id}`; - - switch (match.migrationStatus) { - case MigrationStatus.PENDING: - outputError( - json, - `Agent "${match.name}" is not yet created. Run ${startMigrationCommand} to start migrating the agent.` - ); - return; - case MigrationStatus.COMPLETED: - outputError( - json, - `Agent "${match.name}" has already been migrated.` - ); - return; - case MigrationStatus.IN_PROGRESS: - break; - default: - outputError( - json, - `Agent "${match.name}" has an unexpected migration status: ${match.migrationStatus}.` - ); - return; - } - - const agents = await agentApi.list(); - const selectedAgent = agents.data.find((a) => - a.chains.find((c) => c.acpV2AgentId === numericId) - ); - - if (!selectedAgent) { - outputError( - json, - `No migrated agent found linked to legacy agent ID ${numericId}.` - ); - return; - } - - await agentApi.update(selectedAgent.id, { isHidden: false }); - - setActiveWallet(selectedAgent.walletAddress); - setAgentId(selectedAgent.walletAddress, selectedAgent.id); - - if (json) { - outputResult(json, { - success: true, - activeAgent: match.name, - walletAddress: match.walletAddress, - }); - } else { - console.log( - `\nAgent "${match.name}" has been migrated successfully! This is your active agent now.` - ); - } - return; - } - - // Main migrate flow - let legacyAgents: LegacyAgent[]; - try { - legacyAgents = await agentApi.getLegacyAgents(); - } catch (err) { - outputError( - json, - `Failed to fetch legacy agents: ${ - err instanceof Error ? err.message : String(err) - }` - ); - return; - } - - if (legacyAgents.length === 0) { - outputError(json, "No legacy agents to migrate."); - return; - } - - let selected: LegacyAgent; - const instructions = - "Before proceeding, read migration.md and ensure all prerequisites are complete."; - - if (opts.agentId) { - const numericId = parseLegacyId(opts.agentId, json); - if (numericId === null) return; - const found = legacyAgents.find((a) => a.id === numericId); - if (!found) { - outputError( - json, - `Agent with ID ${opts.agentId} not found in legacy agents.` - ); - return; - } - selected = found; - } else { - selected = await selectOption( - "Select an agent to migrate:", - legacyAgents, - (a) => - `${a.name} ${maskAddress(a.walletAddress)} [${a.migrationStatus}]` - ); - } - - const completeMigrationCommand = `acp agent migrate --agent-id ${selected.id} --complete`; - - switch (selected.migrationStatus) { - case MigrationStatus.IN_PROGRESS: - outputError( - json, - `Agent "${selected.name}" migration is in progress. Run ${completeMigrationCommand} to complete the migration.` - ); - return; - case MigrationStatus.COMPLETED: - outputError( - json, - `Agent "${selected.name}" has already been migrated.` - ); - return; - case MigrationStatus.PENDING: - break; - default: - outputError( - json, - `Agent "${selected.name}" has an unexpected migration status: ${selected.migrationStatus}.` - ); - return; - } - - if (!json) { - console.log(`\nMigrating "${selected.name}"...`); - } - - let migratedAgent: Agent; - try { - migratedAgent = await agentApi.migrateAgent(selected.id); - } catch (err) { - outputError( - json, - `Failed to migrate agent: ${ - err instanceof Error ? err.message : String(err) - }` - ); - return; - } - - if (!json) { - console.log("Migration initiated. Setting up signer...\n"); - } - - const signerOk = await runAddSignerFlow(agentApi, json, migratedAgent); - if (!signerOk) return; - - if (!json) { - console.log( - `Your agent has been created. ${instructions}\n\nWhen you are ready to activate this agent, run:\n\n ${completeMigrationCommand}` - ); - } else { - outputResult(json, { - success: true, - acpAgentId: selected.id, - agentName: selected.name, - instructions, - nextStep: completeMigrationCommand, - }); - } - }); } diff --git a/src/commands/offering.ts b/src/commands/offering.ts index f73f522..c5256d6 100644 --- a/src/commands/offering.ts +++ b/src/commands/offering.ts @@ -1,16 +1,37 @@ import * as readline from "readline"; import type { Command } from "commander"; import { isJson, outputResult, outputError, isTTY } from "../lib/output"; -import { c } from "../lib/color"; -import type { +import { CliError } from "../lib/errors";import type { AgentOffering, CreateOfferingBody, UpdateOfferingBody, } from "../lib/api/agent"; import { getClient } from "../lib/api/client"; import { prompt, selectOption, printTable } from "../lib/prompt"; +import { getActiveWallet, getAgentId } from "../lib/config"; import { validateJsonSchema } from "../lib/validation"; -import { getActiveAgentId } from "../lib/activeAgent"; + +function getActiveAgentId(json: boolean): string | null { + const activeWallet = getActiveWallet(); + if (!activeWallet) { + outputError(json, new CliError( + "No active agent set.", + "NO_ACTIVE_AGENT", + "Run `acp agent use` to set an active agent." + )); + return null; + } + const agentId = getAgentId(activeWallet); + if (!agentId) { + outputError(json, new CliError( + "Agent ID not found for active wallet.", + "NO_ACTIVE_AGENT", + "Run `acp agent list` or `acp agent use` to populate it." + )); + return null; + } + return agentId; +} function parseSchemaOrString( value: string, @@ -18,11 +39,7 @@ function parseSchemaOrString( ): Record | string { try { const parsed = JSON.parse(value); - if ( - typeof parsed === "object" && - parsed !== null && - !Array.isArray(parsed) - ) { + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { return validateJsonSchema(value); } } catch { @@ -46,11 +63,15 @@ async function promptSchemaField( ).trim(); if (type === "2") { - const input = (await prompt(rl, `${fieldName} (JSON schema): `)).trim(); + const input = ( + await prompt(rl, `${fieldName} (JSON schema): `) + ).trim(); return validateJsonSchema(input); } - const value = (await prompt(rl, `${fieldName} (description): `)).trim(); + const value = ( + await prompt(rl, `${fieldName} (description): `) + ).trim(); if (!value) throw new Error(`${fieldName} cannot be empty.`); return value; } @@ -75,6 +96,7 @@ function printOffering(offering: AgentOffering): void { ["SLA", `${offering.slaMinutes} min`], ["Required Funds", offering.requiredFunds ? "Yes" : "No"], ["Hidden", offering.isHidden ? "Yes" : "No"], + ["Private", offering.isPrivate ? "Yes" : "No"], ]); } @@ -116,9 +138,7 @@ export function registerOfferingCommands(program: Command): void { } else { console.log("ID\tNAME\tPRICE\tSLA"); for (const o of offerings) { - console.log( - `${o.id}\t${o.name}\t${o.priceValue} (${o.priceType})\t${o.slaMinutes}m` - ); + console.log(`${o.id}\t${o.name}\t${o.priceValue} (${o.priceType})\t${o.slaMinutes}m`); } } } catch (err) { @@ -135,6 +155,7 @@ export function registerOfferingCommands(program: Command): void { offering .command("create") .description("Create a new offering for the active agent") + .option("--from-file ", "Create from an offering.json file") .option("--name ", "Offering name") .option("--description ", "Description") .option("--price-type ", "Price type: fixed or percentage") @@ -146,6 +167,8 @@ export function registerOfferingCommands(program: Command): void { .option("--no-required-funds", "Do not require funds") .option("--hidden", "Hidden offering") .option("--no-hidden", "Visible offering") + .option("--private", "Private offering") + .option("--no-private", "Public offering") .action(async (opts, cmd) => { const { agentApi } = await getClient(); const json = isJson(cmd); @@ -153,6 +176,58 @@ export function registerOfferingCommands(program: Command): void { const agentId = getActiveAgentId(json); if (!agentId) return; + // --from-file: read offering definition from JSON file + if (opts.fromFile) { + try { + const { readFileSync, writeFileSync } = await import("fs"); + const { resolve } = await import("path"); + const filePath = resolve(opts.fromFile); + const fileContent = JSON.parse(readFileSync(filePath, "utf-8")); + + const body: CreateOfferingBody = { + name: fileContent.name, + description: fileContent.description, + priceType: fileContent.priceType, + priceValue: Number(fileContent.priceValue), + slaMinutes: Number(fileContent.slaMinutes), + requirements: typeof fileContent.requirements === "string" + ? fileContent.requirements + : fileContent.requirements, + deliverable: typeof fileContent.deliverable === "string" + ? fileContent.deliverable + : fileContent.deliverable, + requiredFunds: fileContent.requiredFunds ?? false, + isHidden: fileContent.isHidden ?? false, + isPrivate: fileContent.isPrivate ?? false, + }; + + const created = await agentApi.createOffering(agentId, body); + + // Update the offering.json with the assigned ID + fileContent.id = created.id; + writeFileSync(filePath, JSON.stringify(fileContent, null, 2) + "\n"); + + if (json) { + outputResult(json, created as unknown as Record); + } else { + console.log(`\nOffering created from ${opts.fromFile}!`); + console.log(` ID: ${created.id}`); + console.log(` Name: ${created.name}`); + console.log(` Price: ${created.priceValue} (${created.priceType})`); + console.log(`\n offering.json updated with ID: ${created.id}`); + } + return; + } catch (err) { + outputError( + json, + `Failed to create offering from file: ${ + err instanceof Error ? err.message : String(err) + }` + ); + return; + } + } + const needsPrompt = !opts.name || !opts.description || @@ -162,7 +237,8 @@ export function registerOfferingCommands(program: Command): void { !opts.requirements || !opts.deliverable || opts.requiredFunds === undefined || - opts.hidden === undefined; + opts.hidden === undefined || + opts.private === undefined; let rl: readline.Interface | undefined; @@ -178,34 +254,20 @@ export function registerOfferingCommands(program: Command): void { let name: string; if (opts.name) { name = opts.name.trim(); - if (!name) { - outputError(json, "Name cannot be empty."); - return; - } + if (!name) { outputError(json, "Name cannot be empty."); return; } } else { name = (await prompt(rl!, "Offering name (3-20 chars): ")).trim(); - if (!name) { - outputError(json, "Name cannot be empty."); - return; - } + if (!name) { outputError(json, "Name cannot be empty."); return; } } // Description let description: string; if (opts.description) { description = opts.description.trim(); - if (!description) { - outputError(json, "Description cannot be empty."); - return; - } + if (!description) { outputError(json, "Description cannot be empty."); return; } } else { - description = ( - await prompt(rl!, "Description (10-500 chars): ") - ).trim(); - if (!description) { - outputError(json, "Description cannot be empty."); - return; - } + description = (await prompt(rl!, "Description (10-500 chars): ")).trim(); + if (!description) { outputError(json, "Description cannot be empty."); return; } } // Price type @@ -218,17 +280,11 @@ export function registerOfferingCommands(program: Command): void { } priceType = pt; } else { - rl?.close(); - rl = undefined; priceType = await selectOption( "Price type:", ["fixed", "percentage"] as const, (t) => t ); - rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); } // Price value @@ -269,10 +325,7 @@ export function registerOfferingCommands(program: Command): void { let requirements: Record | string; if (opts.requirements) { try { - requirements = parseSchemaOrString( - opts.requirements, - "Requirements" - ); + requirements = parseSchemaOrString(opts.requirements, "Requirements"); } catch (err) { outputError(json, err instanceof Error ? err : String(err)); return; @@ -311,9 +364,7 @@ export function registerOfferingCommands(program: Command): void { } else { const requiredFundsStr = ( await prompt(rl!, "Required funds? (y/N): ") - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); requiredFunds = requiredFundsStr === "y"; } @@ -321,12 +372,22 @@ export function registerOfferingCommands(program: Command): void { if (opts.hidden !== undefined) { isHidden = opts.hidden; } else { - const isHiddenStr = (await prompt(rl!, "Hidden? (y/N): ")) - .trim() - .toLowerCase(); + const isHiddenStr = ( + await prompt(rl!, "Hidden? (y/N): ") + ).trim().toLowerCase(); isHidden = isHiddenStr === "y"; } + let isPrivate: boolean; + if (opts.private !== undefined) { + isPrivate = opts.private; + } else { + const isPrivateStr = ( + await prompt(rl!, "Private? (y/N): ") + ).trim().toLowerCase(); + isPrivate = isPrivateStr === "y"; + } + const body: CreateOfferingBody = { name, description, @@ -337,6 +398,7 @@ export function registerOfferingCommands(program: Command): void { deliverable, requiredFunds, isHidden, + isPrivate, }; const created = await agentApi.createOffering(agentId, body); @@ -346,7 +408,7 @@ export function registerOfferingCommands(program: Command): void { return; } - console.log(`\n${c.green("Offering created successfully!")}\n`); + console.log("\nOffering created successfully!\n"); printOffering(created); } catch (err) { outputError( @@ -370,15 +432,14 @@ export function registerOfferingCommands(program: Command): void { .option("--price-type ", "New price type: fixed or percentage") .option("--price-value ", "New price value") .option("--sla-minutes ", "New SLA in minutes") - .option( - "--requirements ", - "New requirements (string or JSON schema)" - ) + .option("--requirements ", "New requirements (string or JSON schema)") .option("--deliverable ", "New deliverable (string or JSON schema)") .option("--required-funds", "Set required funds to true") .option("--no-required-funds", "Set required funds to false") .option("--hidden", "Set hidden to true") .option("--no-hidden", "Set hidden to false") + .option("--private", "Set private to true") + .option("--no-private", "Set private to false") .action(async (opts, cmd) => { const { agentApi } = await getClient(); const json = isJson(cmd); @@ -487,13 +548,8 @@ export function registerOfferingCommands(program: Command): void { updates.priceType = pt; } else if (!nonInteractive) { const priceTypeStr = ( - await prompt( - rl!, - `Price type [${selected.priceType}] (fixed/percentage): ` - ) - ) - .trim() - .toLowerCase(); + await prompt(rl!, `Price type [${selected.priceType}] (fixed/percentage): `) + ).trim().toLowerCase(); if (priceTypeStr) { if (priceTypeStr !== "fixed" && priceTypeStr !== "percentage") { outputError(json, "Price type must be 'fixed' or 'percentage'."); @@ -528,10 +584,7 @@ export function registerOfferingCommands(program: Command): void { // Requirements if (opts.requirements) { try { - updates.requirements = parseSchemaOrString( - opts.requirements, - "Requirements" - ); + updates.requirements = parseSchemaOrString(opts.requirements, "Requirements"); } catch (err) { outputError(json, err instanceof Error ? err : String(err)); return; @@ -546,15 +599,10 @@ export function registerOfferingCommands(program: Command): void { rl!, `Update requirements? Current: ${currentReqDisplay} (y/N): ` ) - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); if (updateReq === "y") { try { - updates.requirements = await promptSchemaField( - rl!, - "Requirements" - ); + updates.requirements = await promptSchemaField(rl!, "Requirements"); } catch (err) { outputError(json, err instanceof Error ? err : String(err)); return; @@ -565,10 +613,7 @@ export function registerOfferingCommands(program: Command): void { // Deliverable if (opts.deliverable) { try { - updates.deliverable = parseSchemaOrString( - opts.deliverable, - "Deliverable" - ); + updates.deliverable = parseSchemaOrString(opts.deliverable, "Deliverable"); } catch (err) { outputError(json, err instanceof Error ? err : String(err)); return; @@ -583,9 +628,7 @@ export function registerOfferingCommands(program: Command): void { rl!, `Update deliverable? Current: ${currentDelDisplay} (y/N): ` ) - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); if (updateDel === "y") { try { updates.deliverable = await promptSchemaField(rl!, "Deliverable"); @@ -603,13 +646,9 @@ export function registerOfferingCommands(program: Command): void { const reqFundsStr = ( await prompt( rl!, - `Required funds [${ - selected.requiredFunds ? "Yes" : "No" - }] (y/n): ` + `Required funds [${selected.requiredFunds ? "Yes" : "No"}] (y/n): ` ) - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); if (reqFundsStr === "y") updates.requiredFunds = true; else if (reqFundsStr === "n") updates.requiredFunds = false; } @@ -623,13 +662,25 @@ export function registerOfferingCommands(program: Command): void { rl!, `Hidden [${selected.isHidden ? "Yes" : "No"}] (y/n): ` ) - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); if (hiddenStr === "y") updates.isHidden = true; else if (hiddenStr === "n") updates.isHidden = false; } + // Private + if (opts.private !== undefined) { + updates.isPrivate = opts.private; + } else if (!nonInteractive) { + const privateStr = ( + await prompt( + rl!, + `Private [${selected.isPrivate ? "Yes" : "No"}] (y/n): ` + ) + ).trim().toLowerCase(); + if (privateStr === "y") updates.isPrivate = true; + else if (privateStr === "n") updates.isPrivate = false; + } + if (Object.keys(updates).length === 0) { console.log("No changes made."); return; @@ -646,7 +697,7 @@ export function registerOfferingCommands(program: Command): void { return; } - console.log(`\n${c.green("Offering updated successfully!")}\n`); + console.log("\nOffering updated successfully!\n"); printOffering(updated); } catch (err) { outputError( @@ -717,9 +768,7 @@ export function registerOfferingCommands(program: Command): void { try { const confirm = ( await prompt(rl, `Delete offering '${selected.name}'? (y/N): `) - ) - .trim() - .toLowerCase(); + ).trim().toLowerCase(); if (confirm !== "y") { console.log("Cancelled."); @@ -739,9 +788,7 @@ export function registerOfferingCommands(program: Command): void { deletedOffering: selected.name, }); } else { - console.log( - `\n${c.green(`Offering '${selected.name}' deleted successfully.`)}` - ); + console.log(`\nOffering '${selected.name}' deleted successfully.`); } } catch (err) { outputError( diff --git a/src/commands/serve.ts b/src/commands/serve.ts new file mode 100644 index 0000000..fec01ea --- /dev/null +++ b/src/commands/serve.ts @@ -0,0 +1,662 @@ +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + watchFile, + statSync, + readdirSync, +} from "fs"; +import { homedir } from "os"; +import type { Command } from "commander"; +import { isJson, outputError, outputResult } from "../lib/output"; +import { getActiveWallet, getAgentId, getAgentToken } from "../lib/config"; +import { getApiUrl, getClient } from "../lib/api/client"; +import type { Agent, AgentOffering } from "../lib/api/agent"; +import { provisionDeploySigner } from "../lib/deploySigner"; +import type { DeployProviderName } from "../../serve/providers/types"; +import { RailwayDeployProvider } from "../../serve/providers/railway"; +import { CloudflareDeployProvider } from "../../serve/providers/cloudflare"; + +type Protocol = "x402" | "mpp" | "acp"; + +type LocalOfferingConfig = { + slug: string; + dir: string; + protocols: Protocol[]; + 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 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, + { name?: string; offerings?: Record } + >; + const agentConfig = agents[agentId]; + if (!agentConfig?.offerings) { + return []; + } + + const results: LocalOfferingConfig[] = []; + for (const [slug, entry] of Object.entries(agentConfig.offerings)) { + const dir = resolve(rootDir, entry.dir); + const offeringJsonPath = resolve(dir, "offering.json"); + const offeringJson = existsSync(offeringJsonPath) ? readJsonFile(offeringJsonPath) : {}; + results.push({ + slug, + dir, + protocols: entry.protocols ?? ["x402", "mpp", "acp"], + offeringJson, + }); + } + + return results; +} + +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); + if (matches.length === 1) { + return matches[0]; + } + return undefined; +} + +function materializeOffering( + local: LocalOfferingConfig, + remote: AgentOffering | undefined +): { + id: string; + slug: string; + name: string; + description: string; + priceType: string; + priceValue: number; + slaMinutes: number; + requirements: Record | string; + deliverable: Record | string; +} { + const localName = + typeof local.offeringJson.name === "string" ? local.offeringJson.name : local.slug; + const localDescription = + typeof local.offeringJson.description === "string" + ? local.offeringJson.description + : ""; + const localPriceType = + typeof local.offeringJson.priceType === "string" + ? local.offeringJson.priceType + : "fixed"; + const localPriceValue = + typeof local.offeringJson.priceValue === "number" + ? local.offeringJson.priceValue + : Number(local.offeringJson.priceValue ?? 0); + const localSla = + typeof local.offeringJson.slaMinutes === "number" + ? local.offeringJson.slaMinutes + : Number(local.offeringJson.slaMinutes ?? 5); + + 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 ?? localPriceType, + priceValue: remote ? Number(remote.priceValue) : localPriceValue, + slaMinutes: remote?.slaMinutes ?? localSla, + requirements: + remote?.requirements ?? + ((local.offeringJson.requirements as Record | string) ?? ""), + deliverable: + remote?.deliverable ?? + ((local.offeringJson.deliverable as Record | string) ?? ""), + }; +} + +function getDefaultPort(input: unknown): number { + const port = Number(input ?? 3000); + return Number.isFinite(port) && port > 0 ? port : 3000; +} + +function serviceJobEndpoint( + apiUrl: string, + providerAddress: string, + offeringSlug: string, + protocol: "x402" | "mpp" +): string { + return new URL( + `/${protocol}/${providerAddress}/jobs/${encodeURIComponent(offeringSlug)}`, + apiUrl + ).toString(); +} + +export function registerServeCommands(program: Command): void { + const serve = program + .command("serve") + .description("Scaffold, run, and deploy ACP service endpoints"); + + serve + .command("init") + .description("Scaffold a local offering runtime for an agent offering") + .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}. Delete it or choose a different name.` + ); + } + + mkdirSync(offeringDir, { recursive: true }); + + const scaffoldDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../serve/scaffold" + ); + const offeringTemplate = readFileSync( + resolve(scaffoldDir, "offering.json.template"), + "utf8" + ); + const offeringJson = JSON.parse( + offeringTemplate.replace("{{NAME}}", opts.name) + ) as Record; + + writeFileSync( + resolve(offeringDir, "offering.json"), + JSON.stringify(offeringJson, null, 2) + "\n" + ); + 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; + const offerings = (agentConfig.offerings ?? {}) as Record; + offerings[offeringSlug] = { + dir: `agents/${agentSlug}/offerings/${offeringSlug}`, + protocols: ["x402", "mpp", "acp"], + registered: false, + }; + agentConfig.offerings = offerings; + 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 local runtime for a single offering") + .option("--dir ", "Project root directory", ".") + .option("--offering ", "Offering slug, ID, or name") + .option("--port ", "Port to listen on") + .option("--settle-8183", "Enable on-chain ERC-8183 settlement for x402/MPP") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.dir); + const localOfferings = loadLocalOfferings(rootDir, active.agentId); + const selected = selectLocalOfferings(localOfferings, opts.offering); + + if (selected.length === 0) { + throw new Error("No matching offerings found in serve.json."); + } + if (selected.length > 1) { + throw new Error( + "Multiple offerings matched. Rerun with --offering ." + ); + } + + const { agentApi } = await getClient(active.wallet); + const agent = await agentApi.getById(active.agentId); + const agentToken = getAgentToken(active.wallet); + if (!agentToken) { + throw new Error("Agent auth token not found. Run `acp agent use` first."); + } + const agentSlug = slugify(agent.name); + const local = selected[0]; + const remote = findRemoteOffering(local, agent); + const offering = materializeOffering(local, remote); + + const { startOfferingServer } = await import("../../serve/server/index"); + await startOfferingServer({ + dir: local.dir, + port: opts.port ? Number(opts.port) : getDefaultPort(undefined), + agentSlug, + providerWallet: active.wallet, + offering, + protocols: local.protocols, + settle8183: opts.settle8183 === true, + apiUrl: getApiUrl(), + agentToken, + }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("endpoints") + .description("Show canonical BE endpoint URLs for configured offerings") + .option("--dir ", "Project root directory", ".") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const rootDir = resolve(opts.dir); + const offerings = loadLocalOfferings(rootDir, active.agentId); + const apiUrl = getApiUrl(); + const payload: Record> = {}; + + for (const offering of offerings) { + 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 deploy bundle and deploy it through a provider adapter") + .option("--dir ", "Project root directory", ".") + .option("--provider ", "Deployment provider: railway or cloudflare", "railway") + .option("--offering ", "Offering slug, ID, or name") + .option("--service ", "Provider service name override") + .option("--project ", "Provider project identifier") + .option("--environment ", "Provider environment identifier") + .option("--domain ", "Custom domain (e.g. serve.virtuals.io)") + .option("--execute", "Execute provider CLI commands when supported") + .action(async (opts, cmd) => { + const json = isJson(cmd); + try { + const active = requireActiveAgent(json); + if (!active) return; + + const providerName = opts.provider as DeployProviderName; + if (providerName !== "railway" && providerName !== "cloudflare") { + throw new Error("Unsupported provider. Use `railway` or `cloudflare`."); + } + + const rootDir = resolve(opts.dir); + const localOfferings = loadLocalOfferings(rootDir, active.agentId); + const selected = selectLocalOfferings(localOfferings, opts.offering); + if (selected.length === 0) { + throw new Error("No matching offerings found in serve.json."); + } + + const { agentApi } = await getClient(); + const agent = await agentApi.getById(active.agentId); + const provider = + providerName === "railway" + ? new RailwayDeployProvider() + : new CloudflareDeployProvider(); + + const results: Record[] = []; + for (const local of selected) { + const remote = findRemoteOffering(local, agent); + if (!remote) { + throw new Error( + `Offering "${local.slug}" is not registered on ACP yet. Run \`acp offering create --from-file ${resolve( + local.dir, + "offering.json" + )}\` first.` + ); + } + + const offering = materializeOffering(local, remote); + const deploySigner = await provisionDeploySigner( + agentApi, + agent, + offering.name, + (message) => { + if (!json) { + console.log(message); + } + } + ); + + const walletId = agent.walletProviders?.[0]?.metadata?.walletId; + if (!walletId) { + throw new Error("Wallet ID not found for agent. Cannot deploy."); + } + + const deployment = await provider.deploy( + { + rootDir, + serviceName: opts.service ?? `${slugify(agent.name)}-${local.slug}`, + providerWallet: active.wallet, + agentId: active.agentId, + agentName: agent.name, + apiUrl: getApiUrl(), + walletId, + offering, + entryDir: local.dir, + protocols: local.protocols, + deploySigner: { + publicKey: deploySigner.publicKey, + privateKey: deploySigner.privateKey, + }, + }, + { + project: opts.project, + environment: opts.environment, + service: opts.service, + domain: opts.domain, + execute: opts.execute === true, + } + ); + + results.push({ + offering: local.slug, + provider: deployment.provider, + bundleDir: deployment.bundleDir, + executed: deployment.executed, + serviceName: deployment.serviceName, + endpoints: deployment.endpoints, + nextSteps: deployment.nextSteps, + deploySignerPublicKey: deploySigner.publicKey, + signerApprovalUrl: deploySigner.signerUrl, + }); + } + + outputResult(json, { deployments: results }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("undeploy") + .description("Placeholder for managed undeploy support") + .requiredOption("--offering ", "Offering slug, ID, or name") + .action(async (_opts, cmd) => { + outputError( + isJson(cmd), + "Undeploy is not implemented yet. Remove the managed deployment from the hosting control plane for now." + ); + }); + + serve + .command("stop") + .description("Stop a locally running offering server") + .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 rootDir = resolve(opts.dir); + const selected = selectLocalOfferings( + loadLocalOfferings(rootDir, active.agentId), + opts.offering + ); + + let stopped = 0; + const { getPidFilePath } = await import("../../serve/server/index"); + for (const local of selected) { + 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 { + // Ignore stale pid files. + } + } + + outputResult(json, { success: true, stopped }); + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); + + serve + .command("status") + .description("Show whether local offering servers are running") + .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 rootDir = resolve(opts.dir); + const selected = selectLocalOfferings( + loadLocalOfferings(rootDir, active.agentId), + opts.offering + ); + + const { getPidFilePath } = await import("../../serve/server/index"); + const statuses: Record = {}; + for (const local of selected) { + const offeringId = + typeof local.offeringJson.id === "string" + ? local.offeringJson.id + : local.slug; + const pidFile = getPidFilePath(offeringId); + if (!existsSync(pidFile)) { + statuses[local.slug] = { running: false }; + continue; + } + const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10); + try { + process.kill(pid, 0); + statuses[local.slug] = { running: true, pid }; + } catch { + statuses[local.slug] = { running: false }; + } + } + + outputResult(json, { offerings: statuses }); + } 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") + .option("--level ", "Filter by log level") + .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: Record[] = []; + for (const file of files) { + const contents = readFileSync(file, "utf8").trim(); + if (!contents) continue; + for (const line of contents.split("\n")) { + try { + const parsed = JSON.parse(line) as Record; + if (opts.level && parsed.level !== opts.level) continue; + logs.push(parsed); + } catch { + // Ignore malformed lines. + } + } + } + + logs.sort((a, b) => + String(a.timestamp).localeCompare(String(b.timestamp)) + ); + outputResult(json, { logs: logs.slice(-50) }); + + if (opts.follow && files.length > 0 && !json) { + console.log("Tailing logs. Ctrl+C to stop."); + 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, "utf8").slice(previousSize, currentSize); + process.stdout.write(chunk); + offsets.set(file, currentSize); + }); + } + } + } catch (err) { + outputError(json, err instanceof Error ? err.message : String(err)); + } + }); +} diff --git a/src/lib/agentFactory.ts b/src/lib/agentFactory.ts index 369fb21..1f6e12a 100644 --- a/src/lib/agentFactory.ts +++ b/src/lib/agentFactory.ts @@ -11,7 +11,6 @@ import { SseTransport, AcpApiClient, } from "@virtuals-protocol/acp-node-v2"; -import type { IEvmProviderAdapter } from "@virtuals-protocol/acp-node-v2"; import { getActiveWallet, getPublicKey, @@ -19,14 +18,22 @@ import { setWalletId, } from "./config"; import { getClient } from "./api/client"; -import { createSignFn } from "./acpCliSigner"; +import { loadSignerKey } from "./signerKeychain"; +import { CliError } from "./errors"; import { LegacyBuyerAdapter, type LegacyJobEventHandler, } from "./compat/legacyBuyerAdapter"; -import { CliError } from "./errors"; -import { Chain } from "viem"; import { base, baseSepolia } from "viem/chains"; +import type { Chain } from "viem"; + +export function requireEnv(name: string): string { + const val = process.env[name]; + if (!val) { + throw new Error(`Missing required environment variable: ${name}`); + } + return val; +} export async function getWalletIdByAddress( walletAddress: string @@ -56,22 +63,6 @@ export async function createAgentFromConfig(): Promise { const serverUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; const privyAppId = isTestnet ? TESTNET_PRIVY_APP_ID : PRIVY_APP_ID; - return AcpAgent.create({ - contractAddresses: ACP_CONTRACT_ADDRESSES, - provider: await createProviderFromConfig(chains, serverUrl, privyAppId), - api: new AcpApiClient({ serverUrl }), - transport: new SseTransport({ serverUrl }), - }); -} - -/** - * Create a provider adapter from config — shared between v2 agent and v1 adapter. - */ -async function createProviderFromConfig( - chains: Chain[], - serverUrl: string, - privyAppId: string -): Promise { const walletAddress = getActiveWallet(); if (!walletAddress) { throw new CliError( @@ -94,49 +85,128 @@ async function createProviderFromConfig( getWalletId(walletAddress) ?? (await getWalletIdByAddress(walletAddress)); setWalletId(walletAddress, walletId); - const signFn = createSignFn(publicKey); + const signerPrivateKey = await loadSignerKey(publicKey); + if (!signerPrivateKey) { + throw new CliError( + "Signer key not found in keychain.", + "NO_SIGNER", + "Run `acp agent add-signer` to regenerate the signing key." + ); + } - return PrivyAlchemyEvmProviderAdapter.create({ + const provider = await PrivyAlchemyEvmProviderAdapter.create({ + chains, walletAddress: walletAddress as `0x${string}`, walletId, - signFn, - chains, + signerPrivateKey, serverUrl, privyAppId, }); + + return AcpAgent.create({ + contractAddresses: ACP_CONTRACT_ADDRESSES, + provider, + api: new AcpApiClient({ serverUrl }), + transport: new SseTransport({ serverUrl }), + }); } -/** - * Create a LegacyBuyerAdapter for interacting with legacy (openclaw-cli) sellers. - * Pass onNewTask to connect the old backend's socket and receive real-time events. - */ export async function createLegacyBuyerAdapter(options?: { onNewTask?: LegacyJobEventHandler; -}): Promise { +}) { const isTestnet = process.env.IS_TESTNET === "true"; - const chain = isTestnet ? baseSepolia : base; + const chain: Chain = isTestnet ? baseSepolia : base; const serverUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; const privyAppId = isTestnet ? TESTNET_PRIVY_APP_ID : PRIVY_APP_ID; - const provider = await createProviderFromConfig( - [chain], + const walletAddress = getActiveWallet(); + if (!walletAddress) { + throw new CliError( + "No active agent set.", + "NO_ACTIVE_AGENT", + "Run `acp agent create` or `acp agent use` to set an active agent." + ); + } + + const publicKey = getPublicKey(walletAddress); + if (!publicKey) { + throw new CliError( + "No signer configured for this agent.", + "NO_SIGNER", + "Run `acp agent add-signer` to generate and register a signing key." + ); + } + + const walletId = + getWalletId(walletAddress) ?? (await getWalletIdByAddress(walletAddress)); + setWalletId(walletAddress, walletId); + + const signerPrivateKey = await loadSignerKey(publicKey); + if (!signerPrivateKey) { + throw new CliError( + "Signer key not found in keychain.", + "NO_SIGNER", + "Run `acp agent add-signer` to regenerate the signing key." + ); + } + + const provider = await PrivyAlchemyEvmProviderAdapter.create({ + chains: [chain], + walletAddress: walletAddress as `0x${string}`, + walletId, + signerPrivateKey, serverUrl, - privyAppId - ); + privyAppId, + }); + return LegacyBuyerAdapter.create(provider, chain.id, options); } -/** - * Create a provider adapter using the active wallet config. - * Lightweight alternative to createAgentFromConfig() when only - * signing / provider operations are needed. - */ -export async function createProviderAdapter(): Promise { +export async function createProviderAdapter() { const isTestnet = process.env.IS_TESTNET === "true"; const chains = isTestnet ? EVM_TESTNET_CHAINS : EVM_MAINNET_CHAINS; const serverUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; const privyAppId = isTestnet ? TESTNET_PRIVY_APP_ID : PRIVY_APP_ID; - return createProviderFromConfig(chains, serverUrl, privyAppId); + + const walletAddress = getActiveWallet(); + if (!walletAddress) { + throw new CliError( + "No active agent set.", + "NO_ACTIVE_AGENT", + "Run `acp agent create` or `acp agent use` to set an active agent." + ); + } + + const publicKey = getPublicKey(walletAddress); + if (!publicKey) { + throw new CliError( + "No signer configured for this agent.", + "NO_SIGNER", + "Run `acp agent add-signer` to generate and register a signing key." + ); + } + + const walletId = + getWalletId(walletAddress) ?? (await getWalletIdByAddress(walletAddress)); + setWalletId(walletAddress, walletId); + + const signerPrivateKey = await loadSignerKey(publicKey); + if (!signerPrivateKey) { + throw new CliError( + "Signer key not found in keychain.", + "NO_SIGNER", + "Run `acp agent add-signer` to regenerate the signing key." + ); + } + + return PrivyAlchemyEvmProviderAdapter.create({ + chains, + walletAddress: walletAddress as `0x${string}`, + walletId, + signerPrivateKey, + serverUrl, + privyAppId, + }); } export function getWalletAddress(): string { diff --git a/src/lib/api/agent.ts b/src/lib/api/agent.ts index 4a3a458..2beb228 100644 --- a/src/lib/api/agent.ts +++ b/src/lib/api/agent.ts @@ -22,6 +22,7 @@ export interface AgentOffering { priceValue: string; requiredFunds: boolean; isHidden: boolean; + isPrivate: boolean; createdAt: string; updatedAt: string; } @@ -36,6 +37,7 @@ export interface CreateOfferingBody { priceValue: number; requiredFunds?: boolean; isHidden?: boolean; + isPrivate?: boolean; } export type UpdateOfferingBody = Partial; @@ -104,7 +106,6 @@ export interface Agent { chains: { chainId: number; tokenAddress?: string; - acpV2AgentId?: number; }[]; } @@ -169,6 +170,7 @@ export interface BrowseAgent { priceValue: string; requiredFunds: boolean; isHidden: boolean; + isPrivate: boolean; createdAt: string; updatedAt: string; }[]; @@ -185,22 +187,6 @@ interface AgentBrowseResponse { data: BrowseAgent[]; } -export const MigrationStatus = { - PENDING: "PENDING", - IN_PROGRESS: "IN_PROGRESS", - COMPLETED: "COMPLETED", -} as const; - -export type MigrationStatus = - (typeof MigrationStatus)[keyof typeof MigrationStatus]; - -export interface LegacyAgent { - id: number; - name: string; - walletAddress: string; - migrationStatus: MigrationStatus; -} - export interface TokenizeStatusResponse { hasTokenized: boolean; hasPaid: boolean; @@ -220,13 +206,6 @@ export interface TokenizeResponse { taxRecipient: string; } -export interface UpdateAgentBody { - name: string; - description: string; - image: string; - isHidden: boolean; -} - export class AgentApi { private client: ApiClient; @@ -257,17 +236,6 @@ export class AgentApi { return res.data; } - async update( - agentId: string, - body: Partial - ): Promise { - const res = await this.client.put<{ data: Agent }>( - `/agents/${agentId}`, - body - ); - return res.data; - } - async addQuorum( agentId: string, publicKey: string @@ -384,21 +352,6 @@ export class AgentApi { ); } - async getLegacyAgents(): Promise { - const res = await this.client.get<{ data: LegacyAgent[] }>( - "/agents/legacy" - ); - return res.data; - } - - async migrateAgent(acpAgentId: number): Promise { - const res = await this.client.post<{ message: string; data: Agent }>( - "/agents/migrate", - { acpAgentId } - ); - return res.data; - } - async tokenize( agentId: string, chainId: number, diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index d90070e..32c4b5e 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -1,3 +1,6 @@ +import { EvmAcpClient } from "@virtuals-protocol/acp-node-v2"; +import { createAgentFromConfig } from "../agentFactory"; +import { setAgentToken } from "../config"; import { ApiClient } from "./client"; interface CliUrlResponse { @@ -8,6 +11,16 @@ interface CliTokenResponse { data: { token: string; refreshToken: string }; } +interface AuthTokenResponse { + data: { token: string }; +} + +interface RequestAuthToken { + walletAddress: string; + signature: string; + message: string; + chainId: number; +} export class AuthApi { constructor(private readonly client: ApiClient) {} @@ -43,4 +56,42 @@ export class AuthApi { return null; } } + + async getAuthToken(data: RequestAuthToken): Promise { + try { + const res = await this.client.post("/auth/agent", { + walletAddress: data.walletAddress, + signature: data.signature, + message: data.message, + chainId: data.chainId, + }); + const token = res.data.token; + setAgentToken(data.walletAddress, token); + return token; + } catch (error) { + throw new Error( + `Failed to get auth token: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + static async fetchAndStoreToken( + walletAddress: string, + chainId: number, + baseUrl: string + ): Promise { + const message = `acp-auth:${Date.now()}`; + + const agent = await createAgentFromConfig(); + const acpClient = agent.getClient(); + if (!(acpClient instanceof EvmAcpClient)) { + throw new Error("signMessage requires an EVM provider"); + } + const signature = await acpClient.getProvider().signMessage(chainId, message); + + const authApi = new AuthApi(new ApiClient(baseUrl)); + return authApi.getAuthToken({ walletAddress, signature, message, chainId }); + } } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index fa56fdd..8fdb16c 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,4 +1,5 @@ import { + getAgentToken, getRefreshToken, getToken, isTokenExpired, @@ -62,7 +63,26 @@ export class ApiClient { } } -async function resolveToken(apiUrl: string): Promise { +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( + walletAddress: string | undefined, + apiUrl: string +): Promise { + if (walletAddress) { + let token = getAgentToken(walletAddress); + if (!token || isTokenExpired(token)) { + const chainId = Number(process.env.ACP_CHAIN_ID || "84532"); + token = await AuthApi.fetchAndStoreToken(walletAddress, chainId, apiUrl); + } + return token; + } const token = await getToken(); if (!token) { throw new CliError( @@ -101,14 +121,15 @@ async function resolveToken(apiUrl: string): Promise { export async function getClient( walletAddress?: string, - unauthenticated?: boolean + unauthenticated = false ): Promise<{ agentApi: AgentApi; authApi: AuthApi; }> { - const isTestnet = process.env.IS_TESTNET === "true"; - const apiUrl = isTestnet ? ACP_TESTNET_SERVER_URL : ACP_SERVER_URL; - const token = unauthenticated ? undefined : await resolveToken(apiUrl); + const apiUrl = getApiUrl(); + const token = unauthenticated + ? undefined + : await resolveToken(walletAddress, apiUrl); const httpClient = new ApiClient(apiUrl, token); return { agentApi: new AgentApi(httpClient), diff --git a/src/lib/config.ts b/src/lib/config.ts index 152a733..0baeec8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,11 +3,11 @@ import { resolve } from "path"; import { getPassword, setPassword } from "cross-keychain"; const AUTH_KEYCHAIN_SERVICE = "acp-auth"; - const CONFIG_PATH = resolve(process.cwd(), "config.json"); interface AgentConfig { publicKey: string; + token?: string; walletId?: string; id?: string; } @@ -35,15 +35,22 @@ 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 async function getToken(): Promise { return ( - (await getPassword(AUTH_KEYCHAIN_SERVICE, "access-token")) ?? undefined + getEnv("ACP_ACCESS_TOKEN") ?? + ((await getPassword(AUTH_KEYCHAIN_SERVICE, "access-token")) ?? undefined) ); } export async function getRefreshToken(): Promise { return ( - (await getPassword(AUTH_KEYCHAIN_SERVICE, "refresh-token")) ?? undefined + getEnv("ACP_REFRESH_TOKEN") ?? + ((await getPassword(AUTH_KEYCHAIN_SERVICE, "refresh-token")) ?? undefined) ); } @@ -55,8 +62,20 @@ export async function setTokens( await setPassword(AUTH_KEYCHAIN_SERVICE, "refresh-token", refreshToken); } +export function getAgentToken(walletAddress: string): string | undefined { + return getEnv("ACP_AGENT_TOKEN") ?? loadConfig().agents?.[walletAddress]?.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 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 { @@ -68,7 +87,7 @@ 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 { @@ -80,7 +99,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 { @@ -92,7 +111,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 setActiveWallet(walletAddress: string): void { diff --git a/src/lib/deploySigner.ts b/src/lib/deploySigner.ts new file mode 100644 index 0000000..4e1c95d --- /dev/null +++ b/src/lib/deploySigner.ts @@ -0,0 +1,78 @@ +import { generateP256KeyPair } from "@privy-io/node"; +import { execSync } from "child_process"; +import { storeSignerKey } from "./signerKeychain"; +import type { AgentApi, Agent } from "./api/agent"; + +const POLL_INTERVAL_MS = 5_000; +const TIMEOUT_MS = 5 * 60 * 1_000; + +export interface DeploySignerDetails { + publicKey: string; + privateKey: string; + signerUrl: string; + requestId: string; +} + +function openBrowser(url: string): void { + try { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + execSync(`${cmd} "${url}"`, { stdio: "ignore" }); + } catch { + // Silently fail — URL is printed to console as fallback. + } +} + +export async function provisionDeploySigner( + agentApi: AgentApi, + agent: Agent, + offeringName: string, + onStatus?: (message: string) => void +): Promise { + const keypair = await generateP256KeyPair(); + const addSigner = await agentApi.addSignerWithUrl(agent.id); + const signerUrl = `${addSigner.data.url}&publicKey=${keypair.publicKey}`; + const requestId = addSigner.data.requestId; + + onStatus?.([ + ``, + ` Deploy signer required`, + ` ──────────────────────`, + ` A signing key is needed so the deployed service can act`, + ` on behalf of agent "${agent.name}" for offering "${offeringName}".`, + ``, + ` Opening browser for approval...`, + ` If it doesn't open, visit:`, + ` ${signerUrl}`, + ``, + ` Waiting for approval (expires in 5 minutes)...`, + ].join("\n")); + + openBrowser(signerUrl); + + const deadline = Date.now() + TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + try { + const status = await agentApi.getSignerStatus(agent.id, requestId); + if (status.data.status === "completed") { + onStatus?.(" Deploy signer approved.\n"); + await storeSignerKey(keypair.publicKey, keypair.privateKey); + return { + publicKey: keypair.publicKey, + privateKey: keypair.privateKey, + signerUrl, + requestId, + }; + } + } catch { + // Ignore transient polling errors and keep waiting until timeout. + } + } + + throw new Error("Signer registration timed out. Please try again."); +} diff --git a/src/lib/signerKeychain.ts b/src/lib/signerKeychain.ts new file mode 100644 index 0000000..8366b24 --- /dev/null +++ b/src/lib/signerKeychain.ts @@ -0,0 +1,23 @@ +import { getPassword, setPassword } from "cross-keychain"; + +const KEYCHAIN_SERVICE = "acp-signer"; + +function toKeychainAccount(publicKey: string): string { + return Buffer.from(publicKey, "base64").toString("hex"); +} + +export async function storeSignerKey( + publicKey: string, + privateKey: string +): Promise { + await setPassword(KEYCHAIN_SERVICE, toKeychainAccount(publicKey), privateKey); +} + +export async function loadSignerKey(publicKey: string): Promise { + const envKey = + process.env.ACP_SIGNER_PRIVATE_KEY ?? process.env.DEPLOY_SIGNER_KEY; + if (envKey) { + return envKey; + } + return getPassword(KEYCHAIN_SERVICE, toKeychainAccount(publicKey)); +}