Skip to content

Bounties on Arc: deposits through Circle, payouts sent by the escrow wallet - #183

Merged
maxfain merged 36 commits into
mainfrom
claude/spec-implementation-dkc77z
Oct 7, 2026
Merged

maxfain merged 36 commits into
mainfrom
claude/spec-implementation-dkc77z

Conversation

@maxfain

@maxfain maxfain commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

USDC bounties can now be paid on Arc (eip155:5042), Circle's stablecoin chain, alongside Base and Polygon. Coinbase's CDP facilitator, which settles our Base and Polygon payments, doesn't support Arc. Its /supported and network docs don't list it. Arc therefore runs on two paths, the "Circle in, wallet out" design Max picked.

How Arc settles

paymentProviderFor now returns a NetworkRouter. Base and Polygon go to CDP exactly as before. Arc goes to ArcFacilitator (payments/arc.ts), which routes by who signed the transfer.

Leg Signed by Settled by
Deposit the buyer, to the escrow wallet Circle's Facilitator Service (circle-facilitator.ts), Authorization: Bearer CIRCLE_API_KEY
Release / refund the escrow wallet, to the agent / buyer the escrow wallet itself: it broadcasts its own transferWithAuthorization on Arc

Why payouts don't go through Circle. Circle's docs say settling with an API key binds the payment's payTo to that Circle account. That is right for our escrow wallet. It would be wrong for agents' and buyers' wallets, which a payout or refund pays.

The Circle adapter uses the same {x402Version, paymentPayload, paymentRequirements} contract as CDP. Its response shapes come from Circle's published OpenAPI (facilitator-service.yaml).

  • Every settle carries a payment-identifier taken from the authorization nonce (ba_<nonce>), so retries resolve to one payment.
  • Circle can answer settlement_pending before it has a transaction hash. SettleOutcome.pending now allows a missing hash, and settle.ts keeps any hash an earlier attempt recorded (COALESCE).
  • invalid_transaction_state ("a nonce that already settled") is classified like CDP's nonce_already_used.
  • 401/403 map to auth, 429 to rate_limited, and 409 and 5xx to server, so they are retried and never re-signed.

The relay is the escrow wallet broadcasting its own authorization. EIP-3009's one-use nonce means no retry, race or lost answer can pay twice. Every attempt runs in rounds, and each round starts from the chain:

  1. Used? If authorizationState is set, the money moved. The answer is settled with the transaction from the token's AuthorizationUsed log. The relay finds that transaction by searching the blocks of the authorization's validity window, located by block timestamp however long ago it was, in 5,000-block slices (Arc's nodes refuse 10k). Log queries move 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, which settle.ts already resolves.
  2. Expired? Only the chain's clock decides. The leg is expired, and can be re-signed, only when a block at or past validBefore exists and the authorization is unused when read at that block. That read is pinned to the block, so a lagging node errors rather than answering from older state. Our own clock and the simulation's "expired" never decide it: a transfer sent earlier could still land.
  3. One transaction in flight. The relay waits up to 4 s for any pending escrow-wallet transaction to land, then sends on the next nonce. A send that loses its nonce to a concurrent payout (another Worker) starts a new round, as does one whose nonce goes to another transaction while it is watched.
  4. Simulate with eth_estimateGas, and classify a revert by its FiatToken reason (signature, balance, blocklist).
  5. Fees: maxFeePerGas = 2 × baseFee + tip, never under Arc's 20 gwei floor, which Arc's mempool silently drops below. The relay also checks the USDC balance covers the transfer plus gas, since Arc's gas token is USDC. If it doesn't, it answers insufficient_funds and sends nothing.
  6. Wait up to 6 s for the receipt (Arc finalizes in under a second). Past that, the answer 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. On a leg's first attempt, settle.ts's row says nothing was ever broadcast. It would read a reuse answer or a terminal verdict as "this authorization never moved money" and let the cron sign a new one. So after a send in the same call:

  • a used authorization with no log in its window is pending without a hash, so the hash already recorded stays (a hash this call sent may never have landed);
  • a terminal simulation verdict waits for the next attempt.

Guard rails

  • Offered only while configured. New Arc deposits are offered only while CIRCLE_API_KEY is set (fundableBountyNetworks). This applies to the hire 402, the price list, /.well-known/x402, agent and owner posts, and fundEscrowTask. allowedBountyNetworks still includes Arc, so existing Arc tasks stay visible and payable. The relay needs only the escrow key.
  • This merge changes nothing for buyers until the key is set. Production offers Base and Polygon as today.
  • Arc is escrow only. escrow: false on Arc answers 400 escrow_required. A sign-at-accept transfer would have Circle settle straight to the agent's wallet, binding it to our account. The sign-at-accept path refuses Arc as well.
  • Gas float. The escrow wallet needs a small USDC float on Arc, about 0.002 USDC per payout. The runbook says to send it ~1 USDC first.

Arc constants (read on-chain)

  • Chain: eth_chainId is 0x13b2 (5042).
  • USDC: the ERC-20 interface at 0x3600000000000000000000000000000000000000, 6 decimals (native balance uses 18).
  • EIP-712 domain: name() USDC, version() 2. The DOMAIN_SEPARATOR() recomputed from those matches the contract's. The TRANSFER_WITH_AUTHORIZATION_TYPEHASH is the standard one.
  • Circle's offer: the offer's extra carries assetTransferMethod: "eip3009", which Circle's schema says it expects. Circle's /supported lists eip155:5042 exact {name: USDC, version: 2}.
  • RPC: Arc's own node plus the dRPC, QuickNode and Blockdaemon endpoints from Arc's docs (ARC_RPC_URL first). All four answered eth_blockNumber, a deployless eth_call and eth_getCode.

Also

  • Shared EVM code. payments/evm.ts is a new leaf module: hex, EIP-55, the house key parser, RLP and EIP-1559 signing. house-wallet.ts and wallets/bind.ts import from it; house-wallet.ts re-exports, so its API is unchanged. This avoids an import cycle (index → arc → house-wallet → index).
  • Payout wallets can be bound on Arc (ALLOWED_WALLET_NETWORKS). A verified plain key bound elsewhere is paid at the same address on Arc. The Circle CLI's chain name is ARC.
  • The paid feed counts Arc settlements and links explorer.arc.io. The task page links Arc addresses there, and the web copy names Base, Polygon and Arc.
  • SDK 0.10.4. BOUNTY_NETWORKS includes eip155:5042, and the Circle command uses --chain ARC on Arc.
  • Docs:
    • SPEC: an Arc section, plus updates to custody, the env table and the network table. The network table's stale "only Base" sentence is fixed.
    • OpenAPI: network enums and copy.
    • SECURITY: the house key line.
    • API README: env rows CIRCLE_API_KEY, CIRCLE_FACILITATOR_URL, ARC_RPC_URL.
    • Deploy runbook: step 8.
    • CHANGELOG.

Tests

  • evm.test.ts:
    • RLP reference vectors.
    • Three EIP-1559 transactions that are byte-identical to eth-account 0.13.4: the raw bytes and hashes are pinned, covering long calldata and a two-byte nonce.
    • Sender recovery over 20 random keys, using an RLP decoder.
  • circle-facilitator.test.ts: the request envelope and Bearer header, the payment-identifier, the verify and settle mappings, pending with and without a hash, every error status, and /supported without credentials.
  • arc.test.ts runs against a fake Arc node. The fake keeps a mempool with pending and latest nonces. It changes authorization state only when a transaction is mined. Its blocks are timestamped, and eth_getLogs is range-checked with the 5,000-block cap. Pinned reads error when lagging. The tests cover:
    • the transaction: calldata, nonce, fees and the 20 gwei floor;
    • a used authorization with and without a log, and its transaction found a day later in ≤ 2 log queries;
    • foreign signer, wrong network, recipient or amount;
    • expiry by the chain's clock only. Our clock past validBefore changes nothing. A transfer still in the mempool near expiry is waited for, then settled when it lands. A dropped one expires only at the pinned block, not on a lagging node;
    • waiting for another escrow-wallet transaction;
    • losing a nonce race and resending in the same call;
    • two Workers paying out at once, landing on nonces 4 and 5;
    • a lost nonce;
    • simulation reverts, including "expired", which waits for the chain;
    • the gas float;
    • broadcast errors;
    • receipt timeout, and a revert that is retried later;
    • a transfer that lands while its receipt and log can't be read: it stays open over two attempts, then settles with its transaction once logs are readable;
    • log queries failing over to the next endpoint;
    • an earlier attempt's transfer landing just as a retry broadcasts, with no log in the window: pending without the refused retry's hash;
    • a terminal simulation verdict after a send, which waits for the next attempt;
    • RPC failover;
    • Circle-vs-relay routing and network routing.
  • x402-tasks.test.ts, through the real routes and settle machine, with a fake Circle and the real relay against the fake node:
    • Arc is offered only with the key, Base, then Polygon, then Arc.
    • An Arc hire deposits through Circle and is released by the escrow wallet's own Arc transaction to the deliverer's plain key. Circle sees only the deposit and CDP sees nothing.
    • Cancel refunds the buyer the same way.
    • escrow: false on Arc is refused.
    • A payout still in flight is never re-signed. Retried near expiry, both by the chain's clock and by ours, it stays releasing, and the sweep doesn't re-sign it. Once it lands, it is released with its own transaction, and only one transfer is ever sent. This test fails against the relay's first version, which declared expiry by its own clock 10 s early.
    • A payout that lands while its receipt and log can't be read is not re-signed on its first attempt. It stays releasing through a second retry with logs still unreadable, then resolves to the transfer that landed once logs can be read. This test fails without the "once it has sent" rule.
  • Other tests: index.test.ts covers routing and the memo fingerprint; settle.test.ts covers pending without a hash; front-door.test.ts covers Arc held back without the key. SDK: --chain ARC.
  • Results:
    • Suites: API 1271, SDK 222, web 3, console 45, MCP 73.
    • Typecheck clean; lint 0 errors (1 pre-existing warning).
    • sync-skill, sync-positioning, check-positioning, check-examples and the script self-tests pass, and the web build succeeds.

Checked live on Arc mainnet with a throwaway key that holds nothing:

  • an authorization built by the escrow code passes USDC's signature check in eth_estimateGas, reverting only for "transfer amount exceeds balance";
  • the same authorization with one signature byte flipped reverts "invalid signature";
  • the node decodes the signed type-2 transaction and prices its gas.

Not tested end to end:

  • Circle verify/settle. These need Max's Circle API key. I didn't use the keyless trial, which would sign up for Circle's service.
  • A real Arc payout. This needs USDC on Arc in the escrow wallet.

After merge:

  1. Set CIRCLE_API_KEY (runbook step 8) and send the escrow wallet ~1 USDC on Arc.
  2. Hire for 1 USDC paid on Arc (circle services pay … --chain ARC), then cancel it. The refund exercises the relay.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9


Generated by Claude Code

The hero's proof panel gets a second figure, styled like "Total paid to
agents": "Registered agents" with the live count from GET /v1/status
(agents.total), "All time · On the public registry", and a link to the
agent directory. Like the paid total it prerenders as a dash and fills in
after hydration; an unreachable API shows a dash, never 0.

The homepage already read /v1/status for its live-work section; both now
share one request (lib/registryStatus.ts, reused for a minute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review on #156: registryStatus (lib/registryStatus.ts) now has tests for
two callers sharing one request, reuse for a minute then a fresh fetch,
and a failed request not being kept so the next caller retries. The web
package gets a vitest test script (same vitest as the other packages), so
`npm test` runs it in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Circle agent wallets are smart-contract accounts on Base that stay
counterfactual until their first transaction, so their signatures come
ERC-6492-wrapped. The wallet bind used to refuse those with
undeployed_smart_wallet; it now checks them.

- api: an ERC-6492 signature is verified with the reference
  ValidateSigOffchain bytecode run as a deployless eth_call (no `to`),
  which deploys the wallet in the call, then asks it per ERC-1271. A
  0x01 result binds (recorded as signer_kind erc1271); anything else or
  a revert is bad_signature; an unreachable RPC is rpc_unavailable.
  The bytecode is pinned by sha256 in the tests. wallet_proof.signature
  accepts up to 8192 bytes, since a 6492 wrapper carries the factory
  calldata.
- sdk 0.9.6: `wallet set` without a key also prints the Circle CLI
  command that signs the exact message (`circle wallet sign message
  0x<hex> --hex --address … --chain BASE`), and --json carries it as
  circle_sign_command.
- skill 1.3.7 (min CLI 0.9.6), SPEC, OpenAPI, READMEs, CHANGELOG.

Checked live on Base mainnet against an undeployed smart account: its
6492 signature binds; a changed message, another wallet's claim and a
flipped signature byte are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…yed"

Two review findings on the Circle wallet change:

- The service descriptor's walletProof.smartWallets still said "deployed
  wallets on Base", contradicting the skill. It now says a wallet not
  deployed yet signs per ERC-6492; the .well-known copies are regenerated.
- Whether `circle wallet sign message` signs from a counterfactual wallet
  is not verified. If it answers "Wallet not deployed", Circle's
  documented fix is a zero-amount transfer to itself, after which the
  wallet is deployed and binds per ERC-1271. `wallet set` now prints that
  command next to the sign command (circle_deploy_command in --json), and
  the skill, SDK README and CHANGELOG say when to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9

# Conflicts:
#	CHANGELOG.md
#	package-lock.json
#	packages/sdk/package.json
#	packages/web/public/llms-full.txt
A real Circle agent wallet bind showed three problems:

- Circle's CLI signs only from a deployed wallet ("This wallet isn't
  deployed on-chain yet"). `wallet set` now prints the deploy step first
  for Circle wallets; skill 1.3.8 §3 and the SDK README say so. Once
  deployed, the wallet binds per ERC-1271.
- `circle wallet transfer … --token usdc` fails with 404 "Cannot find
  target token": --token takes a contract address and defaults to USDC.
  circle_deploy_command no longer passes it.
- The API checked smart-wallet signatures against mainnet.base.org alone,
  which answered 429 from Cloudflare's shared egress and failed the bind
  with 503. It now tries BASE_RPC_URL (one URL or a comma-separated
  list), then mainnet.base.org, base-rpc.publicnode.com and base.drpc.org
  (same operators on Sepolia), each checked live for eth_getCode, eth_call
  and the deployless ERC-6492 call. A 429, 5xx, timeout or unsupported
  method moves on; a revert is the wallet's answer and stops there.
  Per-attempt timeout 5s.

basedagents 0.10.1, skill 1.3.8 (min CLI 0.10.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review finding: with up to four endpoints, 5 s per attempt and two calls
(eth_getCode, eth_call), one proof could spend 40 s on RPC, past the
SDK's 30 s request timeout, so a bind that succeeded on a late endpoint
could be abandoned by the client.

All RPC attempts for one proof now share a 15 s deadline (each attempt
gets min(5 s, what's left)), and the endpoint that answered eth_getCode
goes first for eth_call. A test with every endpoint hanging shows the
check returning rpc_unavailable at 15 s after three attempts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review finding on 787079e: with a shared 15 s deadline tried one endpoint
at a time, three nodes that each hang for 5 s use the whole budget, so a
healthy fourth is never tried, and a late eth_getCode can leave no time
for eth_call. Either way a valid bind answers rpc_unavailable.

Each call now starts on the first endpoint and also goes to the next one
on any failure at once, or after 1.5 s of silence (a hedged request). The
first answer wins and the others are cancelled; a revert still wins as
the wallet's answer. Each call has its own 9 s budget, so a proof (at
most two calls) settles within 18 s, inside the SDK's 30 s. The endpoint
that answered first goes first for the next call.

Tests: a hanging configured node is passed over after 1.5 s and the bind
succeeds on the next; with every node hanging, all four are tried and
the call gives up at 9 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review finding on d9c1f1e: with hedged first-answer-wins, a public node
that hasn't seen a wallet deployed seconds ago can answer eth_getCode
"0x" before a slower, up-to-date node, and the bind is refused as
bad_signature. That is exactly the Circle flow: deploy, then bind.

Lag only ever hides a yes, so the verdict is now asymmetric: a yes from
any node wins at once, while a no (wrong answer, no code, a revert)
counts only when every endpoint has answered, or the budget ran out, and
none said yes. Failures still move on at once and 1.5 s of silence still
hedges to the next node.

The separate eth_getCode is gone: eth_call to isValidSignature on an
address with no code answers empty, which is a no, so one call (9 s
budget) does the whole check.

Tests: a slow configured node with the wallet beats three public nodes
answering "no code" (would fail before); a revert on one node loses to
a yes on another; a no needs all three public nodes. Live on Base
mainnet: the ERC-6492 vector still binds, a flipped byte, an address
with no code and a contract without isValidSignature are bad_signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review findings on 21f611d (both security):

1. Without eth_getCode, the prefix check on isValidSignature's result
   accepted the identity precompile (0x…04): it echoes the call data,
   which starts with 0x1626ba7e, so an agent could "verify" it.
2. "A yes from any node wins" let a lagging node approve with a signer
   the wallet has since removed.

Lag runs both ways, so neither a lagging yes nor a lagging no can be
trusted; the fix removes the lag instead:

- Every smart-wallet signature, deployed (ERC-1271) or not (ERC-6492),
  is checked by the ERC-6492 reference validator in one deployless
  eth_call. It answers exactly 0x01 only for a valid signature: it
  asks isValidSignature when the address has code and recovers the
  signer when it has none. With no `to`, no precompile or other
  contract can answer in the wallet's place.
- The call is pinned to the highest head block the nodes report
  (eth_blockNumber to all at once, answers within 1.5 s of the first
  count). Every node that has that block gives the same answer, so
  the first answer wins; one that lags errors and is skipped.
- Head lookup 3 s, check 9 s: inside the SDK's 30 s.

Checked live on Base mainnet on all three public nodes, with a state
override standing in for a deployed wallet: a wallet answering the
ERC-1271 magic -> 0x01, one answering 0xffffffff -> 0x00, precompile
0x04 -> 0x00, a block the node lacks -> error. End to end through
verifyBindProof: the ERC-6492 vector binds; a flipped byte, 0x…04, an
address with no code and USDC are bad_signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Review finding on 66137fc: if the node that reported the freshest block
fails the eth_call (say it's rate limiting us) while the healthy nodes
are a block behind, every node fails at the pinned block and a valid
bind answers 503.

Falling back to an older block would bring back stale answers, so
instead: when every endpoint has failed at the pinned block, the round
starts over at the same block after 2 s (one Base block), by which time
the nodes behind have it. Rounds repeat within the 9 s budget.

Test: mainnet.base.org reports 0x12 and answers 429; the other nodes
report 0x11 and say "block not found: 0x12" on the first round. The bind
succeeds on the second round, and every call is pinned to 0x12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9

# Conflicts:
#	CHANGELOG.md
The first real Circle wallet bind hit `zsh: command not found:
basedagents`: the `Then run:` line printed a bare `basedagents wallet
set …`, but run through npx (the way the docs run it) nothing is
installed. The same was true of every command the CLI prints to run
next: tasks claim/deliver/payment/fund/watch/accept/dispute hints,
register, init, validate and id.

cliCommand() (cli/invocation.ts) answers `npx basedagents@latest` when
the CLI was started through npx (npm sets npm_command=exec and runs the
package from its _npx cache) and `basedagents` when it's installed.
The 24 run-this hints, and the --json `next` field, use it; title
banners and help text keep the bare name.

Tests: cliCommand for npx (both signals, POSIX and Windows cache
paths) and installed runs; `wallet set --json` under npx gives an npx
`next`. The wallet and tasks tests pin npm_command so they pass however
vitest is launched. Checked by running the packed 0.10.2 tarball
through a real npx: `Then run: npx basedagents@latest wallet set …`.

basedagents 0.10.2 (also publishes #163's SDK attribution, merged
without a version bump).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9

# Conflicts:
#	CHANGELOG.md
packages/mcp/dist is committed so the server runs from a checkout
(bin/basedagents-mcp.mjs imports ../dist/index.js). #163 changed the MCP
source without rebuilding, so a checkout ran the pre-attribution server.

Regenerated with `npm run build --workspace=packages/mcp`, and added the
new dist/attribution.{js,d.ts} and their maps with `git add -f`: the new
index.js imports ./attribution.js and dist/ is gitignored. npm publishes
were never affected (prepublishOnly rebuilds).

Checked from a `git archive` of the committed tree: the stdio server
initializes as basedagents 0.7.2 and lists 26 tools; the archived
index.js is byte-identical to a fresh build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
A buyer without an agent key or account can now post a task: the x402
escrow deposit is the authentication, and the paying wallet is the poster.

- POST /v1/x402/tasks takes a buyer-chosen bounty_usdc (at least the
  minimum); /usd-1, /usd-5 and /usd-20 take a fixed price. An empty POST is
  a price check (402, nothing written); the 402 carries a Bazaar discovery
  block. GET /v1/x402/tasks lists the prices.
- The poster is the payer the facilitator verified (creator_kind 'wallet',
  creator_wallet), never a request field.
- Manage by the token returned once (only its sha256 is stored) or by a
  signature from the paying wallet over a one-time action message
  (X-Wallet-Message + X-Wallet-Signature; the 401 gives the message and the
  Circle sign command). Accept, revision, dispute and cancel run the same
  gates as the owner routes.
- Binding the wallet as an agent's payout wallet moves its tasks to that
  agent in the same batch; the wallet keeps access. GET /v1/tasks?creator=0x…
  lists a wallet's tasks.
- Migration 0051 rebuilds tasks (creator_kind 'wallet', creator_wallet,
  manage_token_hash) and adds wallet_action_nonces.
- OpenAPI, /.well-known/x402, /docs, the service descriptor, SPEC, README
  and CHANGELOG document it; the MCP server shows a wallet poster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
… branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…on footers, tier minimum

- The poster's private reads (GET /v1/x402/tasks/:id and /submission) send
  Cache-Control: no-store, and the cache default counts X-Wallet-Signature as a
  credential, so a wallet-signed read is never marked public.
- Web (task detail, board card) and console (Explore) show a wallet poster as
  "a wallet · 0x1234…abcd" instead of "an agent"; the creator types gain
  kind 'wallet' and `wallet`.
- The signed action message's last line now says what the action does with the
  escrowed bounty: accept releases it, cancel refunds it, revision and dispute
  leave it held. The parser requires the footer of the named action.
- A tier priced under the live minimum bounty is refused before any quote
  (400 bounty_below_minimum, no 402) and left out of both price lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…idance, contact)

Circle's agent-readiness check found no paid endpoint declared in
openapi.json. The four wallet-only hire operations now carry x-payment-info
(fixed 1/5/20 USDC on the tiers; dynamic 0.10–1000 on the custom endpoint;
protocols [{x402: {}}]). info.x-guidance replaces the non-standard
info.guidance with a short agent brief, and info.contact gains
hello@basedagents.ai. Tests pin the priced set, the tier amounts against the
route's prices, and route/spec parity for /v1/x402/tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
The cancel route refuses submitted work until it has been disputed; the guidance
listed cancel as a direct option on a delivery, which would earn an agent a 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
GET /openapi.json now passes the spec through openApiForEnv: the custom hire
endpoint's price.min is the deployment's minimum, and a tier priced under it
is left out (the route refuses it before any 402). With the default minimum
the file is served unchanged. x-guidance points to GET /v1/x402/tasks for the
live prices instead of pinning 0.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…adata branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
… any chain

Circle's readiness check asks a paid endpoint to accept 2+ networks. USDC
bounties can now be paid on Polygon PoS (eip155:137, native USDC) through the
same CDP facilitator and the same escrow.

- The wallet-only hire 402 offers the same price on every accepted network
  (production: Base first, then Polygon); payTo is the escrow wallet, the same
  address on each. The payer signs one offer and the task's bounty network is
  the one they paid on: held, released and refunded there, nothing bridged.
  A body `network` restricts the offer. Agents and owners may post
  bounty.network eip155:137.
- payoutWallet (payments/accept.ts) decides where a deliverer is paid on a
  bounty's network: a wallet bound there, or a verified plain-key wallet's
  same address on any EVM chain. It runs at claim (409
  wallet_network_mismatch) and again at every payout (escrow release deferred
  with payee_wallet_wrong_network; sign-at-accept 409), so switching wallets
  after claiming can't route a payout to an address that isn't the agent's.
- Claim bonds are deposited on Base and now always paid back on Base (to a
  Base wallet or a plain key's address); before, a withdrawal followed the
  payout wallet's network, which would have drawn on Polygon escrow funds.
- Polygon USDC's EIP-712 domain (USD Coin, 2) was read on-chain and pinned;
  POLYGON_RPC_URL plus public nodes check smart-wallet signatures; the paid
  feed links Polygonscan; the descriptor lists payments.networks; the Circle
  sign command uses --chain MATIC.
- SDK 0.10.3: BOUNTY_NETWORKS includes eip155:137; Circle command for MATIC.
- OpenAPI enums and copy, SPEC, README, CHANGELOG; tests for the multi-network
  402, Polygon deposits and payouts, the plain-key rule, the deferred release,
  and Base-only bond payouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…ion; review fixes

- GET /v1/x402/tasks/usd-{1,5,20} answers the same 402 quote as an empty POST.
  It used to fall through to GET /:id and 404, so `circle services inspect`
  (GET by default) showed the service as unavailable. A GET carrying a payment
  is refused (405, Allow: POST) and the payment is not used.
- PaymentPayloadV2 keeps `extensions`: a client echoes the 402's bazaar block
  into its payment and a facilitator catalogs the service from it at settle.
  The parser dropped it, so it never reached CDP. Forwarded on verify and on
  settle (from the stored header).
- Greptile: the price list's OpenAPI response documents `networks` (array)
  instead of `network`; the settled-feed tests cover a Polygon settlement
  (Polygonscan link, counted in the totals) and explorerTxUrl for 137.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
…wallet

USDC bounties can be paid on Arc (eip155:5042), Circle's stablecoin chain,
alongside Base and Polygon. CDP doesn't settle on Arc, so paymentProviderFor
now routes by network (NetworkRouter): Base and Polygon to CDP, Arc to
ArcFacilitator, which splits by who signed the transfer.

- Deposits (buyer -> escrow wallet) verify and settle through Circle's
  Facilitator Service (circle-facilitator.ts, Bearer CIRCLE_API_KEY), with a
  payment-identifier from the authorization nonce. Circle's pending answer
  has no tx hash: SettleOutcome 'pending' now allows that, and settle.ts keeps
  a hash an earlier attempt saw. invalid_transaction_state = nonce used.
- Payouts and refunds (escrow wallet -> agent/buyer) never go to Circle,
  which would bind the payee's wallet to our Circle account. The escrow
  wallet broadcasts its own transferWithAuthorization (arc.ts relay): checks
  authorizationState first (a used one resolves to the tx in its
  AuthorizationUsed log), one escrow-wallet tx in flight at a time, simulates,
  pays 2x base fee + tip (>= Arc's 20 gwei floor), checks the USDC float for
  transfer + gas, waits 6 s for the receipt, else pending with the hash.
- EIP-1559 signing and RLP live in a leaf module (evm.ts) with the key
  parser; house-wallet re-exports, so there is no import cycle. The signer
  matches eth-account byte for byte (vectors pinned in evm.test.ts).
- New Arc deposits are offered only while CIRCLE_API_KEY is set
  (fundableBountyNetworks); existing Arc tasks stay visible and payable.
  Arc bounties are escrow-only (400 escrow_required on escrow: false).
- USDC on Arc: 0x3600...0000, EIP-712 domain USDC/2 read on-chain; the
  offer's extra carries assetTransferMethod: eip3009 for Circle.
- Payout wallets bind on Arc (ARC_RPC_URL + Arc's public nodes); the paid
  feed and task page link explorer.arc.io; Circle CLI chain ARC; SDK 0.10.4.
- Docs: SPEC (Arc section, custody, env, network table), OpenAPI enums and
  copy, SECURITY, API README, deploy runbook step 8, CHANGELOG.

Checked live on Arc mainnet with a throwaway key: the escrow code's
authorization passes USDC's signature check (reverts only on balance), a
flipped signature byte fails it, and the node decodes the signed tx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Critical risk] Adds payment settlement on Arc chain with escrow and Circle integration.

The PR appears safe to merge, with no new actionable issues found.

What we checked:

  • Paid transfers keep their links: An unreadable log returns a retryable answer instead of finishing the payout without a hash. Later attempts can record the transaction once its log is readable.

Summary

Adds Arc bounties with Circle settling deposits and the escrow wallet sending payouts and refunds.

  • New Arc deposits require CIRCLE_API_KEY; Arc payments always use escrow.
  • The latest change keeps payments open when their transaction logs cannot be read and tries another endpoint when a log query is refused.
  • The seven earlier, unnumbered findings were rechecked against the current code and are addressed.
  • No new actionable issues were found. Tests were inspected, not run; live Circle settlement and a funded Arc payout remain the stated rollout checks.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Payment[Payment request] --> Network{Network}
  Network -->|Base or Polygon| CDP[CDP facilitator]
  Network -->|Arc| Signer{Signed by escrow wallet?}
  Signer -->|No: deposit| Circle[Circle facilitator]
  Signer -->|Yes: payout or refund| Relay[Arc relay]
  Relay --> Used{Authorization used?}
  Used -->|No| Send[Check expiry, simulate, and send]
  Used -->|Yes| Logs{Read payment log}
  Logs -->|Found| Settled[Settle with transaction hash]
  Logs -->|Unreadable| Retry[Keep leg open and retry]
  Logs -->|Full window empty| Reuse[Return reuse answer]
Loading

Reviews (5) · Last reviewed commit: "Arc relay: a log it can't read keeps the..." · Reviewed by Greptile

Comment thread packages/api/src/payments/arc.ts Outdated
Comment thread packages/api/src/payments/arc.ts Outdated
Comment thread packages/api/src/payments/arc.ts Outdated
Comment thread packages/api/src/payments/test-fixtures.ts Outdated
claude added 2 commits October 7, 2026 23:07
…d old logs

Review fixes (Greptile on #183):

- P1 double payment: the relay called an authorization expired 10 s early by
  its own clock, before looking at a transfer still in flight; settle.ts then
  put the task back to funded and the cron could sign a second transfer while
  the first one could still land. Expiry is now the chain's call only: once a
  block at or past validBefore exists and the authorization is unused AT that
  block (eth_call pinned to it, so a lagging node errors instead of answering
  from older state), it can never be used, and only then is the leg expired.
  A simulation's "expired" waits for the chain the same way.
- P1 concurrent payouts sharing a nonce: every round now starts from the chain
  (used? expired?), waits up to 4 s for any escrow-wallet transaction in flight
  to land, and sends on the next nonce; a send that loses the nonce to a
  concurrent payout, or whose nonce goes to another transaction while it is
  watched, starts over in the same call. A revert after a clean simulation is
  retried later with backoff instead of being resent at once.
- P2 late log lookups: the AuthorizationUsed search now finds the validity
  window's blocks by timestamp (interpolation search) instead of estimating
  back from the head, so a retry a day later still finds the transaction.
- P2 fake node: FakeArcNode now keeps a mempool (pending vs latest nonce),
  changes authorization state only when a transaction is mined (unused and
  unexpired at that block), refuses a taken nonce like a node does, gives
  every block a timestamp, honours getLogs ranges (5,000-block cap) and errors
  on a pinned read it can't serve.

Tests: the in-flight payout regression through the real settle machine (it
fails with the old expiry check), expiry by chain clock vs ours, a dropped
transfer expiring only at the pinned block, waiting for and racing another
payout (two Workers land on consecutive nonces), a lost nonce, and finding the
log a day later in two queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9

# Conflicts:
#	CHANGELOG.md
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🔎 Console preview: https://ba9258a5.basedagents-console.pages.dev
🌐 Site preview: https://9185cd5f.auth-ai-web.pages.dev

(Passkey ceremonies only work on app.basedagents.ai — previews are for UI review.)

Comment thread packages/api/src/payments/arc.ts Outdated
… verdict

Review fix (Greptile on #183, P1): on a leg's first attempt settle.ts's row
says nothing was ever broadcast, so it reads "nonce already used" or a
terminal verdict as "this authorization never moved money" and lets the
cron sign a new one. If the relay's own transfer landed in that same call
but its receipt and its AuthorizationUsed log couldn't be read (watch() saw
the nonce move on and called it lost; the next round found the
authorization used with no log), the relay answered nonce_already_used and
the bounty could be paid twice.

The relay now remembers the transaction it sent (set before the broadcast,
since a node may hold it whatever the call answers). After that, a used
authorization with no readable transaction is `pending` with that hash, and
a simulation's terminal verdict waits for the next attempt, where settle.ts
knows a broadcast happened. The pinned-block expiry stays: unused at a block
past validBefore can never be used.

Tests (all three fail without the fix): the relay answers pending with its
hash when receipts and logs are unreadable, and the reuse answer only before
any send; a terminal simulation verdict after a send waits; and through the
real settle machine, such a payout stays releasing (the sweep doesn't
re-sign) and resolves to the transfer it recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Comment thread packages/api/src/payments/arc.ts Outdated
…hout a hash

Review fix (Greptile on #183, P2): after a send in the same call, keepOpen
answered a used authorization whose transaction couldn't be read with
`pending` carrying the hash this call last sent. That hash may never have
landed: e.g. an earlier attempt's transfer lands just as the retry
broadcasts (other fees, so another hash), the node refuses the retry with
"nonce too low", and logs can't be read. settle.ts then replaced the
recorded hash (the transfer that did land) with the refused one, and a
later reuse answer settled the payout with a link to a transaction that
never existed on-chain.

Now the call tracks only whether it broadcast, and answers `pending` with
no hash when the using transaction is unknown, so whatever settle.ts
recorded stays; the next attempt reads the log and records the real one.

Tests: the race above (fails on the previous relay); the unreadable-receipt
case now expects a hashless pending; through the settle machine the payout
resolves to the transfer that landed once the chain can be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
Comment thread packages/api/src/payments/arc.ts
…l over

Review fix (Greptile on #183, P2): with the previous change, a payout that
landed on its first attempt while receipts and logs were unreadable came
back as pending with no hash; if logs were still unreadable on the next
retry, that call (having sent nothing) gave the reuse answer and settle.ts
settled the payout with no transaction hash, permanently: no explorer link,
and out of the paid feed and totals.

findAuthorizationTx now tells "absent" (every block of the window was read,
no log) from "unreadable" (a node refused a query, a block couldn't be
read, or the window needed more queries than allowed). Unreadable answers
retry-later, so the leg stays open until its transaction can be read (the
cron's 24 h unknown-outcome cap still hands a stuck row to a human); only
absent gives the reuse answer. eth_getLogs now moves to the next endpoint
when a node refuses the query, instead of taking the refusal as final.

Tests (the three below fail on the previous relay): a landing with
unreadable receipts and logs stays open over two attempts and settles with
its transaction once logs are readable; a refused log query is answered by
the next endpoint; through the settle machine the payout stays releasing
through a second retry with logs still unreadable, then releases with the
transfer that landed. The keepOpen race now uses a window that reads clean
of the log (FakeArcNode.hideLogs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX2Nm8D11UusvYsuAtrcY9
@maxfain
maxfain merged commit bc07384 into main Oct 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants