From 80da30a363707aad2c9273776995e9b79460913b Mon Sep 17 00:00:00 2001 From: Bertug Date: Thu, 25 Jun 2026 16:05:54 +0300 Subject: [PATCH] fix(x402-evm): inject EIP-712 USDC domain into requirements (verified on Base mainnet) Found while mainnet-testing Talha's original EVM x402 path: the EVM "exact" scheme (EIP-3009) needs the token's EIP-712 domain (name + version) in PaymentRequirements.extra, or the facilitator rejects verify with `invalid_exact_evm_missing_eip712_domain`. The rail wasn't adding it, so EVM verify failed unless the caller hand-built the requirements. - Add EVM_USDC_EIP712_DOMAINS (Base, Base Sepolia, Ethereum, Polygon, Arbitrum, Optimism) and X402RailConfig.eip712Domain override. - createChallenge auto-injects extra.name/version for EVM (mirrors the Solana feePayer injection); forwarded to the facilitator on verify/settle. - src/__tests__/x402-evm-rail.test.mjs: domain auto-injection, testnet variant, explicit override, no feePayer for EVM, and that verify forwards the domain. Verified live: with the domain, a 0.001 USDC self-transfer settled on Base mainnet via PayAI, gasless (tx 0xc18b14ef94c7ebd5589bf0816f6c8c8820d87e77c4ace5f118486a3f3c2a42f9). Status: x402 EVM -> Beta (verified on Base mainnet). Full suite: 233 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- landing/index.html | 2 +- package.json | 2 +- src/__tests__/x402-evm-rail.test.mjs | 109 +++++++++++++++++++++++++++ src/index.ts | 1 + src/rail-adapters/index.ts | 1 + src/rail-adapters/x402-rail.ts | 41 ++++++++++ 7 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/x402-evm-rail.test.mjs diff --git a/README.md b/README.md index 279e432..31991ce 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,9 @@ Current version: `0.3.0-beta.1`. | SQLite / D1 ledger | Local and single-process paths | | Stripe test mode | Validated with configured test credentials | | Stripe production | Beta; validate your webhook and deployment path | -| x402 (EVM) | Experimental | +| x402 (EVM) | Beta; EIP-712 USDC domain auto-injected, verified on Base mainnet (gasless via facilitator) | | x402 (Solana / SVM) | Experimental; SVM "exact" scheme, facilitator verify/settle ([notes](examples/x402-solana-recovery/NOTES.md)) | -| x402 mainnet | Not tested | +| x402 mainnet | Smoke-tested with small live settles: EVM (Base) and Solana | | MPP | Mocked / spec-path unless verified with real `mppx` integration | | Multi-instance production | Requires durable idempotency; future work | diff --git a/landing/index.html b/landing/index.html index bc97892..590b35f 100644 --- a/landing/index.html +++ b/landing/index.html @@ -421,7 +421,7 @@

Status

SQLite / D1 ledgerLocal and single-process paths Stripe test modeValidated with configured test credentials Stripe productionBeta; validate your webhook and deployment path - x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)Experimental; mainnet not tested + x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)Beta; EIP-712 USDC domain auto-injected, verified on Base mainnet (gasless) x402 — Solana / SVMExperimental; verified on devnet (gasless via x402 facilitators), mainnet not tested MPPMocked / spec-path unless verified with real mppx integration diff --git a/package.json b/package.json index 4f6d3dc..13716a8 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "scenario:x402-testnet:challenge": "npm run build && node examples/x402-testnet-recovery/challenge.mjs", "scenario:x402-testnet:sign": "npm run build && node examples/x402-testnet-recovery/challenge.mjs | node examples/x402-testnet-recovery/sign-payload.mjs", "scenario:x402-testnet": "npm run build && node integrations/x402-testnet/scenario.mjs", - "test": "npm run build && node --test --test-force-exit src/__tests__/paidTool.test.mjs src/__tests__/mcp-adapter.test.mjs src/__tests__/stripe.test.mjs src/__tests__/webhook-handler.test.mjs src/__tests__/db-ledger.test.mjs src/__tests__/rail-adapter.test.mjs src/__tests__/x402-solana-rail.test.mjs src/__tests__/x402-solana-sign.test.mjs src/__tests__/policy.test.mjs src/__tests__/protocol-compliance.test.mjs src/__tests__/idempotency.test.mjs src/__tests__/concurrency.test.mjs src/__tests__/db-idempotency.integration.test.mjs src/__tests__/trace.test.mjs src/__tests__/firecrawl-integration.test.mjs src/__tests__/local-first.test.mjs", + "test": "npm run build && node --test --test-force-exit src/__tests__/paidTool.test.mjs src/__tests__/mcp-adapter.test.mjs src/__tests__/stripe.test.mjs src/__tests__/webhook-handler.test.mjs src/__tests__/db-ledger.test.mjs src/__tests__/rail-adapter.test.mjs src/__tests__/x402-evm-rail.test.mjs src/__tests__/x402-solana-rail.test.mjs src/__tests__/x402-solana-sign.test.mjs src/__tests__/policy.test.mjs src/__tests__/protocol-compliance.test.mjs src/__tests__/idempotency.test.mjs src/__tests__/concurrency.test.mjs src/__tests__/db-idempotency.integration.test.mjs src/__tests__/trace.test.mjs src/__tests__/firecrawl-integration.test.mjs src/__tests__/local-first.test.mjs", "prepublishOnly": "npm run typecheck && npm test" }, "keywords": [ diff --git a/src/__tests__/x402-evm-rail.test.mjs b/src/__tests__/x402-evm-rail.test.mjs new file mode 100644 index 0000000..d7b6c44 --- /dev/null +++ b/src/__tests__/x402-evm-rail.test.mjs @@ -0,0 +1,109 @@ +/** + * x402 EVM Rail Adapter — EIP-712 domain injection. + * + * The EVM "exact" scheme (EIP-3009) requires the token's EIP-712 domain + * (name + version) in PaymentRequirements.extra, or the facilitator rejects + * verify with `invalid_exact_evm_missing_eip712_domain`. These tests assert the + * rail injects it for known USDC networks, honours an explicit override, and + * forwards it to the facilitator — all offline (fetch stubbed). + * + * Run: node --test src/__tests__/x402-evm-rail.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + X402RailAdapter, + EVM_USDC_ADDRESSES, +} from "../../dist/rail-adapters/x402-rail.js"; + +const BASE = "eip155:8453"; +const BASE_SEPOLIA = "eip155:84532"; +const PAY_TO = "0x179516864079FA14a0a718cF767cCDc2483324b6"; +const FACILITATOR = "https://facilitator.example.test"; + +const PARAMS = { + callerId: "0xCaller", + amount: 0.001, + currency: "usd", + toolName: "evm_tool", + publisherKey: "tg_evm", +}; + +function makeAdapter(overrides = {}) { + return new X402RailAdapter({ + payTo: PAY_TO, + network: { kind: "evm", caip2: BASE }, + facilitatorUrl: FACILITATOR, + x402Version: 2, + ...overrides, + }); +} + +describe("x402 EVM — EIP-712 domain in challenge", () => { + it("auto-injects USD Coin v2 for Base mainnet USDC", async () => { + const action = await makeAdapter().createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.asset, EVM_USDC_ADDRESSES[BASE]); + assert.equal(req.extra.name, "USD Coin"); + assert.equal(req.extra.version, "2"); + }); + + it("uses USDC v2 for Base Sepolia testnet", async () => { + const action = await makeAdapter({ + network: { kind: "evm", caip2: BASE_SEPOLIA }, + }).createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.extra.name, "USDC"); + assert.equal(req.extra.version, "2"); + }); + + it("honours an explicit eip712Domain override (custom token)", async () => { + const action = await makeAdapter({ + network: { kind: "evm", caip2: "eip155:99999", asset: "0xabc" }, + eip712Domain: { name: "MyToken", version: "1" }, + }).createChallenge(PARAMS); + const req = action.x402PaymentRequired.accepts[0]; + assert.equal(req.extra.name, "MyToken"); + assert.equal(req.extra.version, "1"); + }); + + it("does not attach feePayer for EVM", async () => { + const action = await makeAdapter().createChallenge(PARAMS); + assert.equal( + action.x402PaymentRequired.accepts[0].extra.feePayer, + undefined, + ); + }); +}); + +describe("x402 EVM — domain forwarded to the facilitator", () => { + const realFetch = globalThis.fetch; + let captured; + + beforeEach(() => { + captured = []; + globalThis.fetch = async (url, init) => { + captured.push({ url: String(url), body: JSON.parse(init.body) }); + return { ok: true, async json() { return { isValid: true, payer: PAY_TO }; } }; + }; + }); + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("verify sends extra.name/version in paymentRequirements", async () => { + const adapter = makeAdapter(); + const action = await adapter.createChallenge(PARAMS); + const proof = { rail: "x402", x402PaymentPayload: { x402Version: 2 } }; + + const result = await adapter.verifyPayment(proof, { + actionId: action.actionId, + }); + assert.ok(result?.verified); + const sent = captured.find((c) => c.url.endsWith("/verify")); + assert.ok(sent, "verify was called"); + assert.equal(sent.body.paymentRequirements.extra.name, "USD Coin"); + assert.equal(sent.body.paymentRequirements.extra.version, "2"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 760f282..674f93b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ export { MppRailAdapter, X402RailAdapter, EVM_USDC_ADDRESSES, + EVM_USDC_EIP712_DOMAINS, SOLANA_USDC_ADDRESSES, } from "./rail-adapters/index.js"; export type { diff --git a/src/rail-adapters/index.ts b/src/rail-adapters/index.ts index b319578..3fe32ca 100644 --- a/src/rail-adapters/index.ts +++ b/src/rail-adapters/index.ts @@ -7,6 +7,7 @@ export type { MppRailConfig } from "./mpp-rail.js"; export { X402RailAdapter, EVM_USDC_ADDRESSES, + EVM_USDC_EIP712_DOMAINS, SOLANA_USDC_ADDRESSES, } from "./x402-rail.js"; export type { X402RailConfig } from "./x402-rail.js"; diff --git a/src/rail-adapters/x402-rail.ts b/src/rail-adapters/x402-rail.ts index c304052..2d1c478 100644 --- a/src/rail-adapters/x402-rail.ts +++ b/src/rail-adapters/x402-rail.ts @@ -51,6 +51,15 @@ export interface X402RailConfig { */ feePayer?: string; + /** + * EIP-712 token domain (name + version) for EVM payments, surfaced to the + * facilitator via `PaymentRequirements.extra` so it can verify the EIP-3009 + * signature. Auto-detected from `EVM_USDC_EIP712_DOMAINS` for known USDC + * networks; set this only for a non-USDC asset or a custom token. Ignored for + * Solana. + */ + eip712Domain?: { name: string; version: string }; + /** * Facilitator URL for payment verification and settlement. * @@ -85,6 +94,25 @@ export const EVM_USDC_ADDRESSES: Record = { "eip155:10": "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", // Optimism }; +/** + * EIP-712 domain (name + version) for USDC on each EVM network. The facilitator + * needs this in `PaymentRequirements.extra` to verify the EIP-3009 signature — + * without it, verify fails with `invalid_exact_evm_missing_eip712_domain`. + * Circle's USDC uses name "USD Coin" v2 on mainnets; Base Sepolia testnet USDC + * uses "USDC" v2. + */ +export const EVM_USDC_EIP712_DOMAINS: Record< + string, + { name: string; version: string } +> = { + "eip155:8453": { name: "USD Coin", version: "2" }, // Base mainnet + "eip155:84532": { name: "USDC", version: "2" }, // Base Sepolia + "eip155:1": { name: "USD Coin", version: "2" }, // Ethereum mainnet + "eip155:137": { name: "USD Coin", version: "2" }, // Polygon + "eip155:42161": { name: "USD Coin", version: "2" }, // Arbitrum + "eip155:10": { name: "USD Coin", version: "2" }, // Optimism +}; + /** USDC mint addresses for Solana networks (SPL Token) */ export const SOLANA_USDC_ADDRESSES: Record = { "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": @@ -156,6 +184,19 @@ export class X402RailAdapter implements RailAdapter { extra.feePayer = this.config.feePayer; } + // EVM "exact" (EIP-3009) requires the token's EIP-712 domain so the + // facilitator can verify the authorization signature. Auto-detect for known + // USDC networks; otherwise honour an explicitly configured domain. + if (!this.isSolana) { + const domain = + this.config.eip712Domain ?? + EVM_USDC_EIP712_DOMAINS[this.config.network.caip2]; + if (domain) { + extra.name = domain.name; + extra.version = domain.version; + } + } + const paymentRequirement: X402PaymentRequirement = { scheme: this.config.scheme ?? "exact", network: this.config.network.caip2,