Seven of the ten agent-payment systems I read can pay twice when the reply is lost
In three days I read the settlement path of ten agent-payment implementations — x402 facilitators, agent wallets, payment SDKs. Seven can charge a payer twice for one purchase. Not through a replay, and not because their defences are missing: in every case the guards were present, correct, and unable to help.
The moment
A payment settles on chain. Before the confirmation gets back, the connection drops — a timeout, a 502, a throttled RPC read. The code now has to decide what happened, and it cannot know.
Almost every implementation resolves that the same way: it files “I don’t know” under “it failed.” Failure means the payer still owes, so the server answers 402 Payment Required. A conforming client does exactly what the protocol tells it to — it signs a new authorization and pays.
That second payment carries a fresh nonce. It is not a replay. Every replay guard in the stack examines it and correctly lets it through, because it is a genuinely new, genuinely valid payment. The first transfer confirms. The customer has paid twice, and both charges are individually legitimate.
How the ten were read
Two tiers of evidence, and every verdict is labelled with the one it rests on.
- Code read. A line-by-line walk of the settle, retry and signing paths. Before a finding is written, I check it against the implementation’s own defences — idempotency keys, replay sets, on-chain nonce checks — and only report it if it survives all of them.
- Battery run. A deliberately hostile facilitator settles the payment and then misbehaves in seven ways — hangs past the timeout, returns 502 after settling, re-challenges a paid request, breaks the reconciliation read — while counting how many payments actually landed. One is safe. Two is the double charge.
Four of the seven now carry a runnable red/green test filed on the project’s own issue. One is a full on-chain reproduction: the implementation’s unmodified merchant code, a local chain, and a proxy that fails only the receipt read — which produced two real transfers and a recipient balance of exactly double for a single purchase.
What this study is not
Ten is not a random sample. I chose implementations that move real money on agent-payment rails, and several were chosen precisely because they were commercially active. A different ten would give a different number. Read “seven of the ten I read”, not “seventy per cent of the ecosystem”.
Six of the seven are code reads, not live reproductions. The control flow, the missing dedup and the fresh-nonce behaviour are verified from source; the second charge is the consequence that follows. Only one has been driven end-to-end on a chain so far.
Nobody is named here while their finding is open. Each was reported privately or as a finding-first issue with the fix attached. Names appear on the index only after a fix ships — as credit, with the time it took.
The seven shapes
Described by structure, not by author. The same category error appears in Rust, TypeScript, PHP and Go.
A try block wraps both the signing and the network send that already carries a signed payment. Its handler is written for signing failures — so a send that fails after the money moved falls through and pays again.
The transaction is broadcast, then the receipt read times out. Both throw from the same function, so the caller cannot tell “nothing was sent” from “it is in the mempool”. It answers 402.
The failure type has no field for a transaction. The hash of a payment that is very likely to land exists for one line and is then structurally unrecoverable — nothing downstream can reconcile.
The key is minted per call from the clock or a random source, so it dedupes retries inside one call and none of the retries an agent framework actually makes.
success aloneThe layer below preserves the hash and marks the outcome ambiguous — correctly. The layer above looks only at success: false, keeps the balance owed, and settles it again on the next sweep.
A timeout returns before the record is written, so the reconciliation net that depends on that record never fires, and the store answers payment-required.
Spend guards check and record across an ambiguous boundary, so concurrent or retried payments each pass a limit that the pair of them breaks.
Two of the seven had idempotency keys. One had an on-chain authorization check before settling. The guards were correct and fired correctly — the category error happened one line earlier.
What the three safe ones do differently
This is not a story about universally broken code. Three implementations survived every mode, and all three do the same two things:
- They keep the evidence. A broadcast that cannot be confirmed still reports its transaction hash, as a non-terminal outcome rather than a failure.
- They re-present, they do not re-mint. On an ambiguous outcome they resend the same signed authorization — which the chain deduplicates — instead of signing a fresh one. One of them says it in its own error message: re-present the same signed authorization; do not re-pay.
The fix is that short. It is not a rewrite; it is refusing to convert an unknown into a negative.
Test your own in about a minute
The battery is MIT-licensed and runs against any client that can make one purchase. It stands up a facilitator that settles and then misbehaves, and counts how many payments actually arrived.
hostile-facilitator run -- <your one-purchase command>
Get the battery
See the full index