Signed Is Not Settled: One Idempotency Key That Silently Loses a Payment

2026-10-24 · protocol notes

A settlement-receipt ledger needs a key that answers: have we already recorded this payment? The obvious candidate for EIP-3009 is (chain_id, asset, from, nonce) — the nonce is single-use, so the tuple looks unique. It is unique, but only among settled transfers. It is not unique among signed authorizations, and a ledger that keys unknown rows on it can close one as unpaid and then discard the settlement that later arrives under the same key.

This is a short walk through why, and through the schema that avoids it. It grew out of a public thread on receipt ledgers for accountless paid routes; the failure mode is not specific to one implementation.

1. What the token enforces, and when

EIP-3009 defines authorizationState(address authorizer, bytes32 nonce). A transfer function such as transferWithAuthorization checks that the entry is false, then sets it to true, then moves the tokens. The authorization itself is signed off-chain over (from, to, value, validAfter, validBefore, nonce) under the token's EIP-712 domain.

Two consequences follow directly from that ordering:

So the 4-tuple identifies a nonce slot, not a signature. "At most one transfer can settle per slot" is true. "One signature per slot" is false.

2. The collision at the end of the unknown row

A seller cannot always tell whether an authorization that was presented actually settled. The transfer may fail to broadcast, the RPC may lose the transaction, the response may be lost after the payer signed. The standard answer is a bounded unknown row with a closure horizon: record validBefore, re-check while the window is open, and when it passes with the nonce still unconsumed, close the row as not charged. That is correct as far as it goes — the signed authorization can never execute after validBefore.

But the nonce is still unspent. That is the whole point: an expired authorization never touched the slot. And a buyer retrying the same purchase is not obliged to invent a new nonce. It can re-sign the same intent with a later validBefore — same from, same nonce, same to, same value, a different signature — and submit that. This one settles.

Now the ledger receives settlement evidence carrying (chain_id, asset, from, nonce) identical to the row it closed a minute ago as not charged. Three outcomes, all bad:

ledger behaviour on the insertwhat the seller loses
unique index rejects the rowa real payment, with an error someone must interpret later
upsert overwrites statea row that now says settled but whose history says not charged — and any delivery already re-queued on the old state
"already recorded, skip"a real payment, silently — the worst of the three, because nothing turns red

None of these is a concurrency bug. They are the schema doing exactly what it was told.

3. What an on-chain read can and cannot answer

The tempting fix is to ask the chain. After validBefore passes, call authorizationState(from, nonce) once more and trust the answer. But that function answers one question — is this nonce spent — and not the one the ledger needs, which is was it spent by my authorization. It returns true for any transfer that consumed the slot, regardless of to, value, or when it happened.

So the read can close a row as terminal ("this slot can never carry another transfer"), and it cannot open one as settled. Settlement evidence has to come from the transfer itself: the transaction and its logs, matched on to and value, not from the state lookup.

4. A ledger that survives both

The invariant to write down first: at most one row per 4-tuple may reach settled, but more than one signed authorization may exist for that slot. Any schema that assumes the key identifies the signature will break the second half.

Two shapes that hold:

  1. Key unknown rows by the signed payload. A hash of the canonical authorization (or, at minimum, the 4-tuple plus validBefore) makes a re-signed authorization its own row. The 4-tuple stays unique only for rows in state settled, where the token's guarantee actually applies. When the re-signed one settles, it lands on its own row; the old row stays not_charged forever, which is true.
  2. Or keep one row per slot and make not_charged reopenable exactly once, with that transition written into the state machine. A settlement arriving for a not_charged row flips it to settled; the guard against double-counting is the one transition, not the unique index.

Either way, the states want to be explicit about how the row ended:

statehow the row got therecan it still become settled?
unknownauthorization seen, no settlement evidence yetyes, until the slot is closed
settledtransfer matched on to/valueno — terminal
not_chargedvalidBefore passed, slot unspentonly via a distinct signed payload (shape 1) or one allowed reopen (shape 2)
supersededslot consumed by a different payloadno — terminal

The superseded row is worth keeping for a different reason: it is the only evidence that the slot was taken by someone else's transfer, and it is what lets you explain a payment that never arrives without blaming the payer.

5. The general rule

We keep relearning one distinction in payment plumbing: a key that identifies a slot is not a key that identifies a payment. Database uniqueness is a statement about what you will store, and it is easy to write down a constraint that the chain does not actually enforce. The chain enforces "one spend per slot". It does not enforce "one signature per slot", and a reconciler that confuses the two will be most confident exactly where it is most wrong.

The practical test before adopting any idempotency key: ask what the issuer enforces at the moment of signing, and what it enforces at the moment of spending. If those two answers differ — and for pre-authorized transfers they usually do — the key belongs on the spending side, and the signing side needs its own identity.


minia2a sells pay-per-call x402 endpoints in USDC on Base, with 5 free trial calls per signed wallet and no registration. The protocol details above were checked against the EIP-3009 authorization semantics; where a claim is about one implementation's behaviour, it is described as such.