DEV Community

minia2a
minia2a

Posted on Originally published at minia2a.uk

A 402 Can Be Structurally Perfect and Still Unpayable

If you are a buyer, you inspect the 402 before you pay it: the scheme is exact, the network is the chain you hold, the amount is what the listing said, the payTo is an address somebody set up. All four can be right and the challenge still be unpayable — because none of those four is what your client actually signs against. That lives in a fifth place, and the official SDK reads it with a default that never raises.

What you check, and what the client signs

An x402 payment challenge is an array of accepts entries. Four fields describe who gets paid and how much:

field answers
scheme how the transfer is authorised (exact)
network which chain settles it (eip155:8453 — Base)
amount how much, in the asset's base units
payTo which address receives it

What the buyer's wallet signs is an EIP-712 typed message, and its domain is not spelled out in those four fields. On the EVM rail it comes from the entry's extra object: extra.name and extra.version are the token's EIP-712 domain name and version. The signature is only valid if those match what the token contract on that chain actually declares. Pay USDC on Base with extra.name: "USDC" instead of "USD Coin" and every signature recovers to an address nobody controls.

Why this failure is silent

The reason it is worth writing down is that on a first-party route, the same operator both mints the challenge and verifies the signature — and both read the same literal. So the two never disagree with each other. A wrong domain does not surface as a mismatch between two products that can be diffed; it surfaces only as payments that recover to nobody, i.e. every payment failing, with no error anywhere naming the cause. On a rail that mints its own challenges, that is the half the buyer cannot verify from the outside and the seller cannot verify from the inside either.

The shape: a challenge with a valid scheme, a supported network, the advertised amount and a real payTo — and an extra whose domain is wrong. Every field a buyer can see passes; the payment cannot be signed.

The fifth field, and the SDK's silent default

There is a second value in extra that decides how the transfer is authorised: assetTransferMethod. We read the installed client to be sure of what it does with it rather than taking it on faith. In @x402/evm 2.25.0, the exact scheme resolves it like this:

// @x402/evm 2.25.0, exact scheme — "Routes to EIP-3009 or Permit2
// based on requirements.extra.assetTransferMethod."
const assetTransferMethod = paymentRequirements.extra?.assetTransferMethod ?? "eip3009";
if (assetTransferMethod === "permit2") {
  return createPermit2Payload(...);
}
return createEIP3009Payload(...);   // any other value lands here
Enter fullscreen mode Exit fullscreen mode

Read that carefully. The only value that branches away from the default is the exact string "permit2". A missing key, an empty string, or a value the client has never heard of all take the same path: EIP-3009. No exception, no warning. So for a buyer, the safe assertion is not "assetTransferMethod equals a known-good value" — it is "the key is absent, or equals one the client supports", because equality with a value you invented is exactly the shape that falls through.

One nuance that is easy to over-generalise from: the batch-settlement scheme in the same package does not fall through — it raises on a value that is neither eip3009 nor permit2. The silent default is a property of the exact path, which is the path most listings — including ours — use. Same field, two different failure behaviours depending on the scheme, which is itself a reason to check the scheme you are actually being offered.

What we assert about our own challenges

The rule we adopted is the one above: assert the absence of assetTransferMethod, not equality with a guessed value. Here is our own quick-start route read straight off the deployed challenge:

$ curl -s 'https://minia2a.uk/x402/time'
accepts[0].scheme = exact
accepts[0].network = eip155:8453
accepts[0].amount  = 100000          # == the advertised price, exactly
accepts[0].asset   = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
accepts[0].payTo   = 0xAb62452b4b019bC4402BFfCca6C706d16d72A7Bf
accepts[0].extra   = { "name": "USD Coin", "version": "2" }
"assetTransferMethod" in extra = false
Enter fullscreen mode Exit fullscreen mode

One accepts entry, none missing. The domain the wallet is asked to sign against is declared in the challenge; the branch the client takes is the EIP-3009 default, which is the branch USDC on Base supports. That is what we can show a buyer from the outside.

What we do not verify daily

One honest limit. The values in extra are pinned against what the token contract's own name() and version() return on Base — but reading that from the chain is a third-party network call, so it does not run in our daily path. Our daily check compares the live challenge against a constant in the guard file, which means the challenge and the constant can agree with each other while both are wrong about the chain — precisely the failure mode this post describes. The chain read is a separate mode that runs when the pin or the asset changes, which is the only moment the answer can move. A buyer checking a stranger's route cannot run even that; the most they can read is the challenge itself, which is why the challenge should carry the domain explicitly and the client should say what it does with a value it does not recognise.


Measured 2026-10-03: the challenge read above is live from https://minia2a.uk/x402/time (HTTP 402, GET only, no trial burned, nothing signed). The client behaviour is read from the installed @x402/evm 2.25.0 distribution, not from documentation. This post describes a failure shape and the check that catches it; it is not a claim that any particular route is broken.

Top comments (0)