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 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 (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 / SVM | Experimental; verified on devnet (gasless via x402 facilitators), mainnet not tested |
| MPP | Mocked / 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,