Skip to content

feat(cashu): offline settlement queue for card payments - #68

Merged
islandbitcoin merged 1 commit into
mainfrom
feat/cashu-offline-settlement
Aug 27, 2026
Merged

feat(cashu): offline settlement queue for card payments#68
islandbitcoin merged 1 commit into
mainfrom
feat/cashu-offline-settlement

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

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_PROOF marks 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:

Nothing is approved to a customer until the witness is durably recorded.

Recovery — why this is smaller than "irreversible" implies

Three facts from the applet, verified in source:

  • GET_PROOF still returns spent slots — only STATUS_EMPTY is refused
  • SIGN_ARBITRARY (0x21) signs without consuming a proof, and takes no PIN
  • the witness message is sha256(utf8(secret)), derived entirely from data that survives the burn

So 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_SPENT is the step that makes loss real, so hasUnsettledForCard() exists to gate it.

What's here

cashuCard.tsgetProof, 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:

Behaviour Why
recordSpend persists before approval a throw means do not approve — nothing would remember it
a witnessless burn → needs-card the money left the card; something must still owe it
drainQueue retries transient, gives up on PermanentSettlementError a double-spend must not retry forever
pendingExposure() the risk is the merchant's, so it's visible — not hidden behind an instant green tick
hasUnsettledForCard() gates CLEAR_SPENT, the one op that turns a recoverable burn into real loss
eviction drops settled entries only a full queue must never discard money still owed

Tests

27 new (321 total, all passing; main is 20 suites / 193). They target the invariants that cost money, not the getters:

  • a queued entry is readable after a simulated relaunch (in-memory store reset to disk contents only)
  • a corrupt store returns empty and stays usable — a bad file must not take the till down
  • one failing settlement doesn't block the others
  • needs-card entries are skipped until the card returns, then settle after attachRecoveredWitness
  • eviction under overflow keeps outstanding entries, including when they alone overflow the cap
  • clearQueue 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 that). Nothing calls spendProof yet — 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

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
islandbitcoin merged commit cb04721 into main Aug 27, 2026
4 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant