Neuronto PaymentsAccept payments

How to test an x402 implementation, and what a conformance vector actually proves

A test suite you wrote tests the specification you believe in. If your reading of it is wrong, your code and your tests are wrong in the same direction, every test passes, and the first real counterparty rejects everything you send. Breaking that symmetry is the whole reason conformance vectors exist: they are artifacts produced by someone else's reading, so agreeing with them is evidence about the protocol rather than about your own consistency. The published EIP-712 vector set for the x402 Offer and Receipt extension carries 13 cases, 4 a conformant verifier must accept and 9 it must reject, each naming the layer expected to catch it, signed with a test key whose seed is published on purpose.

The 402 status code defines nothing, so every detail is a convention someone chose

RFC 9110 still says of the status code x402 is named after, in full: "The 402 (Payment Required) status code is reserved for future use." HTTP contributes the number. The challenge shape, the signature format, the replay primitive and the failure vocabulary all come from the x402 specification and the schemes and extensions under it, so every one of them is a convention somebody chose.

A malformed HTTP request is rejected by every server on the internet within a day of shipping. A malformed x402 payment payload is rejected only when you first talk to somebody who is not you, and by then the green tests rest on the mistake.

Five layers, tested separately

One verify() boolean cannot say whether a payment was refused because the bytes were malformed, because a field was absent rather than empty, because the digest differed, or because the signer had no relationship to the resource. Those are separate failures with separate fixes, and an implementation that collapses them cannot express some conformance requirements at all.

Parse. Does the artifact decode? A 65 byte ECDSA signature arriving 4 bytes short, or with a v byte of 0x07 when only 27, 28, 0 and 1 are meaningful, should die here without a key being touched.

Schema. Is every field present, of the declared type, and is absence distinguished from emptiness? A fixed EIP-712 schema cannot express an optional field, so the Offer and Receipt extension mandates an empty value. An omitted validUntil and a validUntil of 0 differ on the wire, and only one is legal.

Signature. Does the digest you compute match the digest that was signed? This is where silent incompatibility lives, and the next section is why.

Authorization. The extension is blunt that this is a distinct question: "Verifiers MUST distinguish between signature validity and signer authorization. A valid signature proves that a specific key signed the artifact." A key generated five seconds ago can sign a valid offer for any resource URL in the world.

Settlement and replay. Does a consumed payment fail the second time? The exact scheme is plain: "the network's own primitive is authoritative. A consumed primitive MUST produce a settlement failure, never a success." It also warns that "a proof establishes that a payment happened, not that it was made for this request", which is a test case, not an aside.

Five test layers and what a static vector set can reach Five layers of an x402 verifier. Parse is covered by 2 of the 9 invalid vectors, schema by 3, signature by 3 and authorization by 1. The fifth layer, settlement and replay, is covered by 0 vectors because a static file cannot consume an on-chain nonce, and needs a fork or a live network instead. Where the 9 known-bad EIP-712 vectors land vectors what that layer alone can decide 1 parse 2 truncated signature, v byte 0x07: rejected before any key is loaded 2 schema 3 field absent instead of empty, and a types table sent on the wire 3 signature 3 wrong domain chainId, altered amount, domain name in wrong case 4 authorization 1 a valid signature by a key with no relationship to the resource 5 settlement, replay 0 out of reach of a file: a nonce has to actually be consumed somewhere Counts from the 13 vector EIP-712 set for the x402 Offer and Receipt extension: 4 valid, 9 invalid.

Signing formats are where silent incompatibility lives

Every other layer fails loudly. A parse error is a parse error in any language, and a missing field shows up the first time somebody reads the JSON. A wrong digest does not. It produces a signature your signer and your verifier agree on completely and nobody else can check.

EIP-712 is the worked example, because the digest is a hash over a domain and a schema that are never transmitted. The extension is explicit: "The canonical types and primaryType definitions MUST NOT be included in transmitted x402 messages (offers/receipts)." The bytes on the wire carry no evidence of what was actually signed, so two implementations can disagree about the schema and never learn it from each other's traffic.

The specific trap is the chain id. The extension says "The chainId is hardcoded to 1 (Ethereum mainnet) for all EIP-712 signatures in this extension", because it signs off chain and the payment network already travels in the payload's network field. Every instinct pushes the other way: the payment settles on Base, so surely the domain says Base too. The vector set names the consequence without softening it: "An implementation that makes this mistake verifies its own artifacts perfectly and nobody else's."

Two constants settle the argument in one line of test code. For the domain {name, version "1", chainId 1}, the EIP-712 domain separator for x402 offer is 0x5dc92544087ab60aa57c539b9543f9282416b0dcf2c2ae0d4d0051052a9e61b6 and for x402 receipt is 0xfd7f8c81cdd24b25c99ba22e191cee41d8809abbc12e8fdbd42fddfa5bb9e406. Both follow from the EIP-712 definition alone, using only the keccak256 of the domain type string and of the field values. Compute yours before running any vector: if the separator disagrees, nothing downstream will match either, and every later failure is the same failure in a different mask.

Recovery based signatures make this harder to diagnose, because they have no clean failure mode: "With ECDSA recovery there is no such thing as a failed signature for a well-formed 65-byte value: it always recovers SOME address." A wrong domain and an altered payload surface identically, as a recovered address that is not the expected one. Assert on the decision rather than the error string, because two conformant implementations can report the same rejection at different layers.

The JWS half of the extension has its own version. The vectors in x402 pull request 3207 cover 13 cases, 3 valid and 10 invalid, and the author singles one out: "a genuine HMAC-SHA256 over the payload, which a verifier that dispatches on header alg would wrongly accept with a public key". That is algorithm confusion, the same shape of bug as the chain id: a verifier taking its parameters from the message rather than from the specification has let the sender choose what was signed.

What a good vector set contains

A file full of valid examples is close to useless: it proves your happy path works, which your own tests already claimed. The value is in the known-bad cases, specifically the ones bad for a reason your implementation never considered. A usable set has four properties.

Both polarities, with the reject reason attached. The contract is "A conformant verifier ACCEPTS every entry under valid and REJECTS every entry under invalid", and it allows honest implementation differences: "reject_at names the layer we expect to catch it; catching it at a different layer is still conformant. Accepting is never conformant."

Deterministic keys, published deliberately. "The signing key is a TEST key whose seed is published here on purpose; it has never signed a real artifact." The EIP-712 set uses a repeated 0x1111... seed for the authorized signer and 0x7777... for the unauthorized one, so the file is regenerable byte for byte by anyone, which makes disagreement diagnosable. The key deliberately appears in no DID document, because a test key inside a trust anchor is a foothold.

Cases that require two separate answers. One vector is a genuinely valid signature by a key with no relationship to the resource URL, and must still be rejected: "Report signature validity and signer authorization as two separate answers, or you cannot express this case." An implementation returning a single boolean fails it by construction, however correct its cryptography.

A case that attacks the verifier's inputs. One vector transmits a malicious types table with a signature valid under that table and invalid under the canonical one: "Take types and primaryType from the specification, never from the artifact. One vector transmits them precisely to check you ignore them." A verifier that trusts the transmitted schema accepts it, and has handed the sender control over which bytes were signed.

Expiry is a deliberate non-failure in the same set: an expired but well formed offer is marked valid, because expiry is the resource server's enforcement decision rather than a signature fault.

The inconvenient part

A vector set is one author's reading of a specification, mechanised. If that reading is wrong, the set encodes the mistake as confidently as everything else, and an implementation agreeing with it has proven only that two artifacts share an assumption. A set published by an implementation may simply be that implementation's assumptions written down.

That is not a reason to skip vectors. It is why the bar is two independent implementations rather than one plus its test file. The EIP-712 set and the JWS set here come from different codebases and different authors, which is what makes them worth more than either alone. Treat a single set as a strong hypothesis that you are wrong somewhere, not as a certificate.

Testing money paths without money

Three techniques cover most of it, and they are not interchangeable.

A local fork of mainnet. The strongest option for the settlement layer, because it runs the real deployed token contract bytecode rather than a mock you wrote. As the Foundry documentation puts it, "Fork testing lets you run tests against real chain state without deploying to a live network." A mock USDC will happily accept a transfer authorization the real one would revert, because you wrote the mock from the same misunderstanding that produced the bug. A fork is also the only practical way to test replay honestly, since the guarantee that "Each authorization includes a unique 32-byte nonce to prevent replay attacks" is enforced by the contract rather than by your code.

A refunding merchant endpoint. A live 402 exercises header encoding, challenge parsing, retry logic and settlement together, which no fork can, because a fork has no server on the other end. A refunding endpoint makes that affordable: the one here is described as "pay a tenth of a cent and it is sent straight back, so an x402 client can be tested end to end against a live 402."

Testnets, with a caveat. A testnet is only useful if the facilitator you intend to use has it switched on, and that gap is real rather than hypothetical. The capability document here lists Base Sepolia, eip155:84532, beside Base mainnet, eip155:8453, and marks the testnet "available": false while mainnet is true. A network in a table is not a network you can settle on.

One more behaviour deserves its own test: which payment flow you are in. Under authorization, verify runs, the resource executes, settlement follows. Under upfront the payment commits first, and "this specification defines no refund, and any remedy is the resource server's own arrangement." Both share one invariant: "The resource never executes with nothing checked".

Error semantics are a first-class test target

This layer is usually skipped, and it produces the most confusing production failures, because the failure mode is not an exception. It is silence.

The specification gives the facilitator's two endpoints different success fields and, more dangerously, different failure fields. VerifyResponse carries isValid plus invalidReason, "Reason for invalidity (omitted if valid)". SettleResponse carries success plus errorReason, "Error reason if settlement failed (omitted if successful)". The Offer and Receipt vocabulary repeats the split with invalidMessage and errorMessage.

Code reading errorReason off a verify response gets undefined. Not an error, not a crash, just a falsy value that reads exactly like "no problem here" on a response whose isValid is false. The payment was refused, the reason was stated, and the integration logged nothing.

Failure is reported under different field names by verify and settle The verify endpoint reports failure as isValid false with invalidReason. The settle endpoint reports failure as success false with errorReason. Code that reads errorReason on a verify response receives undefined, which reads as no error on a refused payment. The specification defines 16 error codes shared by both endpoints. Same failure, two field names POST /verify isValid: false invalidReason: "insufficient_funds" "Reason for invalidity (omitted if valid)" POST /settle success: false errorReason: "insufficient_funds" "Error reason if settlement failed" Reading errorReason on a verify response returns undefined A refused payment then reads as "no error", because the reason was written under the other name. Both endpoints draw on the same set of 16 error codes defined in the specification, including settlement_pending, which is non-terminal and MUST carry a non-empty transaction hash.

The specification defines 16 error codes shared across the two endpoints, and one is a trap of its own. settlement_pending means "The settlement transaction was broadcast but its confirmation could not be established". It is non-terminal: the transaction may still confirm, and the response MUST carry the broadcast hash so you can reconcile on chain. Treating it as a plain failure and retrying is how a client pays twice for one request, so assert that your retry logic does not fire on it. Check too that an unrecognised code degrades to a refusal rather than an acceptance, and that your own facilitator never returns success: true with a populated errorReason.

A minimum suite

Compute both domain separators and compare them against the two constants above. Run all 13 published vectors, asserting the decision rather than the message. Send one request twice against a fork and assert the second settlement fails. Assert that a verify refusal is read from invalidReason and a settle refusal from errorReason, with a test that fails if either is read from the other. Pay a live refunding endpoint once, end to end, with the client you ship.

None of that proves your implementation is correct. It proves a reading other than your own has checked it, which is the one thing a suite you wrote yourself can never do.

FAQ

What is a conformance vector? A fixed published input with a fixed expected outcome, produced by an implementation other than yours, so agreeing with it says something about the protocol rather than your own consistency. A good set carries known-good and known-bad cases, and names the layer expected to reject each bad one.

Why do my x402 tests pass when nobody can verify my signatures? Almost always because your signer and verifier share a parameter that is never transmitted. With EIP-712 that is the domain and the schema, so a wrong chainId is invisible until a counterparty tries to check your artifact. Compare your domain separator against the published constant first.

Can I test x402 settlement without spending real money? Yes, by forking mainnet locally so tests run against the deployed token contract code rather than a mock, which is also the only honest way to test replay because the nonce must genuinely be consumed. Pair it with one live payment against a refunding endpoint, which a fork cannot replace.

Is a testnet enough for x402 integration testing? Only if your facilitator has that testnet enabled, which is not guaranteed. A facilitator can list a network in its capability document and mark it unavailable, in which case the phase never runs.

Why does my code see no error on a failed x402 payment? Because /verify reports failure as isValid: false with invalidReason, while /settle reports it as success: false with errorReason. Reading the settle field name off a verify response yields undefined, which most code treats as no problem at all.

Should I trust a single published vector set? Treat it as one careful reading of the specification rather than as the specification itself. A set published by an implementation can encode that implementation's own misreadings, which is why two sets from independent codebases are worth much more than one.

Sources