diff --git a/docs/research/x402-spike/README.md b/docs/research/x402-spike/README.md new file mode 100644 index 0000000..835c337 --- /dev/null +++ b/docs/research/x402-spike/README.md @@ -0,0 +1,207 @@ +# Spike: x402 build shape + fully-local facilitator loop (#206) + +**Date:** 2026-07-07 · **Time-box:** ~1 day · **Status:** both questions answered with running evidence. +**Feeds:** the two build decisions #34 needs before it starts. Code here is throwaway; the deliverables +are the two decisions and this reproducible recipe. + +This spike answers two questions #34 (x402 `exact`/EIP-3009 payments, the "$5/day" issue) assumed but +never proved: + +1. **Build shape** — hand-roll the EIP-3009 `TransferWithAuthorization` typed data in `deckard-core`, + or depend on `x402-types`/`x402-reqwest`? +2. **The loop** — does a fully-local x402 `exact` loop actually work on our existing anvil fork of + Ethereum Sepolia, with a *self-hosted* facilitator (no hosted facilitator serves Ethereum Sepolia)? + +--- + +## TL;DR — the two decisions + +1. **Hand-roll the EIP-3009 struct in `deckard-core`.** Proven byte-for-byte: a 6-line `alloy sol!` + struct (exactly the `cow_types.rs` precedent) produces the *identical* EIP-712 type hash, digest, + and signature as x402-rs's own `TransferWithAuthorization`. Depending on `x402-chain-eip155` to get + that type drags **222 crates** (three `rustls` versions, `hyper`, `reqwest`, the full + `alloy-provider`/`rpc`/`transport` stack) into the `#![forbid(unsafe_code)]` trust core for zero + cryptographic gain. Use x402-rs **only** as the demo-fixture facilitator binary, never a build dep — + exactly as #34 already planned. + +2. **The fully-local loop works. Stay on the Ethereum Sepolia fork.** `402 → sign EIP-3009 → + facilitator /verify → /settle → on-chain` runs end-to-end against the real Circle USDC bytecode on + `anvil --fork-url $RPC_URL_SEPOLIA --fork-block-number 10822990`. `./run.sh` reproduces it from a + cold start and asserts the USDC moved, the nonce was consumed, gas was paid by the facilitator (not + the buyer), and replay is rejected. + +Both are green. **Go** for #34 on the Sepolia fork. + +--- + +## Question 1 — hand-roll vs `x402-types` + +### Verdict: hand-roll in `deckard-core`; x402-rs stays a demo binary. + +### Evidence: the digest comparison + +x402-rs does not do anything special to build the EIP-3009 authorization — it declares the same +Solidity struct via `alloy sol!` and calls `eip712_signing_hash`, which is what +`deckard-core/src/cow_types.rs` already does for CoW orders. A throwaway crate (not committed) built +the *same* authorization two ways — a hand-rolled `alloy sol!` struct vs x402-rs's own +`TransferWithAuthorization` — and compared them byte-for-byte: + +``` +== EIP-3009 TransferWithAuthorization: hand-roll vs x402-rs == +typeHash(handroll) = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267 +typeHash(x402-rs) = 0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267 +digest(handroll) = 0x89d2449c668159cd27d97439dcfe47a3a6e3fc1edb7608de1e6bf0e8cb83200f +digest(x402-rs) = 0x89d2449c668159cd27d97439dcfe47a3a6e3fc1edb7608de1e6bf0e8cb83200f +sig(handroll) = 0x3e1236af…924de1b +sig(x402-rs) = 0x3e1236af…924de1b +MATCH: typeHash=true digest=true signature=true +``` + +Four independent implementations agree on that digest and signature: deckard's hand-rolled `alloy +sol!`, x402-rs's `TransferWithAuthorization`, foundry's `cast wallet sign --data`, and — the ultimate +oracle — the **deployed USDC contract itself**, which accepted the hand-rolled signature on-chain +during `/settle`. The type hash `0x7c7c6cdb…` equals the contract's own +`TRANSFER_WITH_AUTHORIZATION_TYPEHASH()` constant. The printed vector is the KAT #34 gates against +([`eip3009-kat.json`](eip3009-kat.json)). + +### Why not the dependency + +| Axis | Hand-roll (`sol!` in `deckard-core`) | Depend on `x402-*` | +|------|--------------------------------------|--------------------| +| Trust-core surface | `alloy-primitives` + `alloy-sol-types` — **already compiled** by `deckard-core` for `cow_types` | `x402-chain-eip155` (client) = **222 transitive crates**: 3× `rustls`, `hyper`, `reqwest`, `tokio`, `tower`, full `alloy-provider`/`rpc-client`/`transport-http`/`contract` | +| Audit load | 6-line struct + a KAT test (the `cow_types` pattern) | a networked payments client inside the `#![forbid(unsafe_code)]` signer boundary | +| What you'd actually reuse | — | only the tiny serde wire structs (`PaymentPayload`/`PaymentRequirements`) — and those are **agent-side transport**, not the trust core. `x402-types` alone does *not* carry the `sol!` signing struct; that lives in the heavy `x402-chain-eip155`. | +| Fit with workspace lints | clean | x402-rs's `client` uses `rand::rng()` (deckard denies `thread_rng`) and has a `tracing`-under-wrong-feature bug we hit | +| alloy version | deckard `1.6.0` | x402 `1.6` (chain crate) — compatible major, but a second edge to manage | + +### Header ownership (v1 `X-PAYMENT` vs v2 `Payment-*`) + +The facilitator's `/verify` and `/settle` take **plain JSON bodies** in both protocol versions — the +version difference is purely *how the client and server exchange the challenge and payload over HTTP*, +which is **agent-side transport, not Deckard's concern** (this is exactly #34's "transport stays +agent-side"). Confirmed against x402-rs source: + +- **v1:** request header `X-Payment` = base64(JSON `PaymentPayload`); response header `Payment-Response`. +- **v2:** 402 response header `Payment-Required` = base64(`PaymentRequired`); client retries with + `Payment-Signature` = base64(signed `PaymentPayload`); response header `Payment-Response`. + +Deckard receives the already-decoded `PaymentRequirements` from the agent and returns a signed +authorization. It never parses an HTTP header. The agent-side x402 wrapper (x402-reqwest or the +`deckard_fetch_paid` client in #208) owns all header encode/decode for both versions. + +--- + +## Question 2 — the fully-local loop, PROVEN + +`./run.sh` (self-contained; starts anvil + the facilitator, runs the whole loop, asserts, cleans up). +Cold-start transcript: + +``` +== 1/8 start anvil fork of Ethereum Sepolia @ block 10822990 == +anvil up: chain=11155111 block=10822990 +== 2/8 start x402-rs facilitator (config: eip155:11155111 -> fork) == +/supported: {"kinds":[{"network":"eip155:11155111","scheme":"exact","x402Version":2}], "signers":{"eip155:11155111":["0xf39F…"]}} +== 3/8 fund throwaway buyer with 100 USDC via setStorageAt (slot 9) == +buyer USDC = 100000000 +== 4/8 buyer signs the EIP-3009 TransferWithAuthorization (EIP-712, gasless) == +signature = 0x3e1236af…924de1b +== 5/8 POST /verify == +{ "isValid": true, "payer": "0x06B92bd3…" } +== 6/8 POST /settle == +{ "success": true, "transaction": "0xd927…", "network": "eip155:11155111", "payer": "0x06B92bd3…" } +== 7/8 assert on-chain effect == +buyer 100000000 -> 99000000 (expect -1000000) +recipient 0 -> 1000000 (expect +1000000) +nonce consumed = true (expect true) +gas paid by = 0xf39f…92266 (facilitator, not the buyer) +== 8/8 replay protection — re-settle the same authorization MUST fail == +{"success":false, "errorMessage":"…FiatTokenV2: authorization is used or canceled…"} +== RESULT: local x402/EIP-3009 facilitator loop PROVEN on the Sepolia fork ✅ == +``` + +The settlement tx (`status 1`, block 10822992) emits the two expected events — `AuthorizationUsed(buyer, +nonce)` and `Transfer(buyer → recipient, 1000000)` — and its `from` is the facilitator's signer, so the +buyer paid **no gas**. This is the security property #34 rests on: the signature pins amount + recipient ++ validity window, so the facilitator can withhold settlement but cannot alter or steal. + +### Facilitator choice + +**x402-rs** ( , Apache-2.0 ). Chosen over +`second-state/x402-facilitator` because it proved out immediately: CAIP-2 JSON config, an +`eip155:11155111` entry pointing at the fork, `cargo build -p x402-facilitator --no-default-features +--features chain-eip155` (~90 s, **no Docker needed**), and it serves `scheme:exact / x402Version:2` +on our chain out of the box. Ethereum Sepolia (`11155111`) is **not** in x402-rs's `KNOWN_NETWORKS` +table (base-sepolia, celo-sepolia, etc. are — no Ethereum), but its `ChainId` accepts arbitrary +`eip155:*` references, so a self-hosted config just works. That absence is also the confirmation that +no hosted facilitator serves this chain — self-hosting is mandatory, as #206 assumed. + +--- + +## Spec corrections v2 forced + +1. **Use protocol v2 (CAIP-2), not v1.** v1 identifies the chain by *network name* + (`"base-sepolia"`); Ethereum Sepolia has no registered v1 name, so the fork loop must run under v2, + where the network is the CAIP-2 id `eip155:11155111`. +2. **This USDC's EIP-712 domain name is `"USDC"`, version `"2"`** — *not* mainnet's `"USD Coin"`. + Verified against the on-chain `DOMAIN_SEPARATOR()` (`0xb90e5057…`). The v2 402 challenge must carry + `extra: { "assetTransferMethod": "eip3009", "name": "USDC", "version": "2" }` so the facilitator + reconstructs the right domain without querying the contract. **#34's `PaymentRequirements` decoder + must not assume the mainnet domain name.** +3. **v2 field renames:** requirements use `amount` (not v1's `maxAmountRequired`), the CAIP-2 `network`, + and drop `resource`/`description`/`mimeType` from the requirements object (they moved to an optional + `resource` block on the payload). The facilitator request is + `{ x402Version, paymentPayload{ accepted, payload{ signature, authorization }, x402Version }, + paymentRequirements }`. +4. **v2 HTTP headers** are `Payment-Required` (402), `Payment-Signature` (retry), `Payment-Response` + (result) — case-insensitive; x402-rs uses that capitalization. Agent-side only. + +--- + +## The gotcha worth remembering: don't sign as an anvil default account + +The loop first failed with `FiatTokenV2: invalid signature` even though the digest was provably correct +(matched on-chain domain separator + typehash; ecrecover returned the signer). Root cause: **anvil's +canonical test accounts (`0xf39F…`, `0x7099…`, `0x3C44…`) carry EIP-7702 delegation designators +(`code == 0xef0100…`) on *real* Sepolia.** USDC's `SignatureChecker` sees code at the `from` address +and routes to EIP-1271 `isValidSignature`, rejecting a plain EOA signature. Fix: sign as a **fresh, +randomly-generated codeless EOA** (the recipe uses deterministic throwaway keys `dec00d01`/`dec00d02` +and asserts `cast code $BUYER == 0x`). Applies to #34's demo fixtures and any 7702-era fork test. + +--- + +## Go / no-go: stay on the Ethereum Sepolia fork + +**Go — stay on the Sepolia fork.** Single strongest reason: **it is the demo world we already run** +(`just demo`, block 10822990, the same fork every other Deckard demo uses), it carries genuine +FiatTokenV2_2 EIP-3009 bytecode, and the full loop is now proven green on it with zero new +infrastructure. Falling back to an anvil fork of Base would buy only *hosted-facilitator parity* — and +this loop is **self-hosted by design** (`x402-rs` as a `demo-check` prerequisite, never a product +dependency), so hosted parity is irrelevant to the local demo. Base becomes relevant only in the later +"hosted-facilitator reach" phase #34 explicitly defers; keep it there. + +--- + +## Reproduce + +```bash +export RPC_URL_SEPOLIA=https://sepolia.drpc.org # free archive tier; serves block 10822990 + +# 1. Build the facilitator once (no Docker): +git clone https://github.com/x402-rs/x402-rs && cd x402-rs +cargo build -p x402-facilitator --no-default-features --features chain-eip155 +export FACILITATOR_BIN=$PWD/target/debug/x402-facilitator + +# 2. From the deckard repo root, run the whole proof (starts anvil + facilitator, asserts, cleans up): +cd docs/research/x402-spike && ./run.sh +``` + +Requirements: `foundry` (anvil, cast), `jq`, `curl`. Throwaway keys only; no secrets in any file here. + +## Files + +| File | What | +|------|------| +| [`run.sh`](run.sh) | self-contained proof of the loop (deliverable #1 / #2) | +| [`facilitator-config.json`](facilitator-config.json) | x402-rs config: the `eip155:11155111 → fork` entry | +| [`typed-data.json`](typed-data.json) | the EIP-3009 `TransferWithAuthorization` EIP-712 typed-data template | +| [`eip3009-kat.json`](eip3009-kat.json) | the known-answer vector (domain, message, typehash, digest, signature) for #34 | diff --git a/docs/research/x402-spike/eip3009-kat.json b/docs/research/x402-spike/eip3009-kat.json new file mode 100644 index 0000000..fdf3438 --- /dev/null +++ b/docs/research/x402-spike/eip3009-kat.json @@ -0,0 +1,22 @@ +{ + "digest": "0x89d2449c668159cd27d97439dcfe47a3a6e3fc1edb7608de1e6bf0e8cb83200f", + "domain": { + "chainId": 11155111, + "name": "USDC", + "verifyingContract": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + "version": "2" + }, + "eip712_type": "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)", + "message": { + "from": "0x06b92bd300c5cf9a8ecc64d7b9c51163d6b177a1", + "nonce": "0x1111111111111111111111111111111111111111111111111111111111111111", + "to": "0x791668cb0cb50d90dfb1ee215de4cefa3eab953e", + "validAfter": "0", + "validBefore": "4102444800", + "value": "1000000" + }, + "note": "EIP-3009 TransferWithAuthorization KAT for Circle USDC on Ethereum Sepolia (fork). deckard #206.", + "signature": "0x3e1236afdad74fe2d1a105ded05a462280867fc614a665b9a5177affcce2c0876ea60a2a4fc85adc8df553a28fe616c5943a5e1361b74f6091c843569a0924de1b", + "signer": "0x06b92bd300c5cf9a8ecc64d7b9c51163d6b177a1", + "type_hash": "0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267" +} \ No newline at end of file diff --git a/docs/research/x402-spike/facilitator-config.json b/docs/research/x402-spike/facilitator-config.json new file mode 100644 index 0000000..0f8d3df --- /dev/null +++ b/docs/research/x402-spike/facilitator-config.json @@ -0,0 +1,14 @@ +{ + "port": 8080, + "host": "127.0.0.1", + "chains": { + "eip155:11155111": { + "eip1559": true, + "signers": ["0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"], + "rpc": [{ "http": "http://127.0.0.1:8545" }] + } + }, + "schemes": [ + { "id": "v2-eip155-exact", "chains": "eip155:*" } + ] +} diff --git a/docs/research/x402-spike/run.sh b/docs/research/x402-spike/run.sh new file mode 100755 index 0000000..82aab1d --- /dev/null +++ b/docs/research/x402-spike/run.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# deckard #206 — prove the fully-local x402 `exact`/EIP-3009 facilitator loop on an anvil +# fork of Ethereum Sepolia. Self-contained: starts anvil + the x402-rs facilitator, funds a +# throwaway buyer, signs an EIP-3009 authorization, drives /verify + /settle, asserts the USDC +# moved and the nonce was consumed, then proves replay protection. Zero secrets; throwaway keys. +# +# Requirements: foundry (anvil, cast), jq, curl, and an x402-rs facilitator binary. +# Env: +# RPC_URL_SEPOLIA an Ethereum Sepolia *archive* RPC (drpc.org free tier works) +# FACILITATOR_BIN path to the x402-facilitator binary +# (build once: cargo install --git https://github.com/x402-rs/x402-rs \ +# --no-default-features --features chain-eip155 x402-facilitator +# or: cargo build -p x402-facilitator --no-default-features --features chain-eip155) +set -euo pipefail + +RPC_URL_SEPOLIA="${RPC_URL_SEPOLIA:-https://sepolia.drpc.org}" +FACILITATOR_BIN="${FACILITATOR_BIN:-x402-facilitator}" +FORK_BLOCK=10822990 +HERE="$(cd "$(dirname "$0")" && pwd)" + +RPC=http://127.0.0.1:8545 +FACIL=http://127.0.0.1:8080 +USDC=0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238 # Circle USDC on Ethereum Sepolia (FiatTokenV2_2) + +# Throwaway keys — deterministic, obviously-fake, ZERO secrets. NB: do NOT use anvil's canonical +# test accounts (0xf39F../0x7099../0x3C44..): on real Sepolia several carry EIP-7702 delegation +# designators (code 0xef0100..), so USDC's SignatureChecker routes them through EIP-1271 and +# rejects a plain EOA signature ("FiatTokenV2: invalid signature"). +FACILITATOR_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # gas payer; funded by anvil +BUYER_KEY=0x00000000000000000000000000000000000000000000000000000000dec00d01 +RECIPIENT=$(cast wallet address --private-key 0x00000000000000000000000000000000000000000000000000000000dec00d02) +BUYER=$(cast wallet address --private-key "$BUYER_KEY") +NONCE=0x1111111111111111111111111111111111111111111111111111111111111111 +VALUE=1000000 # 1.000000 USDC (6 decimals) + +say(){ printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; } +cleanup(){ [ -n "${ANVIL_PID:-}" ] && kill "$ANVIL_PID" 2>/dev/null || true; + [ -n "${FACIL_PID:-}" ] && kill "$FACIL_PID" 2>/dev/null || true; } +trap cleanup EXIT + +say "1/8 start anvil fork of Ethereum Sepolia @ block $FORK_BLOCK" +anvil --fork-url "$RPC_URL_SEPOLIA" --fork-block-number "$FORK_BLOCK" --port 8545 >/tmp/x402-anvil.log 2>&1 & +ANVIL_PID=$! +until cast chain-id --rpc-url "$RPC" >/dev/null 2>&1; do sleep 0.5; done +echo "anvil up: chain=$(cast chain-id --rpc-url $RPC) block=$(cast block-number --rpc-url $RPC)" +[ "$(cast code $USDC --rpc-url $RPC | wc -c)" -gt 3 ] || { echo "FAIL: USDC bytecode absent on fork"; exit 1; } +[ "$(cast code $BUYER --rpc-url $RPC)" = "0x" ] || { echo "FAIL: buyer $BUYER has code — pick a fresh EOA"; exit 1; } + +say "2/8 start x402-rs facilitator (config: eip155:11155111 -> fork)" +CONFIG=$(mktemp) +cat > "$CONFIG" </tmp/x402-facilitator.log 2>&1 & +FACIL_PID=$! +until curl -sf "$FACIL/health" >/dev/null 2>&1; do sleep 0.5; done +echo "facilitator up; /supported:"; curl -s "$FACIL/supported" | jq -c . + +say "3/8 fund throwaway buyer with 100 USDC via setStorageAt (slot 9)" +cast rpc anvil_setStorageAt "$USDC" "$(cast index address $BUYER 9)" "$(cast to-uint256 100000000)" --rpc-url "$RPC" >/dev/null +echo "buyer USDC = $(cast call $USDC 'balanceOf(address)(uint256)' $BUYER --rpc-url $RPC)" + +say "4/8 buyer signs the EIP-3009 TransferWithAuthorization (EIP-712, gasless)" +TYPED=$(mktemp) +cat > "$TYPED" < $BAL_B1 (expect -$VALUE)" +echo "recipient $BAL_R0 -> $BAL_R1 (expect +$VALUE)" +echo "nonce consumed = $USED (expect true)" +echo "gas paid by = $GASPAYER (facilitator, not the buyer)" +[ "$USED" = "true" ] || { echo "FAIL: nonce not consumed"; exit 1; } + +say "8/8 replay protection — re-settle the same authorization MUST fail" +S2=$(curl -s -X POST "$FACIL/settle" -H 'content-type: application/json' -d "$BODY"); echo "$S2" | jq -c . +[ "$(echo "$S2" | jq -r .success)" = "false" ] || { echo "FAIL: replay double-spent!"; exit 1; } + +say "RESULT: local x402/EIP-3009 facilitator loop PROVEN on the Sepolia fork ✅" diff --git a/docs/research/x402-spike/typed-data.json b/docs/research/x402-spike/typed-data.json new file mode 100644 index 0000000..8e23ced --- /dev/null +++ b/docs/research/x402-spike/typed-data.json @@ -0,0 +1,33 @@ +{ + "types": { + "EIP712Domain": [ + {"name":"name","type":"string"}, + {"name":"version","type":"string"}, + {"name":"chainId","type":"uint256"}, + {"name":"verifyingContract","type":"address"} + ], + "TransferWithAuthorization": [ + {"name":"from","type":"address"}, + {"name":"to","type":"address"}, + {"name":"value","type":"uint256"}, + {"name":"validAfter","type":"uint256"}, + {"name":"validBefore","type":"uint256"}, + {"name":"nonce","type":"bytes32"} + ] + }, + "primaryType": "TransferWithAuthorization", + "domain": { + "name": "USDC", + "version": "2", + "chainId": 11155111, + "verifyingContract": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" + }, + "message": { + "from": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "to": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "value": "1000000", + "validAfter": "0", + "validBefore": "4102444800", + "nonce": "0x1111111111111111111111111111111111111111111111111111111111111111" + } +}