๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

The EIP-712 Domain Has No Oracle in Your Own Repo

September 28, 2026

A 402 challenge is a commitment. It tells the client where to send money, how much, and โ€” for scheme: "exact" on EVM โ€” under which EIP-712 domain to sign. That last part is the one that has no second opinion inside your codebase.

Here is the failure mode in one line: the same repository mints the domain literal into the challenge and verifies signatures against that same literal. The two artifacts can agree with each other for as long as you like, and both can be wrong, because the thing they are both supposed to describe is a contract that lives somewhere else.

What actually gets signed

For an EVM exact payment the client builds a typed-data signature over USDC's TransferWithAuthorization. The EIP-712 domain for it is:

{
  name:              <from the accept's extra.name>,
  version:           <from the accept's extra.version>,
  chainId:           8453,
  verifyingContract: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
}

Everything except name and version is pinned by the chain and the accept. Those two come from a JSON field the seller writes. Here is a live accept from one of our own endpoints, trimmed to the fields that matter:

{
  "scheme": "exact",
  "network": "eip155:8453",
  "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "payTo": "0xAb62452b4b019bC4402BFfCca6C706d16d72A7Bf",
  "amount": "500000",
  "maxTimeoutSeconds": 120,
  "extra": { "name": "USD Coin", "version": "2" }
}

When the transfer settles, the token contract recomputes its own domain separator from its own name() and version() and recovers the signer. If extra said something the contract does not say, the digest the client signed is not the digest the contract hashes. ecrecover returns a different address, the authorization is rejected, and the payment does not settle.

Nothing about that failure is attributable from the client's side. The 402 was well formed. The signature was well formed. The nonce was unused. The balance was sufficient. It is the single most expensive shape of bug in this space: every artifact agrees, and money still does not move.

Why your test suite will not catch it

Because both halves of the pin live in your repo. Concretely, ours did:

A test that exercises the builder and the verifier together passes. It is testing that a value equals itself. The constant is not derived from the asset, it does not move when the asset moves, and there is no code path in which the two could disagree โ€” until the day one of them is edited and the other is not, and then the test is the only thing that was watching, and the test is comparing the wrong pair.

Two products generated from one source can be mutually consistent and jointly stale. This is not specific to EIP-712. It is the same shape as a sitemap generator and a sitemap checker that share a file walk, or a published package and a repository that were both bumped except for one file. Whenever a guard's population comes from the same place as the thing it guards, agreement carries no information.

The oracle is a single call

The deployed token contract is outside both artifacts. Reading it is two eth_calls against name() and version() โ€” no ABI guessing, both are standard ERC-20 metadata functions, and USDC's implementation hashes cleanly for the view selector:

asset:    0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913  (Base)
block:    0x3182959
name()    = 'USD Coin'
version() = '2'

Compare that to extra in every accept you serve, and you have turned a self-referential pin into a claim about the world. The check is cheap enough to run on every deploy, and it is the only check in this family that can fail for a reason you did not write down yourself.

One caveat on where it runs: a chain RPC is a third-party egress. If your operational policy constrains outbound requests, this check does not belong on a daily timer that also runs your other guards โ€” one blocked egress takes the whole suite down with it. Run it when the thing it can detect changes: when the asset or the pin is edited, and after a deploy.

The trap one level up: which contract verifies

The naive form of the check is "compare extra to asset's own metadata". That is wrong for any scheme that signs under a domain other than the token's. A batched settlement scheme, for example, signs under a separate gateway contract; comparing it to the token address would flag a perfectly valid accept as broken.

So the reference is not the asset and not the scheme โ€” it is the contract the signature is verified by, which is a property of (scheme, network, asset). That mapping is a second artifact, and it can itself be stale: two verifiers can each hold a copy, agree with each other, and both be wrong about which contract checks the signature. The lesson repeats one level up, which is the reliable sign that you are looking at a structural problem rather than a bug.

Practically: the client already has to hold this mapping, because the client is the one building the domain it signs. So either the server's mapping should be the client's, or a verdict should name the mapping entry it used, so a responder can tell "this accept is bad" apart from "we disagree about which contract verifies". Without that, false conflates two failures with two different remedies.

Two more things that are cheap to check and usually are not

Arity

Comparing accepts[0] is a statement about the endpoint only if the endpoint serves exactly one accept. Nothing in the usual guard expresses that. We added the assertion and measured it: across the most recent full pass, 1,685 first-party endpoints and 17 proxy paths each served exactly one accept, so the reading was sound. But it was sound by luck of the current configuration โ€” the guard had been reading index 0 of a list whose length it never checked, and would have kept printing "uniform network/asset/payTo" while quietly sampling one element of a list that had grown a second one.

The failure is quiet and it is expensive: a client picks the accept nobody checked, and cannot settle it.

Absent domain vs wrong domain

These produce the same false and have nothing else in common:

shapewhat it meansremedy
no extra.name / extra.versionthe accept carries no domain; no scheme that signs a domain can build onethe seller must emit one
extra present, mismatchedthe client signs a valid-looking authorization the contract will refusethe seller's constant is stale

These should be separate reason codes. Collapsed into one, the first looks like a configuration omission and the second looks like an unexplained payment failure after the fact โ€” and only the second one burns a client's money, or its trust in the rail, before anyone works out what happened.

On digesting "the challenge"

There is a related temptation worth naming, because it shows up in every proposal to make a client verify terms before signing. If you bind a check to the 402 payload, note that several sellers put per-request values in the challenge: a nonce, a timestamp, resource.url echoing the query string. A digest over the whole payload changes on every request and will never match anything cached.

The stable part is the normalized per-accept terms โ€” scheme, network, asset, payTo, amount, extra. Those are what the client is actually consenting to, and they hold still across requests. maxTimeoutSeconds is deliberately not in that list: it moves with the seller's configuration and would invalidate a cached verdict over a field that does not change what gets signed.

This came up, in more detail than fits here, in a thread on the x402 working group on discovery records โ€” the shape of a pre-payment verification pointer, what a verdict should name, and why a probe is not evidence of delivery.


If you take one thing: a pin that is only ever compared to another copy of itself is not a check. The asset contract answers name() and version() for free. Ask it.