Two silent footguns shipping x402 to Base mainnet (and a 4-endpoint crypto cluster)
I shipped two things on 402s.shop on the same Saturday: the x402 mainnet rail (real USDC settlement on Base via the Coinbase facilitator) and a four-endpoint crypto-native cluster (wallet_balance, ens_resolve, coin_price, gas_price). The crypto cluster was straightforward — a few hours of viem RPC plumbing. The mainnet rail looked straightforward and wasn't. Two silent footguns.
I'm posting these in case anyone else is at the same point in the x402 spec — testnet round-trip working, mainnet next, deceptively close to ready.
Footgun 1: the open facilitator only knows about testnet
The Coinbase x402 protocol uses a facilitator service that does two things: /verify (validate the EIP-3009 signature is well-formed and the buyer has enough USDC) and /settle (broadcast the signed transfer and pay gas). For Base Sepolia testnet, https://x402.org/facilitator is open, no auth, free.
I assumed mainnet would work the same way. It does not. The first mainnet attempt returned:
{
"isValid": false,
"invalidReason": "unexpected_error",
"invalidMessage": "No facilitator registered for scheme: exact and network: base"
}The mainnet facilitator is a different URL, gated by Coinbase Developer Platform (CDP) API key auth. Specifically: https://api.cdp.coinbase.com/platform/v2/x402, with a per-request EdDSA-signed JWT in the Authorization header. Free tier covers 1,000 facilitator transactions a month, which is plenty for a launch.
The fix is mechanical once you know it: install @coinbase/cdp-sdk, generate a JWT with the request method/host/path before each call, attach it as Authorization: Bearer <jwt>. My x402 library now switches the facilitator URL based on X402_NETWORK and adds the JWT only when on mainnet:
function facilitatorBase() {
if (isMainnet()) return 'https://api.cdp.coinbase.com/platform/v2/x402';
return process.env.X402_FACILITATOR_URL ?? 'https://x402.org/facilitator';
}
async function authHeader(method, subPath) {
if (!isMainnet()) return {};
const token = await generateJwt({
apiKeyId: process.env.CDP_API_KEY_ID,
apiKeySecret: process.env.CDP_API_KEY_SECRET,
requestMethod: method,
requestHost: 'api.cdp.coinbase.com',
requestPath: `/platform/v2/x402${subPath}`,
expiresIn: 120,
});
return { Authorization: `Bearer ${token}` };
}Done. The first call after this fix returned a verify success — and a fresh second bug.
Footgun 2: the EIP-712 domain name is “USD Coin”, not “USDC”
x402 uses EIP-3009 transferWithAuthorization, which is a typed-data signature scheme (EIP-712). The domain separator includes name and version fields that must exactly match what the USDC contract returns from its name() and version() view functions. If they don't match, the recovered signer is the wrong address, and the contract reverts on settle with no useful error.
On Base Sepolia (Circle's testnet USDC at 0x036C...), name() == "USDC". On Base mainnet (Circle's native USDC at 0x8335...), name() == "USD Coin". My code had the testnet value baked into the requirements:
// ❌ what I had
extra: { name: 'USDC', version: '2' }
// ✅ what Base mainnet wants
extra: { name: 'USD Coin', version: '2' }The error you get is contract call failed: execution reverted at settle time, after verify passes. It's a long way from the actual mismatch.
Sanity check by reading the contract directly:
const usdc = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
await client.readContract({ address: usdc, abi, functionName: 'name' });
// → "USD Coin"
await client.readContract({ address: usdc, abi, functionName: 'version' });
// → "2"I made the values env-driven (X402_USDC_NAME + X402_USDC_VERSION) so this never bites again on a chain switch.
Verified end-to-end
After both fixes: 0.001 USDC settled from a test buyer wallet to my merchant wallet, gas paid by Coinbase's relayer (not by the buyer), confirmed at block 45464185 on BaseScan. The whole flow takes about a second.
The crypto cluster (in case you skipped here)
While I was already deep in viem, I added four crypto-native endpoints to round out the catalog. All four are mirrored across both rails (/api/v1/<name> for credits, /api/x402/<name> for autonomous USDC) and exposed as MCP tools:
coin-price— CoinGecko spot prices in USD, 25+ tickersens-resolve— ENS forward + reverse, with avatarwallet-balance— native + USDC across 5 EVM chainsgas-price— live gwei across 5 EVM chains
That brings the catalog to 16 endpoints. Each call is $0.001 USDC via the x402 rail, or one credit (1/5 of a dollar) via the credits rail. Failed calls don't charge on either side.
Takeaways
The x402 protocol is genuinely good — sub-second on-chain settlement, no human checkout, no merchant integration beyond a few env vars. But the mainnet path has two hidden differences from testnet that the spec doesn't make load-bearing in the docs. Hopefully this saves someone three hours of debugging.
If you want to look at the code: it's all open source under MIT at github.com/402shop/402s-shop — the relevant file is lib/x402.ts.