fix(cashu): heal stale proofs + harden auto-redeem against double-spend

Two changes that together address "Mint error (HTTP 400): proofs already
spent" on self-send / self-zap:

1. Replace the auto keyset-migration on wallet load with a non-destructive
   NUT-07 sweep. The migration performed a swap-then-publish that wasn't
   atomic — a failure mid-sequence (cancelled signer prompt, transient
   network) left the mint with the source proofs marked spent while our
   local kind:7375 still held them. The next user-initiated send would
   then pick those ghost proofs and the mint would reject the swap.

   The new sweep does a read-only /v1/checkstate per mint and NIP-09
   deletes any kind:7375 whose proofs are all reported SPENT. Mixed-state
   entries (rare; partial sub-swap landed) are logged and skipped — they
   self-resolve when the user spends the entry. migrateStaleKeysets()
   stays around for explicit on-demand wiring.

2. Track redeemed nutzap ids in an in-memory set inside the redeem mutex.
   Without this, a second triggerAutoRedeem firing in the ~1s window
   between publishing kind:7376 and the bundled newEventBundles emission
   could re-pick the same nutzap and the mint would 400 with "proofs
   already spent" on the P2PK swap.
This commit is contained in:
Claude
2026-05-27 17:45:29 +00:00
parent 1190abb30d
commit 4db226d5f2
2 changed files with 118 additions and 16 deletions
@@ -724,6 +724,19 @@ class CashuWalletOps(
*/
suspend fun fetchActiveKeysetId(mintUrl: String): String = ops(mintUrl).activeKeyset().id
/**
* NUT-07 batched proof-state query. Returns a map keyed by each
* proof's secret to the mint-side state. Used by the wallet's
* stale-proof sweep to detect kind:7375 events whose proofs are
* already spent at the mint (so the parent event can be NIP-09
* deleted before the next send picks them and hits HTTP 400
* "proofs already spent").
*/
suspend fun checkProofStates(
mintUrl: String,
proofs: List<CashuProof>,
): Map<String, ProofState> = ops(mintUrl).checkStates(proofs)
/**
* Swap every proof inside [entries] (which the caller has already
* filtered to "contains at least one stale-keyset proof") onto the
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -82,8 +83,9 @@ import java.util.concurrent.ConcurrentHashMap
* or addressed to this account. The full [LocalCache.notes] map is scanned
* ONCE during init to backfill; after that all updates are incremental.
*
* Auto-redeem is serialized through a [Mutex] so concurrent cache updates
* don't fire duplicate /v1/swap calls against the mint.
* Auto-redeem is serialized through a [Mutex], with successful redemptions
* pinned in an in-memory set so a second triggerAutoRedeem firing before
* the kind:7376 history event propagates back doesn't double-spend.
*/
class CashuWalletState(
private val pubKey: HexKey,
@@ -144,6 +146,18 @@ class CashuWalletState(
private val tokenContents = ConcurrentHashMap<HexKey, TokenContent>()
private val redeemMutex = Mutex()
/**
* Nutzap event ids whose redemption swap has already completed at
* the mint this session. Recorded inside [redeemMutex] right after
* the mint accepts the swap, so a second [triggerAutoRedeem] firing
* before the kind:7376 history event propagates back through
* [LocalCache.live.newEventBundles] still sees the nutzap as
* redeemed and skips it — preventing the "proofs already spent"
* double-redeem race. The set is process-local; on next launch the
* persisted kind:7376 events rebuild equivalent state.
*/
private val sessionRedeemedNutzaps = ConcurrentHashMap.newKeySet<HexKey>()
// ============================================================
// Public flows
// ============================================================
@@ -335,18 +349,21 @@ class CashuWalletState(
}
}
// Backfill from cache once, then run a one-shot keyset
// migration sweep so any proofs we hold on rotated-out
// keysets get consolidated onto the current active one before
// the mint retires the old private key (after which they'd be
// un-spendable). Best-effort: mint-side failures just log.
// Backfill from cache once, then run a one-shot NUT-07 sweep
// that asks each mint which of our held proofs are still
// unspent and NIP-09 deletes any kind:7375 the mint reports
// as fully spent. Heals state when a prior swap (send, nutzap,
// migration) consumed proofs at the mint but failed to publish
// the kind:5 locally — without this, the next send picks the
// ghost proofs and the user sees "proofs already spent".
// Read-only at the mint, so safe to auto-run.
scope.launch(Dispatchers.Default) {
val initial = scanCacheForOwnEvents()
applyEvents(initial)
recomputePending()
triggerAutoRedeem()
runCatching { migrateStaleKeysets() }
.onFailure { Log.w("CashuWallet", "Initial keyset migration sweep failed", it) }
runCatching { scrubLocallyStaleProofs() }
.onFailure { Log.w("CashuWallet", "Initial stale-proof sweep failed", it) }
}
// Keep the relay subscription in sync with the outbox set.
@@ -675,16 +692,25 @@ class CashuWalletState(
val pubkey = p2pkPubkeyHex() ?: return
val alreadyRedeemed =
historyEvents.values
.flatMap { it.redeemedReferences() }
.asSequence()
.flatMap { it.redeemedReferences().asSequence() }
.map { it.eventId }
.toSet()
.toMutableSet()
.apply { addAll(sessionRedeemedNutzaps) }
val candidates = nutzapEvents.values.filter { it.id !in alreadyRedeemed }
if (candidates.isEmpty()) return
for (ev in candidates) {
runCatching { ops.redeemNutzap(ev, privkey, pubkey) }
.onFailure { e ->
.onSuccess {
// Pin redemption in-memory immediately. The kind:7376
// history event is bundled via newEventBundles (~1s)
// before it lands in historyEvents — until then a
// concurrent trigger would re-pick this nutzap and
// get HTTP 400 "proofs already spent" from the mint.
sessionRedeemedNutzaps.add(ev.id)
}.onFailure { e ->
Log.w("CashuWallet") {
"Auto-redeem of nutzap ${ev.id.take(8)} failed: ${describeMintError(e)}"
}
@@ -760,6 +786,67 @@ class CashuWalletState(
return outcome
}
/**
* NUT-07 sanity sweep: ask each mint which of our held proofs it
* still considers unspent, and NIP-09 delete any kind:7375 whose
* proofs are all marked SPENT.
*
* Heals the local wallet after a prior swap (send, nutzap,
* migration) consumed proofs at the mint but failed to publish
* the kind:5 locally — the most common cause of HTTP 400
* "proofs already spent" on the next user-initiated send.
*
* Read-only at the mint (no swap, no signing of new tokens),
* so safe to auto-run on every wallet load. Mixed-state entries
* (some proofs spent, some unspent in the same kind:7375 — rare,
* happens only when a partial sub-swap lands) are left alone and
* logged for visibility; they self-resolve once the user spends
* the entry.
*/
suspend fun scrubLocallyStaleProofs() {
check(started) { "CashuWalletState.start() not called" }
val byMint = _tokenEntries.value.groupBy { it.content.mint }
for ((mintUrl, entries) in byMint) {
val allProofs = entries.flatMap { it.content.proofs }
if (allProofs.isEmpty()) continue
val states =
runCatching { ops.checkProofStates(mintUrl, allProofs) }
.onFailure {
Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it)
}.getOrNull()
?: continue
val staleEntries =
entries.filter { entry ->
val statesForEntry = entry.content.proofs.map { states[it.secret] }
statesForEntry.all { it == ProofState.SPENT }
}
val mixedEntries =
entries.filter { entry ->
val statesForEntry = entry.content.proofs.map { states[it.secret] }
val spent = statesForEntry.count { it == ProofState.SPENT }
spent in 1 until statesForEntry.size
}
if (mixedEntries.isNotEmpty()) {
Log.w("CashuWallet") {
"Skipping ${mixedEntries.size} mixed-state token event(s) at $mintUrl " +
"(some proofs spent, some unspent); user must spend to resolve."
}
}
if (staleEntries.isEmpty()) continue
Log.i("CashuWallet") {
"Scrubbing ${staleEntries.size} fully-spent kind:7375 event(s) at $mintUrl"
}
runCatching {
val template = DeletionEvent.build(staleEntries.map { it.event })
val signed = signer.sign(template)
publishEvent(signed)
}.onFailure {
Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it)
}
}
}
/**
* Migrate proofs held on inactive keysets onto each mint's current
* active keyset. Cheap when nothing needs migrating (one /v1/keys
@@ -767,10 +854,12 @@ class CashuWalletState(
* consolidates them via a swap and republishes as a single kind:7375,
* NIP-09-deleting the source events the same way [sendNutzap] does.
*
* Triggered lazily on wallet load. Mint rotations don't happen often,
* so doing this once per session keeps us ahead of the mint
* retiring an old keyset's private key (after which our proofs
* become un-spendable).
* No longer auto-triggered on wallet load — the swap-then-publish
* sequence isn't atomic, and a failure mid-sequence corrupts local
* state (mint spent proofs, our kind:7375 still holds them). The
* non-destructive [scrubLocallyStaleProofs] runs in its place at
* startup; this method stays available for explicit user-driven
* migration (e.g. a future "compact wallet" action).
*/
suspend fun migrateStaleKeysets() {
check(started) { "CashuWalletState.start() not called" }