Auditing x402 default assets: 26 tokens, one broken EIP-712 domain
A single table inside the x402 SDKs decides which stablecoin a dollar price becomes, which EIP-712 domain the client signs, and which assets an agent is allowed to pay at all. We tested every row of it against the chain.
When a seller writes price: "$0.10", something has to turn that string into a token address and an atomic amount. In x402 that something is the default asset table: one file per chain family, per SDK. On EVM it currently holds 26 rows.
Those rows carry more weight than their size suggests. Each one declares an EIP-712 name and version — the two strings that go into the domain separator the payer signs. Get them wrong and the payer produces a signature that recovers to an address nobody owns. The token rejects it. The payment never settles.
So we did the obvious thing: took every EVM row at repository HEAD, connected to each chain, and asked the token itself whether the declared domain is the one it verifies against.
What the table actually controls
Three separate mechanisms read the same file, which is why a single wrong string has an outsized blast radius.
Money parsing. The resource server resolves "$0.10" through getDefaultAsset(network, symbol?). The first entry for a network is the network default; a suffixed price like "$0.10 USDT" selects by ticker.
The signing domain. The same lookup writes name and version into extra on the payment requirements. The client copies them straight into the EIP-712 domain, without ever consulting the chain:
// typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts
const { name, version } = requirements.extra;
const domain = {
name,
version,
chainId,
verifyingContract: getAddress(requirements.asset),
};
The spend-control allowlist. Since the spend controls shipped in August, the reverse lookup findDefaultAsset(asset, network) is what decides whether an offer survives at all. We covered the default $1 cap and its escape hatches when it landed; the part that matters here is the filter that runs before the cap:
let filtered = allowAnyAsset
? requirements
: requirements.filter(requirement => {
if (defaultAssetFor(requirement) != null) return true;
return findAssetEntry(requirement) != null;
});
An asset not in the table, and not explicitly allowlisted by the operator, is dropped before any amount is compared. The table is not a convenience. For a default-configured client it is the payment surface.
Method
We worked from the x402 monorepo at HEAD e398a9e5 (2026-08-28), the x402-foundation/x402 repository, plus the published packages @x402/core and @x402/evm 2.24.0 from npm. RPC endpoints came from the ethereum-lists chain registry; all 26 chains answered.
For every row we read name(), symbol(), decimals(), version() and DOMAIN_SEPARATOR(), probed authorizationState(address,bytes32) for EIP-3009 support and nonces(address) for EIP-2612, and recomputed the domain separator from the table's own strings.
Then the part that actually proves something. For each of the 20 EIP-3009 rows we signed a real TransferWithAuthorization with a fresh key holding no balance, using exactly the name and version the table declares, and replayed it through eth_call against the live token. The revert message is the verdict. ERC20: transfer amount exceeds balance means the signature verified and only the funds were missing — the domain is right. An invalid-signature revert means the domain is wrong.
The three SDKs agree with each other
First result, and a genuinely good one: TypeScript, Python and Go are identical. Same 26 networks, same addresses, same name, version, symbol, decimals, same transfer-method and EIP-2612 flags. Zero drift across three languages. The alignment commit (PR #3241, 2026-08-24) did its job.
Every network holds exactly one asset — no network yet uses the multi-entry capability the schema supports. Twenty rows are EIP-3009, six are marked assetTransferMethod: "permit2", and five of those six declare supportsEip2612: true. Every one of those flags matched what we found on chain: the six permit2 tokens genuinely revert on authorizationState, and the one without the 2612 flag (Igra's USDC) is the only row in the table with no nonces(address).
Declared decimals matched the chain on all 26 rows.
Nineteen of twenty
Nineteen EIP-3009 rows accepted a signature built from their own declared domain. Base, Ethereum, Polygon, Arbitrum, Avalanche, Celo, Sei, Monad, XDC, ADI, HPP, Flare, Stable mainnet and the testnets alongside them all came back with the balance revert.
One did not.
"USDT0". The token's name() returns "USD₮0", with U+20AE, the Tugrik sign. Signing with the table's string returns TetherToken: invalid signature. Signing with the on-chain string returns ERC20: transfer amount exceeds balance.
The recomputed domain separator confirms it independently: keccak256 over ("USD₮0", "1", 2201, token) reproduces the value DOMAIN_SEPARATOR() returns, bit for bit. The table's string does not.
We then closed the loop with the shipped software rather than our own signer. We had @x402/evm 2.24.0 build a payment payload for eip155:2201 using precisely the extra the resource server would emit, took the signature it produced, and replayed it against the token:
// recovered signer under each candidate domain
x402 table 'USDT0' -> 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 // == payer
on-chain 'USD₮0' -> 0x93dF47D7E9bD6Bc35933BcAf69bb37Adeb228087
// replay of the SDK-signed payload on Stable testnet
TetherToken: invalid signature
The signature is well-formed and recovers cleanly to the payer — under a domain the token does not use. This is not a malformed payload. It is a correctly signed message addressed to the wrong contract identity.
How it survived five months
The string entered the repository on 2026-03-31 in PR #1786, which added Stable testnet support and declared Name: "USDT0" simultaneously in Go, Python and TypeScript. Stable mainnet had landed a week earlier in PR #1775 with the same string — and there it is correct, because that deployment's name() really does return "USDT0". The two deployments of the same brand differ by one character, and the testnet row inherited the mainnet's answer.
On 2026-08-13, PR #3124 (146 files, +3,795/−1,317) restructured everything into the new defaultAssets.ts and carried the string across verbatim. Python and Go followed on 2026-08-18. The same PR added Flare's USDT0 with "USD₮0" spelled correctly — the project handles the character properly one row away from where it does not.
Nothing in the pipeline was positioned to catch it. The unit tests for the EVM default assets assert self-consistency only: that a lookup by checksummed and lowercase address returns the same entry, that Mezo's mUSD is 18 decimals, that an unknown asset returns undefined. There is no test that compares a declared domain to a chain. In fact the string DOMAIN_SEPARATOR does not appear anywhere in the EVM mechanism package.
The contributor documentation is closer than the code. DEFAULT_ASSETS.md instructs: "Read the name() and version() functions from the token contract (EIP-712 domain values)." Follow that literally and you get "USD₮0". The procedure was right; it was copied from a sibling row instead of executed.
The facilitator does know
Worth stating plainly, because it is the part the design got right. When an EIP-3009 simulation fails, the facilitator runs a diagnostic multicall over balanceOf, name, version and authorizationState, and compares the on-chain name against extra.name:
if (
nameResult.status === "success" &&
requirements.extra?.name &&
nameResult.result !== requirements.extra.name
) {
return { isValid: false, invalidReason: Errors.ErrEip3009TokenNameMismatch, payer };
}
So a payer on Stable testnet does not get a mystery failure. They get invalid_exact_evm_token_name_mismatch, which names the problem exactly. That is a well-designed error path, and it is the reason this is a broken testnet row rather than a silent loss of funds.
But note where the check sits. It runs after simulation has already failed, on the facilitator, as a post-mortem. Nothing checks the domain before the client signs, and nothing checks it at build time. The authoritative comparison exists in the codebase; it simply never runs against the table itself. Our audit is that check, executed once, offline.
The allowlist is also a ceiling
The second-order finding has nothing to do with wrong strings and everything to do with missing rows.
The EVM package names 23 networks in its legacy v1 map. Ten of them have no default asset: sepolia, abstract, abstract-testnet, avalanche-fuji, iotex, polygon-amoy, peaq, story, educhain, skale-base-sepolia. Because the reverse lookup gates the offer before the cap does, a default-configured client rejects every offer on those chains outright. We confirmed it against the published packages, not the source:
Base USDC (in table) : SIGNED
Sei USDC (in table) : SIGNED
IoTeX (named, no default) : REJECTED — only default assets or entries in
spendControls.allowedAssets are allowed
Story (named, no default) : REJECTED — idem
Optimism (not in SDK) : REJECTED — idem
Optimism is the one that should make a seller pause. It is not a fringe chain and it is not in the table at all, while Igra, ADI Chain, HPP and Radius are. Nine of the 26 rows are testnets. This is not a criticism of the selections — every row got there because someone did the work of adding it — but it does mean coverage tracks contributor attention rather than volume. A seller pricing in dollars on an uncovered chain is invisible to default clients, and will read the silence as no demand.
The escape hatch is real and documented: spendControls.allowedAssets, or spendControls: false. The trap is that the buyer has to know to reach for it, and the buyer is frequently an autonomous agent reading an error string rather than a human reading release notes.
Two things that look like bugs and are not
Symbol drift is the first. Five rows carry a ticker that differs from the token's own symbol(): MegaUSD against USDm, Mezo's mUSD against MUSD twice, and both USDT0 rows against USD₮0. Nothing breaks, because the table's symbol is what resolves suffixed prices — but it means "$1 USDm" does not resolve on MegaETH while "$1 MegaUSD" does. The ticker an agent reads from the chain is not always the ticker the price string wants.
The second is a methodology trap worth publishing because it cost us an hour. Our first pass used the well-known test key 0x1111…1111 and got invalid-signature reverts on Base, Polygon, Arbitrum and Celo while Avalanche and Sei passed. The domains were fine. That address carries an EIP-7702 delegation on exactly those four chains — 0xef0100 followed by a delegate — and Circle's SignatureChecker routes any signer with code through ERC-1271 instead of ECDSA. It is the strict-verification behaviour we mapped in the 7702 signature audit, reproduced by accident. We reran with a fresh key and cross-checked against six live production authorizations on Base, all of which recover under the declared domain.
One structural note to close the technical section: 24 of the 26 tokens sit behind an upgradeable proxy. The name and version a client compiles into its binary are mutable contract state on almost every row. Correct today is not a property that stays true on its own.
What it means for LLM4Agents
We route model calls and settle them per use in stablecoins. That makes us a payer on someone else's rails far more often than a seller on our own, and this audit describes exactly the class of failure a payer absorbs: a payload that is cryptographically perfect and economically useless, detectable only after a round trip.
Three consequences we are treating as operational, not theoretical.
The table is a dependency, not a constant. A default asset row is a compiled-in claim about mutable on-chain state, shipped inside a package with roughly 976,000 monthly downloads for @x402/core and 612,000 for @x402/evm. Upgrading the SDK can change which chains we can pay on and which domain we sign, without a single line of our code changing. That belongs in dependency review, next to the EIP-3009 settlement path itself.
Failure taxonomy beats retries. invalid_exact_evm_token_name_mismatch is a configuration defect on the seller's side. Retrying it, backing off, or rotating a nonce accomplishes nothing; the same signature will fail forever. An agent that treats every non-200 as transient will burn its budget on a route that cannot succeed. Domain-mismatch and EIP-3009-not-supported are terminal, and our fallback logic has to know the difference.
Coverage limits are business limits. The set of chains where a default client can pay us is smaller than the set of chains we support. If we price in dollars on a chain with no default asset row, well-behaved buyers reject the offer before their spend cap is even consulted, and we never see the request.
Staying on the frontier
Concrete steps, in the order we would do them.
1. Verify the domain before the first payment, not after. For every (chain, asset) pair we are willing to transact on, compute the EIP-712 domain separator from the declared name and version and compare it against the token's DOMAIN_SEPARATOR(). It is one eth_call and one keccak256. Where the getter is absent or the token diverges, fall back to signing a zero-value authorization and reading the revert. Cache the verdict per asset with a short expiry — 24 of 26 tokens are upgradeable, so the cache must be able to go stale.
2. Pin and diff the asset table on every SDK bump. Snapshot the 26 rows on upgrade and fail CI on any change to an address, name, version or decimals we have already verified. This turns a silent behavioural change into a review item. It is the same discipline we applied to the $1 default cap, which also arrived in a minor version.
3. Classify facilitator errors as terminal or transient, explicitly. Build the mapping from the facilitator's error constants rather than string matching. Name mismatch, version mismatch, EIP-3009 unsupported and recipient mismatch are terminal for that offer; nonce-already-used and simulation timeouts are not. Route terminal failures straight to the next offer in accepts, and record them as seller defects rather than network noise. This is the piece that connects to the verify/settle contract we mapped earlier.
4. Declare our own rows explicitly. Where we sell, emit extra.name and extra.version read from the token at deploy time and asserted in our test suite, rather than inheriting whatever the SDK's table happens to hold. Where we buy, carry a small allowlist for chains the table does not cover, with per-asset caps, so coverage gaps are a decision we made rather than a rejection we absorb.
5. Contribute the check upstream. The comparison already exists in the facilitator's diagnostic path. The same logic as an integration test over DEFAULT_ASSETS, run against public RPCs, would have caught this row on the day it was written and would catch the next one. That is a better contribution than a one-line fix to a single string.
The broader lesson is not about one wrong character. It is that agent payment stacks are accumulating configuration tables that carry cryptographic weight — domains, registries, facilitator lists, allowlists — and that these tables are validated by convention rather than by machine. A string that no test can be wrong about is a string no test is checking.
Pay per call, in stablecoins, with an OpenAI-compatible gateway
Register an agent, fund it, and settle each inference on chain.
Register agent