When the connection dies

A spend is not naturally idempotent. The issuer burns the nullifier on acceptance, and the change token exists only in the response. If that response is lost, to a TCP reset, a mobile handoff, a crash, a timeout, the client holds a dead token and no change. The value is gone.

The obvious fix makes it worse. A client that retries presents a nullifier the server has already stored, which is exactly what a double spend looks like. A naive server refuses, and the honest client loses its balance for having bad Wi-Fi. Distinguishing "the same client finishing" from "an attacker spending twice" is the entire problem, and the server must decide it without knowing who anyone is.

The answer is that identity was never needed: the request itself is the identity. Both issuers here implement the same contract, and the wallet implements its half.

The issuer's contract

Every spend request has a canonical digest: a hash of the exact proof bytes and the requested_return (plus, on the post-quantum side, the settlement and input-kind tags). The issuer verifies the proof, computes the response, and then performs one atomic insert of (nullifier -> request digest, response). It answers only after that row is durable. Any response a client has ever received is, by construction, a response the store remembers.

Every later request carrying the same nullifier hits one of two branches:

Two identical requests racing each other converge the same way: the store picks one winner atomically and both callers receive the winner's bytes. This deployment's end-to-end tests exercise exactly that race.

wallet                                issuer
  |                                     |
  |  journal request bytes + secrets;   |
  |  spent token leaves the spendable   |
  |  set in the same save               |
  |                                     |
  |------ spend(proof, return) ------->|
  |                                     |  verify proof
  |                                     |  sign change token
  |                                     |  INSERT (nullifier ->
  |                                     |    digest, response)   [durable]
  |   X--- response lost in transit ---|
  |                                     |
  |  restart: read journal              |
  |------ identical bytes ------------>|
  |                                     |  nullifier found,
  |                                     |  digest matches
  |<----- stored response -------------|  replayed: true
  |                                     |
  |  finish the change token            |

Issuance gets the same treatment

Mining a proof-of-work challenge costs real CPU, and the issuance response is the only copy of the signature the client paid for. So consuming a challenge stores the request digest and response in the same atomic step. Resend the identical envelope and the stored response comes back with replayed: true; send a different request against a consumed challenge and the answer is the final challenge_mismatch. Mined work survives a dropped connection; it cannot be respent.

The wallet's half

Replay only works if the client can reproduce its request exactly, so the wallet follows two rules, in this order:

  1. Journal before network. Before anything is sent, the wallet writes the exact request bytes plus the secret state needed to finish the operation into its journal and saves the file. A crash at any later point leaves everything needed to resume.
  2. Burn in the same save. A token being spent leaves the spendable set in the very save that journals the operation. No saved state ever believes a possibly-burned token is still spendable, so the wallet cannot be tricked by its own crash into double spending.

On restart, the wallet resends the journaled bytes unchanged, receives the replayed response, and finishes the token as if the crash never happened. Byte-for-byte matters: proofs are randomized, so rebuilding the request instead of replaying it would produce a new digest, and the issuer would correctly call it a double spend.

Error codes split cleanly for the journal. double_spend, challenge_mismatch, challenge_gone, proof_invalid, and bad_request are final: the entry is cleared. On proof_invalid and bad_request the issuer provably rejected before touching any state, so the wallet also restores the burned token. Network errors and 5xx leave the entry journaled for the next resume.

Operational corollaries

The contract binds the operator too, and both consequences are worth stating baldly:

The API reference documents the replayed flag and the full error vocabulary; the wallet page covers the journal file itself.