fix(cashu): correct skip-on-decrypt-fail + auto-resume orphan mint quotes

Two small follow-ups to the audit refactor:

* recomputeUnspent: replace the broken `getOrPut { ... return@forEach }`
  pattern (which short-circuited the outer loop on a single decryption
  failure, skipping remaining tokens) with an explicit containsKey
  guard. Decryption failures are now individually skipped without
  affecting other tokens in the same pass.

* CashuWalletScreen: when the wallet opens and pendingQuotes (live
  flow from CashuWalletState) is non-empty, automatically resume the
  most recent kind:7374 by re-polling the mint and reopening the
  receive dialog. Without this, a user who backgrounded the app
  mid-mint would see no indication their pending invoice exists.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:40 +00:00
parent 5cd756cea2
commit bae6e8dcb5
2 changed files with 22 additions and 5 deletions
@@ -324,12 +324,18 @@ class CashuWalletState(
private suspend fun recomputeUnspent() {
val all = tokenEvents.values.toList()
// Decrypt anything we haven't seen before; reuse cached TokenContent
// for events we've already decrypted.
// for events we've already decrypted. Decryption failures are
// skipped — the proof set rebuilds the next time a re-key happens.
all.forEach { evt ->
tokenContents.getOrPut(evt.id) {
runCatching { evt.tokenContent(signer) }
.onFailure { Log.w("CashuWallet") { "Failed to decrypt token ${evt.id.take(8)}: ${it.message}" } }
.getOrNull() ?: return@getOrPut return@forEach
if (!tokenContents.containsKey(evt.id)) {
val content =
runCatching { evt.tokenContent(signer) }
.onFailure {
Log.w("CashuWallet") {
"Failed to decrypt token ${evt.id.take(8)}: ${it.message}"
}
}.getOrNull()
if (content != null) tokenContents[evt.id] = content
}
}
@@ -97,12 +97,23 @@ fun CashuWalletScreen(
val mints by viewModel.mints.collectAsState()
val balanceSats by viewModel.balanceSats.collectAsState()
val history by viewModel.history.collectAsState()
val pendingQuotes by viewModel.pendingQuotes.collectAsState()
var receiveOpen by remember { mutableStateOf(false) }
var sendLnOpen by remember { mutableStateOf(false) }
var sendTokenOpen by remember { mutableStateOf(false) }
var redeemOpen by remember { mutableStateOf(false) }
// If the user has an unfinished kind:7374 quote, surface it the next time
// they open the wallet so the in-flight invoice isn't lost. Auto-resumes
// the most recent quote on first composition.
LaunchedEffect(pendingQuotes) {
if (!receiveOpen && pendingQuotes.isNotEmpty()) {
viewModel.resumeMintQuote(pendingQuotes.first())
receiveOpen = true
}
}
Scaffold(
topBar = {
TopAppBar(