x402 exact on EVM: writing the verify rules down
The x402 spec says a facilitator must check that an EVM payment "meets" the requirements. Three SDKs read that word the same way, and one open pull request wants to stop relying on that luck. It rewrites the verify step as five MUST rules, and in doing so it exposes something the SDKs do not agree on: what to do with an ERC-6492 wrapper that has no deployment data.
PR #3925 was opened on 2026-10-24 by DrVelvetFog. It changes one file, specs/schemes/exact/scheme_exact_evm.md, and no code. It resolves the spec side of issue #2725, opened 2026-06-28 and still open, titled "EVM exact scheme verification is non-normative and under-specified relative to the SVM scheme's Facilitator Verification Rules (MUST)". At the head we read, d0dcbf4, the PR has 13 comments, no formal approval (review decision: required), and the text has been revised twice since the first commit (8d08156, 277bffe, d0dcbf4) because reviewers kept running the code.
This post covers four things: what the current spec actually says, what the five rules are, what we reproduced ourselves on Base mainnet and in the three SDKs, and what a gateway that sells inference per call should take from it. Everything below was checked on 2026-10-27 against x402 main at edf6d9e unless stated otherwise.
The wording that two readers can take two ways
On main, the EIP-3009 verification list in the EVM exact spec ends step 3 with: "Verify the authorization parameters (Amount, Validity Window) meet the PaymentRequirements" (line 79). The Permit2 list says "Verify the permit2Authorization.amount covers the payment" (line 189).
"Meet" can mean equal. "Covers" can mean at least. The core spec has already picked a side: its error table defines invalid_exact_evm_payload_authorization_value_mismatch as "Payment amount does not exactly match the required amount" (line 600 of x402-specification-v2.md). The TypeScript facilitator also picked: eip3009.ts line 285 rejects when BigInt(authorization.value) !== BigInt(requirements.amount). The PR cites the same equality in Go and Python.
So the implementations agree and the prose does not. That is harmless until someone writes a fourth verifier from the prose. The PR description reports exactly that happening: two independent verifiers passed all ten published conformance vectors while disagreeing with the reference SDKs on overpayment and on nonce scoping, because the vectors cannot test what the spec does not state.
For an agent that pays per call this is a money question. A verifier that accepts an overpayment accepts a buyer who signed more than the quote. A verifier that accepts a smaller amount releases the resource for less than it costs. Both are one word apart in the spec.
The five rules, as the PR now words them
The new section is "Facilitator Verification Rules (MUST)", modelled on the one the SVM scheme already has. It frames the rules by a split the token itself uses: USDC's SignatureChecker picks the check by whether the payer has code, not by the shape of the signature. At d0dcbf4 the rules read, in short:
- Signature canonicality. If
fromhas no code and the signature is a plain 65-byte ECDSA signature, reject it whens > secp256k1n/2(the low-s rule from EIP-2). - Signer binding. If
fromhas no code, the signature must recover tofromfrom the EIP-712 digest. Iffromhas code (a contract account, or an EOA with an EIP-7702 delegation),isValidSignature(digest, signature)from ERC-1271 must return the magic value. - Validity window. Reject when
now <= validAfterornow >= validBefore. These are the token's own strict comparisons. A verifier may requirevalidBeforeto exceed verification time by a margin; the reference SDKs use at least 6 seconds. - Exact value. Reject when
valuedoes not equalamount. Overpayment is a mismatch, not a margin. - Replay. Reject when
authorizationState(from, nonce)is true on the token, or the nonce is already in the verifier's own pending set. Nonces are scoped per authorizer: the same 32 bytes from a differentfromis not a replay.
The PR is candid about which rules are the token's and which are the protocol's. Rules 1 to 3 and the on-chain half of rule 5 mirror what transferWithAuthorization enforces. Rule 4 does not: the token executes whatever value the payer signed, overpayment included. That distinction matters for the next section, because it decides which rules a token can enforce for you and which only your verifier can.
What USDC does with a high-s signature
An ECDSA signature (r, s, v) has a twin: (r, n - s, v flipped) recovers to the same address. The low-s rule keeps one of the two. Whether a token enforces it is a property of its ecrecover wrapper. A verifier that does not check low-s and calls ecrecover directly will happily accept a signature the token then refuses.
The issue thread had measured this. We reproduced it independently. On Base mainnet at block 53,151,234, with a throwaway key, a random nonce, and the payer's USDC balance set by eth_call state override (slot keccak256(abi.encode(from, 9))), we called transferWithAuthorization on 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 twice with the same authorization:
low-s signature -> eth_call OK
high-s twin -> execution reverted: ECRecover: invalid signature 's' value
Nothing else differed between the two calls. The authorization is identical, the signer is identical, and the high-s twin is cryptographically a valid signature of it. A verifier that stopped at "this recovers to from" would have released the resource and then failed to settle.
The wording in the PR is careful about this, after review. It does not say a high-s payment "can never settle", because a settler that normalises s can still execute the low-s twin. It says accepting it "predicts a settlement that reverts if the signature is submitted unchanged". That is what the measurement supports and no more.
The status of the SDKs moved while the PR was open. Early in the thread only Go enforced low-s explicitly. TypeScript (#2728) and Python (#3912) merged their checks on 2026-10-26 at 16:48 and 16:49 UTC. On main all three now reject a bare high-s signature. The PR description still described the old state when we read it; a reviewer pointed that out on 2026-10-27.
Where the three SDKs still disagree
Rules 1 and 2 had to be scoped by payer code because of smart wallets. That brings in ERC-6492, which lets a not-yet-deployed wallet sign: the signature is wrapped as abi.encode(factory, factoryCalldata, innerSignature) followed by a 32-byte magic suffix. We covered the happy path in our ERC-6492 audit. The question here is the unhappy one: a wrapper that has the suffix but cannot deploy anything.
The rule the PR settled on at d0dcbf4 is strict. If the payer has no code and the signature ends with the ERC-6492 suffix, the verifier may accept it only through the deployment path (allowlisted factory, then a simulation that deploys and transfers together). It must not strip the wrapper and run ecrecover on the inner signature, "whether or not the wrapper carries deployment data". The justification is ERC-6492's own: the suffix check comes before ecrecover.
To see how far the SDKs are from that, we built seven signatures from one fixed 65-byte inner signature: a bare low-s, a bare high-s, and five wrappers (zero or non-zero factory, empty or non-empty calldata, and one with a high-s inner signature). We fed them to each SDK's own parser at edf6d9e. TypeScript uses the classifyErc6492Payer expression from shared/verifySignature.ts over viem's parseErc6492Signature. Go uses ParseERC6492Signature and HasEIP6492Deployment. Python uses parse_erc6492_signature and has_deployment_info. The column below is each SDK's "this wrapper carries deployment info" answer:
shape TS Go Python
bare low-s false false false
bare high-s false false false
zero factory + empty calldata false false false
zero factory + calldata false false false
factory + empty calldata true false false
factory + calldata true true true
factory + empty cd + high-s true false false
One row differs. For a non-zero factory with empty calldata, TypeScript says "has deployment info" and Go and Python say it does not. The cause is visible in the code. Go and Python test factory != zero && len(calldata) > 0. TypeScript tests the truthiness of erc6492Data.data, and viem returns the non-empty string "0x" for empty calldata, which is truthy.
A reviewer, robertolocatelli81-dev, ran the full verify and settle paths against Base mainnet with broadcast stopped, and reports the consequences. We did not run the full facilitator, so these are their results, not ours. With an allowlisted factory and empty calldata, TypeScript verify returns valid: the factory call deploys nothing, its result is unchecked, and the transfer passes on the inner signature because the payer still has no code. Go and Python, for the same incomplete wrappers, unwrap and run ecrecover, which is the fallback the PR now forbids. Where a high-s inner signature sits inside an incomplete wrapper, those two reject it only at the transfer simulation, and settle with the default configuration runs no simulation, so it submits it.
What we can confirm on our own is the chain-side half. We submitted the two incomplete wrappers directly to USDC. Both revert with ECRecover: invalid signature length. The token never accepts a wrapped signature as submitted. Any acceptance of one is the verifier's own doing, and the settle path has to unwrap it correctly.
The thread's resolution so far: the wrapper rule is strict, the Go and Python fallback becomes non-conformant under the new text, and the same reviewer proposed a one-line fix for TypeScript (add a calldata size check). The text does not yet define "incomplete". The Kanon maintainer asked for that definition, and said the spec should name the refusal code so vectors can follow it. The obvious candidate is invalid_exact_evm_payload_undeployed_smart_wallet, which the TypeScript facilitator already defines but which appears nowhere in the spec.
What is still loose
Four loose ends remain at d0dcbf4, all small and all findable by reading.
First, the section still says rules 1 to 4 are "stateless (pure signed-input to verdict)". That stopped being true once rule 2 can call a contract account whose answer changes over time. A reviewer proposed a rewording on 2026-10-26; it was not in the text we read.
Second, the PR leaves out a mismatch it noticed: all three SDKs return permit2_amount_mismatch, and the core spec's error list does not contain it. The author deferred this to a follow-up to keep the PR to one file.
Third, the ERC-7710 path is untouched; the PR says so explicitly.
Fourth, the conformance vectors live outside the repo, in Kanon. Its maintainer said they will follow the spec's wording once it lands. That is the right dependency direction, but it means the text has to merge before the vectors can test it.
What it means for LLM4Agents
We sell inference per call, settled through x402. The verify step is the moment we decide to spend GPU time on a promise. Anything verify accepts that settle later rejects is inference we served for free. Our earlier work on the two-phase gap and on settlement finality was about the time between verify and settle. This PR is about the correctness of verify itself.
Three consequences follow. One: if we rely on a hosted facilitator for verify and settle, as in the facilitator interface we mapped earlier, rules 1 to 5 are the facilitator's problem, and we should be able to see which implementation and version it runs. Two: if we ever verify locally to cut latency, the PR says plainly that on a local-verification path these rules "are the entire settlement guarantee". We would own every line of them. Three: rule 4 is ours in every case. The token will not check the price for us, and an overpayment accepted by mistake is a billing dispute we have to explain to an agent with no human to ask.
The ERC-6492 finding matters for a narrower reason. Agent wallets are increasingly smart accounts, and counterfactual ones are common for fresh agents. That is exactly the payer population that sends wrapped signatures. A gateway that serves it should know which of the three behaviours its facilitator has.
Staying on the frontier
Concrete steps, in order.
One. Turn the five rules into a table-driven test that we can point at any facilitator we use: build an authorization for each rule, mutate one field, and assert that verify refuses it. The cases are cheap to generate: value plus one, value minus one, now == validAfter, validBefore inside the 6-second margin, a replayed nonce from the same payer, the same nonce from a different payer, and a high-s twin.
Two. Add the seven-shape ERC-6492 wrapper set to the same test and record the answer per facilitator. We have the generator; it is 20 lines. The expected result under the new text is that every incomplete wrapper is refused.
Three. Record, per facilitator, the SDK version it runs and the date, so a result can be tied to a build. The high-s check was added to TypeScript and Python on 2026-10-26; a facilitator running older code behaves differently today.
Four. Ask any hosted facilitator we use whether settle runs a simulation by default. The reviewer's results show that the default no-simulation settle in two SDKs will submit a high-s inner signature that verify's simulation would have refused. Where settle is unsimulated, verify is the only gate.
Five. Add a hard pre-check of our own on amount, independent of the facilitator: compare the signed value with the price we quoted before we call verify. It costs one integer comparison and it makes rule 4 ours instead of borrowed. It pairs with the expected-price controls on the buyer side that we covered in the spend-controls audit.
Six. Watch three things on #3925: whether "incomplete wrapper" gets a definition, whether the stateless sentence is corrected, and which error code the spec adopts for the wrapper refusal. When the text merges, rerun steps one and two against every facilitator on our list and keep the diff.
Pay per call, in stablecoins, over an OpenAI-compatible API
One 402, one signature, 345+ models. No subscription, no minimum.
Register your agent