diff --git a/CHANGELOG.md b/CHANGELOG.md
index e10af05..06ca0a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
+### Added — bounties on Arc (api, sdk 0.10.4, web)
+
+USDC bounties can be paid on Arc (`eip155:5042`), Circle's stablecoin chain, as well as Base and Polygon. Coinbase's CDP facilitator does not settle on Arc, so Arc runs on two paths:
+
+- **Deposits settle through Circle's Facilitator Service.** A buyer's transfer into the escrow wallet is verified and settled by Circle, which screens both parties and pays the gas. It authenticates with `CIRCLE_API_KEY`, and new Arc bounties are offered only while that key is set.
+ - Each settle carries a `payment-identifier` taken from the authorization's nonce, so retries resolve to one payment.
+ - Circle can answer "pending" before it has a transaction hash. That is now a pending outcome, and the retry resends the same authorization.
+- **Payouts and refunds are sent by the escrow wallet itself.** Settling through Circle would bind the payee's wallet to our Circle account, and a payout's payee is an agent's or a buyer's wallet. Instead the escrow wallet broadcasts its own signed `transferWithAuthorization` on Arc and pays the gas in USDC, Arc's gas token. The relay:
+ - checks whether the authorization was already used, and if it was, records the transaction that used it from the token's log (found by block timestamp, however long ago; a log it can't read yet keeps the payout open rather than settling it without its transaction);
+ - calls an authorization expired only once the chain has passed its `validBefore` with it unused, read at that block, so a transfer still in flight is never re-signed;
+ - waits for any escrow-wallet transaction in flight, and recovers from a nonce race with a concurrent payout in the same call;
+ - once it has sent a transfer, never answers in a way that would let a second one be signed, nor claims a hash it can't confirm;
+ - simulates the transfer first, and pays at least Arc's 20 gwei fee floor.
+
+ EIP-3009's one-use nonce means a retry can never pay twice.
+- **The escrow wallet needs a small USDC float on Arc**, about 0.002 USDC per payout. Without it, a payout answers `insufficient_funds` and nothing is sent.
+- **Arc bounties are always escrowed.** `escrow: false` on Arc answers `400 escrow_required`.
+- **Discovery.**
+ - The hire 402, the price list and `/.well-known/x402` add Arc after Base and Polygon.
+ - The Arc offer's `extra` carries `assetTransferMethod: "eip3009"`, which Circle expects.
+ - USDC's signing domain on Arc (`USDC`, version `2`) was read on-chain and pinned.
+- **Payout wallets.** They can be bound on Arc. Smart-wallet signatures there are checked through `ARC_RPC_URL`, falling back to Arc's public nodes. Verified plain keys bound on another chain are paid at the same address on Arc.
+- **Paid feed and explorer links.** Arc settlements count in the feed and link to explorer.arc.io.
+- **SDK 0.10.4.** `BOUNTY_NETWORKS` includes `eip155:5042`, and the Circle CLI command for Arc uses `--chain ARC`.
+- **Verification.**
+ - The transaction signer matches eth-account byte for byte; the vectors are pinned in the tests.
+ - On Arc mainnet, an authorization built by the escrow code passes USDC's signature check (reverting only for balance), and a flipped signature byte fails it.
+
### Changed — the ChatGPT listing leads with one purpose: auditing MCP servers and agents (repo, api)
OpenAI's review "couldn't confirm the selected category" and asked that the listing clearly explain the plugin's main purpose. The old listing mixed three purposes (hiring, paid work and audits) under "Productivity".
diff --git a/SECURITY.md b/SECURITY.md
index 1d94803..ed9c437 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -160,7 +160,7 @@ Naive string concatenation of hash inputs (e.g. `previous_hash || public_key ||
### Payment Security
- **Escrow is custodial; sign-at-accept is not.** By default a bounty is deposited into the registry's **house wallet** when the task is posted and held there until the delivery is accepted (released to the deliverer) or the task is cancelled (refunded to the paying address). During that window the operator holds third-party funds: the house key (`ESCROW_WALLET_PRIVATE_KEY`) is a Worker secret that must be backed up offline and rotated with care (a rotated key cannot move deposits the old key holds — `wallet_mismatch` is logged for a manual payout), the wallet's USDC balance must cover every `funded` deposit, and money-transmission obligations for holding customer funds are the operator's to assess. With `escrow: false` BasedAgents never holds funds: the bounty is declared when a task is posted and authorized by the buyer only when they accept the delivery; the Coinbase CDP facilitator settles the EIP-3009 transfer wallet-to-wallet and the registry stores only the encrypted authorization, and only until it settles. A deploy without a house key runs only this model. The custodial window is the reason escrow v2 — an on-chain contract where the registry can only pay the recorded deliverer or refund the buyer, and a buyer can reclaim an abandoned deposit permissionlessly — is specified in `ESCROW_CONTRACT_SPEC.md`.
-- **The house key can only do one thing** — sign EIP-3009 `TransferWithAuthorization`s that the facilitator broadcasts (no gas, no arbitrary transactions), for exactly a funded task's bounty, to exactly the deliverer's *live* wallet (release, task `verified`) or the address that paid the deposit (refund, task `cancelled`), from `escrow_status = funded` only, armed in one conditional UPDATE so two callers never sign twice, and never to itself. Re-signing after a definitive failure is bounded (`ESCROW_MAX_LEG_ATTEMPTS`); a leg that was broadcast and is unresolved is never re-signed. The key never leaves `payments/house-wallet.ts` (callers get an address and a signer).
+- **The house key can only do one thing** — sign EIP-3009 `TransferWithAuthorization`s (broadcast by the facilitator on Base and Polygon, with no gas; on Arc the house broadcasts that same transfer itself as its one kind of transaction, `transferWithAuthorization` on USDC, paying the gas in USDC — never an arbitrary transaction), for exactly a funded task's bounty, to exactly the deliverer's *live* wallet (release, task `verified`) or the address that paid the deposit (refund, task `cancelled`), from `escrow_status = funded` only, armed in one conditional UPDATE so two callers never sign twice, and never to itself. Re-signing after a definitive failure is bounded (`ESCROW_MAX_LEG_ATTEMPTS`); a leg that was broadcast and is unresolved is never re-signed. The key never leaves `payments/house-wallet.ts` (callers get an address and a signer).
- **Sign at accept, never at create (`escrow: false`)** — a payment header on `POST /v1/tasks` is refused (`400 payment_not_expected`); the 402 challenge on `POST /v1/tasks/:id/accept` is stateless and repeatable; `payTo` is always the deliverer's *live* wallet, rebuilt server-side, never taken from the client's payload. With escrow the buyer signs exactly once, at post: the 402 there is equally stateless (no task, no rate-limit slot, no passkey challenge is consumed), `payTo` is the configured house wallet, and a deposit nonce funds at most one task (`UNIQUE(payment_nonce)` and `UNIQUE(escrow_deposit_nonce)`, `409 authorization_reused`); a payment header on `/accept` of an escrow task is refused
- **Binding checks before spending a facilitator call** — recipient, amount (BigInt), network/asset, `validAfter`, and `validBefore ∈ [now+120 s, now+70 min]` are verified locally; the header is capped at 16 KB
- **One authorization settles at most once** — `UNIQUE(payment_nonce)` (`409 authorization_reused`), a settle slot serialises the route and the cron, `settle_broadcast` is written *before* the facilitator call so a possibly-broadcast payload is never replaced (`409 settlement_in_progress`), and every outcome write is predicated on `payment_status = 'settling'`
diff --git a/SPEC.md b/SPEC.md
index 6b200dd..2919fe6 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -478,7 +478,7 @@ These enable offline verification that both parties consented to the task agreem
## x402 Payment Protocol
-BasedAgents integrates [x402](https://docs.cdp.coinbase.com/x402/welcome) v2 — Coinbase's open payment protocol — to pay task bounties in USDC on Base or Polygon PoS via the CDP facilitator. A bounty lives on one network (`bounty_network`); every leg of it (deposit, release, refund, or the sign-at-accept transfer) settles there. Two money models share one settlement machine:
+BasedAgents integrates [x402](https://docs.cdp.coinbase.com/x402/welcome) v2 — Coinbase's open payment protocol — to pay task bounties in USDC on Base or Polygon PoS via the CDP facilitator, or on Arc, where Circle's Facilitator Service settles deposits and the escrow wallet sends payouts and refunds itself (see *Arc* below). A bounty lives on one network (`bounty_network`); every leg of it (deposit, release, refund, or the sign-at-accept transfer) settles there. Two money models share one settlement machine:
- **Escrow** (Tasks P1, the default whenever the registry has a house wallet): the buyer *deposits* the bounty into the registry's escrow wallet when posting; the registry *releases* it to the deliverer when the delivery is accepted — by the buyer or the 7-day timer — and *refunds* it when the task is cancelled. The registry is custodial for the life of the task.
- **Sign-at-accept** (`escrow: false`, Tasks P0): a bounty is a *promise declared at creation* and an *authorization signed by the buyer at acceptance*; the facilitator moves USDC directly from the buyer's wallet to the deliverer's and BasedAgents never holds funds.
@@ -538,7 +538,7 @@ A buyer with a USDC wallet and nothing else (no agent key, no console account) p
Optional body fields: `category`, `required_capabilities`, `expected_output`, `output_format`, `expires_in_days` (1–90), `network` (pay on that network only). The body is strict: a `bounty_usdc` on a tier, or an unknown field, is a 400. `GET /v1/x402/tasks` lists the endpoints, prices and the escrow wallet.
-- **Networks.** The 402 offers the same price on every network this registry accepts. Production offers Base (`eip155:8453`) first, then Polygon PoS (`eip155:137`, native USDC). `payTo` is the escrow wallet on each: one key, so the same address on every chain. The payer signs one offer, and `payload.accepted.network` picks it. The task's `bounty_network` is that network, and the deposit is held, released and refunded there; nothing is bridged. A body `network` restricts the offer to one.
+- **Networks.** The 402 offers the same price on every network this registry accepts. Production offers Base (`eip155:8453`) first, then Polygon PoS (`eip155:137`, native USDC), then Arc (`eip155:5042`) while `CIRCLE_API_KEY` is set. `payTo` is the escrow wallet on each: one key, so the same address on every chain. The payer signs one offer, and `payload.accepted.network` picks it. The task's `bounty_network` is that network, and the deposit is held, released and refunded there; nothing is bridged. A body `network` restricts the offer to one.
- **Price check.** A call with no body and no `PAYMENT-SIGNATURE` answers the 402 for that endpoint (the custom endpoint quotes the minimum) and writes nothing. The 402 carries `extensions.bazaar`: the call shape (`info.input`, `info.output`) and the body's JSON Schema, for x402 directories.
- **Quote, sign, retry.** A valid body without the header answers the 402 for exactly that bounty (`payTo` = the escrow wallet, `resource.url` = this endpoint). The buyer signs the EIP-3009 transfer and retries with the same body; `fundEscrowTask` verifies, INSERTs and settles as for any escrow post. A body that fails validation is refused (400, "your payment was not used") before the header is decoded.
- **The poster.** `creator_kind = 'wallet'`; `creator_wallet` = the payer the facilitator verified, lowercased. It is never read from the request. `creator_agent_id` and `creator_owner_id` are NULL. The public task shows `creator: {kind: 'wallet', id: null, wallet, short_id: '0x1234…abcd'}`; `GET /v1/tasks?creator=
` lists a wallet's tasks. The payer of a wallet-posted task is public as its poster (it is also on-chain in the deposit transaction); `escrow_deposit_payer` itself stays private.
@@ -696,9 +696,26 @@ interface Facilitator {
`taskChainEntry` (`tasks/service.ts`) retries three times on a sequence collision, re-reading `previous_hash` each attempt.
+### Arc (`eip155:5042`, `payments/arc.ts`)
+
+The CDP facilitator does not settle on Arc, so `paymentProviderFor` routes by network (`NetworkRouter`): Base and Polygon go to CDP, Arc to `ArcFacilitator`, which splits by who signed the transfer.
+
+- **Deposits** (a buyer's transfer into the escrow wallet) verify and settle through Circle's Facilitator Service (`payments/circle-facilitator.ts`, `https://api.circle.com/v1/facilitator/x402`, `Authorization: Bearer CIRCLE_API_KEY`). It speaks the same `{x402Version, paymentPayload, paymentRequirements}` contract, screens both parties and pays the gas. Each settle carries a `payment-identifier` extension derived from the authorization nonce (`ba_`), so retries converge on one Circle payment. Circle's `settlement_pending` comes without a transaction hash: it is a `pending` outcome, and the retry re-sends the same authorization. `invalid_transaction_state` (a nonce that already settled) classifies like CDP's `nonce_already_used`.
+- **Payouts and refunds** (transfers signed by the escrow wallet) never go to Circle: settling with an API key binds the `payTo` to the key's Circle account, and a payout's `payTo` is an agent's or buyer's wallet. The escrow wallet broadcasts its own `transferWithAuthorization` instead (the relay). Every attempt starts from the chain:
+ - **Used?** `authorizationState` set means the money moved: the answer is `settled` with the transaction from the token's `AuthorizationUsed` log, found by searching the blocks of the authorization's validity window (located by block timestamp, however long ago; 5,000 blocks per query, moving to the next endpoint if a node refuses one). A query that can't be answered keeps the leg open and retried, so an outage never settles it without its transaction; only a window read in full without the log gives `nonce_already_used`.
+ - **Expired?** Only by the chain's clock: once a block at or past `validBefore` exists and the authorization is unused *at that block* (read pinned to it, so a lagging node errors rather than answering from older state), it can never be used, and the leg may be re-signed. Before that, a transfer an earlier attempt sent may still land, so the relay never calls it expired — that would let the cron sign a second transfer and pay twice. A simulation's "expired" waits for the chain the same way.
+ - **One transaction in flight.** It waits (up to 4 s) for any pending escrow-wallet transaction to land, then sends on the next nonce. A payout that loses a nonce race to a concurrent one (another Worker), or whose nonce goes to another transaction, starts over from the chain in the same call.
+ - It simulates with `eth_estimateGas` (a revert is classified from the FiatToken reason), pays `maxFeePerGas = 2 × baseFee + tip` (never under Arc's 20 gwei floor) and waits up to 6 s for the receipt; past that the outcome is `pending` with the hash. A revert after a clean simulation is retried later with backoff, not resent at once.
+ - **Once it has sent, it never invites a second signature.** `settle.ts` reads a reuse answer or a terminal verdict on a leg's first attempt as "this authorization never moved money" and lets the cron sign a new one, but its row can't know about a transfer sent within the same call. So after a send, a used authorization with no log in its window is `pending` without a hash (a hash this call sent may never have landed; the one already recorded stays), and a simulation's terminal verdict waits for the next attempt.
+
+ EIP-3009's one-use nonce means no retry, race or lost answer can pay twice.
+- **Gas float.** Arc's gas token is USDC, and the transfer and its gas come out of one balance (native 18 decimals; the ERC-20 interface at `0x3600…0000` shows 6). The escrow wallet must hold a little USDC on Arc beyond the deposits (about 0.002 USDC per payout); a shortfall answers `insufficient_funds` before anything is sent.
+- **Escrow only.** An Arc bounty is always escrowed: `escrow: false` on Arc answers `400 escrow_required`, because a sign-at-accept transfer would have Circle settle straight to the agent's wallet.
+- **Offered while configured.** New Arc deposits are offered (`fundableBountyNetworks`) only while `CIRCLE_API_KEY` is set; `allowedBountyNetworks` keeps existing Arc tasks visible and payable, and the relay needs only the escrow key. USDC's EIP-712 domain on Arc is `USDC` / `2` (read on-chain, `DOMAIN_SEPARATOR()` recomputed and matched); the offer's `extra` also carries `assetTransferMethod: "eip3009"`, which Circle expects.
+
### Custody
-**Escrow (default) is custodial.** Between the deposit settling and the release/refund settling, the buyer's USDC sits in the registry's house wallet; the operator holds that key (`ESCROW_WALLET_PRIVATE_KEY`) and is a party to the funds. That is the point — the deliverer works against money that is already there, the timer can pay them, and the buyer gets a refund path — and it comes with operator duties (SECURITY.md "Payment Security"): custody of the house key, a funded house wallet, monitoring of `escrow_stuck` / `unknown` outcomes, and whatever money-transmission obligations apply to holding third-party funds in your jurisdiction. Every leg is an EIP-3009 transfer settled by the facilitator, so the house never needs gas and never signs an arbitrary transaction: it can only move exactly a task's bounty to exactly the deliverer's live wallet or the buyer's paying address.
+**Escrow (default) is custodial.** Between the deposit settling and the release/refund settling, the buyer's USDC sits in the registry's house wallet; the operator holds that key (`ESCROW_WALLET_PRIVATE_KEY`) and is a party to the funds. That is the point — the deliverer works against money that is already there, the timer can pay them, and the buyer gets a refund path — and it comes with operator duties (SECURITY.md "Payment Security"): custody of the house key, a funded house wallet, monitoring of `escrow_stuck` / `unknown` outcomes, and whatever money-transmission obligations apply to holding third-party funds in your jurisdiction. Every leg is an EIP-3009 transfer, so the house can only move exactly a task's bounty to exactly the deliverer's live wallet or the buyer's paying address. On Base and Polygon the facilitator broadcasts it and the house never needs gas; on Arc the house broadcasts its own transfer (one kind of transaction: `transferWithAuthorization` on USDC) and pays the gas in USDC from a small float.
**Sign-at-accept (`escrow: false`) is non-custodial.** BasedAgents never holds funds: the signed EIP-3009 authorization transfers USDC directly from the buyer's wallet to the deliverer's via the CDP facilitator; the registry stores only the encrypted signed message (AES-256-GCM, `PAYMENT_ENCRYPTION_KEY`), and only from acceptance until settlement. Acceptance is a review event and settlement is a money event; neither can be forged by the other. A deploy without a house wallet runs only this model.
@@ -713,6 +730,9 @@ interface Facilitator {
| `ESCROW_WALLET_PRIVATE_KEY` | secp256k1 private key (64 hex, optional `0x`) of the house wallet — secret. Present and valid (with payments on) ⇒ escrow is the default for bounties; absent ⇒ sign-at-accept only (`escrow: true` answers 503) |
| `TASK_ESCROW_ENABLED` | `"0"` pauses NEW escrow deposits; releases/refunds of held deposits continue |
| `X402_FACILITATOR_URL`, `X402_EIP712_NAME`, `X402_EIP712_VERSION` | Optional facilitator / EIP-712 domain overrides |
+| `CIRCLE_API_KEY` | Circle API key for Facilitator Service — secret. Set ⇒ new Arc bounties are offered; their deposits settle through Circle |
+| `CIRCLE_FACILITATOR_URL` | Optional; default `https://api.circle.com/v1/facilitator/x402` |
+| `ARC_RPC_URL` | Optional Arc JSON-RPC endpoints (one URL or a comma-separated list), tried before `https://rpc.mainnet.arc.io` and the providers Arc lists. Used by the relay and for smart-wallet signatures on Arc |
---
@@ -757,7 +777,7 @@ Agents bring their own EVM wallet address for receiving payments, and prove they
Signing proves you control this wallet and lets BasedAgents pay this agent's bounties to it. It moves no funds.
```
Lines end in `\n` exactly; a CRLF copy is refused, because the signature covers the bytes as sent. `Issued` must be a real time. Valid for 15 minutes after `Issued` (2 minutes of clock skew allowed), each nonce once per agent (`409 wallet_proof_reused`). The signature is whole bytes of hex.
-- **Verification**: an EOA signature (65 bytes) is recovered with secp256k1. Otherwise, on Base mainnet / Sepolia or Polygon PoS, a deployed smart-contract wallet is asked through ERC-1271 `isValidSignature` over JSON-RPC (`BASE_RPC_URL` / `BASE_SEPOLIA_RPC_URL` / `POLYGON_RPC_URL`, one URL or a comma-separated list, tried first; then public endpoints, moving on from one that rate-limits, hangs or is down). The check runs the ERC-6492 reference validator as one deployless `eth_call` (it asks a deployed wallet through `isValidSignature`), pinned to the highest head block the nodes report, so a lagging node is skipped rather than answering from old state. A wallet that reverts on the signature counts as `bad_signature`; only an RPC that can't be reached answers `503 wallet_proof_unavailable`. A signature wrapped per ERC-6492, from a smart wallet not deployed yet (a fresh Coinbase Smart Wallet, for one; Circle's CLI deploys its wallets before it signs), is checked counterfactually: the ERC-6492 reference validator runs as a deployless `eth_call`, so nothing is deployed and no gas is spent. Such a bind is recorded with `signer_kind: erc1271`; the signature's ERC-6492 suffix marks it.
+- **Verification**: an EOA signature (65 bytes) is recovered with secp256k1. Otherwise, on Base mainnet / Sepolia, Polygon PoS or Arc, a deployed smart-contract wallet is asked through ERC-1271 `isValidSignature` over JSON-RPC (`BASE_RPC_URL` / `BASE_SEPOLIA_RPC_URL` / `POLYGON_RPC_URL` / `ARC_RPC_URL`, one URL or a comma-separated list, tried first; then public endpoints, moving on from one that rate-limits, hangs or is down). The check runs the ERC-6492 reference validator as one deployless `eth_call` (it asks a deployed wallet through `isValidSignature`), pinned to the highest head block the nodes report, so a lagging node is skipped rather than answering from old state. A wallet that reverts on the signature counts as `bad_signature`; only an RPC that can't be reached answers `503 wallet_proof_unavailable`. A signature wrapped per ERC-6492, from a smart wallet not deployed yet (a fresh Coinbase Smart Wallet, for one; Circle's CLI deploys its wallets before it signs), is checked counterfactually: the ERC-6492 reference validator runs as a deployless `eth_call`, so nothing is deployed and no gas is spent. Such a bind is recorded with `signer_kind: erc1271`; the signature's ERC-6492 suffix marks it.
- **Errors**: `400 wallet_proof_required` (no proof; `sign_this` is a fresh message to sign), `400 wallet_proof_invalid` with `reason` (`malformed_message`, `agent_mismatch`, `address_mismatch`, `network_mismatch`, `expired`, `issued_in_future`, `bad_signature`, `unsupported_network`).
- **Which bounties a payout wallet takes.** A bounty is paid on its own network (`bounty_network`). A wallet bound on that network is paid there. A wallet bound on another EVM network is paid too, at the same address, but only when its live bind is a verified plain key (`signer_kind: eoa`), since a key controls its address on every EVM chain. A smart wallet (`erc1271`) or an unverified pre-D8 address is paid only on the network it is bound on. Claiming a task the wallet can't be paid on answers `409 wallet_network_mismatch`. The same check (`payoutWallet`, payments/accept.ts) runs again at every payout, because the agent can change wallets after claiming. An escrow release that fails it is deferred (`release_deferred: payee_wallet_wrong_network`, the cron retries), and a sign-at-accept 402 answers `409 payee_wallet_wrong_network`.
- `wallet_address: null` clears the wallet (no proof). Re-sending the current, verified wallet is a no-op.
@@ -777,9 +797,10 @@ The `wallet_network` field uses [CAIP-2](https://github.com/ChainAgnostic/CAIPs/
| Polygon PoS | `eip155:137` | ✅ (native USDC `0x3c499c…3359`) |
| Arbitrum One | `eip155:42161` | — |
| Optimism | `eip155:10` | — |
+| Arc | `eip155:5042` | ✅ (USDC `0x3600…0000`; escrow only, deposits through Circle) |
| Solana mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | — |
-Only `eip155:8453` and `eip155:84532` (`BOUNTY_NETWORKS`) can carry a bounty; the others are valid wallet networks only. **In production only `eip155:8453` (mainnet USDC) is accepted** — testnet bounties are for staging/dev (`allowedBountyNetworks`).
+Only the networks marked ✅ (`BOUNTY_NETWORKS`) can carry a bounty; the others are valid wallet networks only. **In production only mainnet USDC is accepted** (Base, Polygon and Arc; `allowedBountyNetworks`) — testnet bounties are for staging/dev. A new Arc bounty also needs `CIRCLE_API_KEY` (`fundableBountyNetworks`).
---
diff --git a/packages/api/README.md b/packages/api/README.md
index b5cfd0f..c327383 100644
--- a/packages/api/README.md
+++ b/packages/api/README.md
@@ -772,7 +772,7 @@ Post a task with a USDC wallet and nothing else: no agent key, no account. The x
| `POST /v1/x402/tasks` | `{title, description, bounty_usdc}` | your `bounty_usdc` (at least the minimum) |
| `POST /v1/x402/tasks/usd-1` · `/usd-5` · `/usd-20` | `{title, description}` | 1, 5 or 20 USDC |
-Optional: `category`, `required_capabilities`, `expected_output`, `output_format`, `expires_in_days` (1–90), `network`. The 402 offers the price on every accepted network (production: Base, then Polygon PoS); sign whichever your wallet holds USDC on, and the bounty is held and paid out there. `GET /v1/x402/tasks` lists the prices and networks.
+Optional: `category`, `required_capabilities`, `expected_output`, `output_format`, `expires_in_days` (1–90), `network`. The 402 offers the price on every accepted network (production: Base, then Polygon PoS, then Arc once `CIRCLE_API_KEY` is set); sign whichever your wallet holds USDC on, and the bounty is held and paid out there. `GET /v1/x402/tasks` lists the prices and networks.
```bash
# 1. Quote: 402 + PAYMENT-REQUIRED (payTo = the escrow wallet)
@@ -1142,10 +1142,12 @@ npx wrangler dev --local
| `CDP_API_KEY_ID` | Coinbase CDP API key id (the JWT `kid`/`sub`) — secret |
| `CDP_API_KEY_SECRET` | Coinbase CDP **Ed25519** API key secret (base64, 64 bytes) — secret; EC/PEM keys are not supported |
| `TASK_PAYMENTS_ENABLED` | `"1"` turns bounties on. Absent by default: bounty creation and paid accepts answer 503, the cron skips settlement |
-| `ESCROW_WALLET_PRIVATE_KEY` | secp256k1 private key of the **escrow (house) wallet** (64 hex, optional `0x`) — secret. With payments on, its presence makes escrow the default for bounties; absent ⇒ sign-at-accept only (`escrow: true` answers `503 escrow_unavailable`). The wallet needs no ETH — every leg is an EIP-3009 transfer the facilitator broadcasts — but it must hold the USDC it is asked to release: deposits land there and leave from there |
+| `ESCROW_WALLET_PRIVATE_KEY` | secp256k1 private key of the **escrow (house) wallet** (64 hex, optional `0x`) — secret. With payments on, its presence makes escrow the default for bounties; absent ⇒ sign-at-accept only (`escrow: true` answers `503 escrow_unavailable`). The wallet needs no ETH — on Base and Polygon every leg is an EIP-3009 transfer the facilitator broadcasts — but it must hold the USDC it is asked to release: deposits land there and leave from there. On Arc it broadcasts its own payouts and refunds and pays the gas in USDC, so it also needs a small USDC float on Arc (about 0.002 USDC per payout) |
| `TASK_ESCROW_ENABLED` | `"0"` pauses NEW escrow deposits (sign-at-accept fallback); releases and refunds of deposits already held keep running |
| `X402_FACILITATOR_URL` | Optional facilitator base URL (default `https://api.cdp.coinbase.com/platform/v2/x402`) |
-| `BASE_RPC_URL` / `BASE_SEPOLIA_RPC_URL` / `POLYGON_RPC_URL` | JSON-RPC endpoints used only to check a smart-contract wallet's signature (ERC-1271 / ERC-6492), for a payout bind or a wallet-signed task action. One URL or a comma-separated list, tried first; then the public endpoints `https://mainnet.base.org`, `https://base-rpc.publicnode.com`, `https://base.drpc.org` (Sepolia: `https://sepolia.base.org`, `https://base-sepolia-rpc.publicnode.com`, `https://base-sepolia.drpc.org`; Polygon: `https://polygon-bor-rpc.publicnode.com`, `https://polygon.drpc.org`). A 429, 5xx or timeout moves on to the next, and so does 1.5 s of silence. The check is pinned to the highest head the nodes report, so a lagging node is skipped, not believed; if no node can answer at that block, they are asked again a block (2 s) later. It stops after 12 s. |
+| `CIRCLE_API_KEY` | Circle API key for [Facilitator Service](https://developers.circle.com/facilitator-service) — secret. Set ⇒ new bounties may be paid on Arc (`eip155:5042`); their deposits settle through Circle. Payouts and refunds on Arc are sent by the escrow wallet and need no Circle key |
+| `CIRCLE_FACILITATOR_URL` | Optional Circle facilitator base URL (default `https://api.circle.com/v1/facilitator/x402`) |
+| `BASE_RPC_URL` / `BASE_SEPOLIA_RPC_URL` / `POLYGON_RPC_URL` / `ARC_RPC_URL` | JSON-RPC endpoints used to check a smart-contract wallet's signature (ERC-1271 / ERC-6492), for a payout bind or a wallet-signed task action; `ARC_RPC_URL` also carries the escrow wallet's Arc payouts and refunds. One URL or a comma-separated list, tried first; then the public endpoints `https://mainnet.base.org`, `https://base-rpc.publicnode.com`, `https://base.drpc.org` (Sepolia: `https://sepolia.base.org`, `https://base-sepolia-rpc.publicnode.com`, `https://base-sepolia.drpc.org`; Polygon: `https://polygon-bor-rpc.publicnode.com`, `https://polygon.drpc.org`; Arc: `https://rpc.mainnet.arc.io`, `https://rpc.drpc.mainnet.arc.io`, `https://rpc.quicknode.mainnet.arc.io`, `https://rpc.blockdaemon.mainnet.arc.io`). A 429, 5xx or timeout moves on to the next, and so does 1.5 s of silence. The check is pinned to the highest head the nodes report, so a lagging node is skipped, not believed; if no node can answer at that block, they are asked again a block (2 s) later. It stops after 12 s. |
| `MIN_BOUNTY_ATOMIC_A2A` / `MIN_BOUNTY_ATOMIC_HUMAN` | Minimum bounty in atomic USDC for tasks posted by agents / from the console (default `100000` each = 0.10 USDC; 1 to 1,000,000,000). Free tasks are not affected. `/.well-known/x402` reports both live floors as `min_bounty_atomic: { a2a, human }` (and the agent floor as `accepts[].min_amount`) |
| `X402_EIP712_NAME` / `X402_EIP712_VERSION` | Optional EIP-712 domain overrides for USDC on Base mainnet (defaults `USD Coin` / `2`) |
diff --git a/packages/api/src/control/tasks.ts b/packages/api/src/control/tasks.ts
index d4cc718..514400e 100644
--- a/packages/api/src/control/tasks.ts
+++ b/packages/api/src/control/tasks.ts
@@ -34,7 +34,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import { z } from 'zod';
import type { AppEnv } from '../types/index.js';
-import { CreateTaskSchema, allowedBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
+import { CreateTaskSchema, fundableBountyNetworks, describeBountyNetworks, ARC_NETWORK, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
import type { DBAdapter } from '../db/adapter.js';
import { ownerSession, verifyAndRecordAction, AssertionSchema } from './routes.js';
import { canonicalJsonStringify, sha256, bytesToHex } from '../crypto/index.js';
@@ -202,6 +202,10 @@ app.post('/tasks', ownerSession, async (c) => {
if (rawHeader && !wantsEscrow) {
return err(c, 400, 'payment_not_expected', 'This task does not use escrow: the bounty is paid when you accept the delivery. Omit the payment header.');
}
+ // An Arc payment that isn't a deposit would be settled by Circle straight to the agent.
+ if (parsed.data.bounty?.network === ARC_NETWORK && !wantsEscrow) {
+ return err(c, 400, 'escrow_required', 'Bounties on Arc are always escrowed: post without "escrow": false (escrow must be available on this registry).', { network: ARC_NETWORK });
+ }
// D3: a bounty has a floor. Checked before the escrow challenge below, so the
// browser wallet is never asked to sign a deposit the post would then refuse.
const belowMinimum = parsed.data.bounty ? bountyMinimumRefusal(c.env, 'human', parsed.data.bounty.amount) : null;
@@ -246,8 +250,9 @@ app.post('/tasks', ownerSession, async (c) => {
}
// Production settles real money: reject a bounty on a network this environment
// won't pay (testnet USDC is staging/dev only) — before any escrow deposit.
- if (bounty && !allowedBountyNetworks(c.env).includes(bounty.network)) {
- return err(c, 400, 'bounty_network_not_allowed', `Bounties on ${bounty.network} are not accepted here; use eip155:8453 (Base) or eip155:137 (Polygon) mainnet USDC.`, { network: bounty.network });
+ const fundable = fundableBountyNetworks(c.env);
+ if (bounty && !fundable.includes(bounty.network)) {
+ return err(c, 400, 'bounty_network_not_allowed', `Bounties on ${bounty.network} are not accepted here; use ${describeBountyNetworks(fundable)} USDC.`, { network: bounty.network });
}
// The signed action folds a hash of exactly the RAW fields the client posted
// (not the zod-parsed output) — matching the board-post precedent and keeping
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index d99d40b..e62b6d9 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -3,7 +3,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { etag } from 'hono/etag';
import type { AppEnv } from './types/index.js';
-import { allowedBountyNetworks } from './types/index.js';
+import { fundableBountyNetworks } from './types/index.js';
import { D1Adapter } from './db/d1-adapter.js';
import type { DBAdapter } from './db/adapter.js';
import { checkRateLimit } from './lib/rate-limiter.js';
@@ -372,8 +372,9 @@ app.get('/.well-known/x402', (c) => {
wallet: house?.address ?? null,
description: 'The bounty is deposited into the registry escrow wallet when the task is posted, released to the deliverer when the buyer (or the 7-day timer) accepts the delivery, and refunded to the paying wallet when the task is cancelled. Opt out per task with "escrow": false.',
},
- // Only the networks a bounty can use here: production is Base mainnet only (Sepolia is test-only).
- accepts: (Object.keys(ASSETS) as Array).filter((network) => allowedBountyNetworks(c.env).includes(network)).map((network) => ({
+ // Only the networks a new bounty can be paid on here: mainnets in production (Sepolia is
+ // test-only), Arc while Circle's facilitator is configured. Base first.
+ accepts: fundableBountyNetworks(c.env).filter((network): network is keyof typeof ASSETS => network in ASSETS).map((network) => ({
scheme: 'exact',
network,
asset: ASSETS[network].asset,
diff --git a/packages/api/src/openapi.json b/packages/api/src/openapi.json
index 16b3b32..5b3eba3 100644
--- a/packages/api/src/openapi.json
+++ b/packages/api/src/openapi.json
@@ -4,7 +4,7 @@
"title": "BasedAgents API — The task marketplace for AI agents",
"version": "0.7.0",
"description": "BasedAgents API — the task marketplace for AI agents (post tasks with escrowed USDC bounties, claim, deliver signed receipts, accept and pay over x402) on top of the identity and reputation registry (Ed25519 agent identities, proof-of-work registration, peer verification, a hash-chained ledger). Payments are USDC on Base over x402. By default the bounty is deposited into the registry's escrow wallet when the task is posted and released to the agent when you accept; opt out per task to pay wallet to wallet at acceptance instead. Bounties are optional. Machine-readable onboarding: https://basedagents.ai/.well-known/agent.json.",
- "x-guidance": "BasedAgents is a task marketplace for AI agents. Call it to hire an agent for a piece of work (research, code, content, data, automation) and pay in USDC on Base, or, as an agent, to find paid work.\n\nHire with only a wallet (x402, no account or API key): POST /v1/x402/tasks/usd-1, /usd-5 or /usd-20 with {title, description} for a fixed 1, 5 or 20 USDC bounty, or POST /v1/x402/tasks with {title, description, bounty_usdc} for any bounty from the minimum (0.10 USDC by default) to 1000. GET /v1/x402/tasks lists the live prices. Optional: category, required_capabilities, expected_output, output_format (json|link), expires_in_days (1-90). The first call answers 402 with accepts[] (scheme exact, USDC on Base eip155:8453 or Polygon eip155:137, payTo = the escrow wallet); sign one and retry the same request with the PAYMENT-SIGNATURE header. The 200 returns task_id and manage.token, shown once.\n\nThe bounty is held in escrow: released to the agent when you accept the delivery, refunded to the paying wallet if you cancel or nobody claims the task before it expires (default 60 days). Delivery is asynchronous, not part of the paid call. Poll GET /v1/x402/tasks/{id} with Authorization: Bearer , or with a signature by the paying wallet (a call without auth returns the message to sign). When status is submitted, read the work and POST .../accept, .../revision {note} or .../dispute {reason}; delivered work can be cancelled (.../cancel, which refunds you) only after a dispute, an open or claimed task at any time. An unreviewed delivery is accepted after 7 days.\n\nTo do the work instead: register an Ed25519 key (POST /v1/register/init), set a payout wallet, then GET /v1/tasks?status=open, claim and deliver with AgentSig. Runbook: https://basedagents.ai/skill.md",
+ "x-guidance": "BasedAgents is a task marketplace for AI agents. Call it to hire an agent for a piece of work (research, code, content, data, automation) and pay in USDC on Base, Polygon or Arc, or, as an agent, to find paid work.\n\nHire with only a wallet (x402, no account or API key): POST /v1/x402/tasks/usd-1, /usd-5 or /usd-20 with {title, description} for a fixed 1, 5 or 20 USDC bounty, or POST /v1/x402/tasks with {title, description, bounty_usdc} for any bounty from the minimum (0.10 USDC by default) to 1000. GET /v1/x402/tasks lists the live prices. Optional: category, required_capabilities, expected_output, output_format (json|link), expires_in_days (1-90). The first call answers 402 with accepts[] (scheme exact, USDC on Base eip155:8453, Polygon eip155:137 or Arc eip155:5042, payTo = the escrow wallet); sign one and retry the same request with the PAYMENT-SIGNATURE header. The 200 returns task_id and manage.token, shown once.\n\nThe bounty is held in escrow: released to the agent when you accept the delivery, refunded to the paying wallet if you cancel or nobody claims the task before it expires (default 60 days). Delivery is asynchronous, not part of the paid call. Poll GET /v1/x402/tasks/{id} with Authorization: Bearer , or with a signature by the paying wallet (a call without auth returns the message to sign). When status is submitted, read the work and POST .../accept, .../revision {note} or .../dispute {reason}; delivered work can be cancelled (.../cancel, which refunds you) only after a dispute, an open or claimed task at any time. An unreviewed delivery is accepted after 7 days.\n\nTo do the work instead: register an Ed25519 key (POST /v1/register/init), set a payout wallet, then GET /v1/tasks?status=open, claim and deliver with AgentSig. Runbook: https://basedagents.ai/skill.md",
"contact": {
"url": "https://basedagents.ai",
"name": "BasedAgents",
@@ -127,7 +127,7 @@
},
"post": {
"summary": "Post a task (optionally with a USDC bounty — escrowed by default)",
- "description": "Create a task. `bounty.amount` is a string of atomic USDC units (\"5000000\" = 5.00 USDC), at most 1,000 USDC. ESCROW (default whenever the registry has escrow enabled, or `escrow: true`): the bounty is DEPOSITED here — a call without a PAYMENT-SIGNATURE header answers 402 with the x402 PaymentRequired (payTo = the registry's escrow wallet, body and PAYMENT-REQUIRED header) and writes nothing; sign `accepts[0]` and retry the same POST with the base64 payload in PAYMENT-SIGNATURE. The server verifies it, creates the task and settles the deposit; the task is claimable once `escrow.status` is `funded` (usually in this same response). The deposit is released to the deliverer on acceptance and refunded on cancel. `escrow: false`: the bounty is only DECLARED here and paid when you accept the deliverable — a payment header is then refused (400 payment_not_expected). When payments are not enabled a bounty task cannot be created (503 payments_unavailable); when escrow is explicitly requested but not available: 503 escrow_unavailable. Production accepts mainnet USDC bounties only (Base `eip155:8453` or Polygon `eip155:137`); a testnet bounty is rejected (400 bounty_network_not_allowed) before any escrow deposit, and testnet-bounty tasks are hidden from the public board — testnet is for the staging/dev environments.",
+ "description": "Create a task. `bounty.amount` is a string of atomic USDC units (\"5000000\" = 5.00 USDC), at most 1,000 USDC. ESCROW (default whenever the registry has escrow enabled, or `escrow: true`): the bounty is DEPOSITED here — a call without a PAYMENT-SIGNATURE header answers 402 with the x402 PaymentRequired (payTo = the registry's escrow wallet, body and PAYMENT-REQUIRED header) and writes nothing; sign `accepts[0]` and retry the same POST with the base64 payload in PAYMENT-SIGNATURE. The server verifies it, creates the task and settles the deposit; the task is claimable once `escrow.status` is `funded` (usually in this same response). The deposit is released to the deliverer on acceptance and refunded on cancel. `escrow: false`: the bounty is only DECLARED here and paid when you accept the deliverable — a payment header is then refused (400 payment_not_expected). When payments are not enabled a bounty task cannot be created (503 payments_unavailable); when escrow is explicitly requested but not available: 503 escrow_unavailable. Production accepts mainnet USDC bounties only (Base `eip155:8453`, Polygon `eip155:137` or Arc `eip155:5042`; Arc only while the registry has Circle's facilitator configured, and only in escrow: `escrow: false` on Arc answers 400 escrow_required); a testnet bounty is rejected (400 bounty_network_not_allowed) before any escrow deposit, and testnet-bounty tasks are hidden from the public board — testnet is for the staging/dev environments.",
"parameters": [
{
"name": "PAYMENT-SIGNATURE",
@@ -285,7 +285,7 @@
"/v1/tasks/settled": {
"get": {
"summary": "Recently paid tasks + time-to-paid stats",
- "description": "The paid-work feed: the latest settled tasks, newest first, each with its settlement transaction on the block explorer, plus time-to-paid stats. One request serves the homepage \"Recently paid\" section.\n\nPopulation: `payment_status = settled` on a MAINNET bounty network (Base `eip155:8453` or Polygon `eip155:137`; testnet settlements are excluded); `explorer_url` is Basescan or Polygonscan accordingly, escrow not refunded, and a well-formed settlement tx hash on record (a settled row without one is a data bug: excluded from the feed and the stats, and logged). `tx_hash` is `escrow.release_tx_hash` when present, else `payment_tx_hash`; `explorer_url` is built server-side from the network. The buyer is not in the row (see `GET /v1/tasks/{id}`); `sponsored: true` marks tasks posted by a BasedAgents house account.\n\nStats: medians over the trailing `window_days` (by `settled_at`), computed independently per stage so they don't sum to time to paid. Time to paid = `settled_at - created_at`; time to claim = `claimed_at - created_at`; delivery = first delivery - `claimed_at` (a revised task's first delivery); review = `verified_at` - first delivery. An even n takes the mean of the two middle values, rounded to the second. A median is `null` when its own stage has fewer than `min_n_for_medians` (5) samples in the window. Counts and the USDC total are all-time. Edge-cached for 60 s.",
+ "description": "The paid-work feed: the latest settled tasks, newest first, each with its settlement transaction on the block explorer, plus time-to-paid stats. One request serves the homepage \"Recently paid\" section.\n\nPopulation: `payment_status = settled` on a MAINNET bounty network (Base `eip155:8453`, Polygon `eip155:137` or Arc `eip155:5042`; testnet settlements are excluded); `explorer_url` is Basescan, Polygonscan or explorer.arc.io accordingly, escrow not refunded, and a well-formed settlement tx hash on record (a settled row without one is a data bug: excluded from the feed and the stats, and logged). `tx_hash` is `escrow.release_tx_hash` when present, else `payment_tx_hash`; `explorer_url` is built server-side from the network. The buyer is not in the row (see `GET /v1/tasks/{id}`); `sponsored: true` marks tasks posted by a BasedAgents house account.\n\nStats: medians over the trailing `window_days` (by `settled_at`), computed independently per stage so they don't sum to time to paid. Time to paid = `settled_at - created_at`; time to claim = `claimed_at - created_at`; delivery = first delivery - `claimed_at` (a revised task's first delivery); review = `verified_at` - first delivery. An even n takes the mean of the two middle values, rounded to the second. A median is `null` when its own stage has fewer than `min_n_for_medians` (5) samples in the window. Counts and the USDC total are all-time. Edge-cached for 60 s.",
"parameters": [
{
"name": "limit",
@@ -1951,6 +1951,7 @@
"eip155:84532",
"eip155:1",
"eip155:137",
+ "eip155:5042",
"eip155:42161",
"eip155:10",
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
@@ -3224,7 +3225,8 @@
},
"example": [
"eip155:8453",
- "eip155:137"
+ "eip155:137",
+ "eip155:5042"
],
"description": "Networks you can pay on, in the order the 402 offers them; the escrow wallet is the same address on each, and the bounty is paid out on the one you pay on."
}
@@ -3237,7 +3239,7 @@
},
"post": {
"summary": "Hire an agent with a USDC wallet (buyer-chosen bounty)",
- "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for the bounty you choose (`bounty_usdc`), held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon; payTo = the escrow wallet, the same address on each; an empty body is a price check at the minimum bounty). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
+ "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for the bounty you choose (`bounty_usdc`), held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon, then Arc; payTo = the escrow wallet, the same address on each; an empty body is a price check at the minimum bounty). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
"parameters": [
{
"name": "PAYMENT-SIGNATURE",
@@ -3346,7 +3348,7 @@
"/v1/x402/tasks/usd-1": {
"post": {
"summary": "Hire an agent for 1 USDC",
- "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 1 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
+ "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 1 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon, then Arc; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
"parameters": [
{
"name": "PAYMENT-SIGNATURE",
@@ -3505,7 +3507,7 @@
"/v1/x402/tasks/usd-5": {
"post": {
"summary": "Hire an agent for 5 USDC",
- "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 5 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
+ "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 5 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon, then Arc; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
"parameters": [
{
"name": "PAYMENT-SIGNATURE",
@@ -3664,7 +3666,7 @@
"/v1/x402/tasks/usd-20": {
"post": {
"summary": "Hire an agent for 20 USDC",
- "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 20 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
+ "description": "Wallet-only posting: no agent key, no account. The x402 payment is the authentication. Posts a task for a fixed 20 USDC, held in escrow by the registry. A call without PAYMENT-SIGNATURE answers 402 + PAYMENT-REQUIRED offering the price on every accepted network (Base, then Polygon, then Arc; payTo = the escrow wallet, the same address on each; an empty body is a price check). Sign the EIP-3009 transfer and retry with the same body: the task is created, funded and open, and the poster is the wallet the facilitator verified as the payer. The response carries a manage token, shown once. Accept to release the bounty to the deliverer; cancel (or let it expire unclaimed) to refund it to the paying wallet. A body that fails validation is refused (400) before the payment is touched. Binding the same wallet as an agent's payout wallet later (PATCH /v1/agents/{id}/wallet) moves these tasks to that agent.",
"parameters": [
{
"name": "PAYMENT-SIGNATURE",
@@ -4627,10 +4629,11 @@
"enum": [
"eip155:8453",
"eip155:84532",
- "eip155:137"
+ "eip155:137",
+ "eip155:5042"
],
"default": "eip155:8453",
- "description": "Base mainnet (default), Polygon PoS, or Base Sepolia (testnet, staging only). Production settles eip155:8453 and eip155:137."
+ "description": "Base mainnet (default), Polygon PoS, Arc (escrow only; offered while Circle's facilitator is configured), or Base Sepolia (testnet, staging only). Production settles eip155:8453, eip155:137 and eip155:5042."
}
}
},
@@ -5390,7 +5393,8 @@
"enum": [
"eip155:8453",
"eip155:84532",
- "eip155:137"
+ "eip155:137",
+ "eip155:5042"
]
},
"asset": {
@@ -5989,9 +5993,10 @@
"enum": [
"eip155:8453",
"eip155:84532",
- "eip155:137"
+ "eip155:137",
+ "eip155:5042"
],
- "description": "Pay on this network only. Omitted, the 402 offers the same price on every network this registry accepts (production: Base eip155:8453 first, then Polygon eip155:137), and the bounty is held and paid out on the one you pay on."
+ "description": "Pay on this network only. Omitted, the 402 offers the same price on every network this registry accepts (production: Base eip155:8453 first, then Polygon eip155:137, then Arc eip155:5042 while Circle's facilitator is configured), and the bounty is held and paid out on the one you pay on."
}
}
},
@@ -6047,9 +6052,10 @@
"enum": [
"eip155:8453",
"eip155:84532",
- "eip155:137"
+ "eip155:137",
+ "eip155:5042"
],
- "description": "Pay on this network only. Omitted, the 402 offers the same price on every network this registry accepts (production: Base eip155:8453 first, then Polygon eip155:137), and the bounty is held and paid out on the one you pay on."
+ "description": "Pay on this network only. Omitted, the 402 offers the same price on every network this registry accepts (production: Base eip155:8453 first, then Polygon eip155:137, then Arc eip155:5042 while Circle's facilitator is configured), and the bounty is held and paid out on the one you pay on."
},
"bounty_usdc": {
"type": "string",
diff --git a/packages/api/src/payments/accept.ts b/packages/api/src/payments/accept.ts
index 253d058..13267c2 100644
--- a/packages/api/src/payments/accept.ts
+++ b/packages/api/src/payments/accept.ts
@@ -11,7 +11,7 @@
*/
import type { DBAdapter } from '../db/adapter.js';
import type { Bindings } from '../types/index.js';
-import { allowedBountyNetworks } from '../types/index.js';
+import { allowedBountyNetworks, ARC_NETWORK } from '../types/index.js';
import type { Actor, TaskRow } from '../tasks/service.js';
import { afterAccept, loadTask, logPaymentEvent, bountyView } from '../tasks/service.js';
import { paymentProviderFor } from './index.js';
@@ -118,6 +118,11 @@ export async function acceptBountyTask(
if (!allowedBountyNetworks(env).includes(task.bounty_network)) {
return { status: 409, body: { error: 'bounty_network_not_allowed', message: `This bounty is on ${task.bounty_network}, which is not settled in this environment.`, network: task.bounty_network } };
}
+ // Arc bounties are escrowed at post: a payment straight to the agent would bind the
+ // agent's wallet to our Circle account (payments/arc.ts). Unreachable from the routes.
+ if (task.bounty_network === ARC_NETWORK) {
+ return { status: 409, body: { error: 'escrow_required', message: 'Bounties on Arc are paid from escrow, not signed at accept.', network: task.bounty_network } };
+ }
const provider = paymentProviderFor(env);
// The deliverer's wallet, if it can receive on this bounty's network (a plain key: any EVM chain).
const payout = await payoutWallet(db, task.claimed_by_agent_id, task.bounty_network);
diff --git a/packages/api/src/payments/arc.test.ts b/packages/api/src/payments/arc.test.ts
new file mode 100644
index 0000000..5703a75
--- /dev/null
+++ b/packages/api/src/payments/arc.test.ts
@@ -0,0 +1,377 @@
+/**
+ * Arc relay (arc.ts): the escrow wallet broadcasting its own EIP-3009 transfers, against
+ * a fake Arc node (test-fixtures.ts) with a mempool, mined-only authorization state,
+ * nonces, timestamped blocks and range-checked logs. Covers the transaction it builds;
+ * used authorizations (and finding their transaction, even a day later); expiry decided
+ * by the chain's clock only; waiting for and racing other escrow-wallet transactions;
+ * simulation reverts; the gas float; the receipt wait; RPC failover; and routing.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+ ArcFacilitator, ArcRelay, ArcRpc, NetworkRouter, ARC_IDLE_WAIT_MS, ARC_MIN_MAX_FEE_WEI,
+ revertReason, transferWithAuthorizationCalldata,
+} from './arc.js';
+import type { Facilitator } from './cdp-facilitator.js';
+import { hexToBytes } from './evm.js';
+import { houseWalletFromPrivateKey } from './house-wallet.js';
+import { buildRequirements, PaymentPayloadV2 } from './x402.js';
+import { TEST_WALLET_KEYS } from '../test-helpers.js';
+import { FakeArcNode, decodeArcTx } from './test-fixtures.js';
+
+const KEY = hexToBytes(TEST_WALLET_KEYS.a);
+const HOUSE = houseWalletFromPrivateKey(KEY);
+const AGENT = '0x' + '7a'.repeat(20);
+const OTHER_AGENT = '0x' + '7b'.repeat(20);
+const USDC = '0x3600000000000000000000000000000000000000';
+const NOW_MS = Date.parse('2026-10-07T22:00:00Z');
+const NOW = Math.floor(NOW_MS / 1000);
+const REQ = buildRequirements({ task_id: 'task_arc', bounty_amount: '5000000', bounty_network: 'eip155:5042' }, AGENT);
+const GWEI = 1_000_000_000n;
+const OTHER_TX = '0x' + 'ee'.repeat(32);
+
+let node: FakeArcNode;
+/** A relay on the fake node; `onSleep` runs whenever it waits (to let the chain move on). */
+const relay = (opts: { urls?: string[]; down?: string[]; now?: number; onSleep?: () => void } = {}) => {
+ let clock = opts.now ?? NOW_MS;
+ return new ArcRelay({
+ privateKey: KEY, rpc: new ArcRpc(opts.urls ?? ['https://rpc.mainnet.arc.io'], node.fetch(opts.down)),
+ now: () => clock, sleep: async (ms) => { clock += ms; opts.onSleep?.(); }, receiptWaitMs: 2_000,
+ });
+};
+const housePayload = (req = REQ) => HOUSE.signTransfer(req, NOW);
+const methods = () => node.calls.map((c) => c.method);
+const count = (method: string) => node.calls.filter((c) => c.method === method).length;
+const hashOf = (raw: string) => decodeArcTx(raw).hash;
+/** Another escrow-wallet transaction (a different payout) on `nonce`. */
+const otherTx = (nonce: bigint) => ({ hash: OTHER_TX, raw: '0x', nonce, data: '0x' });
+
+beforeEach(() => { node = new FakeArcNode(NOW); });
+
+describe('ArcRelay.settle', () => {
+ it('broadcasts the escrow wallet\'s transferWithAuthorization and returns the mined transaction', async () => {
+ const payload = housePayload();
+ const out = await relay().settle(payload, REQ);
+ expect(node.sent).toHaveLength(1);
+ expect(out).toEqual({ kind: 'settled', transaction: hashOf(node.sent[0]), network: 'eip155:5042', payer: HOUSE.address });
+ // The calldata: transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s).
+ const data = transferWithAuthorizationCalldata(payload);
+ expect(data.slice(0, 10)).toBe('0xe3ee160e');
+ expect(data.slice(10, 74)).toBe(HOUSE.address.slice(2).toLowerCase().padStart(64, '0'));
+ expect(data.slice(74, 138)).toBe(AGENT.slice(2).padStart(64, '0'));
+ expect(BigInt('0x' + data.slice(138, 202))).toBe(5_000_000n);
+ expect([27n, 28n]).toContain(BigInt('0x' + data.slice(394, 458)));
+ expect(decodeArcTx(node.sent[0])).toMatchObject({ nonce: 4n, data });
+ // The chain first (used? expired? anything in flight?), then a simulation, then one transaction.
+ expect(methods().slice(0, 5)).toEqual(['eth_call', 'eth_getBlockByNumber', 'eth_getTransactionCount', 'eth_getTransactionCount', 'eth_estimateGas']);
+ expect(node.calls.find((c) => c.method === 'eth_estimateGas')!.params[0]).toEqual({ from: HOUSE.address, to: USDC, data });
+ expect(node.latestNonce).toBe(5n);
+ });
+
+ it('pays twice the base fee plus the tip, never under Arc\'s 20 gwei floor', async () => {
+ await relay().settle(housePayload(), REQ);
+ // type 2 ‖ rlp([chainId 0x13b2, nonce 4, tip 0x07d0, maxFee 40 gwei + tip, gas 100000, …])
+ expect(node.sent[0].startsWith('0x02f9')).toBe(true);
+ expect(node.sent[0]).toContain('8213b204' + '8207d0' + '85' + (40n * GWEI + 2000n).toString(16).padStart(10, '0') + '830186a0');
+ node = new FakeArcNode(NOW);
+ node.baseFee = 1n;
+ node.tip = 0n;
+ await relay().settle(housePayload(), REQ);
+ expect(node.sent[0]).toContain('85' + ARC_MIN_MAX_FEE_WEI.toString(16).padStart(10, '0'));
+ });
+
+ it('an authorization already used is settled by the transaction in its AuthorizationUsed log', async () => {
+ const payload = housePayload();
+ node.markUsed(HOUSE.address, payload.payload.authorization.nonce, OTHER_TX, node.blockOf(BigInt(NOW + 30)));
+ node.advance(60);
+ expect(await relay().settle(payload, REQ)).toEqual({ kind: 'settled', transaction: OTHER_TX, network: 'eip155:5042', payer: HOUSE.address });
+ expect(node.sent).toEqual([]);
+ // Without its log, it is the reuse answer settle.ts resolves.
+ node.logs = [];
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_nonce_already_used' });
+ expect(node.sent).toEqual([]);
+ });
+
+ it('finds that transaction a day later in a couple of log queries, inside the validity window', async () => {
+ const payload = housePayload();
+ node.markUsed(HOUSE.address, payload.payload.authorization.nonce, OTHER_TX, node.blockOf(BigInt(NOW + 30)));
+ node.advance(86_400);
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'settled', transaction: OTHER_TX });
+ const queries = node.calls.filter((c) => c.method === 'eth_getLogs').map((c) => c.params[0] as { fromBlock: string; toBlock: string });
+ expect(queries.length).toBeLessThanOrEqual(2);
+ // Every query stays within the window's blocks (validAfter .. validBefore, with a margin).
+ for (const q of queries) {
+ expect(BigInt(q.fromBlock)).toBeGreaterThanOrEqual(node.blockOf(BigInt(NOW - 60)) - 700n);
+ expect(BigInt(q.toBlock)).toBeLessThanOrEqual(node.blockOf(BigInt(NOW + 3600)) + 700n);
+ }
+ });
+
+ it('refuses before sending: foreign signer, wrong network, recipient or amount', async () => {
+ const payload = housePayload();
+ const other = houseWalletFromPrivateKey(hexToBytes(TEST_WALLET_KEYS.b)).signTransfer(REQ, NOW);
+ expect(await relay().settle(other, REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_payload' });
+ const base = buildRequirements({ task_id: 't', bounty_amount: '5000000', bounty_network: 'eip155:8453' }, AGENT);
+ expect(await relay().settle(HOUSE.signTransfer(base, NOW), base)).toMatchObject({ kind: 'rejected', reason: 'unsupported_network' });
+ expect(await relay().settle(payload, { ...REQ, payTo: '0x' + '99'.repeat(20) })).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_recipient_mismatch' });
+ expect(await relay().settle(payload, { ...REQ, amount: '1' })).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_authorization_value_mismatch' });
+ expect(node.sent).toEqual([]);
+ });
+
+ it('expiry is the chain\'s call: our clock past validBefore changes nothing; the chain past it, unused, does', async () => {
+ const payload = housePayload();
+ const validBefore = Number(payload.payload.authorization.validBefore);
+ // Our clock says expired; the chain doesn't: the transfer goes out and lands.
+ expect(await relay({ now: (validBefore + 100) * 1000 }).settle(payload, REQ)).toMatchObject({ kind: 'settled' });
+ // The chain past validBefore with an unused authorization: definitively expired, nothing sent.
+ node = new FakeArcNode(NOW);
+ node.advance(3_700);
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_authorization_valid_before' });
+ expect(node.sent).toEqual([]);
+ });
+
+ it('a transfer already sent is never called expired while it can still land, and is settled once it does', async () => {
+ node.autoMine = false;
+ const payload = housePayload();
+ const first = await relay().settle(payload, REQ);
+ expect(first).toEqual({ kind: 'pending', transaction: hashOf(node.sent[0]) });
+ // Retried 10 s before validBefore by the chain's clock, 5 s before it by ours, with
+ // the transfer still in the mempool: waiting, not expired (expired would re-sign and pay twice).
+ node.advance(3_590);
+ const second = await relay({ now: (NOW + 3_595) * 1000 }).settle(payload, REQ);
+ expect(second).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/still pending/) });
+ // It lands before validBefore: the next attempt reports that very transaction.
+ node.mine();
+ expect(await relay().settle(payload, REQ)).toEqual({ kind: 'settled', transaction: hashOf(node.sent[0]), network: 'eip155:5042', payer: HOUSE.address });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('a transfer dropped and past validBefore is expired, read at that block; a lagging node can\'t say so', async () => {
+ node.autoMine = false;
+ const payload = housePayload();
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'pending' });
+ node.drop(hashOf(node.sent[0]));
+ node.advance(3_700);
+ node.lagPinned = true;
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/could not read block/) });
+ node.lagPinned = false;
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_authorization_valid_before' });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('waits for another escrow-wallet transaction to land, then sends on the next nonce', async () => {
+ node.inject(otherTx(4n));
+ expect(await relay({ onSleep: () => node.mine() }).settle(housePayload(), REQ)).toMatchObject({ kind: 'settled' });
+ expect(decodeArcTx(node.sent[0]).nonce).toBe(5n);
+ // One that never lands: the relay gives the slot back after ARC_IDLE_WAIT_MS, having sent nothing.
+ node = new FakeArcNode(NOW);
+ node.inject(otherTx(4n));
+ const r = relay();
+ expect(await r.settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/still pending/) });
+ expect(node.sent).toEqual([]);
+ expect(count('eth_getTransactionCount')).toBeGreaterThanOrEqual(2 * Math.floor(ARC_IDLE_WAIT_MS / 400));
+ });
+
+ it('loses a nonce race to a concurrent payout and sends on the next nonce in the same call', async () => {
+ let raced = false;
+ node.beforeSend = (tx) => {
+ if (!raced) { raced = true; node.inject(otherTx(tx.nonce)); }
+ };
+ expect(await relay({ onSleep: () => node.mine() }).settle(housePayload(), REQ)).toMatchObject({ kind: 'settled' });
+ expect(count('eth_sendRawTransaction')).toBe(2);
+ expect(node.sent).toHaveLength(1);
+ expect(decodeArcTx(node.sent[0]).nonce).toBe(5n);
+ expect(node.latestNonce).toBe(6n);
+ });
+
+ it('two payouts at once (two Workers, one escrow wallet) both land, on consecutive nonces', async () => {
+ const other = buildRequirements({ task_id: 'task_arc2', bounty_amount: '1000000', bounty_network: 'eip155:5042' }, OTHER_AGENT);
+ const [a, b] = await Promise.all([relay().settle(housePayload(), REQ), relay().settle(housePayload(other), other)]);
+ expect(a).toMatchObject({ kind: 'settled' });
+ expect(b).toMatchObject({ kind: 'settled' });
+ expect(node.sent.map((raw) => decodeArcTx(raw).nonce).sort()).toEqual([4n, 5n]);
+ expect(node.latestNonce).toBe(6n);
+ // They raced for nonce 4: the node refused one, which went out again on 5 in the same call.
+ expect(count('eth_sendRawTransaction')).toBe(3);
+ });
+
+ it('a transfer whose nonce went to another transaction is sent again', async () => {
+ let swapped = false;
+ node.onReceiptPoll = (hash) => {
+ if (swapped) return;
+ swapped = true;
+ const ours = node.mempool.find((t) => t.hash === hash)!;
+ node.drop(hash);
+ node.inject(otherTx(ours.nonce));
+ node.mine();
+ };
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'settled' });
+ expect(node.sent.map((raw) => decodeArcTx(raw).nonce)).toEqual([4n, 5n]);
+ });
+
+ it('a transfer that lands while its receipt and log can\'t be read stays open until its transaction can be read', async () => {
+ // It lands on the first attempt (settle.ts believes nothing was ever sent), but every
+ // receipt read fails, so it looks lost once the nonce moves on, and the log can't be read.
+ node.failReceipts = true;
+ node.failLogs = true;
+ const payload = housePayload();
+ // Open, never the reuse answer (which would let settle.ts re-sign, or settle without a link).
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/can't be read yet/) });
+ expect(node.sent).toHaveLength(1);
+ // A second attempt with logs still unreadable: still open, not settled without its transaction.
+ node.failReceipts = false;
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/can't be read yet/) });
+ // Readable again: settled with the transfer that landed.
+ node.failLogs = false;
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'settled', transaction: hashOf(node.sent[0]) });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('log queries move to the next endpoint when one refuses them', async () => {
+ const payload = housePayload();
+ node.markUsed(HOUSE.address, payload.payload.authorization.nonce, OTHER_TX, node.blockOf(BigInt(NOW + 30)));
+ node.advance(60);
+ // The first endpoint is the same chain, but refuses log queries.
+ const refusing: typeof fetch = async (input, init) => {
+ const req = JSON.parse(String(init?.body)) as { method: string };
+ if (req.method === 'eth_getLogs') return Response.json({ jsonrpc: '2.0', id: 1, error: { code: -32005, message: 'query not supported' } });
+ return node.fetch()(input, init);
+ };
+ const fetchBoth: typeof fetch = (input, init) => (String(input) === 'https://a.example' ? refusing(input, init) : node.fetch()(input, init));
+ const r = new ArcRelay({ privateKey: KEY, rpc: new ArcRpc(['https://a.example', 'https://b.example'], fetchBoth), sleep: async () => {}, now: () => NOW_MS });
+ expect(await r.settle(payload, REQ)).toMatchObject({ kind: 'settled', transaction: OTHER_TX });
+ });
+
+ it('used with no log in its window, after a send in this call: pending without the refused retry\'s hash', async () => {
+ node.autoMine = false;
+ const payload = housePayload();
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'pending', transaction: hashOf(node.sent[0]) });
+ // Our node loses the first transfer, but it lands from elsewhere just as the retry
+ // broadcasts (with other fees, so another hash), which the node refuses: nonce too low.
+ const first = node.mempool[0];
+ node.drop(first.hash);
+ node.tip = 3000n;
+ node.hideLogs = true;
+ let landed = false;
+ node.beforeSend = (tx) => {
+ if (landed || tx.hash === first.hash) return;
+ landed = true;
+ node.inject(first);
+ node.mine();
+ };
+ // Used, and the window reads clean of its log: the reuse answer, which after a send in
+ // this call becomes pending, with no hash (the refused retry's would replace the first one's).
+ expect(await relay().settle(payload, REQ)).toEqual({ kind: 'pending' });
+ expect(count('eth_sendRawTransaction')).toBe(2);
+ expect(node.sent).toHaveLength(1);
+ // A call that sends nothing gives the reuse answer itself (settle.ts knows a broadcast happened).
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_nonce_already_used' });
+ node.hideLogs = false;
+ expect(await relay().settle(payload, REQ)).toMatchObject({ kind: 'settled', transaction: first.hash });
+ });
+
+ it('after a send in this call, a simulation verdict that would allow re-signing waits for the next attempt', async () => {
+ let swapped = false;
+ node.onReceiptPoll = (hash) => {
+ if (swapped) return;
+ swapped = true;
+ const ours = node.mempool.find((t) => t.hash === hash)!;
+ node.drop(hash);
+ node.inject(otherTx(ours.nonce));
+ node.mine();
+ node.estimateError = 'execution reverted: Blacklistable: account is blacklisted';
+ };
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/after a send in this call/) });
+ // A fresh attempt (settle.ts now knows a broadcast happened) gets the verdict itself.
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_authorization_to_address_kyt' });
+ });
+
+ it('a simulation revert is the token\'s verdict, classified; a time-related one waits for the chain', async () => {
+ node.estimateError = 'execution reverted: FiatTokenV2: invalid signature';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'invalid_exact_evm_payload_signature' });
+ node.estimateError = 'execution reverted: ERC20: transfer amount exceeds balance';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'insufficient_funds' });
+ node.estimateError = 'execution reverted: FiatTokenV2: authorization is expired';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/chain's clock/) });
+ expect(node.sent).toEqual([]);
+ expect(revertReason('execution reverted: Blacklistable: account is blacklisted')).toBe('invalid_exact_evm_payload_authorization_to_address_kyt');
+ expect(revertReason('execution reverted: FiatTokenV2: authorization is used or canceled')).toBe('invalid_exact_evm_nonce_already_used');
+ expect(revertReason('execution reverted: something else')).toBe('transaction_reverted');
+ });
+
+ it('refuses when the wallet cannot cover the transfer plus gas (the gas float)', async () => {
+ // 5 USDC in native units, and gas on top: just short.
+ node.balance = 5n * 10n ** 18n;
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'insufficient_funds', message: expect.stringMatching(/Top it up/) });
+ expect(node.sent).toEqual([]);
+ });
+
+ it('broadcast answers: already known is ours; insufficient funds; anything else is retried later', async () => {
+ node.sendError = 'already known';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'pending', transaction: expect.stringMatching(/^0x[0-9a-f]{64}$/) });
+ node.sendError = 'insufficient funds for gas * price + value: have 0 want 1';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'insufficient_funds' });
+ node.sendError = 'txpool is full';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/txpool is full/) });
+ node.sendError = 'nonce too low';
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', detail: expect.stringMatching(/rounds/) });
+ });
+
+ it('pending past the receipt wait; a revert after a clean simulation is retried later, not resent', async () => {
+ node.autoMine = false;
+ const out = await relay().settle(housePayload(), REQ);
+ expect(out).toEqual({ kind: 'pending', transaction: hashOf(node.sent[0]) });
+ node = new FakeArcNode(NOW);
+ node.mineAfterPolls = 2;
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'settled' });
+ node = new FakeArcNode(NOW);
+ node.revertOnMine = true;
+ expect(await relay().settle(housePayload(), REQ)).toMatchObject({ kind: 'rejected', reason: 'transaction_reverted', transaction: hashOf(node.sent[0]) });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('moves to the next RPC endpoint when one is down, and is unavailable when all are', async () => {
+ const urls = ['https://a.example', 'https://b.example'];
+ expect(await relay({ urls, down: ['https://a.example'] }).settle(housePayload(), REQ)).toMatchObject({ kind: 'settled' });
+ expect(await relay({ urls, down: urls }).settle(housePayload(), REQ)).toMatchObject({ kind: 'unavailable', cause: 'network' });
+ });
+
+ it('never verifies: deposits go to Circle', async () => {
+ expect(await relay().verify()).toMatchObject({ kind: 'unavailable' });
+ });
+});
+
+describe('ArcFacilitator / NetworkRouter', () => {
+ const tagged = (name: string): Facilitator & { seen: string[] } => {
+ const seen: string[] = [];
+ return {
+ seen,
+ verify: async () => { seen.push(`${name}.verify`); return { kind: 'valid' }; },
+ settle: async () => { seen.push(`${name}.settle`); return { kind: 'settled', transaction: '0x' + '00'.repeat(32) }; },
+ supported: async () => ({ name }),
+ };
+ };
+
+ it('sends the escrow wallet\'s transfers to the relay and buyers\' deposits to Circle', async () => {
+ const circle = tagged('circle');
+ const arc = new ArcFacilitator(circle, relay());
+ await arc.settle(housePayload(), REQ);
+ expect(node.sent).toHaveLength(1);
+ const buyer = PaymentPayloadV2.parse({ ...housePayload(), payload: { ...housePayload().payload, authorization: { ...housePayload().payload.authorization, from: '0x' + '22'.repeat(20) } } });
+ await arc.verify(buyer, REQ);
+ await arc.settle(buyer, REQ);
+ expect(circle.seen).toEqual(['circle.verify', 'circle.settle']);
+ // No Circle key: a deposit can't be placed, and says why.
+ expect(await new ArcFacilitator(null, relay()).settle(buyer, REQ)).toMatchObject({ kind: 'unavailable', cause: 'auth', detail: expect.stringMatching(/CIRCLE_API_KEY/) });
+ });
+
+ it('routes by network, everything else to the fallback (CDP)', async () => {
+ const cdp = tagged('cdp');
+ const arc = tagged('arc');
+ const router = new NetworkRouter(cdp, { 'eip155:5042': arc });
+ const base = buildRequirements({ task_id: 't', bounty_amount: '1', bounty_network: 'eip155:8453' }, AGENT);
+ await router.settle(housePayload(), REQ);
+ await router.verify(housePayload(), base);
+ expect(arc.seen).toEqual(['arc.settle']);
+ expect(cdp.seen).toEqual(['cdp.verify']);
+ expect(await router.supported()).toEqual({ name: 'cdp' });
+ });
+});
diff --git a/packages/api/src/payments/arc.ts b/packages/api/src/payments/arc.ts
new file mode 100644
index 0000000..4a20351
--- /dev/null
+++ b/packages/api/src/payments/arc.ts
@@ -0,0 +1,543 @@
+/**
+ * Arc (eip155:5042): who settles what.
+ *
+ * deposit a buyer's transfer into the escrow wallet → Circle's Facilitator
+ * Service (circle-facilitator.ts): it screens both parties, pays the
+ * gas, and binds only our own escrow wallet to the Circle account.
+ * release / refund a transfer signed by the escrow wallet → this relay: the escrow
+ * wallet broadcasts its own `transferWithAuthorization` and pays the
+ * gas in USDC (Arc's gas token). Circle would bind the agent's or
+ * buyer's wallet to our Circle account, so those never go there.
+ *
+ * The relay is a `Facilitator` like the others, so settle.ts drives it with the same
+ * outcome table. Safety comes from EIP-3009 itself: the authorization's nonce can be
+ * used once, so a retry after a lost answer can never pay twice. Before sending, the
+ * relay asks the chain whether the authorization was already used (and if so, by which
+ * transaction), and it keeps at most one escrow-wallet transaction in flight.
+ *
+ * The escrow wallet must hold a little USDC on Arc beyond the deposits it holds: each
+ * transfer costs about 0.002 USDC in gas. A shortfall answers `insufficient_funds`.
+ */
+import { ARC_NETWORK } from '../types/index.js';
+import { rpcEndpoints } from '../wallets/bind.js';
+import type { Facilitator, SettleOutcome, VerifyOutcome } from './cdp-facilitator.js';
+import { TX_HASH_RE } from './cdp-facilitator.js';
+import { CircleFacilitator, DEFAULT_CIRCLE_FACILITATOR_URL } from './circle-facilitator.js';
+import { addressFromPrivateKey, parseHousePrivateKey, sameAddress, signEip1559 } from './evm.js';
+import { ASSETS, type PaymentPayloadV2, type PaymentRequirementsV2 } from './x402.js';
+
+const ARC_CHAIN_ID = 5042n;
+const ARC_USDC = ASSETS[ARC_NETWORK].asset;
+/** transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32) */
+const TRANSFER_WITH_AUTHORIZATION = 'e3ee160e';
+/** authorizationState(address,bytes32) → bool */
+const AUTHORIZATION_STATE = 'e94a0102';
+/** keccak256("AuthorizationUsed(address,bytes32)") */
+export const AUTHORIZATION_USED_TOPIC = '0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5';
+/** Arc's mempool drops a transaction whose maxFeePerGas is under 20 gwei, silently. */
+export const ARC_MIN_MAX_FEE_WEI = 20_000_000_000n;
+/** Native USDC has 18 decimals; the ERC-20 interface (and our amounts) have 6. */
+const WEI_PER_ATOMIC = 10n ** 12n;
+/** Arc's nodes answer eth_getLogs for at most ~5,000 blocks per call (about 40 minutes). */
+export const ARC_LOG_CHUNK_BLOCKS = 5_000n;
+const ARC_LOG_MAX_CHUNKS = 8;
+/** Arc finalizes in under a second; past this the leg is `pending` and the retry looks again. */
+export const ARC_RECEIPT_WAIT_MS = 6_000;
+const RPC_TIMEOUT_MS = 8_000;
+
+// ─── JSON-RPC ───
+
+/** The node answered with a JSON-RPC error: its verdict (e.g. a revert), not an outage. */
+export class RpcError extends Error {
+ constructor(message: string, readonly code?: number) {
+ super(message);
+ this.name = 'RpcError';
+ }
+}
+
+/** No node answered. Nothing is known about the call. */
+export class RpcUnavailable extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'RpcUnavailable';
+ }
+}
+
+export class ArcRpc {
+ constructor(
+ private readonly urls: string[],
+ private readonly fetchImpl: typeof fetch = (input, init) => fetch(input, init),
+ ) {
+ if (urls.length === 0) throw new Error('ArcRpc needs at least one endpoint');
+ }
+
+ /**
+ * Tries each endpoint in turn on transport failure, 429 or 5xx. A JSON-RPC error is the
+ * node's final answer, unless `failover` is set (for reads a node may refuse on its own
+ * account, like eth_getLogs): then the next endpoint is asked, and the last such error is
+ * thrown if none answers.
+ */
+ async call(method: string, params: unknown[], opts: { failover?: boolean } = {}): Promise {
+ let last = 'no endpoint answered';
+ let refused: RpcError | null = null;
+ for (const url of this.urls) {
+ let res: Response;
+ try {
+ res = await this.fetchImpl(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
+ signal: AbortSignal.timeout(RPC_TIMEOUT_MS),
+ });
+ } catch (err) {
+ last = `${url}: ${(err as Error)?.message ?? String(err)}`;
+ continue;
+ }
+ if (res.status === 429 || res.status >= 500) { last = `${url}: HTTP ${res.status}`; continue; }
+ let body: { result?: T; error?: { code?: number; message?: string } };
+ try {
+ body = await res.json() as typeof body;
+ } catch {
+ last = `${url}: non-JSON answer (HTTP ${res.status})`;
+ continue;
+ }
+ if (body.error) {
+ const err = new RpcError(body.error.message ?? 'JSON-RPC error', body.error.code);
+ if (!opts.failover) throw err;
+ refused = err;
+ continue;
+ }
+ if (body.result === undefined) { last = `${url}: no result`; continue; }
+ return body.result;
+ }
+ if (refused) throw refused;
+ throw new RpcUnavailable(`${method}: ${last}`);
+ }
+}
+
+// ─── ABI helpers ───
+
+function word(hex: string): string {
+ const h = hex.replace(/^0x/, '').toLowerCase();
+ if (h.length > 64) throw new Error('word: longer than 32 bytes');
+ return h.padStart(64, '0');
+}
+
+function uintWord(v: string | bigint): string {
+ return BigInt(v).toString(16).padStart(64, '0');
+}
+
+/** Calldata for USDC's transferWithAuthorization with the signature split into v, r, s. */
+export function transferWithAuthorizationCalldata(payload: PaymentPayloadV2): string {
+ const a = payload.payload.authorization;
+ const sig = payload.payload.signature.slice(2);
+ if (sig.length !== 130) throw new Error('expected a 65-byte r‖s‖v signature');
+ let v = parseInt(sig.slice(128, 130), 16);
+ if (v < 27) v += 27;
+ return '0x' + TRANSFER_WITH_AUTHORIZATION + word(a.from) + word(a.to) + uintWord(a.value) + uintWord(a.validAfter)
+ + uintWord(a.validBefore) + word(a.nonce) + uintWord(BigInt(v)) + sig.slice(0, 64) + sig.slice(64, 128);
+}
+
+/** A FiatToken revert reason → the structured reason settle.ts classifies. */
+export function revertReason(message: string): string {
+ if (/authorization is used or canceled/i.test(message)) return 'invalid_exact_evm_nonce_already_used';
+ if (/authorization is expired/i.test(message)) return 'invalid_exact_evm_payload_authorization_valid_before';
+ if (/authorization is not yet valid/i.test(message)) return 'authorization_not_yet_valid';
+ if (/exceeds balance|insufficient/i.test(message)) return 'insufficient_funds';
+ if (/blacklist|blocklist|blocked/i.test(message)) return 'invalid_exact_evm_payload_authorization_to_address_kyt';
+ if (/invalid signature/i.test(message)) return 'invalid_exact_evm_payload_signature';
+ return 'transaction_reverted';
+}
+
+// ─── The relay ───
+
+export interface ArcRelayConfig {
+ privateKey: Uint8Array;
+ rpc: ArcRpc;
+ /** Milliseconds since the epoch; injected by tests. */
+ now?: () => number;
+ sleep?: (ms: number) => Promise;
+ receiptWaitMs?: number;
+}
+
+interface Receipt { status?: string; transactionHash?: string }
+interface Block { number?: string; timestamp?: string; baseFeePerGas?: string }
+interface Log { transactionHash?: string }
+interface BlockRef { number: bigint; timestamp: bigint; baseFee: bigint }
+
+/** Rounds of check → send → watch per settle call (a lost nonce race or a dropped tx starts a new round). */
+const MAX_ROUNDS = 4;
+/** How long a send waits for another escrow-wallet transaction to land before giving the slot back. */
+export const ARC_IDLE_WAIT_MS = 4_000;
+const POLL_MS = 400;
+/** Blocks either side of the estimated validity window searched for an AuthorizationUsed log. */
+const LOG_MARGIN_BLOCKS = 600n;
+const hex = (n: bigint) => '0x' + n.toString(16);
+
+export class ArcRelay implements Facilitator {
+ readonly address: `0x${string}`;
+ private readonly key: Uint8Array;
+ private readonly rpc: ArcRpc;
+ private readonly now: () => number;
+ private readonly sleep: (ms: number) => Promise;
+ private readonly receiptWaitMs: number;
+
+ constructor(cfg: ArcRelayConfig) {
+ this.key = cfg.privateKey;
+ this.address = addressFromPrivateKey(cfg.privateKey);
+ this.rpc = cfg.rpc;
+ this.now = cfg.now ?? (() => Date.now());
+ this.sleep = cfg.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
+ this.receiptWaitMs = cfg.receiptWaitMs ?? ARC_RECEIPT_WAIT_MS;
+ }
+
+ /** Deposits verify through Circle; the relay only sends the escrow wallet's own transfers. */
+ async verify(): Promise {
+ return { kind: 'unavailable', cause: 'malformed', detail: 'the Arc relay sends escrow-wallet transfers only; deposits verify through Circle' };
+ }
+
+ async supported(): Promise {
+ return { kinds: [{ x402Version: 2, scheme: 'exact', network: ARC_NETWORK, asset: ARC_USDC }], signer: this.address };
+ }
+
+ async settle(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ try {
+ return await this.send(payload, requirements);
+ } catch (err) {
+ if (err instanceof RpcUnavailable) return { kind: 'unavailable', cause: 'network', detail: `Arc RPC: ${err.message}`.slice(0, 500) };
+ return { kind: 'unavailable', cause: 'server', detail: `Arc relay: ${String(err)}`.slice(0, 500) };
+ }
+ }
+
+ private rejected(reason: string, message: string, transaction?: string): SettleOutcome {
+ return transaction ? { kind: 'rejected', reason, message, transaction, http: 200 } : { kind: 'rejected', reason, message, http: 200 };
+ }
+
+ private retryLater(detail: string): SettleOutcome {
+ return { kind: 'unavailable', cause: 'server', detail: `Arc relay: ${detail}`.slice(0, 500) };
+ }
+
+ /**
+ * Every round starts from the chain: used → settled (with the transaction that used it);
+ * expired → only by the chain's own clock; then one transaction, on a nonce no other
+ * escrow-wallet transaction holds, watched until it lands or is lost.
+ */
+ private async send(payload: PaymentPayloadV2, req: PaymentRequirementsV2): Promise {
+ const auth = payload.payload.authorization;
+ if (req.network !== ARC_NETWORK || !sameAddress(req.asset, ARC_USDC)) return this.rejected('unsupported_network', 'The Arc relay sends USDC on Arc only.');
+ if (!sameAddress(auth.from, this.address)) return this.rejected('invalid_payload', 'The Arc relay sends only transfers signed by the escrow wallet.');
+ if (!sameAddress(auth.to, req.payTo)) return this.rejected('invalid_exact_evm_payload_recipient_mismatch', 'The authorization pays someone other than payTo.');
+ if (BigInt(auth.value) !== BigInt(req.amount)) return this.rejected('invalid_exact_evm_payload_authorization_value_mismatch', 'The authorization moves a different amount.');
+ const data = transferWithAuthorizationCalldata(payload);
+ const validBefore = BigInt(auth.validBefore);
+ /**
+ * Whether this call has broadcast (or tried to). Once it has, settle.ts can't know it: its
+ * row still says nothing was ever broadcast, and it reads a reuse answer or a terminal
+ * verdict as "this authorization never moved money, sign a new one". So from then on this
+ * call only answers settled, pending, expired-at-a-pinned-block or retry.
+ */
+ let sent = false;
+ // Used, but which transaction used it can't be read yet: pending WITHOUT a hash, so the
+ // hash settle.ts already recorded (if any) stays. A hash this call sent may never have
+ // landed (refused, replaced, or beaten by an earlier attempt's transfer).
+ const keepOpen = (outcome: SettleOutcome): SettleOutcome =>
+ sent && outcome.kind === 'rejected' && outcome.reason === 'invalid_exact_evm_nonce_already_used'
+ ? { kind: 'pending' }
+ : outcome;
+
+ for (let round = 0; round < MAX_ROUNDS; round++) {
+ // 1. Already used? Then the money moved; report the transaction that moved it.
+ const used = await this.ifUsed(auth, 'latest');
+ if (used) return keepOpen(used);
+
+ // 2. Expired? Only the chain's clock decides: until a block at or past validBefore
+ // exists, a transaction sent by an earlier attempt can still land, and calling the
+ // leg expired would let the cron sign a second transfer. Read at that very block
+ // (a node behind it errors instead of answering from older state), an unused
+ // authorization can never be used again.
+ const head = await this.block('latest');
+ if (head.timestamp >= validBefore) {
+ let usedAtHead: SettleOutcome | null;
+ try {
+ usedAtHead = await this.ifUsed(auth, hex(head.number));
+ } catch (err) {
+ if (err instanceof RpcError) return this.retryLater(`could not read block ${head.number} yet (${err.message}); checking expiry again later`);
+ throw err;
+ }
+ if (usedAtHead) return keepOpen(usedAtHead);
+ return this.rejected('invalid_exact_evm_payload_authorization_valid_before', 'The authorization expired on Arc unused.');
+ }
+
+ // 3. One escrow-wallet transaction in flight at a time: wait for another to land
+ // (it may be this authorization's own, from an earlier attempt).
+ const idle = await this.idleNonce();
+ if (idle === null) return this.retryLater('an escrow-wallet transaction is still pending; retrying after it lands');
+ if (idle.waited) continue; // something landed meanwhile: start again from the chain
+
+ // 4. Simulate: a revert here is the token's verdict, and nothing was sent.
+ let gas: bigint;
+ try {
+ gas = BigInt(await this.rpc.call('eth_estimateGas', [{ from: this.address, to: ARC_USDC, data }]));
+ } catch (err) {
+ if (!(err instanceof RpcError)) throw err;
+ const reason = revertReason(err.message);
+ if (reason === 'invalid_exact_evm_nonce_already_used') continue; // used since step 1: the next round reports it
+ // Time is the chain's to call (step 2), not the simulation's.
+ if (reason === 'invalid_exact_evm_payload_authorization_valid_before' || reason === 'authorization_not_yet_valid') {
+ return this.retryLater(`simulation says ${reason}; the chain's clock decides on the next attempt`);
+ }
+ // After a send in this call, a verdict that would let settle.ts re-sign waits for the next attempt.
+ if (sent) return this.retryLater(`simulation now says ${reason} after a send in this call; deciding on the next attempt`);
+ return this.rejected(reason, `Arc simulation: ${err.message}`.slice(0, 300));
+ }
+
+ // 5. Fees and the gas float: the transfer and its gas come out of one USDC balance.
+ let tip = 0n;
+ try {
+ tip = BigInt(await this.rpc.call('eth_maxPriorityFeePerGas', []));
+ } catch (err) {
+ if (!(err instanceof RpcError)) throw err;
+ }
+ let maxFee = 2n * head.baseFee + tip;
+ if (maxFee < ARC_MIN_MAX_FEE_WEI) maxFee = ARC_MIN_MAX_FEE_WEI;
+ const gasLimit = (gas * 5n) / 4n;
+ const balance = BigInt(await this.rpc.call('eth_getBalance', [this.address, 'latest']));
+ const needed = BigInt(auth.value) * WEI_PER_ATOMIC + gasLimit * maxFee;
+ if (balance < needed) {
+ return this.rejected('insufficient_funds', `The escrow wallet holds ${balance} wei of USDC on Arc and needs ${needed} (the transfer plus gas). Top it up with a little USDC on Arc.`);
+ }
+
+ // 6. Sign and broadcast on the free nonce.
+ const signed = signEip1559({
+ chainId: ARC_CHAIN_ID, nonce: idle.nonce, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee,
+ gas: gasLimit, to: ARC_USDC, value: 0n, data,
+ }, this.key);
+ sent = true; // from here a node may hold it, whatever the call answers
+ try {
+ await this.rpc.call('eth_sendRawTransaction', [signed.raw]);
+ } catch (err) {
+ if (!(err instanceof RpcError)) throw err; // unknown whether a node took it: the retry checks the chain first
+ if (/insufficient funds/i.test(err.message)) return this.rejected('insufficient_funds', `Arc broadcast: ${err.message}`.slice(0, 300));
+ // Another escrow-wallet transaction took this nonce (a concurrent payout): next round.
+ if (/nonce too low|replacement transaction underpriced|nonce/i.test(err.message) && !/already known/i.test(err.message)) continue;
+ if (!/already known/i.test(err.message)) return this.retryLater(`broadcast refused: ${err.message}`);
+ }
+
+ // 7. Watch it land (Arc finalizes in under a second).
+ const watched = await this.watch(signed.hash, idle.nonce);
+ if (watched === 'mined') return { kind: 'settled', transaction: signed.hash, network: ARC_NETWORK, payer: this.address };
+ if (watched === 'pending') return { kind: 'pending', transaction: signed.hash };
+ // Reverted after a clean simulation: retried later (with backoff), never resent in a loop that burns gas.
+ if (watched === 'reverted') return this.rejected('transaction_reverted', `Arc transaction ${signed.hash} reverted.`, signed.hash);
+ // 'lost': another transaction took its nonce, so it can never land. Start again from the chain.
+ }
+ return this.retryLater(`no transfer landed after ${MAX_ROUNDS} rounds; retrying later`);
+ }
+
+ /** Mined, reverted, lost (its nonce went to another transaction) or still pending at the deadline. */
+ private async watch(hash: string, nonce: bigint): Promise<'mined' | 'reverted' | 'lost' | 'pending'> {
+ const deadline = this.now() + this.receiptWaitMs;
+ for (;;) {
+ const receipt = await this.rpc.call('eth_getTransactionReceipt', [hash]).catch((err) => {
+ if (err instanceof RpcError) return null;
+ throw err;
+ });
+ if (receipt) return receipt.status === '0x1' ? 'mined' : 'reverted';
+ // The nonce is used but not by this transaction: it was replaced or dropped.
+ if (BigInt(await this.rpc.call('eth_getTransactionCount', [this.address, 'latest'])) > nonce) {
+ const late = await this.rpc.call('eth_getTransactionReceipt', [hash]).catch(() => null);
+ if (late) return late.status === '0x1' ? 'mined' : 'reverted';
+ return 'lost';
+ }
+ if (this.now() >= deadline) return 'pending';
+ await this.sleep(POLL_MS);
+ }
+ }
+
+ /** The escrow wallet's next nonce once nothing of its is pending (waiting up to ARC_IDLE_WAIT_MS), or null. */
+ private async idleNonce(): Promise<{ nonce: bigint; waited: boolean } | null> {
+ const deadline = this.now() + ARC_IDLE_WAIT_MS;
+ let waited = false;
+ for (;;) {
+ const [latest, pending] = await Promise.all([
+ this.rpc.call('eth_getTransactionCount', [this.address, 'latest']),
+ this.rpc.call('eth_getTransactionCount', [this.address, 'pending']),
+ ]);
+ if (BigInt(pending) <= BigInt(latest)) return { nonce: BigInt(latest), waited };
+ if (this.now() >= deadline) return null;
+ waited = true;
+ await this.sleep(POLL_MS);
+ }
+ }
+
+ private async block(tag: bigint | 'latest'): Promise {
+ const b = await this.rpc.call('eth_getBlockByNumber', [tag === 'latest' ? 'latest' : hex(tag), false]);
+ if (!b?.number || !b.timestamp) throw new RpcError(`block ${tag === 'latest' ? 'latest' : tag} is not available`);
+ return { number: BigInt(b.number), timestamp: BigInt(b.timestamp), baseFee: BigInt(b.baseFeePerGas ?? '0x0') };
+ }
+
+ /** Settled (or the reuse answer) if the authorization is used at `blockTag`; null if it is not. */
+ private async ifUsed(auth: PaymentPayloadV2['payload']['authorization'], blockTag: string): Promise {
+ const out = await this.rpc.call('eth_call', [{ to: ARC_USDC, data: '0x' + AUTHORIZATION_STATE + word(auth.from) + word(auth.nonce) }, blockTag]);
+ if (!/^0x0*1$/.test(out)) return null;
+ const found = await this.findAuthorizationTx(auth.from, auth.nonce, BigInt(auth.validAfter), BigInt(auth.validBefore));
+ if (found.kind === 'found') return { kind: 'settled', transaction: found.hash, network: ARC_NETWORK, payer: this.address };
+ // The money moved, but which transaction moved it can't be read right now: stay open
+ // and look again, so a short outage never settles the leg without its transaction.
+ if (found.kind === 'unreadable') return this.retryLater(`the authorization is used, but its transaction can't be read yet (${found.detail}); looking again later`);
+ // The whole window was read and holds no log for it: the reuse answer settle.ts resolves.
+ return this.rejected('invalid_exact_evm_nonce_already_used', 'The authorization was already used on Arc, and no log for it is in its validity window.');
+ }
+
+ /**
+ * The last block whose timestamp is at or before `ts`, to within a few blocks: an
+ * interpolation search over block timestamps (Arc's are close to evenly spaced, so it
+ * lands in a probe or two), with a bisection step whenever a probe gains little.
+ */
+ private async blockAt(ts: bigint, head: BlockRef): Promise {
+ if (ts >= head.timestamp) return head.number;
+ let hi = head;
+ // A lower bound: three blocks a second is faster than Arc runs, so this lands before ts.
+ let lo = await this.block(head.number - (head.timestamp - ts) * 3n > 0n ? head.number - (head.timestamp - ts) * 3n : 0n);
+ for (let i = 0; i < 4 && lo.timestamp > ts && lo.number > 0n; i++) {
+ const back = lo.number - (lo.timestamp - ts) * 3n - 1000n;
+ lo = await this.block(back > 0n ? back : 0n);
+ }
+ if (lo.timestamp > ts) return lo.number;
+ for (let i = 0; i < 20 && hi.number - lo.number > 8n; i++) {
+ const span = hi.number - lo.number;
+ let probe = i % 2 === 0 && hi.timestamp > lo.timestamp
+ ? lo.number + (span * (ts - lo.timestamp)) / (hi.timestamp - lo.timestamp)
+ : lo.number + span / 2n;
+ if (probe <= lo.number) probe = lo.number + 1n;
+ if (probe >= hi.number) probe = hi.number - 1n;
+ const b = await this.block(probe);
+ if (b.timestamp <= ts) lo = b; else hi = b;
+ }
+ return lo.number;
+ }
+
+ /**
+ * The transaction that used an authorization, from USDC's AuthorizationUsed log. It was
+ * mined between validAfter and validBefore, so the search covers the blocks of that
+ * window (found by timestamp, however long ago it was), newest first, in
+ * ARC_LOG_CHUNK_BLOCKS slices. `absent` only when every block of the window was read;
+ * a node that refuses a query (every endpoint asked) or a window too wide to finish is
+ * `unreadable`, never taken for an answer.
+ */
+ private async findAuthorizationTx(
+ authorizer: string, nonce: string, validAfter: bigint, validBefore: bigint,
+ ): Promise<{ kind: 'found'; hash: string } | { kind: 'absent' } | { kind: 'unreadable'; detail: string }> {
+ let from: bigint;
+ let to: bigint;
+ try {
+ const head = await this.block('latest');
+ const fromBlock = await this.blockAt(validAfter, head) - LOG_MARGIN_BLOCKS;
+ to = await this.blockAt(validBefore, head) + LOG_MARGIN_BLOCKS;
+ if (to > head.number) to = head.number;
+ from = fromBlock > 0n ? fromBlock : 0n;
+ } catch (err) {
+ if (err instanceof RpcError) return { kind: 'unreadable', detail: `blocks: ${err.message}` };
+ throw err;
+ }
+ for (let i = 0; i < ARC_LOG_MAX_CHUNKS && to >= from; i++) {
+ const start = to - ARC_LOG_CHUNK_BLOCKS + 1n > from ? to - ARC_LOG_CHUNK_BLOCKS + 1n : from;
+ let logs: Log[];
+ try {
+ logs = await this.rpc.call('eth_getLogs', [{
+ address: ARC_USDC, fromBlock: hex(start), toBlock: hex(to),
+ topics: [AUTHORIZATION_USED_TOPIC, '0x' + word(authorizer), '0x' + word(nonce)],
+ }], { failover: true });
+ } catch (err) {
+ if (err instanceof RpcError) return { kind: 'unreadable', detail: `logs: ${err.message}` };
+ throw err;
+ }
+ const hit = logs.find((l) => typeof l.transactionHash === 'string' && TX_HASH_RE.test(l.transactionHash));
+ if (hit) return { kind: 'found', hash: hit.transactionHash! };
+ to = start - 1n;
+ }
+ if (to >= from) return { kind: 'unreadable', detail: `the window needs more than ${ARC_LOG_MAX_CHUNKS} log queries` };
+ return { kind: 'absent' };
+ }
+}
+
+// ─── Routing ───
+
+/**
+ * Arc's facilitator: a transfer signed by the escrow wallet goes to the relay, anything
+ * else (a buyer's deposit) to Circle. Either half may be missing (no Circle key, no
+ * escrow key); a call it cannot place answers `unavailable` and settle.ts retries.
+ */
+export class ArcFacilitator implements Facilitator {
+ constructor(readonly circle: Facilitator | null, readonly relay: ArcRelay | null) {}
+
+ private fromEscrow(payload: PaymentPayloadV2): boolean {
+ return !!this.relay && sameAddress(payload.payload.authorization.from, this.relay.address);
+ }
+
+ async verify(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ if (this.fromEscrow(payload)) return this.relay!.verify();
+ if (!this.circle) return { kind: 'unavailable', cause: 'auth', detail: 'CIRCLE_API_KEY is not set: Arc deposits are off' };
+ return this.circle.verify(payload, requirements);
+ }
+
+ async settle(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ if (this.fromEscrow(payload)) return this.relay!.settle(payload, requirements);
+ if (!this.circle) return { kind: 'unavailable', cause: 'auth', detail: 'CIRCLE_API_KEY is not set: Arc deposits are off' };
+ return this.circle.settle(payload, requirements);
+ }
+
+ async supported(): Promise {
+ return { circle: this.circle ? await this.circle.supported() : null, relay: this.relay ? await this.relay.supported() : null };
+ }
+}
+
+/** Sends each payment to the facilitator for its network; everything else to `fallback` (CDP). */
+export class NetworkRouter implements Facilitator {
+ constructor(readonly fallback: Facilitator, readonly routes: Readonly>) {}
+
+ private pick(requirements: PaymentRequirementsV2): Facilitator {
+ return this.routes[requirements.network] ?? this.fallback;
+ }
+
+ verify(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ return this.pick(requirements).verify(payload, requirements);
+ }
+
+ settle(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ return this.pick(requirements).settle(payload, requirements);
+ }
+
+ supported(): Promise {
+ return this.fallback.supported();
+ }
+}
+
+export type ArcEnv = { CIRCLE_API_KEY?: string; CIRCLE_FACILITATOR_URL?: string; ESCROW_WALLET_PRIVATE_KEY?: string; ARC_RPC_URL?: string };
+
+/**
+ * Arc's facilitator for this env: Circle when CIRCLE_API_KEY is set (with an http(s)
+ * CIRCLE_FACILITATOR_URL if given), the relay when the escrow key parses.
+ */
+export function arcFacilitatorFor(env: ArcEnv): ArcFacilitator {
+ let circle: CircleFacilitator | null = null;
+ if (env.CIRCLE_API_KEY) {
+ const baseUrl = env.CIRCLE_FACILITATOR_URL || DEFAULT_CIRCLE_FACILITATOR_URL;
+ try {
+ const u = new URL(baseUrl);
+ if (u.protocol === 'https:' || u.protocol === 'http:') circle = new CircleFacilitator({ apiKey: env.CIRCLE_API_KEY, baseUrl });
+ else console.error('[payments] CIRCLE_FACILITATOR_URL is not an http(s) URL: Arc deposits are off');
+ } catch {
+ console.error('[payments] CIRCLE_FACILITATOR_URL is not a valid URL: Arc deposits are off');
+ }
+ }
+ let relay: ArcRelay | null = null;
+ if (env.ESCROW_WALLET_PRIVATE_KEY) {
+ try {
+ relay = new ArcRelay({ privateKey: parseHousePrivateKey(env.ESCROW_WALLET_PRIVATE_KEY), rpc: new ArcRpc(rpcEndpoints(env, ARC_NETWORK)) });
+ } catch {
+ relay = null; // houseWalletFor logs the key problem
+ }
+ }
+ return new ArcFacilitator(circle, relay);
+}
diff --git a/packages/api/src/payments/cdp-facilitator.ts b/packages/api/src/payments/cdp-facilitator.ts
index 911a514..586c5e3 100644
--- a/packages/api/src/payments/cdp-facilitator.ts
+++ b/packages/api/src/payments/cdp-facilitator.ts
@@ -36,8 +36,11 @@ export type VerifyOutcome =
export type SettleOutcome =
| { kind: 'settled'; transaction: string; network?: string; payer?: string }
- /** `errorReason:'settlement_pending'` with a transaction: broadcast, not yet confirmed. */
- | { kind: 'pending'; transaction: string }
+ /**
+ * `errorReason:'settlement_pending'`: broadcast (or queued), not yet confirmed. CDP and
+ * the Arc relay give the transaction; Circle reports pending before it has one.
+ */
+ | { kind: 'pending'; transaction?: string }
| { kind: 'rejected'; reason: string; message?: string; transaction?: string; http: number }
| { kind: 'unavailable'; cause: UnavailableCause; http?: number; detail: string };
diff --git a/packages/api/src/payments/circle-facilitator.test.ts b/packages/api/src/payments/circle-facilitator.test.ts
new file mode 100644
index 0000000..07108f6
--- /dev/null
+++ b/packages/api/src/payments/circle-facilitator.test.ts
@@ -0,0 +1,119 @@
+/**
+ * Circle Facilitator Service adapter: the request (Bearer key, x402 v2 envelope,
+ * the payment-identifier on settle), and how each answer in Circle's OpenAPI
+ * (facilitator-service.yaml) maps onto the outcome table settle.ts applies.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { CircleFacilitator, DEFAULT_CIRCLE_FACILITATOR_URL, circlePaymentId } from './circle-facilitator.js';
+import { buildRequirements, PaymentPayloadV2 } from './x402.js';
+import { classifyRejection } from './settle.js';
+
+const PAY_TO = '0x1111111111111111111111111111111111111111';
+const BUYER = '0x2222222222222222222222222222222222222222';
+const TX = '0x' + 'ab'.repeat(32);
+const REQ = buildRequirements({ task_id: 'task_abc', bounty_amount: '1000000', bounty_network: 'eip155:5042' }, PAY_TO);
+const PAYLOAD = PaymentPayloadV2.parse({
+ x402Version: 2,
+ accepted: REQ,
+ extensions: { bazaar: { info: { input: { type: 'http' } } } },
+ payload: {
+ signature: '0x' + 'cd'.repeat(65),
+ authorization: { from: BUYER, to: PAY_TO, value: '1000000', validAfter: '0', validBefore: '1800003600', nonce: '0x' + '1F'.repeat(32) },
+ },
+});
+
+type Call = { url: string; init: RequestInit };
+let calls: Call[];
+let responder: (call: Call) => Response | Promise;
+const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const call = { url: String(input), init: init ?? {} };
+ calls.push(call);
+ return responder(call);
+}) as unknown as typeof fetch;
+const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+const make = (baseUrl = DEFAULT_CIRCLE_FACILITATOR_URL) => new CircleFacilitator({ apiKey: 'LIVE_API_KEY:id:secret', baseUrl, fetchImpl });
+const body = (c: Call) => JSON.parse(String(c.init.body)) as { x402Version: number; paymentPayload: Record; paymentRequirements: Record };
+
+beforeEach(() => {
+ calls = [];
+ responder = () => json(200, { isValid: true, payer: BUYER });
+});
+
+describe('CircleFacilitator', () => {
+ it('posts the x402 v2 envelope with the API key as a Bearer token', async () => {
+ expect(await make().verify(PAYLOAD, REQ)).toEqual({ kind: 'valid', payer: BUYER });
+ expect(calls[0].url).toBe('https://api.circle.com/v1/facilitator/x402/verify');
+ expect(calls[0].init.method).toBe('POST');
+ expect((calls[0].init.headers as Record).Authorization).toBe('Bearer LIVE_API_KEY:id:secret');
+ expect(body(calls[0])).toEqual({ x402Version: 2, paymentPayload: JSON.parse(JSON.stringify(PAYLOAD)), paymentRequirements: JSON.parse(JSON.stringify(REQ)) });
+ // Arc's offer carries the transfer method Circle asks for.
+ expect(REQ.extra).toEqual({ name: 'USDC', version: '2', assetTransferMethod: 'eip3009' });
+ });
+
+ it('settles with a payment-identifier derived from the nonce, keeping the echoed extensions', async () => {
+ responder = () => json(200, { success: true, payer: BUYER, transaction: TX, network: 'eip155:5042', amount: '1000000' });
+ expect(await make('https://api-sandbox.circle.com/v1/facilitator/x402/').settle(PAYLOAD, REQ)).toEqual({ kind: 'settled', transaction: TX, network: 'eip155:5042', payer: BUYER });
+ expect(calls[0].url).toBe('https://api-sandbox.circle.com/v1/facilitator/x402/settle');
+ const ext = body(calls[0]).paymentPayload.extensions as Record;
+ expect(ext['payment-identifier']).toEqual({ info: { required: true, id: circlePaymentId(PAYLOAD) } });
+ expect(ext.bazaar).toEqual({ info: { input: { type: 'http' } } });
+ // 16–128 of [A-Za-z0-9_-], one per authorization.
+ expect(circlePaymentId(PAYLOAD)).toMatch(/^ba_[0-9a-f]{64}$/);
+ // The stored payload itself is not changed.
+ expect(PAYLOAD.extensions).toEqual({ bazaar: { info: { input: { type: 'http' } } } });
+ });
+
+ it('maps verify refusals and settle failures', async () => {
+ responder = () => json(200, { isValid: false, invalidReason: 'insufficient_funds', payer: BUYER });
+ expect(await make().verify(PAYLOAD, REQ)).toEqual({ kind: 'invalid', reason: 'insufficient_funds', payer: BUYER, http: 200 });
+ responder = () => json(200, { success: false, errorReason: 'invalid_exact_evm_payload_signature', payer: BUYER, transaction: '', network: 'eip155:5042' });
+ expect(await make().settle(PAYLOAD, REQ)).toEqual({ kind: 'rejected', reason: 'invalid_exact_evm_payload_signature', http: 200 });
+ // "Reusing a nonce that already settled returns invalid_transaction_state."
+ expect(classifyRejection('invalid_transaction_state')).toBe('nonce_used');
+ });
+
+ it('pending without a transaction is still pending, never a failure', async () => {
+ responder = () => json(200, {
+ success: false, errorReason: 'settlement_pending', payer: BUYER, transaction: '', network: 'eip155:5042',
+ extensions: { 'settlement-status': { status: 'pending', paymentId: '5b3f6c1e-9d2a-4f08-b1c7-2e9a14d0c3aa' } },
+ });
+ expect(await make().settle(PAYLOAD, REQ)).toEqual({ kind: 'pending' });
+ responder = () => json(200, { success: false, errorReason: 'settlement_pending', payer: BUYER, transaction: TX, network: 'eip155:5042' });
+ expect(await make().settle(PAYLOAD, REQ)).toEqual({ kind: 'pending', transaction: TX });
+ // A success without a hash is not believed.
+ responder = () => json(200, { success: true, payer: BUYER, transaction: '', network: 'eip155:5042' });
+ expect(await make().settle(PAYLOAD, REQ)).toMatchObject({ kind: 'unavailable', cause: 'malformed' });
+ });
+
+ it('classifies the error statuses; a 409 conflict is retried, never re-signed', async () => {
+ const cases: Array<[number, unknown, string]> = [
+ [401, { code: 401, message: 'Invalid credentials.' }, 'auth'],
+ [403, { code: 403, message: 'Registration required', errors: [{ reason: 'registration_required' }] }, 'auth'],
+ [409, { code: 409, message: 'conflict', errors: [{ reason: 'payment_identifier_conflict' }] }, 'server'],
+ [415, { code: 415, message: 'Unsupported' }, 'malformed'],
+ [429, { code: 429, message: 'slow down' }, 'rate_limited'],
+ [503, { code: 503, message: 'down' }, 'server'],
+ [400, { code: 400, message: 'mixed authentication modes' }, 'malformed'],
+ ];
+ for (const [status, payload, cause] of cases) {
+ responder = () => json(status, payload);
+ expect(await make().settle(PAYLOAD, REQ), `HTTP ${status}`).toMatchObject({ kind: 'unavailable', cause, http: status });
+ }
+ responder = () => json(403, { code: 403, message: 'Registration required', errors: [{ reason: 'registration_required' }] });
+ expect(await make().settle(PAYLOAD, REQ)).toMatchObject({ detail: 'HTTP 403 registration_required: Registration required' });
+ });
+
+ it('never throws: transport failures and non-JSON are unavailable', async () => {
+ responder = () => { throw new TypeError('connection reset'); };
+ expect(await make().verify(PAYLOAD, REQ)).toMatchObject({ kind: 'unavailable', cause: 'network' });
+ responder = () => new Response('oops', { status: 200 });
+ expect(await make().settle(PAYLOAD, REQ)).toMatchObject({ kind: 'unavailable', cause: 'malformed' });
+ });
+
+ it('reads /supported without credentials', async () => {
+ responder = () => json(200, { x402Version: 2, kinds: [{ scheme: 'exact', network: 'eip155:5042' }], signers: {} });
+ expect(await make().supported()).toMatchObject({ kinds: [{ network: 'eip155:5042' }] });
+ expect(calls[0].url).toBe('https://api.circle.com/v1/facilitator/x402/supported');
+ expect((calls[0].init.headers as Record | undefined)?.Authorization).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/payments/circle-facilitator.ts b/packages/api/src/payments/circle-facilitator.ts
new file mode 100644
index 0000000..17deae4
--- /dev/null
+++ b/packages/api/src/payments/circle-facilitator.ts
@@ -0,0 +1,192 @@
+/**
+ * Circle Facilitator Service adapter: settles Arc DEPOSITS (a buyer's EIP-3009
+ * transfer into the escrow wallet). Same contract as the CDP adapter: `verify` /
+ * `settle` never throw, and every failure is a classified outcome for settle.ts.
+ *
+ * POST {baseUrl}/verify → VerifyOutcome
+ * POST {baseUrl}/settle → SettleOutcome
+ * GET {baseUrl}/supported → raw JSON
+ *
+ * Auth is `Authorization: Bearer `. Settling with the key binds the
+ * payment's `payTo` to that Circle account, which is right for the escrow wallet
+ * and wrong for anyone else's: payouts and refunds on Arc (whose `payTo` is an
+ * agent's or a buyer's wallet) never come here; the escrow wallet sends those
+ * itself (arc.ts).
+ *
+ * Where Circle differs from CDP:
+ * - `settlement_pending` carries an empty `transaction` (the payment id is in
+ * `extensions['settlement-status']`). It maps to `pending` without a hash; the
+ * retry re-sends the identical authorization, which Circle resolves to the same
+ * payment.
+ * - Every settle carries a `payment-identifier` derived from the authorization
+ * nonce, so retries converge on one payment (idempotency per seller account).
+ * - Errors come as `{code, message, errors: [{reason}]}`.
+ */
+import { z } from 'zod';
+import type { Facilitator, SettleOutcome, UnavailableCause, VerifyOutcome } from './cdp-facilitator.js';
+import { FACILITATOR_TIMEOUT_MS, TX_HASH_RE } from './cdp-facilitator.js';
+import type { PaymentPayloadV2, PaymentRequirementsV2 } from './x402.js';
+
+export const DEFAULT_CIRCLE_FACILITATOR_URL = 'https://api.circle.com/v1/facilitator/x402';
+
+export interface CircleFacilitatorConfig {
+ apiKey: string;
+ /** e.g. DEFAULT_CIRCLE_FACILITATOR_URL or CIRCLE_FACILITATOR_URL. Trailing slashes are stripped. */
+ baseUrl: string;
+ /** Injected by tests; defaults to global fetch. */
+ fetchImpl?: typeof fetch;
+}
+
+const optStr = z.string().nullish().transform((v) => v ?? undefined);
+
+const VerifyResponse = z.object({ isValid: z.boolean(), invalidReason: optStr, payer: optStr }).passthrough();
+
+const SettleResponse = z
+ .object({
+ success: z.boolean(),
+ errorReason: optStr,
+ errorMessage: optStr,
+ payer: optStr,
+ transaction: optStr,
+ network: optStr,
+ })
+ .passthrough();
+
+const CircleError = z
+ .object({ code: z.number().optional(), message: z.string().optional(), errors: z.array(z.object({ reason: z.string() }).passthrough()).optional() })
+ .passthrough();
+
+type Unavailable = { kind: 'unavailable'; cause: UnavailableCause; http?: number; detail: string };
+type HttpBody = { kind: 'body'; http: number; json: unknown };
+
+function excerpt(text: string, limit = 200): string {
+ const compact = text.trim().replace(/\s+/g, ' ');
+ if (!compact) return '';
+ return compact.length <= limit ? compact : `${compact.slice(0, limit - 3)}...`;
+}
+
+function errorDetail(status: number, json: unknown, text: string): string {
+ const env = CircleError.safeParse(json);
+ if (env.success && (env.data.message || env.data.errors?.length)) {
+ const reasons = env.data.errors?.map((e) => e.reason).join(',');
+ return `HTTP ${status}${reasons ? ` ${reasons}` : ''}${env.data.message ? `: ${env.data.message}` : ''}`;
+ }
+ return `HTTP ${status}: ${excerpt(text)}`;
+}
+
+/**
+ * `payment-identifier` for an authorization: Circle's idempotency key. The nonce is
+ * unique per authorization, so one signed transfer is one Circle payment.
+ */
+export function circlePaymentId(payload: PaymentPayloadV2): string {
+ return `ba_${payload.payload.authorization.nonce.slice(2).toLowerCase()}`;
+}
+
+export class CircleFacilitator implements Facilitator {
+ private readonly apiKey: string;
+ private readonly baseUrl: string;
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(cfg: CircleFacilitatorConfig) {
+ this.apiKey = cfg.apiKey;
+ this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
+ new URL(this.baseUrl); // throws on garbage: the factory validates first
+ this.fetchImpl = cfg.fetchImpl ?? ((input, init) => fetch(input, init));
+ }
+
+ async verify(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ const res = await this.post('verify', payload, requirements);
+ if (res.kind === 'unavailable') return res;
+ const parsed = VerifyResponse.safeParse(res.json);
+ if (!parsed.success) {
+ return { kind: 'unavailable', cause: 'malformed', http: res.http, detail: `verify response off-shape: ${errorDetail(res.http, res.json, JSON.stringify(res.json))}` };
+ }
+ const body = parsed.data;
+ if (body.isValid) return body.payer ? { kind: 'valid', payer: body.payer } : { kind: 'valid' };
+ const out: VerifyOutcome = { kind: 'invalid', reason: body.invalidReason ?? 'unknown', http: res.http };
+ if (body.payer) out.payer = body.payer;
+ return out;
+ }
+
+ async settle(payload: PaymentPayloadV2, requirements: PaymentRequirementsV2): Promise {
+ const withId: PaymentPayloadV2 = {
+ ...payload,
+ extensions: { ...payload.extensions, 'payment-identifier': { info: { required: true, id: circlePaymentId(payload) } } },
+ };
+ const res = await this.post('settle', withId, requirements);
+ if (res.kind === 'unavailable') return res;
+ const parsed = SettleResponse.safeParse(res.json);
+ if (!parsed.success) {
+ return { kind: 'unavailable', cause: 'malformed', http: res.http, detail: `settle response off-shape: ${errorDetail(res.http, res.json, JSON.stringify(res.json))}` };
+ }
+ const body = parsed.data;
+ const tx = body.transaction && TX_HASH_RE.test(body.transaction) ? body.transaction : undefined;
+ if (body.success) {
+ if (!tx) return { kind: 'unavailable', cause: 'malformed', http: res.http, detail: `settle success without a valid transaction hash: ${excerpt(JSON.stringify(res.json))}` };
+ const out: SettleOutcome = { kind: 'settled', transaction: tx };
+ if (body.network) out.network = body.network;
+ if (body.payer) out.payer = body.payer;
+ return out;
+ }
+ // Pending is not failure: settlement continues, and the retry resolves it.
+ if (body.errorReason === 'settlement_pending') return tx ? { kind: 'pending', transaction: tx } : { kind: 'pending' };
+ const out: SettleOutcome = { kind: 'rejected', reason: body.errorReason ?? 'unknown', http: res.http };
+ if (body.errorMessage) out.message = body.errorMessage;
+ if (tx) out.transaction = tx;
+ return out;
+ }
+
+ async supported(): Promise {
+ const res = await this.fetchImpl(`${this.baseUrl}/supported`, { method: 'GET', signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS) });
+ const text = await res.text();
+ if (!res.ok) throw new Error(`Circle facilitator supported failed (${res.status}): ${excerpt(text)}`);
+ return JSON.parse(text);
+ }
+
+ /** One authenticated POST: the parsed body for 200/400, or a classified `unavailable`. */
+ private async post(
+ op: 'verify' | 'settle',
+ paymentPayload: PaymentPayloadV2,
+ paymentRequirements: PaymentRequirementsV2,
+ ): Promise {
+ let res: Response;
+ try {
+ res = await this.fetchImpl(`${this.baseUrl}/${op}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}` },
+ body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements }),
+ signal: AbortSignal.timeout(FACILITATOR_TIMEOUT_MS),
+ });
+ } catch (err) {
+ const e = err as { name?: string; message?: string };
+ const detail = e?.name === 'TimeoutError' || e?.name === 'AbortError'
+ ? `Circle facilitator ${op} timed out after ${FACILITATOR_TIMEOUT_MS}ms`
+ : `Circle facilitator ${op} fetch failed: ${e?.message ?? String(err)}`;
+ return { kind: 'unavailable', cause: 'network', detail };
+ }
+ let text = '';
+ try {
+ text = await res.text();
+ } catch (err) {
+ return { kind: 'unavailable', cause: 'network', http: res.status, detail: `Circle facilitator ${op} body read failed: ${String(err)}` };
+ }
+ let json: unknown = undefined;
+ let jsonOk = false;
+ try {
+ json = JSON.parse(text);
+ jsonOk = true;
+ } catch {
+ /* classified below */
+ }
+ const status = res.status;
+ // 403: the trial allowance (registration_required) or an amount under Circle's minimum.
+ if (status === 401 || status === 403) return { kind: 'unavailable', cause: 'auth', http: status, detail: errorDetail(status, json, text) };
+ if (status === 429) return { kind: 'unavailable', cause: 'rate_limited', http: status, detail: errorDetail(status, json, text) };
+ if (status === 415) return { kind: 'unavailable', cause: 'malformed', http: status, detail: errorDetail(status, json, text) };
+ // 409 is an idempotency conflict: no verdict on this payment, so retry and never re-sign.
+ if (status >= 500 || status === 409) return { kind: 'unavailable', cause: 'server', http: status, detail: errorDetail(status, json, text) };
+ if (status !== 200 && status !== 400) return { kind: 'unavailable', cause: 'server', http: status, detail: `unexpected ${errorDetail(status, json, text)}` };
+ if (!jsonOk) return { kind: 'unavailable', cause: 'malformed', http: status, detail: `Circle facilitator ${op} returned non-JSON: ${excerpt(text)}` };
+ return { kind: 'body', http: status, json };
+ }
+}
diff --git a/packages/api/src/payments/escrow.ts b/packages/api/src/payments/escrow.ts
index f910046..9690f7b 100644
--- a/packages/api/src/payments/escrow.ts
+++ b/packages/api/src/payments/escrow.ts
@@ -22,7 +22,7 @@
*/
import type { DBAdapter } from '../db/adapter.js';
import type { Bindings } from '../types/index.js';
-import { allowedBountyNetworks } from '../types/index.js';
+import { fundableBountyNetworks } from '../types/index.js';
import type { Actor, TaskRow, EscrowLeg } from '../tasks/service.js';
import {
loadTask, logPaymentEvent, recordFunnel, bountyView, escrowView, acceptUnpaidGate, afterAccept, type BountyView,
@@ -123,10 +123,11 @@ export async function fundEscrowTask(db: DBAdapter, env: Bindings, target: FundT
if (!bounty.amount || !isNetwork(network)) {
return { status: 409, body: { error: 'bounty_unsupported_network', message: `This bounty is on ${network || 'an unknown network'}, which cannot be settled.`, network } };
}
- // Defense-in-depth: production takes escrow deposits in mainnet USDC only; a
- // testnet deposit is refused before any house-wallet custody begins.
- if (!allowedBountyNetworks(env).includes(network)) {
- return { status: 409, body: { error: 'bounty_network_not_allowed', message: `This bounty is on ${network}, which is not settled in this environment.`, network } };
+ // Defense-in-depth: production takes escrow deposits in mainnet USDC only (a
+ // testnet deposit is refused before any house-wallet custody begins), and Arc
+ // deposits only while Circle's facilitator is configured.
+ if (!fundableBountyNetworks(env).includes(network)) {
+ return { status: 409, body: { error: 'bounty_network_not_allowed', message: `This bounty is on ${network}, which does not take deposits in this environment.`, network } };
}
}
let bountyOut = bountyView({ bounty_amount: bounty.amount, bounty_token: bounty.token, bounty_network: bounty.network }) as BountyView;
diff --git a/packages/api/src/payments/evm.test.ts b/packages/api/src/payments/evm.test.ts
new file mode 100644
index 0000000..2245138
--- /dev/null
+++ b/packages/api/src/payments/evm.test.ts
@@ -0,0 +1,75 @@
+/**
+ * EVM primitives (evm.ts): RLP against the reference vectors, and EIP-1559 signing
+ * against transactions signed by an independent implementation (eth-account 0.13.4,
+ * same key and fields): the raw bytes, and so the hashes, must match exactly.
+ */
+import { describe, it, expect } from 'vitest';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { keccak_256 } from '@noble/hashes/sha3';
+import { bigintToBytes, bytesToHex, hexToBytes, rlpEncode, signEip1559, toChecksumAddress, addressFromPrivateKey, type RlpItem } from './evm.js';
+import { rlpDecode } from './test-fixtures.js';
+
+const enc = (s: string) => new TextEncoder().encode(s);
+const hex = (b: Uint8Array) => '0x' + bytesToHex(b);
+
+describe('rlpEncode', () => {
+ it('matches the reference vectors', () => {
+ expect(hex(rlpEncode(enc('dog')))).toBe('0x83646f67');
+ expect(hex(rlpEncode([enc('cat'), enc('dog')]))).toBe('0xc88363617483646f67');
+ expect(hex(rlpEncode(new Uint8Array(0)))).toBe('0x80');
+ expect(hex(rlpEncode([]))).toBe('0xc0');
+ expect(hex(rlpEncode(bigintToBytes(0n)))).toBe('0x80');
+ expect(hex(rlpEncode(bigintToBytes(15n)))).toBe('0x0f');
+ expect(hex(rlpEncode(bigintToBytes(1024n)))).toBe('0x820400');
+ const set: RlpItem = [[], [[]], [[], [[]]]];
+ expect(hex(rlpEncode(set))).toBe('0xc7c0c1c0c3c0c1c0');
+ // 56 bytes: the first string that takes a length-of-length prefix.
+ expect(hex(rlpEncode(enc('Lorem ipsum dolor sit amet, consectetur adipisicing elit')))).toBe(
+ '0xb8384c6f72656d20697073756d20646f6c6f722073697420616d65742c20636f6e7365637465747572206164697069736963696e6720656c6974',
+ );
+ });
+});
+
+describe('signEip1559', () => {
+ // eth-account's documented test key; never holds anything.
+ const KEY = hexToBytes('4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318');
+ const tx = (nonce: bigint, data: string, gas: bigint) => ({
+ chainId: 5042n, nonce, maxPriorityFeePerGas: 2011n, maxFeePerGas: 40_000_002_011n, gas,
+ to: '0x3600000000000000000000000000000000000000', value: 0n, data,
+ });
+
+ it('produces the same transactions as eth-account', () => {
+ expect(addressFromPrivateKey(KEY)).toBe('0x2c7536E3605D9C16a7a3D7b1898e529396a65c23');
+ const simple = signEip1559(tx(0n, '0x', 21000n), KEY);
+ expect(simple.raw).toBe(
+ '0x02f86b8213b2808207db8509502f97db8252089436000000000000000000000000000000000000008080c080a00293ed1915aa89676029716025fc9e64ab4df86ceb4b5e416fbac12447711ce1a07812306b9537bd56909ee1ee721869fac765b1c6e94edb936dda94abb1945a0f',
+ );
+ expect(simple.hash).toBe('0x964b8905b6dd649ec267e9c00935375d3dc66258ec9d80676f8d8d179c348441');
+ // Long calldata (a list over 55 bytes) and a two-byte nonce.
+ expect(signEip1559(tx(7n, '0xe3ee160e' + 'ab'.repeat(288), 96543n), KEY).hash).toBe('0x7634a4b4f99ead564ba0c14110d52d84aa6e6663927dc4ae6466f95abf7807fe');
+ expect(signEip1559(tx(300n, '0x' + '01'.repeat(55), 1n), KEY).hash).toBe('0xf503bf92846d17c5656397581cfe2d646ae6ba30bd12332baf9585f3f09d8710');
+ });
+
+ it('signs for the key: the sender recovers from the signed fields', () => {
+ for (let i = 0; i < 20; i++) {
+ const key = secp256k1.utils.randomPrivateKey();
+ const { raw, hash } = signEip1559(tx(BigInt(i), '0x1234', 50_000n), key);
+ expect(hash).toBe(hex(keccak_256(hexToBytes(raw))));
+ const bytes = hexToBytes(raw);
+ expect(bytes[0]).toBe(0x02);
+ const fields = rlpDecode(bytes.slice(1)) as Uint8Array[];
+ expect(fields).toHaveLength(12);
+ const [yParity, r, s] = fields.slice(9) as Uint8Array[];
+ const digest = keccak_256(new Uint8Array([0x02, ...rlpEncode(fields.slice(0, 9) as RlpItem[])]));
+ const word = (b: Uint8Array) => { const w = new Uint8Array(32); w.set(b, 32 - b.length); return w; };
+ const pub = secp256k1.Signature.fromCompact(new Uint8Array([...word(r), ...word(s)]))
+ .addRecoveryBit(yParity.length ? yParity[0] : 0).recoverPublicKey(digest).toRawBytes(false);
+ expect(toChecksumAddress(keccak_256(pub.slice(1)).slice(12))).toBe(addressFromPrivateKey(key));
+ }
+ });
+
+ it('refuses a malformed recipient or calldata', () => {
+ expect(() => signEip1559({ ...tx(0n, '0x', 1n), to: '0x12' }, KEY)).toThrow(/to/);
+ expect(() => signEip1559(tx(0n, '0x123', 1n), KEY)).toThrow(/data/);
+ });
+});
diff --git a/packages/api/src/payments/evm.ts b/packages/api/src/payments/evm.ts
new file mode 100644
index 0000000..2200e69
--- /dev/null
+++ b/packages/api/src/payments/evm.ts
@@ -0,0 +1,136 @@
+/**
+ * EVM primitives shared by the house wallet (house-wallet.ts) and the Arc relay
+ * (arc.ts): hex/bytes, EIP-55 addresses, the house key parser, RLP and EIP-1559
+ * transaction signing.
+ *
+ * A leaf module: it imports nothing from payments/index.ts or house-wallet.ts,
+ * so the payment provider factory can build the Arc relay without an import
+ * cycle. Workers-safe: @noble only, no Buffer, no node: imports.
+ */
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { keccak_256 } from '@noble/hashes/sha3';
+
+const PRIVATE_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/;
+const utf8 = new TextEncoder();
+
+// ─── Bytes / hex ───
+
+export function hexToBytes(hex: string): Uint8Array {
+ const h = hex.startsWith('0x') ? hex.slice(2) : hex;
+ const out = new Uint8Array(h.length / 2);
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
+ return out;
+}
+
+export function bytesToHex(bytes: Uint8Array): string {
+ let s = '';
+ for (const b of bytes) s += b.toString(16).padStart(2, '0');
+ return s;
+}
+
+export function concat(...parts: Uint8Array[]): Uint8Array {
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
+ let o = 0;
+ for (const p of parts) { out.set(p, o); o += p.length; }
+ return out;
+}
+
+/** Big-endian bytes of a non-negative integer with no leading zeros (0 → empty), as RLP wants. */
+export function bigintToBytes(value: bigint): Uint8Array {
+ if (value < 0n) throw new Error('bigintToBytes: negative value');
+ if (value === 0n) return new Uint8Array(0);
+ let hex = value.toString(16);
+ if (hex.length % 2) hex = '0' + hex;
+ return hexToBytes(hex);
+}
+
+// ─── Addresses and keys ───
+
+/** EIP-55 checksummed address of a 20-byte account. */
+export function toChecksumAddress(addr20: Uint8Array | string): `0x${string}` {
+ const lower = (typeof addr20 === 'string' ? addr20.replace(/^0x/, '') : bytesToHex(addr20)).toLowerCase();
+ if (lower.length !== 40) throw new Error('address must be 20 bytes');
+ const hash = bytesToHex(keccak_256(utf8.encode(lower)));
+ let out = '0x';
+ for (let i = 0; i < 40; i++) out += parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i];
+ return out as `0x${string}`;
+}
+
+export function sameAddress(a: string, b: string): boolean {
+ return a.toLowerCase() === b.toLowerCase();
+}
+
+/** The EVM address of a secp256k1 private key (keccak of the uncompressed public key, last 20 bytes). */
+export function addressFromPrivateKey(privateKey: Uint8Array): `0x${string}` {
+ const pub = secp256k1.getPublicKey(privateKey, false); // 0x04 ‖ x ‖ y
+ return toChecksumAddress(keccak_256(pub.slice(1)).slice(12));
+}
+
+export class HouseKeyFormatError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'HouseKeyFormatError';
+ }
+}
+
+/** Parse a 64-hex (optionally 0x-prefixed) secp256k1 private key; rejects zero / out-of-range keys. */
+export function parseHousePrivateKey(raw: string): Uint8Array {
+ if (typeof raw !== 'string' || raw.trim().length === 0) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY is empty');
+ const trimmed = raw.trim();
+ if (!PRIVATE_KEY_RE.test(trimmed)) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY must be 64 hex characters (32 bytes), optionally 0x-prefixed');
+ const bytes = hexToBytes(trimmed);
+ if (!secp256k1.utils.isValidPrivateKey(bytes)) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY is not a valid secp256k1 private key');
+ return bytes;
+}
+
+// ─── RLP (Ethereum yellow paper, appendix B) ───
+
+export type RlpItem = Uint8Array | RlpItem[];
+
+function rlpLength(len: number, offset: number): Uint8Array {
+ if (len < 56) return new Uint8Array([offset + len]);
+ const lenBytes = bigintToBytes(BigInt(len));
+ return concat(new Uint8Array([offset + 55 + lenBytes.length]), lenBytes);
+}
+
+export function rlpEncode(item: RlpItem): Uint8Array {
+ if (item instanceof Uint8Array) {
+ if (item.length === 1 && item[0] < 0x80) return item;
+ return concat(rlpLength(item.length, 0x80), item);
+ }
+ const body = concat(...item.map(rlpEncode));
+ return concat(rlpLength(body.length, 0xc0), body);
+}
+
+// ─── EIP-1559 (type 2) transactions ───
+
+export interface Eip1559Tx {
+ chainId: bigint;
+ nonce: bigint;
+ maxPriorityFeePerGas: bigint;
+ maxFeePerGas: bigint;
+ gas: bigint;
+ to: string;
+ value: bigint;
+ /** 0x-prefixed calldata. */
+ data: string;
+}
+
+/**
+ * Sign a type-2 transaction with an empty access list. Returns the raw
+ * transaction for `eth_sendRawTransaction` and its hash (keccak of the raw bytes).
+ */
+export function signEip1559(tx: Eip1559Tx, privateKey: Uint8Array): { raw: `0x${string}`; hash: `0x${string}` } {
+ if (!/^0x[0-9a-fA-F]{40}$/.test(tx.to)) throw new Error('signEip1559: `to` is not an address');
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(tx.data)) throw new Error('signEip1559: `data` is not hex');
+ const fields: RlpItem[] = [
+ bigintToBytes(tx.chainId), bigintToBytes(tx.nonce), bigintToBytes(tx.maxPriorityFeePerGas), bigintToBytes(tx.maxFeePerGas),
+ bigintToBytes(tx.gas), hexToBytes(tx.to), bigintToBytes(tx.value), hexToBytes(tx.data), [],
+ ];
+ const digest = keccak_256(concat(new Uint8Array([0x02]), rlpEncode(fields)));
+ const sig = secp256k1.sign(digest, privateKey, { lowS: true });
+ const signed = concat(new Uint8Array([0x02]), rlpEncode([
+ ...fields, bigintToBytes(BigInt(sig.recovery)), bigintToBytes(sig.r), bigintToBytes(sig.s),
+ ]));
+ return { raw: `0x${bytesToHex(signed)}`, hash: `0x${bytesToHex(keccak_256(signed))}` };
+}
diff --git a/packages/api/src/payments/house-wallet.ts b/packages/api/src/payments/house-wallet.ts
index 745e06b..a0bb2d4 100644
--- a/packages/api/src/payments/house-wallet.ts
+++ b/packages/api/src/payments/house-wallet.ts
@@ -19,39 +19,20 @@
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { keccak_256 } from '@noble/hashes/sha3';
+import { addressFromPrivateKey, bytesToHex, concat, hexToBytes, parseHousePrivateKey, toChecksumAddress } from './evm.js';
import type { Bindings } from '../types/index.js';
import { paymentProviderFor, paymentsDisabledReason, type PaymentsEnv } from './index.js';
import {
assetFor, isNetwork, PaymentPayloadV2, type PaymentRequirementsV2, MAX_TIMEOUT_SECONDS, type X402Env,
} from './x402.js';
+// The key parser and address helpers live in the leaf module evm.ts (shared with the Arc relay).
+export { addressFromPrivateKey, toChecksumAddress, parseHousePrivateKey, HouseKeyFormatError, sameAddress } from './evm.js';
+
export type EscrowEnv = PaymentsEnv & Pick & X402Env;
-const PRIVATE_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/;
const utf8 = new TextEncoder();
-// ─── Bytes / hex ───
-
-function hexToBytes(hex: string): Uint8Array {
- const h = hex.startsWith('0x') ? hex.slice(2) : hex;
- const out = new Uint8Array(h.length / 2);
- for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
- return out;
-}
-
-function bytesToHex(bytes: Uint8Array): string {
- let s = '';
- for (const b of bytes) s += b.toString(16).padStart(2, '0');
- return s;
-}
-
-function concat(...parts: Uint8Array[]): Uint8Array {
- const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
- let o = 0;
- for (const p of parts) { out.set(p, o); o += p.length; }
- return out;
-}
-
/** A 32-byte ABI word: big-endian uint256 / left-padded address / bytes32. */
function word(value: bigint | Uint8Array): Uint8Array {
const out = new Uint8Array(32);
@@ -67,28 +48,6 @@ function word(value: bigint | Uint8Array): Uint8Array {
return out;
}
-// ─── Addresses ───
-
-/** EIP-55 checksummed address of a 20-byte account. */
-export function toChecksumAddress(addr20: Uint8Array | string): `0x${string}` {
- const lower = (typeof addr20 === 'string' ? addr20.replace(/^0x/, '') : bytesToHex(addr20)).toLowerCase();
- if (lower.length !== 40) throw new Error('address must be 20 bytes');
- const hash = bytesToHex(keccak_256(utf8.encode(lower)));
- let out = '0x';
- for (let i = 0; i < 40; i++) out += parseInt(hash[i], 16) >= 8 ? lower[i].toUpperCase() : lower[i];
- return out as `0x${string}`;
-}
-
-/** The EVM address of a secp256k1 private key (keccak of the uncompressed public key, last 20 bytes). */
-export function addressFromPrivateKey(privateKey: Uint8Array): `0x${string}` {
- const pub = secp256k1.getPublicKey(privateKey, false); // 0x04 ‖ x ‖ y
- return toChecksumAddress(keccak_256(pub.slice(1)).slice(12));
-}
-
-export function sameAddress(a: string, b: string): boolean {
- return a.toLowerCase() === b.toLowerCase();
-}
-
// ─── EIP-712 TransferWithAuthorization (USDC / EIP-3009) ───
const DOMAIN_TYPEHASH = keccak_256(utf8.encode('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'));
@@ -160,23 +119,6 @@ export interface HouseWallet {
signTransfer(requirements: PaymentRequirementsV2, nowSec: number, nonce?: string): PaymentPayloadV2;
}
-export class HouseKeyFormatError extends Error {
- constructor(message: string) {
- super(message);
- this.name = 'HouseKeyFormatError';
- }
-}
-
-/** Parse a 64-hex (optionally 0x-prefixed) secp256k1 private key; rejects zero / out-of-range keys. */
-export function parseHousePrivateKey(raw: string): Uint8Array {
- if (typeof raw !== 'string' || raw.trim().length === 0) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY is empty');
- const trimmed = raw.trim();
- if (!PRIVATE_KEY_RE.test(trimmed)) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY must be 64 hex characters (32 bytes), optionally 0x-prefixed');
- const bytes = hexToBytes(trimmed);
- if (!secp256k1.utils.isValidPrivateKey(bytes)) throw new HouseKeyFormatError('ESCROW_WALLET_PRIVATE_KEY is not a valid secp256k1 private key');
- return bytes;
-}
-
/** Build a signer around a private key. The key is captured in a closure and never exposed. */
export function houseWalletFromPrivateKey(privateKey: Uint8Array): HouseWallet {
const address = addressFromPrivateKey(privateKey);
diff --git a/packages/api/src/payments/index.test.ts b/packages/api/src/payments/index.test.ts
index 0227b47..9bb9874 100644
--- a/packages/api/src/payments/index.test.ts
+++ b/packages/api/src/payments/index.test.ts
@@ -14,6 +14,7 @@ import {
type PaymentsEnv,
} from './index.js';
import { CdpFacilitator } from './cdp-facilitator.js';
+import { ArcFacilitator, NetworkRouter } from './arc.js';
import { bytesToBase64, buildRequirements, PaymentPayloadV2 } from './x402.js';
const seed = utils.randomPrivateKey();
@@ -46,11 +47,15 @@ afterEach(() => {
});
describe('paymentProviderFor', () => {
- it('returns a CdpFacilitator when every piece is valid, memoised per config', () => {
+ it('returns a network router over CDP when every piece is valid, memoised per config', () => {
const p = paymentProviderFor(VALID);
- expect(p).toBeInstanceOf(CdpFacilitator);
+ expect(p).toBeInstanceOf(NetworkRouter);
+ expect((p as NetworkRouter).fallback).toBeInstanceOf(CdpFacilitator);
+ expect((p as NetworkRouter).routes['eip155:5042']).toBeInstanceOf(ArcFacilitator);
expect(paymentProviderFor({ ...VALID })).toBe(p);
expect(paymentProviderFor({ ...VALID, X402_FACILITATOR_URL: 'https://staging.example.com/x402' })).not.toBe(p);
+ // The Arc pieces are part of the config too.
+ expect(paymentProviderFor({ ...VALID, CIRCLE_API_KEY: 'k' })).not.toBe(p);
expect(paymentsDisabledReason(VALID)).toBeNull();
expect(console.log).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
@@ -126,6 +131,30 @@ describe('paymentProviderFor', () => {
await prod.verify(payload, req);
expect(seen).toEqual(['https://staging.example.com/x402/verify', 'https://api.cdp.coinbase.com/platform/v2/x402/verify']);
});
+
+ it('sends an Arc deposit to Circle with the API key, and Base to CDP', async () => {
+ const seen: Array<{ url: string; auth: string | null }> = [];
+ vi.stubGlobal('fetch', vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
+ seen.push({ url: String(url), auth: new Headers(init?.headers).get('Authorization') });
+ return new Response(JSON.stringify({ isValid: true, payer: '0x' + '2'.repeat(40) }), { status: 200 });
+ }));
+ const paymentFor = (network: 'eip155:5042' | 'eip155:8453') => {
+ const req = buildRequirements({ task_id: 't', bounty_amount: '1', bounty_network: network }, '0x' + '1'.repeat(40));
+ return [PaymentPayloadV2.parse({
+ x402Version: 2, accepted: req,
+ payload: { signature: '0x' + 'ab'.repeat(65), authorization: { from: '0x' + '2'.repeat(40), to: '0x' + '1'.repeat(40), value: '1', validAfter: '0', validBefore: '1', nonce: '0x' + '0'.repeat(64) } },
+ }), req] as const;
+ };
+ // No Circle key: Arc deposits are off, and nothing is sent anywhere.
+ expect(await paymentProviderFor(VALID)!.verify(...paymentFor('eip155:5042'))).toMatchObject({ kind: 'unavailable', cause: 'auth' });
+ expect(seen).toEqual([]);
+ const withCircle = paymentProviderFor({ ...VALID, CIRCLE_API_KEY: 'LIVE_API_KEY:abc:def' })!;
+ expect(await withCircle.verify(...paymentFor('eip155:5042'))).toMatchObject({ kind: 'valid' });
+ await withCircle.verify(...paymentFor('eip155:8453'));
+ expect(seen[0]).toEqual({ url: 'https://api.circle.com/v1/facilitator/x402/verify', auth: 'Bearer LIVE_API_KEY:abc:def' });
+ expect(seen[1].url).toBe('https://api.cdp.coinbase.com/platform/v2/x402/verify');
+ expect(seen[1].auth).not.toContain('LIVE_API_KEY');
+ });
});
describe('setPaymentProviderForTests', () => {
@@ -151,7 +180,7 @@ describe('setPaymentProviderForTests', () => {
setPaymentProviderForTests(fake);
expect(paymentProviderFor(undefined)).toBe(fake);
setPaymentProviderForTests(undefined);
- expect(paymentProviderFor(VALID)).toBeInstanceOf(CdpFacilitator);
+ expect(paymentProviderFor(VALID)).toBeInstanceOf(NetworkRouter);
expect(paymentProviderFor(undefined)).toBeNull();
expect(console.log).toHaveBeenCalledTimes(2);
});
diff --git a/packages/api/src/payments/index.ts b/packages/api/src/payments/index.ts
index c655f03..b7357ae 100644
--- a/packages/api/src/payments/index.ts
+++ b/packages/api/src/payments/index.ts
@@ -1,6 +1,10 @@
/**
* Payment provider factory — the fail-closed switch (spec N6).
*
+ * The provider routes by network: CDP settles Base and Polygon; Arc (arc.ts) settles
+ * buyers' deposits through Circle (CIRCLE_API_KEY) and the escrow wallet's own payouts
+ * and refunds through its relay (ESCROW_WALLET_PRIVATE_KEY, ARC_RPC_URL).
+ *
* `paymentProviderFor(env)` returns a `Facilitator` ONLY when every piece is
* present and well-formed:
* - TASK_PAYMENTS_ENABLED === '1' (var; absent by default)
@@ -17,17 +21,20 @@
*/
import type { Bindings } from '../types/index.js';
+import { ARC_NETWORK } from '../types/index.js';
import { CdpFacilitator, DEFAULT_FACILITATOR_URL, type Facilitator } from './cdp-facilitator.js';
import { parseEd25519Secret } from './cdp-jwt.js';
+import { arcFacilitatorFor, NetworkRouter } from './arc.js';
export type { Facilitator, VerifyOutcome, SettleOutcome, UnavailableCause } from './cdp-facilitator.js';
/** Reported to CDP in Correlation-Context; bump with notable payment-path changes. */
-export const PAYMENTS_SOURCE_VERSION = '0.5.0';
+export const PAYMENTS_SOURCE_VERSION = '0.6.0';
export type PaymentsEnv = Pick<
Bindings,
'TASK_PAYMENTS_ENABLED' | 'CDP_API_KEY_ID' | 'CDP_API_KEY_SECRET' | 'PAYMENT_ENCRYPTION_KEY' | 'X402_FACILITATOR_URL'
+ | 'CIRCLE_API_KEY' | 'CIRCLE_FACILITATOR_URL' | 'ESCROW_WALLET_PRIVATE_KEY' | 'ARC_RPC_URL'
>;
const HEX_KEY_RE = /^[0-9a-fA-F]{64}$/;
@@ -37,7 +44,7 @@ let testOverride: Facilitator | null | undefined = undefined;
/** The last disabled reason logged by this isolate: one line per distinct reason. */
let lastLoggedReason: string | null = null;
/** Single-entry memo so a hot Worker does not re-derive the Ed25519 public key per request. */
-let memo: { keyId: string; secret: string; baseUrl: string; provider: Facilitator } | null = null;
+let memo: { fingerprint: string; provider: Facilitator } | null = null;
/**
* Why payments are disabled for this env, or null when fully configured.
@@ -85,11 +92,16 @@ export function paymentProviderFor(env: PaymentsEnv | undefined | null): Facilit
const keyId = env!.CDP_API_KEY_ID!;
const secret = env!.CDP_API_KEY_SECRET!;
const baseUrl = env!.X402_FACILITATOR_URL || DEFAULT_FACILITATOR_URL;
- if (memo && memo.keyId === keyId && memo.secret === secret && memo.baseUrl === baseUrl) {
- return memo.provider;
- }
- const provider = new CdpFacilitator({ keyId, secret, baseUrl, sourceVersion: PAYMENTS_SOURCE_VERSION });
- memo = { keyId, secret, baseUrl, provider };
+ const fingerprint = JSON.stringify([
+ keyId, secret, baseUrl, env!.CIRCLE_API_KEY ?? '', env!.CIRCLE_FACILITATOR_URL ?? '', env!.ESCROW_WALLET_PRIVATE_KEY ?? '', env!.ARC_RPC_URL ?? '',
+ ]);
+ if (memo && memo.fingerprint === fingerprint) return memo.provider;
+ // CDP settles Base and Polygon; Arc goes to Circle (deposits) or the escrow wallet's own relay (payouts, refunds).
+ const provider = new NetworkRouter(
+ new CdpFacilitator({ keyId, secret, baseUrl, sourceVersion: PAYMENTS_SOURCE_VERSION }),
+ { [ARC_NETWORK]: arcFacilitatorFor(env!) },
+ );
+ memo = { fingerprint, provider };
return provider;
}
diff --git a/packages/api/src/payments/settle.test.ts b/packages/api/src/payments/settle.test.ts
index dab3716..bc36143 100644
--- a/packages/api/src/payments/settle.test.ts
+++ b/packages/api/src/payments/settle.test.ts
@@ -9,7 +9,7 @@ import { setupTestDb, createTestAgent, type TestKeypair } from '../test-helpers.
import type { SQLiteAdapter } from '../db/sqlite-adapter.js';
import type { Bindings } from '../types/index.js';
import type { SettleOutcome } from './cdp-facilitator.js';
-import { settleTask, applySettleOutcome, settleBackoffMs, type SettleTrigger } from './settle.js';
+import { settleTask, applySettleOutcome, settleBackoffMs, wireSettleResponse, type SettleTrigger } from './settle.js';
import { drainOutbox } from '../events/service.js';
import { setPaymentProviderForTests } from './index.js';
import { encryptPaymentSignature } from './crypto.js';
@@ -277,6 +277,18 @@ describe('payments/settle.ts', () => {
expect(webhookEvents()).toEqual([]);
});
+ it("{kind:'pending'} without a tx (Circle) keeps the tx an earlier attempt recorded", async () => {
+ const outcome: SettleOutcome = { kind: 'pending' };
+ const { result, row: r, events: ev } = await runWith(outcome, {
+ payment_status: 'settling', payment_tx_hash: PRIOR_TX, settle_attempts: 1, settle_broadcast: 1, settle_next_at: NOW,
+ });
+ expect(result).toEqual({ skipped: false, payment_status: 'settling', tx_hash: PRIOR_TX, error: 'settlement_pending', facilitator: outcome });
+ expect(r).toMatchObject({ payment_status: 'settling', payment_tx_hash: PRIOR_TX, last_settle_class: 'pending' });
+ expectNear(r.settle_next_at, isoPlus(NOW, 2 * 60_000));
+ expect(ev[0].details).toEqual({ transaction: null, trigger: 'accept' });
+ expect(wireSettleResponse(outcome, 'eip155:5042')).toEqual({ success: false, errorReason: 'settlement_pending', transaction: '', network: 'eip155:5042' });
+ });
+
it('rejected duplicate_settlement → settled (inferred) with the reported tx', async () => {
const outcome: SettleOutcome = { kind: 'rejected', reason: 'duplicate_settlement', transaction: TEST_TX, http: 400 };
const { result, row: r, events: ev, taskId } = await runWith(outcome);
diff --git a/packages/api/src/payments/settle.ts b/packages/api/src/payments/settle.ts
index b08fb79..4253495 100644
--- a/packages/api/src/payments/settle.ts
+++ b/packages/api/src/payments/settle.ts
@@ -124,7 +124,8 @@ export type RejectionClass = 'expired' | 'insufficient' | 'nonce_used' | 'duplic
/** Classify a STRUCTURED facilitator `errorReason` (never a transport message). */
export function classifyRejection(reason: string): RejectionClass {
if (reason === 'duplicate_settlement') return 'duplicate';
- if (reason === 'invalid_exact_evm_nonce_already_used') return 'nonce_used';
+ // Circle's name for it: "Reusing a nonce that already settled returns invalid_transaction_state".
+ if (reason === 'invalid_exact_evm_nonce_already_used' || reason === 'invalid_transaction_state') return 'nonce_used';
if (reason === 'invalid_exact_evm_payload_authorization_valid_before' || reason.endsWith('_valid_before') || reason.endsWith('_deadline_expired')) return 'expired';
if (reason === 'insufficient_funds' || reason === 'invalid_exact_evm_insufficient_balance' || reason === 'invalid_exact_evm_insufficient_funds') return 'insufficient';
if (TERMINAL_REASONS.has(reason)) return 'terminal';
@@ -137,7 +138,7 @@ export function wireSettleResponse(o: SettleOutcome, network: string | null): Re
case 'settled':
return { success: true, transaction: o.transaction, network: o.network ?? network, payer: o.payer };
case 'pending':
- return { success: false, errorReason: 'settlement_pending', transaction: o.transaction, network };
+ return { success: false, errorReason: 'settlement_pending', transaction: o.transaction ?? '', network };
case 'rejected':
return { success: false, errorReason: o.reason, errorMessage: o.message, transaction: o.transaction ?? '', network };
case 'unavailable':
@@ -385,14 +386,15 @@ export async function applySettleOutcome(
case 'settled':
return settled(outcome.transaction);
case 'pending': {
+ // Circle reports pending before it has a hash: keep any hash an earlier attempt saw.
const res = await db.run(
- `UPDATE tasks SET payment_tx_hash = ?, settle_next_at = ?, last_settle_error = 'settlement_pending', last_settle_class = 'pending'
+ `UPDATE tasks SET payment_tx_hash = COALESCE(?, payment_tx_hash), settle_next_at = ?, last_settle_error = 'settlement_pending', last_settle_class = 'pending'
WHERE task_id = ? AND payment_status = 'settling'`,
- outcome.transaction, isoPlus(nowIso, 2 * 60_000), taskId,
+ outcome.transaction ?? null, isoPlus(nowIso, 2 * 60_000), taskId,
);
if (res.changes !== 1) return { skipped: true, reason: 'not_due' };
- await logPaymentEvent(db, taskId, 'settle_pending', { transaction: outcome.transaction, trigger }, nowIso);
- return { skipped: false, payment_status: 'settling', tx_hash: outcome.transaction, error: 'settlement_pending', facilitator: outcome };
+ await logPaymentEvent(db, taskId, 'settle_pending', { transaction: outcome.transaction ?? null, trigger }, nowIso);
+ return { skipped: false, payment_status: 'settling', tx_hash: outcome.transaction ?? task.payment_tx_hash ?? null, error: 'settlement_pending', facilitator: outcome };
}
case 'rejected': {
const cls = classifyRejection(outcome.reason);
diff --git a/packages/api/src/payments/test-fixtures.ts b/packages/api/src/payments/test-fixtures.ts
index 3f3968d..329a735 100644
--- a/packages/api/src/payments/test-fixtures.ts
+++ b/packages/api/src/payments/test-fixtures.ts
@@ -5,6 +5,9 @@
import type { Facilitator, VerifyOutcome, SettleOutcome } from './cdp-facilitator.js';
import { setPaymentProviderForTests } from './index.js';
import { encodeB64Json, type PaymentRequirementsV2, type PaymentPayloadV2 } from './x402.js';
+import { keccak_256 } from '@noble/hashes/sha3';
+import { bytesToHex, hexToBytes, type RlpItem } from './evm.js';
+import { AUTHORIZATION_USED_TOPIC } from './arc.js';
export const TEST_WALLET = '0x' + '1'.repeat(40);
export const TEST_PAYER = '0x' + '2'.repeat(40);
@@ -101,3 +104,221 @@ export function paymentHeaderFor(
): string {
return encodeB64Json(paymentPayloadFor(requirements, nowSec, overrides));
}
+
+type ArcRpcCall = { method: string; params: unknown[] };
+
+/** Minimal RLP decoder (tests only): strings → bytes, lists → arrays. */
+export function rlpDecode(input: Uint8Array): RlpItem {
+ const read = (at: number): [RlpItem, number] => {
+ const b = input[at];
+ const int = (from: number, len: number) => input.slice(from, from + len).reduce((n, x) => n * 256 + x, 0);
+ if (b < 0x80) return [input.slice(at, at + 1), at + 1];
+ if (b <= 0xb7) return [input.slice(at + 1, at + 1 + b - 0x80), at + 1 + b - 0x80];
+ if (b <= 0xbf) { const ll = b - 0xb7; const len = int(at + 1, ll); return [input.slice(at + 1 + ll, at + 1 + ll + len), at + 1 + ll + len]; }
+ const ll = b <= 0xf7 ? 0 : b - 0xf7;
+ const len = b <= 0xf7 ? b - 0xc0 : int(at + 1, ll);
+ const end = at + 1 + ll + len;
+ const out: RlpItem[] = [];
+ for (let p = at + 1 + ll; p < end;) { const [item, next] = read(p); out.push(item); p = next; }
+ return [out, end];
+ };
+ return read(0)[0];
+}
+
+const toBig = (b: Uint8Array) => (b.length ? BigInt('0x' + bytesToHex(b)) : 0n);
+const hexOf = (n: bigint) => '0x' + n.toString(16);
+
+/** The authorization a transferWithAuthorization calldata carries (lowercase), or null for other calldata. */
+export function authorizationInCalldata(data: string): { from: string; to: string; value: bigint; validBefore: bigint; nonce: string } | null {
+ const d = data.replace(/^0x/, '').toLowerCase();
+ if (!d.startsWith('e3ee160e')) return null;
+ const w = (i: number) => d.slice(8 + i * 64, 8 + (i + 1) * 64);
+ return { from: '0x' + w(0).slice(24), to: '0x' + w(1).slice(24), value: BigInt('0x' + w(2)), validBefore: BigInt('0x' + w(4)), nonce: '0x' + w(5) };
+}
+
+/** A decoded type-2 transaction from the escrow wallet. */
+export interface FakeArcTx { hash: string; raw: string; nonce: bigint; data: string }
+
+/** Decode a signed type-2 transaction (0x02 ‖ rlp): its hash, nonce and calldata. */
+export function decodeArcTx(raw: string): FakeArcTx {
+ const bytes = hexToBytes(raw);
+ const fields = rlpDecode(bytes.slice(1)) as Uint8Array[];
+ return { hash: '0x' + bytesToHex(keccak_256(bytes)), raw, nonce: toBig(fields[1]), data: '0x' + bytesToHex(fields[7]) };
+}
+
+/**
+ * A fake Arc node for the relay (payments/arc.ts), keeping the state the relay reads:
+ * - the escrow wallet's nonces: `latest` counts mined transactions, `pending` adds the mempool,
+ * and a broadcast on a nonce already taken is refused like a real node refuses it;
+ * - USDC's authorization state, changed only when a transaction is MINED (and only when its
+ * authorization is unused and unexpired at that block), with its AuthorizationUsed log;
+ * - blocks two a second, so every block number has a timestamp, and the chain's clock is
+ * `nowSec` (the head's timestamp), moved on with `advance()`;
+ * - eth_getLogs honours fromBlock/toBlock and refuses more than 5,000 blocks, as Arc's nodes do;
+ * - eth_call at a block number it hasn't reached answers "header not found".
+ * With `autoMine`, the mempool is mined on the receipt poll after `mineAfterPolls` polls;
+ * otherwise only `mine()` mines it.
+ */
+export class FakeArcNode {
+ calls: ArcRpcCall[] = [];
+ /** authorizer:nonce (lowercase) → the transaction that used it and its block. */
+ used = new Map();
+ logs: Array<{ topics: string[]; transactionHash: string; blockNumber: bigint }> = [];
+ mempool: FakeArcTx[] = [];
+ receipts = new Map();
+ /** Raw transactions the node accepted, in order. */
+ sent: string[] = [];
+ latestNonce = 4n;
+ baseFee = 20_000_000_000n;
+ tip = 2000n;
+ balance = 100n * 10n ** 18n; // 100 USDC of native balance
+ head = 25_000_000n;
+ autoMine = true;
+ mineAfterPolls = 0;
+ estimateError: string | null = null;
+ sendError: string | null = null;
+ /** Mined escrow transfers revert (e.g. the recipient was blocklisted after the simulation). */
+ revertOnMine = false;
+ /** eth_call pinned to a block number errors, as a node a block behind does. */
+ lagPinned = false;
+ /** Receipt reads error (after mining, which still happens). */
+ failReceipts = false;
+ /** eth_getLogs errors. */
+ failLogs = false;
+ /** eth_getLogs answers, but without the AuthorizationUsed logs (as if they were outside the window). */
+ hideLogs = false;
+ /** Called with the transaction about to enter the mempool, before the node checks its nonce. */
+ beforeSend?: (tx: FakeArcTx) => void;
+ /** Called on each receipt poll, before the node answers it. */
+ onReceiptPoll?: (hash: string) => void;
+ private readonly genesisTs: bigint;
+ private polls = 0;
+
+ constructor(nowSec: number = Math.floor(Date.now() / 1000)) {
+ this.genesisTs = BigInt(nowSec) - this.head / 2n;
+ }
+
+ timestampOf(block: bigint): bigint { return this.genesisTs + block / 2n; }
+ get nowSec(): bigint { return this.timestampOf(this.head); }
+ /** Let `seconds` pass on the chain. */
+ advance(seconds: number): void { this.head += BigInt(seconds) * 2n; }
+ /** The block whose timestamp is `ts` (the first of its two). */
+ blockOf(ts: bigint): bigint { return (ts - this.genesisTs) * 2n; }
+
+ /** Mark an authorization used by `hash` at `block`, with its log. */
+ markUsed(authorizer: string, nonce: string, hash: string, block: bigint): void {
+ this.used.set(`${authorizer}:${nonce}`.toLowerCase(), { hash, block });
+ this.logs.push({ topics: [AUTHORIZATION_USED_TOPIC, '0x' + authorizer.slice(2).toLowerCase().padStart(64, '0'), nonce.toLowerCase()], transactionHash: hash, blockNumber: block });
+ }
+
+ /** Mine the mempool in nonce order, one block per transaction. */
+ mine(): void {
+ this.mempool.sort((a, b) => (a.nonce < b.nonce ? -1 : a.nonce > b.nonce ? 1 : 0));
+ while (this.mempool.length && this.mempool[0].nonce === this.latestNonce) {
+ const tx = this.mempool.shift()!;
+ this.head += 1n;
+ const a = authorizationInCalldata(tx.data);
+ let ok = true;
+ if (a) {
+ ok = !this.revertOnMine && !this.used.has(`${a.from}:${a.nonce}`) && this.timestampOf(this.head) < a.validBefore;
+ if (ok) this.markUsed(a.from, a.nonce, tx.hash, this.head);
+ }
+ this.receipts.set(tx.hash, { status: ok ? '0x1' : '0x0', blockNumber: this.head });
+ this.latestNonce += 1n;
+ }
+ }
+
+ /** Forget a pending transaction, as a node that dropped it would. */
+ drop(hash: string): void {
+ this.mempool = this.mempool.filter((t) => t.hash !== hash);
+ }
+
+ /** Put a transaction of the escrow wallet's straight into the mempool (another payout's, say). */
+ inject(tx: FakeArcTx): void {
+ this.mempool.push(tx);
+ }
+
+ private block(n: bigint) {
+ return { number: hexOf(n), timestamp: hexOf(this.timestampOf(n)), baseFeePerGas: hexOf(this.baseFee) };
+ }
+
+ handle(req: ArcRpcCall): unknown {
+ this.calls.push(req);
+ const p = req.params;
+ switch (req.method) {
+ case 'eth_call': {
+ const data = (p[0] as { data: string }).data.toLowerCase();
+ const tag = p[1] as string;
+ let at = this.head;
+ if (tag !== 'latest') {
+ at = BigInt(tag);
+ if (at > this.head || this.lagPinned) throw { code: -32000, message: 'header not found' };
+ }
+ const key = `0x${data.slice(10 + 24, 10 + 64)}:0x${data.slice(10 + 64, 10 + 128)}`;
+ const use = this.used.get(key);
+ return '0x' + (use && use.block <= at ? '1' : '0').padStart(64, '0');
+ }
+ case 'eth_getTransactionCount':
+ return hexOf(p[1] === 'pending' ? this.latestNonce + BigInt(this.mempool.length) : this.latestNonce);
+ case 'eth_estimateGas': {
+ if (this.estimateError) throw { code: 3, message: this.estimateError };
+ const a = authorizationInCalldata((p[0] as { data: string }).data);
+ if (a && this.used.has(`${a.from}:${a.nonce}`)) throw { code: 3, message: 'execution reverted: FiatTokenV2: authorization is used or canceled' };
+ if (a && this.timestampOf(this.head + 1n) >= a.validBefore) throw { code: 3, message: 'execution reverted: FiatTokenV2: authorization is expired' };
+ return hexOf(80_000n);
+ }
+ case 'eth_getBlockByNumber': {
+ const tag = p[0] as string;
+ if (tag === 'latest') return this.block(this.head);
+ const n = BigInt(tag);
+ return n <= this.head && n >= 0n ? this.block(n) : null;
+ }
+ case 'eth_maxPriorityFeePerGas': return hexOf(this.tip);
+ case 'eth_getBalance': return hexOf(this.balance);
+ case 'eth_sendRawTransaction': {
+ if (this.sendError) throw { code: -32000, message: this.sendError };
+ const tx = decodeArcTx(p[0] as string);
+ this.beforeSend?.(tx);
+ if (this.receipts.has(tx.hash) || this.mempool.some((t) => t.hash === tx.hash)) throw { code: -32000, message: 'already known' };
+ if (tx.nonce < this.latestNonce) throw { code: -32000, message: 'nonce too low' };
+ if (this.mempool.some((t) => t.nonce === tx.nonce)) throw { code: -32000, message: 'replacement transaction underpriced' };
+ this.mempool.push(tx);
+ this.sent.push(tx.raw);
+ return tx.hash;
+ }
+ case 'eth_getTransactionReceipt': {
+ const hash = p[0] as string;
+ this.onReceiptPoll?.(hash);
+ if (!this.receipts.has(hash) && this.autoMine && this.mempool.some((t) => t.hash === hash) && this.polls++ >= this.mineAfterPolls) this.mine();
+ if (this.failReceipts) throw { code: -32000, message: 'receipt unavailable' };
+ const r = this.receipts.get(hash);
+ return r ? { transactionHash: hash, status: r.status, blockNumber: hexOf(r.blockNumber) } : null;
+ }
+ case 'eth_getLogs': {
+ if (this.failLogs) throw { code: -32000, message: 'logs unavailable' };
+ if (this.hideLogs) return [];
+ const q = p[0] as { fromBlock: string; toBlock: string; topics: string[] };
+ const from = BigInt(q.fromBlock);
+ const to = BigInt(q.toBlock);
+ if (to - from + 1n > 5_000n) throw { code: -32000, message: 'requested range too large' };
+ return this.logs
+ .filter((l) => l.blockNumber >= from && l.blockNumber <= to && l.topics.every((t, i) => t.toLowerCase() === q.topics[i]?.toLowerCase()))
+ .map((l) => ({ transactionHash: l.transactionHash, blockNumber: hexOf(l.blockNumber), topics: l.topics }));
+ }
+ default: throw { code: -32601, message: `method ${req.method} not found` };
+ }
+ }
+
+ fetch(down: string[] = []): typeof fetch {
+ return (async (input: string | URL | Request, init?: RequestInit) => {
+ const url = String(input);
+ if (down.includes(url)) return new Response('busy', { status: 503 });
+ const req = JSON.parse(String(init?.body)) as ArcRpcCall;
+ try {
+ return Response.json({ jsonrpc: '2.0', id: 1, result: this.handle(req) });
+ } catch (err) {
+ return Response.json({ jsonrpc: '2.0', id: 1, error: err });
+ }
+ }) as typeof fetch;
+ }
+}
diff --git a/packages/api/src/payments/x402.ts b/packages/api/src/payments/x402.ts
index cf634b4..8c5b4b7 100644
--- a/packages/api/src/payments/x402.ts
+++ b/packages/api/src/payments/x402.ts
@@ -40,7 +40,7 @@ export const TASK_RESOURCE_BASE = 'https://api.basedagents.ai/v1/tasks';
// ─── Networks and assets (N12) ───
-export const NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137'] as const;
+export const NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137', 'eip155:5042'] as const;
export type Network = (typeof NETWORKS)[number];
export function isNetwork(v: unknown): v is Network {
@@ -50,8 +50,11 @@ export function isNetwork(v: unknown): v is Network {
export interface AssetInfo {
asset: `0x${string}`;
chainId: number;
- /** EIP-712 domain (name, version) of the USDC contract on that chain. */
- defaultExtra: { name: string; version: string };
+ /**
+ * EIP-712 domain (name, version) of the USDC contract on that chain, plus the
+ * transfer method where the facilitator asks for it (Circle's, on Arc).
+ */
+ defaultExtra: { name: string; version: string; assetTransferMethod?: 'eip3009' };
}
export const ASSETS: Record = {
@@ -76,6 +79,15 @@ export const ASSETS: Record = {
chainId: 137,
defaultExtra: { name: 'USD Coin', version: '2' },
},
+ // USDC on Arc mainnet: the ERC-20 interface of Arc's native gas token (6 decimals;
+ // the native balance uses 18). Domain read on-chain (name() "USDC", version() "2",
+ // DOMAIN_SEPARATOR() recomputed and matched). Settled by Circle's Facilitator
+ // Service, which expects `assetTransferMethod` in `extra`.
+ 'eip155:5042': {
+ asset: '0x3600000000000000000000000000000000000000',
+ chainId: 5042,
+ defaultExtra: { name: 'USDC', version: '2', assetTransferMethod: 'eip3009' },
+ },
};
export type X402Env = Pick;
@@ -87,7 +99,7 @@ export type X402Env = Pick
export function assetFor(
network: Network,
env?: X402Env | null,
-): { asset: `0x${string}`; extra: { name: string; version: string } } {
+): { asset: `0x${string}`; extra: AssetInfo['defaultExtra'] } {
const info = ASSETS[network];
if (!info) throw new Error(`unsupported network: ${String(network)}`);
let { name, version } = info.defaultExtra;
@@ -95,7 +107,7 @@ export function assetFor(
if (env?.X402_EIP712_NAME) name = env.X402_EIP712_NAME;
if (env?.X402_EIP712_VERSION) version = env.X402_EIP712_VERSION;
}
- return { asset: info.asset, extra: { name, version } };
+ return { asset: info.asset, extra: { ...info.defaultExtra, name, version } };
}
// ─── Zod schemas (spec Step 5) ───
diff --git a/packages/api/src/routes/front-door.test.ts b/packages/api/src/routes/front-door.test.ts
index 7140f66..aed24fe 100644
--- a/packages/api/src/routes/front-door.test.ts
+++ b/packages/api/src/routes/front-door.test.ts
@@ -229,15 +229,22 @@ describe('GET /.well-known/x402 networks', () => {
worker.fetch(new Request(`${BASE}/.well-known/x402`), env as never, { waitUntil() {}, passThroughOnException() {} } as never);
it('production advertises the mainnets, Base first, matching the manifest', async () => {
- const res = await discover({ ENVIRONMENT: 'production' });
+ const res = await discover({ ENVIRONMENT: 'production', CIRCLE_API_KEY: 'k' });
expect(res.status).toBe(200);
const body = await res.json() as { accepts: Array<{ network: string; asset: string }> };
- expect(body.accepts.map((a) => a.network)).toEqual(['eip155:8453', 'eip155:137']);
+ expect(body.accepts.map((a) => a.network)).toEqual(['eip155:8453', 'eip155:137', 'eip155:5042']);
const payments = (buildDescriptor({ version: '0' }) as { payments: { contract: string; networks: Array<{ network: string; contract: string }> } }).payments;
expect(body.accepts[0].asset).toBe(payments.contract);
expect(body.accepts.map((a) => [a.network, a.asset])).toEqual(payments.networks.map((n) => [n.network, n.contract]));
});
+ it('holds Arc back until Circle can settle its deposits', async () => {
+ const body = await (await discover({ ENVIRONMENT: 'production' })).json() as { accepts: Array<{ network: string; extra: Record }> };
+ expect(body.accepts.map((a) => a.network)).toEqual(['eip155:8453', 'eip155:137']);
+ const arc = (await (await discover({ ENVIRONMENT: 'production', CIRCLE_API_KEY: 'k' })).json() as typeof body).accepts[2];
+ expect(arc.extra).toEqual({ name: 'USDC', version: '2', assetTransferMethod: 'eip3009' });
+ });
+
it('advertises the live minimum bounty for agent and console posters', async () => {
type Discovery = { accepts: Array<{ min_amount: string }>; min_bounty_atomic: { a2a: string; human: string } };
const dflt = await (await discover({ ENVIRONMENT: 'production' })).json() as Discovery;
diff --git a/packages/api/src/routes/tasks.ts b/packages/api/src/routes/tasks.ts
index 88b4b06..f09e41f 100644
--- a/packages/api/src/routes/tasks.ts
+++ b/packages/api/src/routes/tasks.ts
@@ -25,7 +25,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import { z } from 'zod';
import type { AppEnv } from '../types/index.js';
-import { CreateTaskSchema, SubmitDeliverableSchema, DeliverTaskSchema, TaskQuerySchema, BOUNTY_NETWORKS, allowedBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
+import { CreateTaskSchema, SubmitDeliverableSchema, DeliverTaskSchema, TaskQuerySchema, BOUNTY_NETWORKS, allowedBountyNetworks, fundableBountyNetworks, describeBountyNetworks, ARC_NETWORK, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
import { agentAuth } from '../middleware/auth.js';
import { bytesToHex } from '../crypto/index.js';
import { generatePublicId } from '../lib/ids.js';
@@ -126,11 +126,21 @@ tasks.post('/', agentAuth, async (c) => {
}, 503);
}
// Production settles real money: reject a bounty on a network this environment
- // won't pay (testnet USDC is staging/dev only) — BEFORE any escrow deposit is taken.
- if (bounty && !allowedBountyNetworks(c.env).includes(bounty.network)) {
+ // won't pay (testnet USDC is staging/dev only), or can't take a deposit on right
+ // now (Arc without CIRCLE_API_KEY) — BEFORE any escrow deposit is taken.
+ const fundable = fundableBountyNetworks(c.env);
+ if (bounty && !fundable.includes(bounty.network)) {
return c.json({
error: 'bounty_network_not_allowed',
- message: `Bounties on ${bounty.network} are not accepted here; use eip155:8453 (Base) or eip155:137 (Polygon) mainnet USDC.`,
+ message: `Bounties on ${bounty.network} are not accepted here; use ${describeBountyNetworks(fundable)} USDC.`,
+ network: bounty.network,
+ }, 400);
+ }
+ // An Arc payment that isn't a deposit would be settled by Circle straight to the agent.
+ if (bounty && bounty.network === ARC_NETWORK && !wantsEscrow) {
+ return c.json({
+ error: 'escrow_required',
+ message: 'Bounties on Arc are always escrowed: post without "escrow": false (escrow must be available on this registry).',
network: bounty.network,
}, 400);
}
diff --git a/packages/api/src/routes/x402-tasks.test.ts b/packages/api/src/routes/x402-tasks.test.ts
index c12c287..9ecabac 100644
--- a/packages/api/src/routes/x402-tasks.test.ts
+++ b/packages/api/src/routes/x402-tasks.test.ts
@@ -11,8 +11,14 @@ import {
} from '../test-helpers.js';
import type { SQLiteAdapter } from '../db/sqlite-adapter.js';
import {
- enablePaymentsForTests, resetPaymentsForTests, paymentHeaderFor, paymentPayloadFor, TEST_WALLET, TEST_TX, type FakeFacilitator,
+ enablePaymentsForTests, resetPaymentsForTests, paymentHeaderFor, paymentPayloadFor, fakeFacilitator, FakeArcNode, decodeArcTx, TEST_WALLET, TEST_TX, type FakeFacilitator,
} from '../payments/test-fixtures.js';
+import { setPaymentProviderForTests } from '../payments/index.js';
+import { ArcFacilitator, ArcRelay, ArcRpc, NetworkRouter } from '../payments/arc.js';
+import { settleTask } from '../payments/settle.js';
+import { escrowSweep } from '../payments/escrow.js';
+import { decryptPaymentSignature } from '../payments/crypto.js';
+import { decodePaymentHeader } from '../payments/x402.js';
import { encodeB64Json, type PaymentRequirementsV2 } from '../payments/x402.js';
import { houseWalletFromPrivateKey, parseHousePrivateKey, setHouseWalletForTests, addressFromPrivateKey } from '../payments/house-wallet.js';
import { parseActionMessage } from '../wallets/action.js';
@@ -463,6 +469,142 @@ describe('Wallet-only hiring over x402 (/v1/x402/tasks)', () => {
expect(facilitator.settleCalls.length).toBe(settlesBefore);
});
+ // ─── Arc: deposits through Circle, payouts sent by the escrow wallet ───
+
+ /**
+ * Production with Circle configured. CDP stays the fake; Arc's facilitator is the real
+ * router: a fake Circle for deposits and the relay against a fake Arc node for payouts.
+ */
+ function useArc(): { node: FakeArcNode; circle: FakeFacilitator; setRelayClock: (ms: number) => void } {
+ const node = new FakeArcNode();
+ const circle = fakeFacilitator({ verify: [{ kind: 'valid', payer: BUYER }], settle: [{ kind: 'settled', transaction: TEST_TX, network: 'eip155:5042', payer: BUYER }] });
+ // The relay's clock moves only when it waits, so waits cost no real time.
+ let clock = Date.now();
+ const relay = new ArcRelay({
+ privateKey: parseHousePrivateKey('0x' + TEST_WALLET_KEYS.a), rpc: new ArcRpc(['https://rpc.mainnet.arc.io'], node.fetch()),
+ now: () => clock, sleep: async (ms) => { clock += ms; },
+ });
+ setPaymentProviderForTests(new NetworkRouter(facilitator, { 'eip155:5042': new ArcFacilitator(circle, relay) }));
+ app = createTestApp(db, { ...ENV, ENVIRONMENT: 'production', CIRCLE_API_KEY: 'LIVE_API_KEY:x:y' });
+ return { node, circle, setRelayClock: (ms) => { clock = ms; } };
+ }
+ /** The transferWithAuthorization the relay broadcast: [from, to, value]. */
+ const sentTransfer = (raw: string) => {
+ const at = raw.indexOf('e3ee160e') + 8;
+ return [raw.slice(at + 24, at + 64), raw.slice(at + 64 + 24, at + 128), BigInt('0x' + raw.slice(at + 128, at + 192))];
+ };
+
+ it('Arc is offered once Circle is configured: Base, Polygon, then Arc', async () => {
+ useArc();
+ const json = await (await post('/v1/x402/tasks/usd-1')).json() as Json;
+ expect(json.accepts.map((o: Json) => o.network)).toEqual(['eip155:8453', 'eip155:137', 'eip155:5042']);
+ expect(json.accepts[2]).toMatchObject({
+ asset: '0x3600000000000000000000000000000000000000', payTo: house.address, amount: '1000000',
+ extra: { name: 'USDC', version: '2', assetTransferMethod: 'eip3009' },
+ });
+ });
+
+ it('without the Circle key, Arc is refused before any payment', async () => {
+ const json = await (await post('/v1/x402/tasks/usd-5', { ...BRIEF, network: 'eip155:5042' })).json() as Json;
+ expect(json).toMatchObject({ error: 'bounty_network_not_allowed', network: 'eip155:5042' });
+ expect(json.message).not.toContain('eip155:5042 (Arc)');
+ expect(facilitator.verifyCalls).toEqual([]);
+ });
+
+ it('an Arc hire deposits through Circle and is paid out by the escrow wallet on Arc', async () => {
+ const { node, circle } = useArc();
+ await bindClaimer('eip155:8453');
+ const { res, json, taskId, token } = await hire('/v1/x402/tasks/usd-5', BRIEF, 'eip155:5042');
+ expect(res.status).toBe(200);
+ expect(json.bounty).toMatchObject({ network: 'eip155:5042', amount_display: '5.00' });
+ expect(json.escrow).toMatchObject({ status: 'funded', deposit_tx_hash: TEST_TX });
+ expect(circle.settleCalls[0].requirements).toMatchObject({ network: 'eip155:5042', payTo: house.address });
+
+ await claimAndDeliver(taskId);
+ const accept = await post(`/v1/x402/tasks/${taskId}/accept`, {}, bearer(token));
+ expect(accept.status).toBe(200);
+ const after = await row(taskId);
+ expect(after.escrow_status).toBe('released');
+ // The release is the escrow wallet's own transaction on Arc, to the deliverer's plain key.
+ expect(node.sent).toHaveLength(1);
+ expect(sentTransfer(node.sent[0])).toEqual([house.address.slice(2).toLowerCase(), DELIVERER.slice(2).toLowerCase(), 5_000_000n]);
+ expect(after.escrow_release_tx_hash).toMatch(/^0x[0-9a-f]{64}$/);
+ expect(after.escrow_release_tx_hash).not.toBe(TEST_TX);
+ // Circle saw the deposit only; CDP saw nothing.
+ expect(circle.settleCalls).toHaveLength(1);
+ expect(facilitator.verifyCalls).toEqual([]);
+ expect(facilitator.settleCalls).toEqual([]);
+ });
+
+ it('cancelling an Arc task refunds the paying wallet from the escrow wallet on Arc', async () => {
+ const { node, circle } = useArc();
+ const { taskId, token } = await hire('/v1/x402/tasks/usd-1', BRIEF, 'eip155:5042');
+ const cancel = await post(`/v1/x402/tasks/${taskId}/cancel`, {}, bearer(token));
+ expect(cancel.status).toBe(200);
+ expect((await row(taskId)).escrow_status).toBe('refunded');
+ expect(sentTransfer(node.sent[0])).toEqual([house.address.slice(2).toLowerCase(), BUYER.slice(2).toLowerCase(), 1_000_000n]);
+ expect(circle.settleCalls).toHaveLength(1);
+ });
+
+ it('a payout still in flight is never re-signed: it is released by its own transaction once it lands', async () => {
+ const { node, setRelayClock } = useArc();
+ node.autoMine = false;
+ await bindClaimer('eip155:8453');
+ const { taskId, token } = await hire('/v1/x402/tasks/usd-5', BRIEF, 'eip155:5042');
+ await claimAndDeliver(taskId);
+ expect((await post(`/v1/x402/tasks/${taskId}/accept`, {}, bearer(token))).status).toBe(200);
+ // The release went out but hasn't landed: held as releasing, with its hash.
+ const sentHash = decodeArcTx(node.sent[0]).hash;
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'releasing', payment_status: 'settling', payment_tx_hash: sentHash });
+ const env = { ...ENV, ENVIRONMENT: 'production', CIRCLE_API_KEY: 'LIVE_API_KEY:x:y' } as Bindings;
+ // Retries near the end of the authorization's life (by the chain's clock too) wait for it: never expired, never back to funded.
+ const validBefore = Number(decodePaymentHeader(await decryptPaymentSignature((await row(taskId)).payment_signature, ENV.PAYMENT_ENCRYPTION_KEY!)).payload.authorization.validBefore);
+ node.advance(validBefore - Number(node.nowSec) - 10);
+ setRelayClock((validBefore - 5) * 1000);
+ const later = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString();
+ await settleTask(db, env, taskId, 'cron', later(3));
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'releasing', payment_status: 'failed', last_settle_class: 'transient' });
+ expect((await escrowSweep(db, env, later(4))).attempted).toBe(0);
+ // It lands: the next retry finds it and releases with that very transaction. One transfer, ever.
+ node.mine();
+ const settled = await settleTask(db, env, taskId, 'cron', later(30));
+ expect(settled).toMatchObject({ skipped: false, payment_status: 'settled', tx_hash: sentHash });
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'released', escrow_release_tx_hash: sentHash });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('a payout that lands while its receipt and log can\'t be read is not re-signed on its first attempt', async () => {
+ const { node } = useArc();
+ node.failReceipts = true;
+ node.failLogs = true;
+ await bindClaimer('eip155:8453');
+ const { taskId, token } = await hire('/v1/x402/tasks/usd-5', BRIEF, 'eip155:5042');
+ await claimAndDeliver(taskId);
+ expect((await post(`/v1/x402/tasks/${taskId}/accept`, {}, bearer(token))).status).toBe(200);
+ // The transfer landed; the relay couldn't read it back, so the leg stays open (retried, no hash claimed).
+ const sentHash = decodeArcTx(node.sent[0]).hash;
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'releasing', payment_status: 'failed', last_settle_class: 'transient', payment_tx_hash: null });
+ const env = { ...ENV, ENVIRONMENT: 'production', CIRCLE_API_KEY: 'LIVE_API_KEY:x:y' } as Bindings;
+ const later = (minutes: number) => new Date(Date.now() + minutes * 60_000).toISOString();
+ expect((await escrowSweep(db, env, later(1))).attempted).toBe(0);
+ // A second retry with logs still unreadable: still open, not settled without its transaction.
+ node.failReceipts = false;
+ await settleTask(db, env, taskId, 'cron', later(3));
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'releasing', payment_status: 'failed', last_settle_class: 'transient', payment_tx_hash: null });
+ // Once the logs can be read, the retry finds the transfer that landed and releases with it.
+ node.failLogs = false;
+ await settleTask(db, env, taskId, 'cron', later(10));
+ expect(await row(taskId)).toMatchObject({ escrow_status: 'released', escrow_release_tx_hash: sentHash });
+ expect(node.sent).toHaveLength(1);
+ });
+
+ it('an agent cannot post an Arc bounty outside escrow', async () => {
+ useArc();
+ const res = await signedPost(claimer, '/v1/tasks', { ...BRIEF, bounty: { amount: '1000000', network: 'eip155:5042' }, escrow: false });
+ expect(res.status).toBe(400);
+ expect(await res.json()).toMatchObject({ error: 'escrow_required', network: 'eip155:5042' });
+ });
+
// ─── Wallet identity: list and attach ───
it('GET /v1/tasks?creator= lists the tasks a wallet posted, any case', async () => {
diff --git a/packages/api/src/routes/x402-tasks.ts b/packages/api/src/routes/x402-tasks.ts
index 5f651c2..be92942 100644
--- a/packages/api/src/routes/x402-tasks.ts
+++ b/packages/api/src/routes/x402-tasks.ts
@@ -38,7 +38,7 @@ import { Hono } from 'hono';
import type { Context } from 'hono';
import { z } from 'zod';
import type { AppEnv } from '../types/index.js';
-import { CreateTaskSchema, BOUNTY_NETWORKS, allowedBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
+import { CreateTaskSchema, BOUNTY_NETWORKS, fundableBountyNetworks, describeBountyNetworks, RatingFields, withRatingRule, ratingInputOf, isRatingIssue, RATING_RULE_MESSAGE } from '../types/index.js';
import type { DBAdapter } from '../db/adapter.js';
import { sha256, bytesToHex } from '../crypto/index.js';
import { generatePublicId } from '../lib/ids.js';
@@ -142,7 +142,7 @@ function paymentHeader(c: Ctx): string | null {
/** The chain name `circle wallet sign message --chain` takes for a network, or null. */
function circleChain(network: string): string | null {
- return ({ 'eip155:8453': 'BASE', 'eip155:84532': 'BASE-SEPOLIA', 'eip155:137': 'MATIC' } as Record)[network] ?? null;
+ return ({ 'eip155:8453': 'BASE', 'eip155:84532': 'BASE-SEPOLIA', 'eip155:137': 'MATIC', 'eip155:5042': 'ARC' } as Record)[network] ?? null;
}
function toHex(text: string): string {
@@ -227,7 +227,7 @@ function hireResource(tier: string | null, amountAtomic: string, custom: boolean
/** GET /v1/x402/tasks — the price list. */
app.get('/', (c) => {
- const networks = allowedBountyNetworks(c.env);
+ const networks = fundableBountyNetworks(c.env);
const min = String(minBountyAtomic(c.env, 'a2a'));
return c.json({
ok: true,
@@ -260,7 +260,7 @@ async function hire(c: Ctx, tier: string | null): Promise {
const json = await readJson(c);
if (!json.ok) return c.json({ error: 'bad_request', message: 'Invalid JSON body' }, 400);
- const accepted = allowedBountyNetworks(c.env);
+ const accepted = fundableBountyNetworks(c.env);
const minimum = String(minBountyAtomic(c.env, 'a2a'));
// An empty call is a price check (x402 clients, directories, health checks):
@@ -297,7 +297,7 @@ async function hire(c: Ctx, tier: string | null): Promise {
}
if (data.network && !accepted.includes(data.network)) {
- return c.json({ error: 'bounty_network_not_allowed', message: `Bounties on ${data.network} are not accepted here; use ${accepted.join(' or ')}, or leave network out to be offered every one.`, network: data.network }, 400);
+ return c.json({ error: 'bounty_network_not_allowed', message: `Bounties on ${data.network} are not accepted here; use ${describeBountyNetworks(accepted)}, or leave network out to be offered every one.`, network: data.network }, 400);
}
// The 402 offers each network; the deposit lands on the one the payer signs for.
const networks = data.network ? [data.network] : accepted;
@@ -406,7 +406,7 @@ async function quoteByGet(c: Ctx, tier: string): Promise {
if (belowMinimum) {
return c.json({ ...belowMinimum, message: `This tier is below this registry's minimum bounty of ${belowMinimum.minimum_usdc} USDC.` }, 400);
}
- return quote(c, c.get('db'), tier, X402_TIERS[tier], allowedBountyNetworks(c.env));
+ return quote(c, c.get('db'), tier, X402_TIERS[tier], fundableBountyNetworks(c.env));
}
// ─── Manage: the manage token or the paying wallet's signature ───
diff --git a/packages/api/src/tasks/settled.ts b/packages/api/src/tasks/settled.ts
index 9c2eba1..bc6c168 100644
--- a/packages/api/src/tasks/settled.ts
+++ b/packages/api/src/tasks/settled.ts
@@ -16,13 +16,14 @@ import { atomicToDisplay } from '../payments/x402.js';
import { sanitizeDisplayName } from '../lib/display-name.js';
/** Networks whose settlements count as real payments. */
-export const PAID_NETWORKS = ['eip155:8453', 'eip155:137'] as const;
+export const PAID_NETWORKS = ['eip155:8453', 'eip155:137', 'eip155:5042'] as const;
/** Block explorer per network; the client never builds explorer URLs itself. */
export const TX_EXPLORERS: Record = {
'eip155:8453': 'https://basescan.org/tx/',
'eip155:84532': 'https://sepolia.basescan.org/tx/',
'eip155:137': 'https://polygonscan.com/tx/',
+ 'eip155:5042': 'https://explorer.arc.io/tx/',
};
export function explorerTxUrl(network: string, txHash: string): string | null {
diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts
index 376d975..44603d1 100644
--- a/packages/api/src/types/index.ts
+++ b/packages/api/src/types/index.ts
@@ -48,6 +48,7 @@ export const ALLOWED_WALLET_NETWORKS_CONST = [
'eip155:137', // Polygon
'eip155:42161', // Arbitrum One
'eip155:10', // Optimism
+ 'eip155:5042', // Arc
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // Solana mainnet
] as const;
@@ -108,9 +109,10 @@ export const VerifySubmitSchema = z.object({
* A bounty as declared at task creation (Tasks P0, N1). `amount` is a string
* of ATOMIC USDC units ("5000000" = 5.00 USDC) capped at 1,000 USDC; SDK/CLI/
* MCP convert human decimals at the edge. USDC on Base (mainnet or Sepolia) or
- * Polygon PoS, the networks the CDP facilitator settles with EIP-3009.
+ * Polygon PoS, the networks the CDP facilitator settles with EIP-3009, or on Arc,
+ * where Circle's Facilitator Service settles deposits (escrow only).
*/
-export const BOUNTY_NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137'] as const;
+export const BOUNTY_NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137', 'eip155:5042'] as const;
export const BountySchema = z.object({
amount: z.string().regex(/^[1-9][0-9]{0,9}$/, 'atomic USDC units, digits only')
// The regex issue is still collected when this runs, so guard the BigInt.
@@ -122,7 +124,14 @@ export const BountySchema = z.object({
export type Bounty = z.infer;
/** The bounty networks that settle real money, in the order a 402 offers them (Base first). */
-export const MAINNET_BOUNTY_NETWORKS: readonly string[] = ['eip155:8453', 'eip155:137'];
+export const MAINNET_BOUNTY_NETWORKS: readonly string[] = ['eip155:8453', 'eip155:137', 'eip155:5042'];
+
+/**
+ * Arc mainnet. Its deposits settle through Circle's Facilitator Service (not CDP), and
+ * its payouts and refunds are sent by the escrow wallet itself, so an Arc bounty is
+ * always escrowed: sign-at-accept would make Circle settle straight to the agent.
+ */
+export const ARC_NETWORK = 'eip155:5042';
/**
* Bounty networks accepted in THIS environment. Production settles real money
@@ -136,6 +145,32 @@ export function allowedBountyNetworks(env: { ENVIRONMENT?: string } | undefined
return env?.ENVIRONMENT === 'production' ? MAINNET_BOUNTY_NETWORKS : BOUNTY_NETWORKS;
}
+/** Human names for the bounty networks, for error messages. */
+export const BOUNTY_NETWORK_NAMES: Readonly> = {
+ 'eip155:8453': 'Base', 'eip155:84532': 'Base Sepolia', 'eip155:137': 'Polygon', 'eip155:5042': 'Arc',
+};
+
+/** "eip155:8453 (Base), eip155:137 (Polygon) or eip155:5042 (Arc)". */
+export function describeBountyNetworks(networks: readonly string[]): string {
+ const named = networks.map((n) => `${n} (${BOUNTY_NETWORK_NAMES[n] ?? n})`);
+ return named.length > 1 ? `${named.slice(0, -1).join(', ')} or ${named[named.length - 1]}` : (named[0] ?? 'none');
+}
+
+const WITHOUT_ARC = {
+ mainnet: MAINNET_BOUNTY_NETWORKS.filter((n) => n !== ARC_NETWORK),
+ all: BOUNTY_NETWORKS.filter((n) => n !== ARC_NETWORK),
+};
+
+/**
+ * Bounty networks a NEW deposit can be paid on here: the allowed networks, less Arc
+ * while CIRCLE_API_KEY is unset (no facilitator would settle the deposit). Existing
+ * Arc tasks stay visible and payable through allowedBountyNetworks.
+ */
+export function fundableBountyNetworks(env: { ENVIRONMENT?: string; CIRCLE_API_KEY?: string } | undefined | null): readonly string[] {
+ if (env?.CIRCLE_API_KEY) return allowedBountyNetworks(env);
+ return env?.ENVIRONMENT === 'production' ? WITHOUT_ARC.mainnet : WITHOUT_ARC.all;
+}
+
/**
* The optional rating a poster may attach when accepting or disputing a
* delivery (decision D11): an integer 1–5, plus an optional short comment
@@ -548,6 +583,13 @@ export type Bindings = {
// (existing deposits are still released/refunded).
ESCROW_WALLET_PRIVATE_KEY?: string;
TASK_ESCROW_ENABLED?: string;
+ // Arc (eip155:5042). Deposits settle through Circle's Facilitator Service, so new
+ // Arc bounties are offered only while CIRCLE_API_KEY is set; payouts and refunds are
+ // broadcast by the escrow wallet itself through ARC_RPC_URL (one URL or a
+ // comma-separated list, then the public nodes) and need no Circle key.
+ CIRCLE_API_KEY?: string; // Circle API key (Bearer) for Facilitator Service
+ CIRCLE_FACILITATOR_URL?: string; // override https://api.circle.com/v1/facilitator/x402
+ ARC_RPC_URL?: string;
GITHUB_TOKEN?: string; // raises GitHub API rate limits for repo scans
// Board: global uncertified-class write valve, posts/hour (default 2000).
// The emergency dial for a PoW-identity spam wave — see routes/board.ts.
diff --git a/packages/api/src/wallets/bind.ts b/packages/api/src/wallets/bind.ts
index edae591..e5d00ed 100644
--- a/packages/api/src/wallets/bind.ts
+++ b/packages/api/src/wallets/bind.ts
@@ -20,8 +20,8 @@
* is accepted for BIND_MAX_AGE_MS after `Issued` and each nonce once per agent.
*
* Verification: an EOA signature is recovered with secp256k1 (no network).
- * Otherwise, on Base (eip155:8453 / 84532) or Polygon (eip155:137), over JSON-RPC
- * (BASE_RPC_URL / BASE_SEPOLIA_RPC_URL / POLYGON_RPC_URL first, then public endpoints), the ERC-6492 reference validator runs
+ * Otherwise, on Base (eip155:8453 / 84532), Polygon (eip155:137) or Arc (eip155:5042), over JSON-RPC
+ * (BASE_RPC_URL / BASE_SEPOLIA_RPC_URL / POLYGON_RPC_URL / ARC_RPC_URL first, then public endpoints), the ERC-6492 reference validator runs
* as one deployless eth_call, pinned to the freshest block the nodes report:
* - a deployed smart-contract wallet is asked through ERC-1271 isValidSignature;
* - a signature wrapped per ERC-6492 (a smart wallet not deployed yet, such as
@@ -30,7 +30,7 @@
*/
import { secp256k1 } from '@noble/curves/secp256k1';
import { keccak_256 } from '@noble/hashes/sha3';
-import { toChecksumAddress, sameAddress } from '../payments/house-wallet.js';
+import { toChecksumAddress, sameAddress } from '../payments/evm.js';
export const BIND_TITLE = 'BasedAgents payout wallet';
export const BIND_FOOTER = "Signing proves you control this wallet and lets BasedAgents pay this agent's bounties to it. It moves no funds.";
@@ -151,6 +151,8 @@ const RPC_DEFAULTS: Record = {
'eip155:84532': { env: 'BASE_SEPOLIA_RPC_URL', urls: ['https://sepolia.base.org', 'https://base-sepolia-rpc.publicnode.com', 'https://base-sepolia.drpc.org'] },
// polygon-rpc.com now requires a key, so it is not a default.
'eip155:137': { env: 'POLYGON_RPC_URL', urls: ['https://polygon-bor-rpc.publicnode.com', 'https://polygon.drpc.org'] },
+ // Arc's own node, then the providers Arc's docs list (each checked as above).
+ 'eip155:5042': { env: 'ARC_RPC_URL', urls: ['https://rpc.mainnet.arc.io', 'https://rpc.drpc.mainnet.arc.io', 'https://rpc.quicknode.mainnet.arc.io', 'https://rpc.blockdaemon.mainnet.arc.io'] },
};
/** The endpoints to try for a chain: the configured ones first, then the public defaults. */
diff --git a/packages/console/public/.well-known/basedagents.json b/packages/console/public/.well-known/basedagents.json
index d65cccc..f18dda7 100644
--- a/packages/console/public/.well-known/basedagents.json
+++ b/packages/console/public/.well-known/basedagents.json
@@ -38,6 +38,11 @@
"network": "eip155:137",
"chainId": 137,
"contract": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"
+ },
+ {
+ "network": "eip155:5042",
+ "chainId": 5042,
+ "contract": "0x3600000000000000000000000000000000000000"
}
],
"decimals": 6,
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 4d22c3c..f8f2c09 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "basedagents",
- "version": "0.10.3",
+ "version": "0.10.4",
"description": "SDK and CLI for BasedAgents, the task marketplace for AI agents — register an agent identity, find and claim paid tasks, deliver signed receipts, get paid in USDC; post tasks with escrowed bounties; search the registry and submit peer verifications.",
"type": "module",
"main": "dist/index.js",
diff --git a/packages/sdk/src/cli/wallet.test.ts b/packages/sdk/src/cli/wallet.test.ts
index 23f4946..ca1c983 100644
--- a/packages/sdk/src/cli/wallet.test.ts
+++ b/packages/sdk/src/cli/wallet.test.ts
@@ -90,6 +90,7 @@ describe('basedagents wallet set', () => {
expect(m![2]).toBe(WALLET_ADDR);
expect(circleSignCommand(printed.message, WALLET_ADDR, 'eip155:84532')).toMatch(/--chain BASE-SEPOLIA$/);
expect(circleSignCommand(printed.message, WALLET_ADDR, 'eip155:137')).toMatch(/--chain MATIC$/);
+ expect(circleSignCommand(printed.message, WALLET_ADDR, 'eip155:5042')).toMatch(/--chain ARC$/);
expect(circleSignCommand(printed.message, WALLET_ADDR, 'eip155:1')).toBeNull();
// Deploys the wallet (Circle won't sign before): a zero-amount transfer to itself. No --token:
// Circle's CLI takes a contract address there and rejects "usdc" (404 "Cannot find target token").
diff --git a/packages/sdk/src/cli/wallet.ts b/packages/sdk/src/cli/wallet.ts
index acce70c..80e8c60 100644
--- a/packages/sdk/src/cli/wallet.ts
+++ b/packages/sdk/src/cli/wallet.ts
@@ -216,7 +216,7 @@ function dropPendingBinds(list: PendingBind[]): void {
for (const p of list) { try { unlinkSync(p.file); } catch { /* already gone */ } }
}
/** Circle's chain names (circle wallet ... --chain) for the networks a payout wallet can bind on. */
-const CIRCLE_CHAINS: Record = { 'eip155:8453': 'BASE', 'eip155:84532': 'BASE-SEPOLIA', 'eip155:137': 'MATIC' };
+const CIRCLE_CHAINS: Record = { 'eip155:8453': 'BASE', 'eip155:84532': 'BASE-SEPOLIA', 'eip155:137': 'MATIC', 'eip155:5042': 'ARC' };
/**
* The Circle CLI command that signs a bind message with a Circle agent wallet,
* or null off the networks Circle names. The message goes hex-encoded (--hex)
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 7102846..2e042bd 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -62,7 +62,7 @@ export const TASK_CATEGORIES = ['research', 'code', 'content', 'data', 'automati
export type TaskCategory = (typeof TASK_CATEGORIES)[number];
/** Networks a bounty can settle on (USDC on Base mainnet, Base Sepolia, Polygon PoS). */
-export const BOUNTY_NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137'] as const;
+export const BOUNTY_NETWORKS = ['eip155:8453', 'eip155:84532', 'eip155:137', 'eip155:5042'] as const;
export type BountyNetwork = (typeof BOUNTY_NETWORKS)[number];
// ─── Amounts (copied verbatim from packages/api/src/payments/x402.ts — no cross-package import) ───
diff --git a/packages/web/public/.well-known/basedagents.json b/packages/web/public/.well-known/basedagents.json
index d65cccc..f18dda7 100644
--- a/packages/web/public/.well-known/basedagents.json
+++ b/packages/web/public/.well-known/basedagents.json
@@ -38,6 +38,11 @@
"network": "eip155:137",
"chainId": 137,
"contract": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"
+ },
+ {
+ "network": "eip155:5042",
+ "chainId": 5042,
+ "contract": "0x3600000000000000000000000000000000000000"
}
],
"decimals": 6,
diff --git a/packages/web/src/components/RecentlyPaid.tsx b/packages/web/src/components/RecentlyPaid.tsx
index 21bd144..6d8d6ea 100644
--- a/packages/web/src/components/RecentlyPaid.tsx
+++ b/packages/web/src/components/RecentlyPaid.tsx
@@ -62,8 +62,8 @@ function useSettledFeed(limit: number): Feed {
const STAT_DEFS = {
paid: 'Median time from a task being posted to its bounty settling on-chain (settled_at − created_at), over the window.',
delivery: 'Median time from an agent claiming a task to its first delivery (first delivery − claimed_at), over the window.',
- count: 'All-time count of tasks whose USDC bounty settled on Base or Polygon mainnet with a settlement transaction on record.',
- usdc: 'All-time sum of settled USDC bounties on Base and Polygon mainnet.',
+ count: 'All-time count of tasks whose USDC bounty settled on Base, Polygon or Arc mainnet with a settlement transaction on record.',
+ usdc: 'All-time sum of settled USDC bounties on Base, Polygon and Arc mainnet.',
medians: 'Stage medians are computed independently, so they do not add up to time to paid. Waiting for a claim and buyer review are the slow stages.',
};
@@ -151,7 +151,7 @@ export default function RecentlyPaid(): React.ReactElement | null {
Recently paid
-
Settled USDC bounties on Base and Polygon. Every payment links to its transaction.
+
Settled USDC bounties on Base, Polygon and Arc. Every payment links to its transaction.
{feed.kind === 'loading' ? : (
diff --git a/packages/web/src/pages/TaskDetail.tsx b/packages/web/src/pages/TaskDetail.tsx
index 674114c..dc69ee6 100644
--- a/packages/web/src/pages/TaskDetail.tsx
+++ b/packages/web/src/pages/TaskDetail.tsx
@@ -581,7 +581,7 @@ export default function TaskDetail(): React.ReactElement {
A wallet ·{' '}