Skip to content

x402 phase 2: exact/EIP-3009 payments on the Ethereum reference path, fully local demo loop #34

Open

Description

@hellno

Why

The flagship: the agent hits HTTP 402, Deckard validates the payment demand against policy, signs an EIP-3009 TransferWithAuthorization (EIP-712), the agent retries and gets the resource — keys never leave the machine, the client pays no gas (the settler does), and the facilitator cannot alter amount or destination (worst case is non-settlement, never theft). Self-custodial, policy-bounded machine payments on Ethereum.

Canonical scheme spec: https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_evm.md · protocol v2: https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md

Corrected 2026-07-10 (codebase re-verified): #34 is the x402/MPP linchpin — the whole track (#207/#208/#209/#210) is gated on it. It is a LARGE net-new build but fully unblocked and de-risked: every stated dependency is on main, and spike #206 (PR #211) proved the exact crypto byte-for-byte. Spike-forced corrections and a split/sequencing plan added below.

Ready to start — dependencies all landed (verified 2026-07-10)

Spike-forced corrections (from #206 / PR #211 — the spec had these wrong)

  • Use protocol v2 / CAIP-2 network ids; emit v2, decode v1 and v2.
  • EIP-712 domain name is "USDC" (version "2"), NOT "USD Coin".
  • The KAT lives as a JSON fixture (docs/research/x402-spike/eip3009-kat.json) — re-encode it as a Rust #[test] inside this issue (digest 0x89d2449c…, sig 0x3e1236af…).
  • propose_payment must copy the Send propose auto-allow-within-budget + guardrail-downgrade path (daemon.rs:683), NOT the always-NeedsApproval propose_message path.
  • Do not sign as an anvil default account (EIP-7702 delegation routes USDC to EIP-1271 and rejects) — the spike run.sh documents the working setup.
  • The x402-rs facilitator binary is a demo prerequisite surfaced by demo-check (a just demo sibling process on the fork), never a build dependency.

In scope

  • deckard-core: hand-rolled EIP-3009 TransferWithAuthorization via alloy sol! mirroring cow_types.rs:26-59 + eip712_signing_hash sign + the Rust KAT. (No wire files — this PR can run in parallel with the browser milestone.)
  • deckard-contract: PaymentRequirements v1/v2 decode + ProposePayment/SignPayment wire variants + CAP_X402_EXACT. (Wire files — rebase this on Connections: daemon-held dapp sessions, listed in the app, revocable per site #199's SignerRequest delta; land one enum change first, then the other. See Connections: daemon-held dapp sessions, listed in the app, revocable per site #199 "Wire sequencing".)
  • policy: per-asset token_budgets (per-tx + daily + spent-today, UTC rollover) + asset_allow (default-deny) + max_validity_secs + evaluate_payment untrusted-input checks (asset ∈ allow, amount ≤ budget, payTo rendered, validity ≤ max, network == chain). Any miss → typed Deny (Freeze the Deny-reason vocabulary in deckard-contract #28). Mainnet guardrail unchanged.
  • daemon: propose_payment (auto-allow-within-budget + guardrail-downgrade) + per-asset SpendStore + bearer-nonce idempotent retry-reuse.
  • MCP: deckard_x402_sign, capability-gated.
  • Demo fixtures (toy 402 server + x402-rs facilitator on the fork) + demo-check probes.
  • Acceptance X3–X10 headless against the fixtures (within-budget pay → settle; hostile asset → Deny; amount > budget → Deny; validity > max → Deny; retry reuses same auth; daily budget exhausted → Deny until rollover; chain-1 → NeedsApproval; transcript scan clean).

Suggested split (3 PRs)

  1. core (EIP-3009 struct + sign + KAT) — no wire files, parallelizable with browser work.
  2. policy + daemon + wire — rebases on Connections: daemon-held dapp sessions, listed in the app, revocable per site #199's enum delta.
  3. mcp + fixtures + demo-check.

Not in scope

upto/batch-settlement, Permit2, Base/hosted facilitators, the private Railgun leg + ERC-7710 settlement, general ERC-20 beyond the allowlisted demo asset.

Definition of done

  • All in-scope items runnable headless; X3–X10 green.
  • cargo fmt --all --check clean; just check green (both feature configs); cargo test --workspace green.
  • No new deps (hand-roll the x402 structs — alloy-sol-types is already compiled for cow_types); anything else approved before merge.
  • THREAT-MODEL.md delta merged (typed-data signing surface; bearer-authorization residual risk).

Ordering

Unblocked today. #32 (sign-in) is NOT a blocker (its message seam already shipped — see #32). Sequence the x402 track: #34 → #208 → #209 (+#207 for GUI beats); #210 is a near-free follow-on to #208.

Activity

  1. hellno commented on Jun 19, 2026

    @hellno
    OwnerAuthor

    ⛓ Blocked by #31 and #32 — adds PaymentAuthorization (needs #31's evolution rules); x402 phase 2 follows phase 1 sign-in (#32).

  2. hellno commented on Jul 7, 2026

    @hellno
    OwnerAuthor

    Spike #206 decision — build shape + local loop (evidence-backed)

    Ran the #206 spike (throwaway; recipe in #211 → docs/research/x402-spike/). Both questions answered with a running loop, not just reading. TL;DR: hand-roll the EIP-3009 struct in deckard-core, and build the demo on the existing Ethereum Sepolia fork. Details below, plus the spec corrections v2 forces on this issue.

    Decision 1 — hand-roll the typed data, do not depend on x402-types/x402-reqwest

    Built the same TransferWithAuthorization authorization two ways and compared byte-for-byte:

    typeHash(handroll) == typeHash(x402-rs) = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267
    digest(handroll)   == digest(x402-rs)   = 0x89d2449c668159cd27d97439dcfe47a3a6e3fc1edb7608de1e6bf0e8cb83200f
    sig(handroll)      == sig(x402-rs)       (identical 65 bytes)
    

    Four independent implementations agree on that digest+sig: deckard's hand-rolled alloy sol!, x402-rs's TransferWithAuthorization, foundry's cast wallet sign --data, and the deployed USDC contract itself (it accepted the hand-rolled signature during /settle). The typehash equals the contract's own TRANSFER_WITH_AUTHORIZATION_TYPEHASH().

    Why hand-roll wins: x402-rs's type is literally a 6-field sol! struct + eip712_signing_hash — the exact pattern deckard-core/src/cow_types.rs already ships for CoW orders (a harder struct). To get that type from x402 you'd depend on x402-chain-eip155, which drags 222 transitive crates (3× rustls, hyper, reqwest, tokio, tower, the full alloy-provider/rpc-client/transport-http/contract stack) into the #![forbid(unsafe_code)] trust core — a networked payments client behind the signer boundary — for zero cryptographic gain. x402-types alone does not even carry the signing struct (only the JSON wire structs, which are agent-side transport). It also fights our lints (client uses rand::rng(); we deny thread_rng).

    Concretely for this issue:

    • Add the 6-field sol! TransferWithAuthorization to deckard-core next to cow_types; KAT-gate it against docs/research/x402-spike/eip3009-kat.json (domain, message, typehash, digest, signature — same discipline as the cow_types ethers fixture).
    • The daemon's "EIP-712 signing for exactly the EIP-3009 struct" clause is satisfied by that one sol! + eip712_signing_hash. No general typed-data path.
    • x402-rs stays a demo-fixture facilitator binary surfaced by demo-check (build: cargo build -p x402-facilitator --no-default-features --features chain-eip155, ~90s, no Docker), never a build dependency. DoD rule 4 stays satisfied: no new deps in the trust core.
    • Header ownership: X-Payment (v1) and Payment-Required/Payment-Signature (v2) parsing is agent-side — the facilitator API is plain JSON in both versions. Deckard receives the decoded PaymentRequirements and returns a signed authorization; it never touches an HTTP header. The deckard_fetch_paid client (Agent-side x402 client: detect 402 → policy check → pay → retry #208) / x402-reqwest owns both.

    Decision 2 — the fully-local loop is PROVEN; stay on the Ethereum Sepolia fork

    402 → sign EIP-3009 → /verify → /settle → on-chain runs end-to-end against real Circle USDC FiatTokenV2_2 on anvil --fork-url $RPC_URL_SEPOLIA --fork-block-number 10822990 with a self-hosted x402-rs facilitator (eip155:11155111 → fork). Cold-start run.sh asserts:

    /verify  -> { isValid: true, payer: 0x06B9… }
    /settle  -> { success: true, transaction: 0xd927…, network: eip155:11155111 }
    buyer     100000000 -> 99000000   (-1 USDC)
    recipient 0 -> 1000000            (+1 USDC)
    nonce consumed = true
    gas paid by    = 0xf39f…  (facilitator, NOT the buyer)
    replay /settle -> success:false  "FiatTokenV2: authorization is used or canceled"
    

    Ethereum Sepolia (11155111) is not in x402-rs's KNOWN_NETWORKS (confirming no hosted facilitator serves it), but its ChainId accepts arbitrary eip155:*, so a self-hosted config just works. Go/no-go: stay on the Sepolia fork — single strongest reason: it's the demo world every Deckard demo already runs, it carries genuine EIP-3009 bytecode, and the loop is green on it with zero new infra. A Base fork would buy only hosted-facilitator parity, which is irrelevant to a self-hosted local demo; keep Base for the later hosted-reach phase this issue already defers.

    Spec corrections this issue must absorb (v2)

    1. Emit/decode v2, keyed by CAIP-2. v1 identifies the chain by network name ("base-sepolia"); Ethereum Sepolia has no v1 name, so the fork path must be v2 (network: "eip155:11155111"). The PaymentAuthorization.requirements decoder should accept v1 and v2 shapes (as this issue says) but the demo runs v2.
    2. Domain name is "USDC", version "2" on this deployment — not mainnet's "USD Coin". Verified against on-chain DOMAIN_SEPARATOR(). The untrusted PaymentRequirements carries the domain in extra: {assetTransferMethod:"eip3009", name, version}; the daemon must sign with the domain from the requirements it validated, and network-match must pin chainId 11155111 — do not hardcode the mainnet domain name.
    3. v2 field shape: requirements use amount (not v1 maxAmountRequired), CAIP-2 network, payTo, maxTimeoutSeconds, asset, extra. Facilitator request body: { x402Version, paymentPayload{ accepted, payload{ signature, authorization{from,to,value,validAfter,validBefore,nonce} }, x402Version }, paymentRequirements }. Amounts/timestamps are decimal strings.
    4. Test-fixture gotcha (affects the acceptance asserts): do not sign as an anvil default account — 0xf39F…/0x7099…/0x3C44… carry EIP-7702 delegation designators (code 0xef0100…) on real Sepolia, so USDC's SignatureChecker routes them to EIP-1271 and rejects the EOA signature. Use fresh codeless keys and assert cast code $BUYER == 0x. The demo buyer funds via anvil_setStorageAt on balance slot 9.

    Full write-up, transcripts, config, and the KAT: #211 → docs/research/x402-spike/.

  3. added 2 commits that reference this issue on Jul 7, 2026
  4. hellno commented on Jul 7, 2026

    @hellno
    OwnerAuthor

    Owner ratified (2026-07-07): both spike decisions are confirmed for implementation —

    1. Hand-roll the EIP-3009 TransferWithAuthorization in deckard-core via alloy sol! + eip712_signing_hash (the cow_types precedent), KAT-gated against docs/research/x402-spike/eip3009-kat.json. No x402-types/x402-chain-eip155 dependency in the trust core; x402-rs stays a demo-fixture facilitator binary.
    2. Stay on the Ethereum Sepolia fork for the local $5/day demo. Base / hosted-facilitator reach remains the deferred later phase.

    Full rationale + evidence: the decision comment above and docs/research/x402-spike/ (merged in #211).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions