Skip to content

x402: pluggable self-hosted facilitator + on-chain payment confirmation gate - #47

Merged
douglasgan merged 3 commits into
mainfrom
x402-selfhost-facilitator-hardening
Aug 25, 2026
Merged

douglasgan merged 3 commits into
mainfrom
x402-selfhost-facilitator-hardening

Conversation

@douglasgan

Copy link
Copy Markdown
Contributor

Why

Sera doesn't host the x402 service — devs self-host it (README: "agents.sera.cx does not offer a public x402 URL"). Two problems with that today:

  1. The facilitator client is hard-coupled to Coinbase CDP, which doesn't serve Ethereum Sepolia — so a dev can't run the payment leg on Sera's own chain, and can't run without a Coinbase dependency at all.
  2. Delivery trusts the facilitator's word. verify.isValid + settle.success alone release the swap — so a compromised/lying facilitator (a real concern once devs run their own) could trigger free FX delivery from the vault.

What

1. Pluggable Facilitator interfaceX402_FACILITATOR_KIND=cdp|selfhosted:

  • cdp: existing CDP client, unchanged flow — and now sends the aud: ["cdp_service"] claim CDP requires (fixes the live-blocker flagged in the fix(x402): use CDP JWT authentication #39 review, where every live JWT would be rejected).
  • selfhosted: any open x402 /verify+/settle facilitator (e.g. the Apache-2.0 reference from coinbase/x402) on any EVM chain incl. Ethereum Sepolia (eip155:11155111), optional static bearer. No Coinbase dependency; payment + Sera settlement can share one chain.
  • Both fail closed: only explicit isValid === true / success === true pass (adopts the hardening from Feature/x402 base sepolia e2e #32's review).

2. On-chain confirmation gate (payment-confirm.ts) — the trust boundary: before any swap executes, the service independently confirms via an operator-controlled RPC (X402_RPC_URL) that the settle tx succeeded, moved ≥ the required USDC to the vault, at ≥ X402_CONFIRMATION_DEPTH confirmations. Sits on the verified → executing edge so every retry re-checks (202 payment_not_confirmed_onchain) until confirmed. A compromised facilitator can no longer cause free delivery. Raw eth_* JSON-RPC over fetch — zero new dependencies. Fail-closed on every error path (no receipt, revert, wrong token/recipient, short value, shallow depth, RPC error).

3. Boot gates (env.ts): CDP keys required only for kind=cdp; selfhosted requires X402_USDC_ADDRESS (no safe cross-chain default); live mode requires X402_RPC_URL unless X402_SKIP_ONCHAIN_CONFIRM=true is set explicitly (with a loud warning).

4. Docs: README "self-hosted facilitator" run mode + a trust-model section (what a hacked facilitator can/can't do; keep SIGNER_PRIVATE_KEY off the facilitator host; self-host at your own risk); .env.example Ethereum-Sepolia example.

Verification

  • 17 new tests: full fail-closed matrix for the on-chain gate (lied txHash, revert, wrong recipient/token, short amount, shallow confirmations, RPC failure) + selfhosted backend (own URL, static bearer, no CDP JWT) + fail-closed result handling.
  • 71/71 x402 tests pass, workspace typecheck clean.
  • Back-compat: FacilitatorConfig.kind defaults to "cdp"; existing function API unchanged.

Notes

  • This delivers the "safe-by-default, dev-hosted" posture: Sera ships the template, never hosts it, and the template can't easily lose a self-hoster's money via a lying facilitator.
  • Remaining before any live flip: real Sepolia E2E (unchanged requirement).

🤖 Generated with Claude Code

douglasgan and others added 3 commits August 26, 2026 01:19
…on gate

Sera does not host the x402 service; devs do. This makes that safe:

1. Facilitator interface with two backends (facilitator.ts):
   - cdp: existing Coinbase CDP client (unchanged flow; now also sends the
     aud:["cdp_service"] claim CDP requires — fixes the live-blocker where
     every JWT would be rejected)
   - selfhosted: any open x402 /verify+/settle facilitator on any EVM chain
     (incl. Ethereum Sepolia, eip155:11155111) with optional static bearer.
     Removes the Coinbase dependency and lets payment + Sera settlement
     share one chain.
   Both fail closed: only explicit isValid===true / success===true pass.

2. On-chain confirmation gate (payment-confirm.ts): before any swap
   executes, independently confirm via an operator-controlled RPC that the
   settle tx succeeded, moved >= the required USDC to the vault, and is
   buried under >= X402_CONFIRMATION_DEPTH blocks. Sits on the
   verified->executing edge so retries re-check until confirmed (202
   payment_not_confirmed_onchain). A compromised facilitator can no longer
   trigger free FX delivery from the vault. Raw eth_* JSON-RPC over fetch —
   no new dependencies. Fail-closed on every error path.

3. Env gates (env.ts): X402_FACILITATOR_KIND=cdp|selfhosted; CDP keys only
   required for kind=cdp; selfhosted requires X402_USDC_ADDRESS (no safe
   cross-chain default); live mode now requires X402_RPC_URL unless
   X402_SKIP_ONCHAIN_CONFIRM=true is set explicitly (loud warning).

4. Docs: README "self-hosted facilitator" run mode + trust-model section
   (what a hacked facilitator can/cannot do, keep SIGNER_PRIVATE_KEY off
   the facilitator host); .env.example Ethereum Sepolia example.

Tests: 17 new (onchain-confirm fail-closed matrix; selfhosted backend +
fail-closed result handling). 71/71 x402 tests pass; workspace typecheck
clean. Back-compat: FacilitatorConfig.kind defaults to "cdp".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review of the first cut found the headline property false: the
confirm gate checked only "some USDC >= amount reached the vault", so a
compromised facilitator could point txHash at someone ELSE's legitimate
transfer (another payment, a deposit) and reuse one hash across unlimited
payment_ids — free delivery. Fixed, plus every MEDIUM/LOW from the review:

CRITICAL — payment binding + single-use ledger:
- payment-binding.ts (new): parse the signed EIP-3009 authorization out of
  the X-PAYMENT header (base64 x402 v1 / raw JSON). Unparseable => hard
  reject in live mode — never pay out against a proof we cannot bind.
- payment-confirm.ts: optional payerFrom/exactValueBaseUnits binding — the
  Transfer must be FROM the authorized payer of EXACTLY the authorized
  value; Transfer data must be one 32-byte word (no non-standard layouts).
- state.ts: consumed_txs ledger (SQLite PRIMARY KEY / memory map) —
  store.claimTx() lets a settle tx authorize at most ONE payment_id,
  idempotent for retries of the same payment. server.ts claims atomically
  after confirm, before executing; reuse => 402 payment_proof_already_used.
- confirmPayment also requires auth.to === vault and auth.value >= charged.

MEDIUMs:
- settle-failure is now terminal `settle_failed` (single CAS from verified)
  — no USDC was charged, so it no longer lands in the refundables queue
  (false refund signal) and no longer races a concurrent execution.
- X402_USDC_ADDRESS now required whenever X402_NETWORK != base (the baked
  default is Base-mainnet USDC; on any other chain it can never match).
- No more 60s in-handler poll: single checkOnce per request; clients drive
  retry cadence via 202.

LOWs:
- Upstream error text never echoed to clients (generic 202/502 bodies);
  stderr logs newline-stripped (no log forging) and length-capped.
- 2xx facilitator bodies that aren't plain objects (null/array/string)
  normalize to {} and fail closed instead of throwing.

Tests: 10 new (binding matrix incl. wrong-payer/wrong-value/non-standard
data, parser fail-closed set, claimTx single-use + idempotent retry,
null-body facilitator, non-base USDC gate). 81/81 pass; workspace
typecheck clean. README trust-model updated to describe the binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-2 adversarial review found the binding was to attacker-chosen header
values — nothing verified the EIP-3009 signature, so a malicious facilitator
could name a victim's `from`, point at the victim's real vault transfer, and
get free delivery once per real inbound tx (attacker profit + victim loss).

CRITICAL fix — cryptographic binding:
- eip3009.ts (new): recover the transferWithAuthorization EIP-712 signer via
  viem's audited recoverTypedDataAddress and require it to equal `from`.
  `from` is now un-forgeable — an attacker can't sign as a payer they don't
  control. Wrong domain fails closed (rejects, never forges). Also enforces
  the validAfter/validBefore window.
- payment-binding.ts: parse ALL signed fields (from,to,value,validAfter,
  validBefore,nonce) + signature; fail-closed if any missing/invalid.
- payment.ts confirmPayment: verify the signature BEFORE the on-chain check;
  no chainId → refuse.
- env.ts: derive chainId from network / X402_CHAIN_ID; X402_USDC_NAME /
  _VERSION for the EIP-712 domain (USDC defaults).

HIGH fix — durable ledger:
- Live mode now requires X402_STATE_DB (SQLite). A memory-only ledger is
  wiped on restart, which would let a consumed settle tx be replayed.

LOW fix:
- settle_failed now has an idempotent retry branch (clean 409, not a 500
  leaking internal state).

Adds viem (audited secp256k1 + EIP-712 — never hand-rolled). Tests: eip3009
signature matrix incl. the forged-`from` attacker case, mutated-auth,
wrong-domain, time-window, garbage sigs; env gates for state-db + chainId.
89/89 pass; workspace typecheck clean; prod audit clean. README + .env.example
document the signature verification + required vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@douglasgan
douglasgan merged commit b503d0e into main Aug 25, 2026
4 checks passed
@douglasgan douglasgan added the rate-level-4-tier Hard: concurrency / money-path / security-critical label Aug 25, 2026
mashharuki pushed a commit to mashharuki/sera-agents that referenced this pull request Aug 30, 2026
…nits

Follow-up to PR sera-cx#47. Adds the live-testnet verification the fail-closed
payment gate needs (a green real payment is the only proof the EIP-712 USDC
domain is right), plus the three LOW cosmetic items from the third review.

E2E tooling (proofs from the round-3 review's "what Sepolia must prove"):
- scripts/x402-client.mjs: minimal buyer-side EIP-3009 signer + X-PAYMENT
  header builder, matching the server's parser/verifier exactly. Refuses to
  sign for a `from` it doesn't hold the key to.
- scripts/sepolia-e2e.mjs (`npm run e2e:sepolia`): env-gated live harness —
  proof 1 (genuine payment SUCCEEDS end-to-end, catches the LOW-1 domain
  footgun), proof 2 (delivery gated on >= confirmationDepth), proof 4
  (forged-`from` rejected against a real facilitator). Missing config exits 2
  with instructions; never invents keys or funds anything.
- test/e2e-client-contract.test.ts: CI-side de-risking of proof 1 with ZERO
  infra — a buyer-signed payment round-trips through the server's own
  parsePaymentAuthorization + verifyTransferAuthorization, and a wrong-domain
  signature is rejected (fail-closed). Catches client/server crypto drift
  before it becomes silent live-payment rejection.
- E2E-SEPOLIA.md: full runbook incl. proof 3 (cross-payment txHash replay +
  durable-ledger restart persistence, facilitator/restart-orchestrated) and
  the pre-mainnet sign-off checklist.

LOW nits from the third adversarial review:
- LOW-1: env.ts now prints a loud [x402] WARNING when a live non-base network
  runs the on-chain confirm with X402_USDC_NAME/_VERSION left at Base defaults
  — a domain mismatch silently rejects every payment (fail-closed footgun).
- LOW-2: removed the dead confirmPaymentOnChain poll (no callers); fixed the
  stale checkOnce doc comment.
- LOW-3: simplified the eip3009 signature-length guard and documented that it
  rejects EIP-2098 compact sigs (known limitation, safe direction).

92/92 tests pass (89 + 3 new contract tests); tsc + prod audit clean; biome
check clean. README "Remaining gate" points at the new tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rate-level-4-tier Hard: concurrency / money-path / security-critical

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant