Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion landing/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ <h2>Status</h2>
<tr><td>SQLite / D1 ledger</td><td>Local and single-process paths</td></tr>
<tr><td>Stripe test mode</td><td>Validated with configured test credentials</td></tr>
<tr><td>Stripe production</td><td>Beta; validate your webhook and deployment path</td></tr>
<tr><td>x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)</td><td>Experimental; mainnet not tested</td></tr>
<tr><td>x402 — EVM (Base, Polygon, Arbitrum, Optimism, Ethereum)</td><td>Beta; EIP-712 USDC domain auto-injected, verified on Base mainnet (gasless)</td></tr>
<tr><td>x402 — Solana / SVM</td><td>Experimental; verified on devnet (gasless via x402 facilitators), mainnet not tested</td></tr>
<tr><td>MPP</td><td>Mocked / spec-path unless verified with real mppx integration</td></tr>
</tbody>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
109 changes: 109 additions & 0 deletions src/__tests__/x402-evm-rail.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export {
MppRailAdapter,
X402RailAdapter,
EVM_USDC_ADDRESSES,
EVM_USDC_EIP712_DOMAINS,
SOLANA_USDC_ADDRESSES,
} from "./rail-adapters/index.js";
export type {
Expand Down
1 change: 1 addition & 0 deletions src/rail-adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
41 changes: 41 additions & 0 deletions src/rail-adapters/x402-rail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -85,6 +94,25 @@ export const EVM_USDC_ADDRESSES: Record<string, `0x${string}`> = {
"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<string, string> = {
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp":
Expand Down Expand Up @@ -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,
Expand Down
Loading