From 5b2307a2c8e01eb8eec26426fe1a53e799a2766e Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 7 Jul 2026 18:20:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?docs(x402):=20spike=20#206=20=E2=80=94=20ha?= =?UTF-8?q?nd-roll=20verdict=20+=20proven=20local=20EIP-3009=20facilitator?= =?UTF-8?q?=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone spike feeding two build decisions into #34: Q1 (dep decision): hand-roll the EIP-3009 TransferWithAuthorization in deckard-core (the cow_types alloy sol! precedent), NOT x402-types. Proven byte-for-byte: hand-roll, x402-rs, foundry cast, and the deployed USDC contract all agree on typehash/digest/ signature. Pulling x402-chain-eip155 into the trust core = 222 transitive crates (3x rustls, hyper, reqwest, full alloy-provider stack) for zero cryptographic gain. Q2 (local loop): PROVEN on anvil --fork-url $RPC_URL_SEPOLIA --fork-block-number 10822990. Self-hosted x402-rs facilitator (eip155:11155111 -> fork, no Docker) runs 402 -> sign -> /verify -> /settle end-to-end against real Circle USDC FiatTokenV2_2: USDC moves, nonce consumed, facilitator pays gas, replay rejected. Go: stay on the Sepolia fork. Recipe reproduces from a cold start via run.sh given $RPC_URL_SEPOLIA. Spike code (digest-cmp) is self-excluded from the deckard workspace; no workspace Cargo.toml/lock changes. Refs #206, feeds #34. --- docs/research/x402-spike/README.md | 210 ++++++++++++++++++ .../research/x402-spike/digest-cmp/Cargo.toml | 27 +++ .../x402-spike/digest-cmp/src/main.rs | 157 +++++++++++++ docs/research/x402-spike/eip3009-kat.json | 22 ++ .../x402-spike/facilitator-config.json | 14 ++ docs/research/x402-spike/run.sh | 123 ++++++++++ docs/research/x402-spike/typed-data.json | 33 +++ 7 files changed, 586 insertions(+) create mode 100644 docs/research/x402-spike/README.md create mode 100644 docs/research/x402-spike/digest-cmp/Cargo.toml create mode 100644 docs/research/x402-spike/digest-cmp/src/main.rs create mode 100644 docs/research/x402-spike/eip3009-kat.json create mode 100644 docs/research/x402-spike/facilitator-config.json create mode 100755 docs/research/x402-spike/run.sh create mode 100644 docs/research/x402-spike/typed-data.json diff --git a/docs/research/x402-spike/README.md b/docs/research/x402-spike/README.md new file mode 100644 index 0000000..d513181 --- /dev/null +++ b/docs/research/x402-spike/README.md @@ -0,0 +1,210 @@ +# 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 ( `digest-cmp/` ) + +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. The spike builds the *same* authorization +two ways and compares: + +``` +== 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 + +# 3. (Question 1) reproduce the digest comparison + regenerate the KAT: +cd digest-cmp && cargo run +``` + +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 | +| [`digest-cmp/`](digest-cmp/) | Question-1 harness: hand-roll vs x402-rs, byte-for-byte (standalone; git dep on x402-rs) | diff --git a/docs/research/x402-spike/digest-cmp/Cargo.toml b/docs/research/x402-spike/digest-cmp/Cargo.toml new file mode 100644 index 0000000..c2a6707 --- /dev/null +++ b/docs/research/x402-spike/digest-cmp/Cargo.toml @@ -0,0 +1,27 @@ +# Reference / throwaway spike crate (deckard #206). NOT a deckard workspace member — it lives +# outside the workspace on purpose so the no-new-deps DoD rule stays intact. Build it standalone +# (needs network to fetch the x402-rs git dep): +# +# cd docs/research/x402-spike/digest-cmp && cargo run +# +# It compares deckard's hand-rolled `alloy sol!` EIP-3009 digest/signature against x402-rs's own +# `TransferWithAuthorization` type, byte-for-byte, and writes the KAT vector to /tmp/x402-spike/. +# Empty table: opt this crate OUT of the surrounding deckard workspace so it builds standalone +# and never becomes a deckard dependency. +[workspace] + +[package] +name = "digest-cmp" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +# The reference implementation we compare against (x402-rs's own EIP-3009 type). +x402-chain-eip155 = { git = "https://github.com/x402-rs/x402-rs", features = ["client", "telemetry"] } +# Mirror deckard-core's toolchain: hand-roll the typed data via alloy sol! + eip712. +alloy-primitives = "1.6" +alloy-sol-types = { version = "1.6", features = ["json"] } +alloy-signer = "2.0" +alloy-signer-local = "2.0" +serde_json = "1.0" diff --git a/docs/research/x402-spike/digest-cmp/src/main.rs b/docs/research/x402-spike/digest-cmp/src/main.rs new file mode 100644 index 0000000..109e5cd --- /dev/null +++ b/docs/research/x402-spike/digest-cmp/src/main.rs @@ -0,0 +1,157 @@ +//! Spike (#206) — Question 1 evidence. +//! +//! Build the SAME EIP-3009 `TransferWithAuthorization` authorization two ways and +//! compare the EIP-712 digest AND the ECDSA signature byte-for-byte: +//! +//! A. HAND-ROLL — a local `alloy sol!` struct + `eip712_domain!` + `eip712_signing_hash`. +//! This is exactly how `deckard-core/src/cow_types.rs` builds CoW orders today. +//! B. x402-rs — `x402_chain_eip155::v1_eip155_exact::types::TransferWithAuthorization`. +//! +//! If A == B byte-for-byte, then depending on `x402-types` buys deckard-core nothing over the +//! `sol!` it already uses — and the printed vector doubles as the KAT #34 gates against. + +use alloy_primitives::{address, b256, Address, B256, U256}; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{eip712_domain, sol, SolStruct}; + +// A. HAND-ROLL: the deckard-core way. Identical Solidity struct shape to EIP-3009. +sol! { + #[allow(non_snake_case)] + struct TransferWithAuthorization { + address from; + address to; + uint256 value; + uint256 validAfter; + uint256 validBefore; + bytes32 nonce; + } +} + +// B. The x402-rs reference type. +use x402_chain_eip155::v1_eip155_exact::types::TransferWithAuthorization as X402TWA; + +fn main() { + // ---- Fixed KAT inputs (fresh throwaway keys; zero secrets) ---- + // NB: do NOT reuse anvil's canonical test accounts (0x7099.., 0x3C44..) — on real + // Sepolia those carry EIP-7702 delegation designators (0xef0100..), so USDC's + // SignatureChecker routes them through EIP-1271 and rejects a plain EOA signature. + let from: Address = address!("06B92bd300C5Cf9A8ECc64D7B9c51163d6b177a1"); // buyer (dec00d01) + let to: Address = address!("791668cB0CB50D90DFb1Ee215De4CefA3EAb953e"); // payTo (dec00d02) + let value = U256::from(1_000_000u64); // 1.000000 USDC (6 decimals) + let valid_after = U256::from(0u64); + let valid_before = U256::from(4_102_444_800u64); // 2100-01-01, always in-window on the fork + let nonce: B256 = + b256!("1111111111111111111111111111111111111111111111111111111111111111"); + + // EIP-712 domain of Circle USDC on Ethereum Sepolia (verified against on-chain DOMAIN_SEPARATOR). + let verifying_contract: Address = address!("1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"); + let chain_id: u64 = 11_155_111; + + // Buyer's throwaway key. Recovers to `from`. + let buyer: PrivateKeySigner = + "0x00000000000000000000000000000000000000000000000000000000dec00d01" + .parse() + .expect("valid key"); + assert_eq!(buyer.address(), from, "buyer key must recover to `from`"); + + let domain = eip712_domain! { + name: "USDC", + version: "2", + chain_id: chain_id, + verifying_contract: verifying_contract, + }; + + // A. hand-rolled + let a = TransferWithAuthorization { + from, + to, + value, + validAfter: valid_after, + validBefore: valid_before, + nonce, + }; + // B. x402-rs + let b = X402TWA { + from, + to, + value, + validAfter: valid_after, + validBefore: valid_before, + nonce, + }; + + let type_hash_a = TransferWithAuthorization::eip712_type_hash(&a); + let type_hash_b = X402TWA::eip712_type_hash(&b); + let digest_a = a.eip712_signing_hash(&domain); + let digest_b = b.eip712_signing_hash(&domain); + + let sig_a = buyer.sign_hash_sync(&digest_a).expect("sign a"); + let sig_b = buyer.sign_hash_sync(&digest_b).expect("sign b"); + let sig_a_hex = format!("0x{}", hex(&sig_a.as_bytes())); + let sig_b_hex = format!("0x{}", hex(&sig_b.as_bytes())); + + // ---- Assertions (Question 1) ---- + assert_eq!(type_hash_a, type_hash_b, "type hash diverged"); + assert_eq!(digest_a, digest_b, "EIP-712 digest diverged"); + assert_eq!(sig_a_hex, sig_b_hex, "signature diverged"); + + // Independent cross-check: the EIP-3009 struct type string is the canonical one. + let expected_type = + "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; + assert_eq!( + ::eip712_encode_type(), + expected_type, + "canonical EIP-712 type string diverged" + ); + + println!("== EIP-3009 TransferWithAuthorization: hand-roll vs x402-rs =="); + println!("typeHash(handroll) = {type_hash_a}"); + println!("typeHash(x402-rs) = {type_hash_b}"); + println!("digest(handroll) = {digest_a}"); + println!("digest(x402-rs) = {digest_b}"); + println!("sig(handroll) = {sig_a_hex}"); + println!("sig(x402-rs) = {sig_b_hex}"); + println!( + "MATCH: typeHash={} digest={} signature={}", + type_hash_a == type_hash_b, + digest_a == digest_b, + sig_a_hex == sig_b_hex + ); + + // ---- Emit the reproducible KAT vector for #34 ---- + let kat = serde_json::json!({ + "note": "EIP-3009 TransferWithAuthorization KAT for Circle USDC on Ethereum Sepolia (fork). deckard #206.", + "domain": { + "name": "USDC", + "version": "2", + "chainId": chain_id, + "verifyingContract": format!("{verifying_contract:#x}"), + }, + "message": { + "from": format!("{from:#x}"), + "to": format!("{to:#x}"), + "value": value.to_string(), + "validAfter": valid_after.to_string(), + "validBefore": valid_before.to_string(), + "nonce": format!("{nonce:#x}"), + }, + "eip712_type": expected_type, + "type_hash": format!("{type_hash_a:#x}"), + "digest": format!("{digest_a:#x}"), + "signer": format!("{from:#x}"), + "signature": sig_a_hex, + }); + std::fs::write("kat.json", serde_json::to_string_pretty(&kat).unwrap()).unwrap(); + // Also emit just the signature for the facilitator HTTP loop shell step. + std::fs::write("sig.hex", &sig_a_hex).unwrap(); + println!("wrote ./kat.json and ./sig.hex"); +} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s +} 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" + } +} From 122ce986ae015ff797bdfa6b9dc5a61d1238dbbb Mon Sep 17 00:00:00 2001 From: hellno Date: Tue, 7 Jul 2026 20:15:31 +0200 Subject: [PATCH 2/2] docs(x402): drop throwaway digest-cmp harness, keep the writeup + KAT vector Per review: the Q1 evidence (byte-identical digests, transcript) and the KAT vector stay; the throwaway comparison crate is dropped from the repo (it was never a workspace member). --- docs/research/x402-spike/README.md | 11 +- .../research/x402-spike/digest-cmp/Cargo.toml | 27 --- .../x402-spike/digest-cmp/src/main.rs | 157 ------------------ 3 files changed, 4 insertions(+), 191 deletions(-) delete mode 100644 docs/research/x402-spike/digest-cmp/Cargo.toml delete mode 100644 docs/research/x402-spike/digest-cmp/src/main.rs diff --git a/docs/research/x402-spike/README.md b/docs/research/x402-spike/README.md index d513181..835c337 100644 --- a/docs/research/x402-spike/README.md +++ b/docs/research/x402-spike/README.md @@ -38,12 +38,13 @@ Both are green. **Go** for #34 on the Sepolia fork. ### Verdict: hand-roll in `deckard-core`; x402-rs stays a demo binary. -### Evidence: the digest comparison ( `digest-cmp/` ) +### 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. The spike builds the *same* authorization -two ways and compares: +`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 == @@ -192,9 +193,6 @@ 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 - -# 3. (Question 1) reproduce the digest comparison + regenerate the KAT: -cd digest-cmp && cargo run ``` Requirements: `foundry` (anvil, cast), `jq`, `curl`. Throwaway keys only; no secrets in any file here. @@ -207,4 +205,3 @@ Requirements: `foundry` (anvil, cast), `jq`, `curl`. Throwaway keys only; no sec | [`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 | -| [`digest-cmp/`](digest-cmp/) | Question-1 harness: hand-roll vs x402-rs, byte-for-byte (standalone; git dep on x402-rs) | diff --git a/docs/research/x402-spike/digest-cmp/Cargo.toml b/docs/research/x402-spike/digest-cmp/Cargo.toml deleted file mode 100644 index c2a6707..0000000 --- a/docs/research/x402-spike/digest-cmp/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -# Reference / throwaway spike crate (deckard #206). NOT a deckard workspace member — it lives -# outside the workspace on purpose so the no-new-deps DoD rule stays intact. Build it standalone -# (needs network to fetch the x402-rs git dep): -# -# cd docs/research/x402-spike/digest-cmp && cargo run -# -# It compares deckard's hand-rolled `alloy sol!` EIP-3009 digest/signature against x402-rs's own -# `TransferWithAuthorization` type, byte-for-byte, and writes the KAT vector to /tmp/x402-spike/. -# Empty table: opt this crate OUT of the surrounding deckard workspace so it builds standalone -# and never becomes a deckard dependency. -[workspace] - -[package] -name = "digest-cmp" -version = "0.0.0" -edition = "2024" -publish = false - -[dependencies] -# The reference implementation we compare against (x402-rs's own EIP-3009 type). -x402-chain-eip155 = { git = "https://github.com/x402-rs/x402-rs", features = ["client", "telemetry"] } -# Mirror deckard-core's toolchain: hand-roll the typed data via alloy sol! + eip712. -alloy-primitives = "1.6" -alloy-sol-types = { version = "1.6", features = ["json"] } -alloy-signer = "2.0" -alloy-signer-local = "2.0" -serde_json = "1.0" diff --git a/docs/research/x402-spike/digest-cmp/src/main.rs b/docs/research/x402-spike/digest-cmp/src/main.rs deleted file mode 100644 index 109e5cd..0000000 --- a/docs/research/x402-spike/digest-cmp/src/main.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Spike (#206) — Question 1 evidence. -//! -//! Build the SAME EIP-3009 `TransferWithAuthorization` authorization two ways and -//! compare the EIP-712 digest AND the ECDSA signature byte-for-byte: -//! -//! A. HAND-ROLL — a local `alloy sol!` struct + `eip712_domain!` + `eip712_signing_hash`. -//! This is exactly how `deckard-core/src/cow_types.rs` builds CoW orders today. -//! B. x402-rs — `x402_chain_eip155::v1_eip155_exact::types::TransferWithAuthorization`. -//! -//! If A == B byte-for-byte, then depending on `x402-types` buys deckard-core nothing over the -//! `sol!` it already uses — and the printed vector doubles as the KAT #34 gates against. - -use alloy_primitives::{address, b256, Address, B256, U256}; -use alloy_signer::SignerSync; -use alloy_signer_local::PrivateKeySigner; -use alloy_sol_types::{eip712_domain, sol, SolStruct}; - -// A. HAND-ROLL: the deckard-core way. Identical Solidity struct shape to EIP-3009. -sol! { - #[allow(non_snake_case)] - struct TransferWithAuthorization { - address from; - address to; - uint256 value; - uint256 validAfter; - uint256 validBefore; - bytes32 nonce; - } -} - -// B. The x402-rs reference type. -use x402_chain_eip155::v1_eip155_exact::types::TransferWithAuthorization as X402TWA; - -fn main() { - // ---- Fixed KAT inputs (fresh throwaway keys; zero secrets) ---- - // NB: do NOT reuse anvil's canonical test accounts (0x7099.., 0x3C44..) — on real - // Sepolia those carry EIP-7702 delegation designators (0xef0100..), so USDC's - // SignatureChecker routes them through EIP-1271 and rejects a plain EOA signature. - let from: Address = address!("06B92bd300C5Cf9A8ECc64D7B9c51163d6b177a1"); // buyer (dec00d01) - let to: Address = address!("791668cB0CB50D90DFb1Ee215De4CefA3EAb953e"); // payTo (dec00d02) - let value = U256::from(1_000_000u64); // 1.000000 USDC (6 decimals) - let valid_after = U256::from(0u64); - let valid_before = U256::from(4_102_444_800u64); // 2100-01-01, always in-window on the fork - let nonce: B256 = - b256!("1111111111111111111111111111111111111111111111111111111111111111"); - - // EIP-712 domain of Circle USDC on Ethereum Sepolia (verified against on-chain DOMAIN_SEPARATOR). - let verifying_contract: Address = address!("1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"); - let chain_id: u64 = 11_155_111; - - // Buyer's throwaway key. Recovers to `from`. - let buyer: PrivateKeySigner = - "0x00000000000000000000000000000000000000000000000000000000dec00d01" - .parse() - .expect("valid key"); - assert_eq!(buyer.address(), from, "buyer key must recover to `from`"); - - let domain = eip712_domain! { - name: "USDC", - version: "2", - chain_id: chain_id, - verifying_contract: verifying_contract, - }; - - // A. hand-rolled - let a = TransferWithAuthorization { - from, - to, - value, - validAfter: valid_after, - validBefore: valid_before, - nonce, - }; - // B. x402-rs - let b = X402TWA { - from, - to, - value, - validAfter: valid_after, - validBefore: valid_before, - nonce, - }; - - let type_hash_a = TransferWithAuthorization::eip712_type_hash(&a); - let type_hash_b = X402TWA::eip712_type_hash(&b); - let digest_a = a.eip712_signing_hash(&domain); - let digest_b = b.eip712_signing_hash(&domain); - - let sig_a = buyer.sign_hash_sync(&digest_a).expect("sign a"); - let sig_b = buyer.sign_hash_sync(&digest_b).expect("sign b"); - let sig_a_hex = format!("0x{}", hex(&sig_a.as_bytes())); - let sig_b_hex = format!("0x{}", hex(&sig_b.as_bytes())); - - // ---- Assertions (Question 1) ---- - assert_eq!(type_hash_a, type_hash_b, "type hash diverged"); - assert_eq!(digest_a, digest_b, "EIP-712 digest diverged"); - assert_eq!(sig_a_hex, sig_b_hex, "signature diverged"); - - // Independent cross-check: the EIP-3009 struct type string is the canonical one. - let expected_type = - "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)"; - assert_eq!( - ::eip712_encode_type(), - expected_type, - "canonical EIP-712 type string diverged" - ); - - println!("== EIP-3009 TransferWithAuthorization: hand-roll vs x402-rs =="); - println!("typeHash(handroll) = {type_hash_a}"); - println!("typeHash(x402-rs) = {type_hash_b}"); - println!("digest(handroll) = {digest_a}"); - println!("digest(x402-rs) = {digest_b}"); - println!("sig(handroll) = {sig_a_hex}"); - println!("sig(x402-rs) = {sig_b_hex}"); - println!( - "MATCH: typeHash={} digest={} signature={}", - type_hash_a == type_hash_b, - digest_a == digest_b, - sig_a_hex == sig_b_hex - ); - - // ---- Emit the reproducible KAT vector for #34 ---- - let kat = serde_json::json!({ - "note": "EIP-3009 TransferWithAuthorization KAT for Circle USDC on Ethereum Sepolia (fork). deckard #206.", - "domain": { - "name": "USDC", - "version": "2", - "chainId": chain_id, - "verifyingContract": format!("{verifying_contract:#x}"), - }, - "message": { - "from": format!("{from:#x}"), - "to": format!("{to:#x}"), - "value": value.to_string(), - "validAfter": valid_after.to_string(), - "validBefore": valid_before.to_string(), - "nonce": format!("{nonce:#x}"), - }, - "eip712_type": expected_type, - "type_hash": format!("{type_hash_a:#x}"), - "digest": format!("{digest_a:#x}"), - "signer": format!("{from:#x}"), - "signature": sig_a_hex, - }); - std::fs::write("kat.json", serde_json::to_string_pretty(&kat).unwrap()).unwrap(); - // Also emit just the signature for the facilitator HTTP loop shell step. - std::fs::write("sig.hex", &sig_a_hex).unwrap(); - println!("wrote ./kat.json and ./sig.hex"); -} - -fn hex(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -}