x402: pluggable self-hosted facilitator + on-chain payment confirmation gate - #47
Merged
Merged
Conversation
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
verify.isValid+settle.successalone 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
Facilitatorinterface —X402_FACILITATOR_KIND=cdp|selfhosted:cdp: existing CDP client, unchanged flow — and now sends theaud: ["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+/settlefacilitator (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.isValid === true/success === truepass (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_DEPTHconfirmations. Sits on theverified → executingedge so every retry re-checks (202 payment_not_confirmed_onchain) until confirmed. A compromised facilitator can no longer cause free delivery. Raweth_*JSON-RPC overfetch— 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 forkind=cdp;selfhostedrequiresX402_USDC_ADDRESS(no safe cross-chain default); live mode requiresX402_RPC_URLunlessX402_SKIP_ONCHAIN_CONFIRM=trueis 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_KEYoff the facilitator host; self-host at your own risk);.env.exampleEthereum-Sepolia example.Verification
FacilitatorConfig.kinddefaults to"cdp"; existing function API unchanged.Notes
🤖 Generated with Claude Code