diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index 6388a2e071..ff9d4a8bd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -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> = + combine(_mints, mintBalances) { configured, balances -> + (configured + balances.keys).distinct() + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt index ade2f60022..f23214591d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt index aabb32be19..3ae037082f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/CashuWalletViewModel.kt @@ -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> 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