feat(cashu): surface all token-holding mints and sync them on wallet open

Two related gaps around nutzaps redeemed from mints not in the user's
configured kind:17375 list (e.g. a NIP-61 nutzap auto-redeemed from a
mint outside the recipient's kind:10019):

- The wallet screen's per-mint list iterated only the configured mints,
  so a token-only mint contributed to the total balance but had no row —
  the displayed per-mint balances under-counted the wallet. Add
  `displayMints` (union of configured + token-derived mints) so the rows
  sum to the full balance.

- Stale-proof reconciliation (`scrubLocallyStaleProofs`) only ran for the
  single mint a spend targeted, so proofs held at a non-configured mint
  were never checked until spent. Add `syncAllMints()` (an all-mint,
  non-destructive sweep) and wire it to the wallet screen opening via
  `CashuWalletViewModel.refresh()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
This commit is contained in:
Claude
2026-06-17 22:38:02 +00:00
parent 4d7b88aa3f
commit e1c3ebbab3
3 changed files with 71 additions and 1 deletions
@@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -219,6 +220,23 @@ class CashuWalletState(
}.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, emptyMap())
/**
* Mints to surface in the wallet screen's per-mint list: the union of
* our configured mints (kind:17375 — listed even at zero balance so the
* user can top them up) and every mint we actually hold tokens at
* (token-derived, via [mintBalances]). The token-derived half is what
* keeps the per-mint rows summing to [balanceSats]: a balance
* auto-redeemed from a mint we never configured (e.g. a nutzap on a mint
* not in our kind:10019) contributes to the total, so without a row for
* it the displayed mint balances would silently under-count the wallet.
* Configured mints come first; extra token-only mints follow.
*/
val displayMints: StateFlow<List<String>> =
combine(_mints, mintBalances) { configured, balances ->
(configured + balances.keys).distinct()
}.flowOn(Dispatchers.Default)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
@@ -1143,6 +1161,25 @@ class CashuWalletState(
}
}
/**
* Proactively reconcile *every* mint we currently hold tokens at against
* its NUT-07 `/checkstate` — not just the one mint a spend happens to
* target. [scrubLocallyStaleProofs] with a null filter already iterates
* the token-derived mint set ([mintBalances]), so proofs auto-redeemed
* from a mint we never configured (e.g. a nutzap on a mint not in our
* kind:10019) get their spent state checked here too, instead of sitting
* unverified until the user happens to spend from that mint.
*
* Non-destructive (it only prunes proofs the mint reports SPENT) and
* idempotent — safe to call on every wallet-screen open. Deliberately
* does NOT run [migrateStaleKeysets]; that swap-then-publish sequence
* isn't atomic and stays user-driven.
*/
suspend fun syncAllMints() {
if (!started) return
scrubLocallyStaleProofs()
}
/**
* Migrate proofs held on inactive keysets onto each mint's current
* active keyset. Cheap when nothing needs migrating (one /v1/keys
@@ -113,12 +113,25 @@ fun CashuWalletScreen(
val walletEvent by viewModel.walletEvent.collectAsState()
val discovering by viewModel.discovering.collectAsState()
// `mints` is the configured (kind:17375) list — used by the send/receive
// dialogs. `displayMints` adds any mint we merely hold tokens at so the
// per-mint rows below sum to the full balance.
val mints by viewModel.mints.collectAsState()
val displayMints by viewModel.displayMints.collectAsState()
val balanceSats by viewModel.balanceSats.collectAsState()
val mintBalances by viewModel.mintBalances.collectAsState()
val history by viewModel.history.collectAsState()
val pendingQuotes by viewModel.pendingQuotes.collectAsState()
// Reconcile every mint we hold tokens at whenever the wallet opens —
// sweeps stale proofs across all mints, not just the one a spend
// targets, so a balance auto-redeemed from a mint not in our configured
// list (e.g. a nutzap on a mint outside our kind:10019) still gets
// checked. No-ops when the wallet is empty or nothing is stale.
LaunchedEffect(walletEvent != null) {
if (walletEvent != null) viewModel.refresh()
}
var receiveOpen by remember { mutableStateOf(false) }
var sendLnOpen by remember { mutableStateOf(false) }
var sendTokenOpen by remember { mutableStateOf(false) }
@@ -158,7 +171,7 @@ fun CashuWalletScreen(
CashuWalletContent(
modifier = Modifier.padding(padding),
balanceSats = balanceSats,
mints = mints,
mints = displayMints,
mintBalances = mintBalances,
history = history,
pendingQuoteCount = pendingQuotes.size,
@@ -154,6 +154,7 @@ class CashuWalletViewModel : ViewModel() {
val walletEvent get() = state.walletEvent
val mints get() = state.mints
val displayMints get() = state.displayMints
val balanceSats get() = state.balanceSats
val mintBalances get() = state.mintBalances
val tokenEntries: StateFlow<List<TokenEntry>> get() = state.tokenEntries
@@ -187,6 +188,25 @@ class CashuWalletViewModel : ViewModel() {
// that lifecycle and is alive for the whole login session.
}
/**
* Reconcile every mint we hold tokens at against its NUT-07 `/checkstate`
* — not just the mint a spend targets. Wired to the wallet screen opening
* so a balance auto-redeemed from a mint we never configured (e.g. a
* nutzap on a mint not in our kind:10019) still gets its stale proofs
* swept. Safe to call repeatedly; no-ops when nothing is stale or the
* wallet hasn't started yet.
*/
fun refresh() {
val vm = accountViewModel ?: return
vm.launchSigner {
try {
state.syncAllMints()
} catch (e: Exception) {
Log.w("CashuWallet", "wallet refresh sync failed", e)
}
}
}
/** Verify a mint URL is reachable + speaks Cashu v1. */
fun pingMint(url: String) {
val vm = accountViewModel ?: return