feat(cashu): offline settlement queue for card payments - #68
Merged
Conversation
Implements the agreed spend-path model: approve offline, queue the
settlement, show the merchant what they are carrying.
The card marks a proof spent BEFORE returning the signature, on purpose —
reversing that would let someone yank the card mid-response and keep both
the money and a valid witness. The cost is a window where the money has
left the card but the mint has not been told. This queue stands in that
window, enforcing one rule:
nothing is approved to a customer until the witness is durably recorded.
Card layer (cashuCard.ts):
- getProof — spent slots are still readable, which is what makes recovery
possible at all; only an empty slot is refused
- spendProof — irreversible, documented as such at the call site
- signArbitrary — the recovery path: re-derives an equally valid witness
from a burned slot without consuming a second proof and without a PIN
- status words for already-spent / empty / out-of-range / signing failure
Queue (cashuSettlement.ts):
- recordSpend persists before approval; a throw means do not approve
- a burn that returned no witness is recorded as `needs-card`, not lost
- drainQueue retries transient failures and gives up only on
PermanentSettlementError, so a double-spend does not retry forever
- pendingExposure() is what the UI shows — the risk is the merchant's and
is therefore visible, not hidden behind an instant green tick
- hasUnsettledForCard() gates CLEAR_SPENT, which erases spent slots and is
the one operation that turns a recoverable burn into real loss
- eviction drops settled entries only, never money still owed
27 tests covering the invariants that matter: persistence survives a
simulated relaunch, a corrupt store does not take the till down, one
failure does not block other settlements, needs-card entries are skipped
until the card returns, and clearing refuses while anything is outstanding.
Not yet wired: the UI surface, the per-merchant switch, and the mint swap
itself (cashu-client's swapProofs supplies it). Nothing calls spendProof
yet — this is the foundation those sit on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
islandbitcoin
pushed a commit
that referenced
this pull request
Aug 28, 2026
Addresses the review on #68. The queue durably recorded everything except the fields and invariants it needed to actually settle. Correctness: - Persist the NUT-10 `secret` on every entry. The card returns the nonce, not the secret (GET_PROOF), so without it `drainQueue` could not build a Proof and the recovery path had no message to re-sign. Adds `toCashuProof` and `recoveryMessage` as named, tested functions rather than an unstated reconstruction assumption. - Fail closed on an unreadable store. `getSecureStrict` rethrows Keychain failures so `loadQueue` can tell "absent" from "failed": `recordSpend` throws instead of overwriting outstanding settlements with a queue of one, `hasUnsettledForCard` answers true so the CLEAR_SPENT gate stays shut, and `pendingExposure` refuses to report a clean till. - Serialise every mutation through a single promise chain. A tap landing mid-drain used to be clobbered by the drain's stale snapshot — a slot burned on the card that nothing on disk remembered. - Move the post-swap write out of the swap try block. A failed write after a successful mint swap was misclassified as a settlement failure and written off as 'failed'. It now retries and surfaces SettlementPersistenceError, and the confirmed id is held in-process so no later drain re-submits it. - Fix eviction: `slice(-room)` with room === 0 is `slice(0)`, so in the exact case the cap exists for, nothing was evicted. - Guard drainQueue against overlapping runs, so two triggers cannot submit the same proof twice. - attachRecoveredWitness only moves needs-card -> pending; it no longer resurrects a settled or failed entry into the drain loop. - recordSpend rejects a duplicate id, which update() could not patch. - drainQueue counts an unattributable outcome as `lost` rather than reporting a settlement that was not persisted. - Quarantine unparseable queue bytes to @cashu_settlement_queue_corrupt before the next write overwrites them; drop the comment claiming they were logged upstream. - Drop clearQueue's `force` escape hatch — a boolean that deletes unsettled payments has no production use case. Tests: - cashuSettlement: eviction with a full outstanding queue, concurrent recordSpend/markSettled, post-swap write failure, reentrant drain, duplicate id, settled/failed entries surviving a re-tap, corrupt-store quarantine, and every fail-closed path with getSecureStrict rejecting. - cashuCard: getProof, spendProof and signArbitrary had no coverage at all. Adds full 78-byte decode with distinct bytes per field, unsigned amount above 2^31, wrong-length and unknown-status rejection, exact APDU framing for both signing commands, the 32-byte message and 64-byte signature guards, and the six previously untested status words. - secureStorage: getSecureStrict rethrows vs getSecure flattening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR
islandbitcoin
added a commit
that referenced
this pull request
Aug 28, 2026
) * fix(cashu): review fixes for the settlement queue that missed the #68 merge #68 was merged from the original commit while the review workflow was still pushing fixes to its branch; none of them landed on main. This carries the branch's full fix content: confirmed-spend-only settlement, queue schema versioning with forward reads, corrupt-blob quarantine, drain re-entrancy/backoff, ambiguous-failure handling, and the recovery (resignWitness) path, with their tests. Content-identical to feat/cashu-offline-settlement tip 5b78692 relative to the merged base. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR * fix(cashu): additive-only migration, quarantine write-rate bound, no signing oracle The three findings the review's final round left open on #68. - Migration arms now fill only what is absent, never overwrite what is present, and the version doc states that as a contract. A downgraded build re-stamps a newer blob at its own version while preserving the newer fields, so the version tag alone cannot prove a field is missing — an unconditional `mintUrl: LEGACY_MINT_URL` arm would overwrite the real mint with the stand-in on the rollforward, the adapter would fall back to the configured mint, the proof would be rejected as unknown, and owed money booked as lost. (Not fixed by stamping the read version back: a v1 build would then write entries lacking the v2 field under a v2 tag, and the v2 validator drops them as corrupt, which is worse.) - quarantine() now writes each corrupt blob's copy once per process. It runs from every read path and a corrupt blob is only cleared by the next write, so an exposure banner polling pendingExposure() once a second ground the Keychain at two writes per second over byte-identical bytes for as long as the app stayed open. In-process set, keyed by content, so a different corrupt blob still gets its copy; cleared in the relaunch seam so a restart re-verifies the copy. - __signArbitraryForTests is gone. A __ prefix is a naming convention, not an access control, and the export was an oracle for a BIP-340 signature under the card's P2PK identity over any 32 bytes a caller chose. What it existed to test was dead code: the length guard in signArbitrary, which its only caller (resignWitness) can never reach — that guard is deleted too, and stays in spendProof where the message is a real parameter. The wrong-length-signature case moved onto resignWitness, and the only-signer regression test now pins both names. Both behavioural fixes verified by mutation: reverting each makes exactly its test fail. 441 tests, typecheck and lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR --------- Co-authored-by: Dread <dread@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the agreed spend-path model — approve offline, queue the settlement, show the merchant what they're carrying. Decision memo: The Burned Slot.
The window this closes
SPEND_PROOFmarks the slot spent before it returns the signature. That ordering is deliberate — reverse it and you can yank the card mid-response and keep both the money and a valid witness. The cost is a window where the money has left the card but the mint hasn't been told.One rule holds the window shut:
Recovery — why this is smaller than "irreversible" implies
Three facts from the applet, verified in source:
GET_PROOFstill returns spent slots — onlySTATUS_EMPTYis refusedSIGN_ARBITRARY(0x21) signs without consuming a proof, and takes no PINsha256(utf8(secret)), derived entirely from data that survives the burnSo a failed settlement recovers by presenting the card again. The burn doesn't destroy the money; it converts it into money that needs the card once more.
CLEAR_SPENTis the step that makes loss real, sohasUnsettledForCard()exists to gate it.What's here
cashuCard.ts—getProof,spendProof(irreversibility documented at the call site),signArbitrary(the recovery path), plus status words for already-spent / empty / out-of-range / signing failure.cashuSettlement.ts— the durable queue:recordSpendpersists before approvalneeds-carddrainQueueretries transient, gives up onPermanentSettlementErrorpendingExposure()hasUnsettledForCard()CLEAR_SPENT, the one op that turns a recoverable burn into real lossTests
27 new (321 total, all passing;
mainis 20 suites / 193). They target the invariants that cost money, not the getters:needs-cardentries are skipped until the card returns, then settle afterattachRecoveredWitnessclearQueuerefuses while anything is outstandingNot yet wired
The UI surface, the per-merchant switch, and the mint swap itself (cashu-client's
swapProofssupplies that). Nothing callsspendProofyet — this is the foundation those sit on, landed separately so the money-safety logic can be reviewed on its own.Typecheck and lint clean. No hardware has run any of this.
🤖 Generated with Claude Code
https://claude.ai/code/session_016MsAGwtS4sNodzWu2VMTKR