From e1c3ebbab35f1bef117b08e7a69063b244cbed8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 20:24:34 +0000 Subject: [PATCH 1/4] feat(cashu): surface all token-holding mints and sync them on wallet open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR --- .../model/nip60Cashu/CashuWalletState.kt | 37 +++++++++++++++++++ .../loggedIn/wallet/CashuWalletScreen.kt | 15 +++++++- .../loggedIn/wallet/CashuWalletViewModel.kt | 20 ++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) 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 From dbe1757ee9bebfab576b66dcb1273ad5604f42d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 21:13:43 +0000 Subject: [PATCH 2/4] feat(cashu): highlight balances held at unconfigured mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface coins sitting at a mint the user never configured — almost always auto-redeemed from a NIP-61 nutzap sent on a mint outside the recipient's kind:10019. Until now such a balance counted toward the total and showed a plain mint row, with nothing to tell the user it came from an unvetted issuer. - `CashuWalletState.unconfiguredMintBalances`: token-held mints minus the configured (kind:17375) set, keyed by mint URL -> sats. - Wallet screen shows an error-styled recommendation banner when any exist and badges the offending mint rows ("Not in your wallet"). Informational first cut; the per-mint "move these coins to a trusted mint or withdraw to Lightning" action (reusing rebalance / meltToLightning / sendAsToken) follows separately. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR --- .../model/nip60Cashu/CashuWalletState.kt | 16 ++++ .../loggedIn/wallet/CashuWalletScreen.kt | 86 ++++++++++++++++++- .../loggedIn/wallet/CashuWalletViewModel.kt | 1 + amethyst/src/main/res/values/strings.xml | 7 ++ 4 files changed, 107 insertions(+), 3 deletions(-) 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 ff9d4a8bd5..938dccf661 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 @@ -237,6 +237,22 @@ class CashuWalletState( }.flowOn(Dispatchers.Default) .stateIn(scope, SharingStarted.Eagerly, emptyList()) + /** + * Mints we hold a spendable balance at but never configured in our + * kind:17375 wallet — keyed by mint URL → balance in sats. Almost + * always coins auto-redeemed from a NIP-61 nutzap that was sent on a + * mint outside our kind:10019. Surfaced so the wallet can highlight + * them and nudge the user to move the funds to a mint they trust (or + * out to Lightning): holding ecash at an unvetted mint means trusting + * an issuer the user never chose. Empty in the common case where every + * mint we hold is also configured. + */ + val unconfiguredMintBalances: StateFlow> = + combine(_mints, mintBalances) { configured, balances -> + balances.filterKeys { it !in configured }.filterValues { it > 0 } + }.flowOn(Dispatchers.Default) + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + private val _history = MutableStateFlow>(emptyList()) val history: StateFlow> = _history.asStateFlow() 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 f23214591d..c1d5f0d9d5 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 @@ -118,6 +118,10 @@ fun CashuWalletScreen( // per-mint rows below sum to the full balance. val mints by viewModel.mints.collectAsState() val displayMints by viewModel.displayMints.collectAsState() + // Mints we hold a balance at but never configured (typically coins from a + // nutzap auto-redeemed on a mint outside our kind:10019). Highlighted so + // the user can move them somewhere they trust. + val unconfiguredMintBalances by viewModel.unconfiguredMintBalances.collectAsState() val balanceSats by viewModel.balanceSats.collectAsState() val mintBalances by viewModel.mintBalances.collectAsState() val history by viewModel.history.collectAsState() @@ -173,6 +177,7 @@ fun CashuWalletScreen( balanceSats = balanceSats, mints = displayMints, mintBalances = mintBalances, + unconfiguredMints = unconfiguredMintBalances.keys, history = history, pendingQuoteCount = pendingQuotes.size, accountViewModel = accountViewModel, @@ -311,6 +316,7 @@ private fun CashuWalletContent( balanceSats: Long, mints: List, mintBalances: Map, + unconfiguredMints: Set, history: List, pendingQuoteCount: Int, accountViewModel: AccountViewModel, @@ -338,6 +344,10 @@ private fun CashuWalletContent( item { PendingQuoteBanner(count = pendingQuoteCount, onResume = onResumePendingQuote) } } + if (unconfiguredMints.isNotEmpty()) { + item { UntrustedMintBanner(count = unconfiguredMints.size) } + } + item { ActionRow( onReceive = onReceive, @@ -356,7 +366,12 @@ private fun CashuWalletContent( ) } items(mints, key = { it }) { mint -> - MintRow(mint = mint, balanceSats = mintBalances[mint] ?: 0L, onTopUp = { onTopUpMint(mint) }) + MintRow( + mint = mint, + balanceSats = mintBalances[mint] ?: 0L, + untrusted = mint in unconfiguredMints, + onTopUp = { onTopUpMint(mint) }, + ) } if (history.isNotEmpty()) { @@ -437,6 +452,53 @@ private fun PendingQuoteBanner( } } +/** + * Surfaced above the mint list when we hold a balance at one or more mints + * the user never configured — almost always coins auto-redeemed from a + * NIP-61 nutzap sent on a mint outside our kind:10019. Informational for + * now (it explains the situation and recommends moving the funds); the + * per-mint "move coins off this mint" action lands in a follow-up. + */ +@Composable +private fun UntrustedMintBanner(count: Int) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onErrorContainer, + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = + pluralStringResource( + R.plurals.cashu_untrusted_mint_title, + count, + count, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.cashu_untrusted_mint_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + ) + } + } + } +} + @Composable private fun BalanceCard(balanceSats: Long) { val formatted = @@ -543,6 +605,7 @@ private fun ActionTile( private fun MintRow( mint: String, balanceSats: Long, + untrusted: Boolean, onTopUp: () -> Unit, ) { val formattedBalance = @@ -551,7 +614,15 @@ private fun MintRow( } Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + colors = + CardDefaults.cardColors( + containerColor = + if (untrusted) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), ) { Row( modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), @@ -563,7 +634,16 @@ private fun MintRow( modifier = Modifier.size(20.dp), ) Spacer(modifier = Modifier.width(12.dp)) - Text(text = mint, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Column(modifier = Modifier.weight(1f)) { + Text(text = mint, style = MaterialTheme.typography.bodyMedium) + if (untrusted) { + Text( + text = stringRes(R.string.cashu_untrusted_mint_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } Spacer(modifier = Modifier.width(8.dp)) Text( text = "$formattedBalance ${stringRes(R.string.wallet_sats)}", 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 3ae037082f..4b682f1ac6 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 @@ -155,6 +155,7 @@ class CashuWalletViewModel : ViewModel() { val walletEvent get() = state.walletEvent val mints get() = state.mints val displayMints get() = state.displayMints + val unconfiguredMintBalances get() = state.unconfiguredMintBalances val balanceSats get() = state.balanceSats val mintBalances get() = state.mintBalances val tokenEntries: StateFlow> get() = state.tokenEntries diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index baaea037b7..142238422c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3294,6 +3294,13 @@ Tap to resume and check status. Resume + + Funds held at %1$d mint you don\'t use + Funds held at %1$d mints you don\'t use + + These coins arrived from a nutzap. Move them to a mint you trust or withdraw to Lightning to keep them safe. + Not in your wallet + <%1$s Connecting Downloading From f9c8ce0213ea184b4bf12b06bcf43d9ef0f41974 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:12:43 +0000 Subject: [PATCH 3/4] feat(cashu): let users move coins off an unconfigured mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the untrusted-mint highlight: the warning banner and each flagged mint row are now actionable, opening an EvacuateMintDialog that offers the three exits whose backends already exist — - Move to a mint you trust: a new rebalanceOut() over the tested CashuWalletState.rebalance (mint-to-mint, no new Lightning sats). The amount is editable and defaults to the balance, with a hint that the Lightning fee is taken from the source so the full balance may not fit. - Withdraw via Lightning: hands off to the existing Send-LN dialog. - Export as Cashu token: hands off to the existing Send-token dialog. The two Send dialogs now source from displayMints (not just configured mints) and accept an initial mint, so they can be pre-pointed at the mint being evacuated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR --- .../loggedIn/wallet/CashuWalletScreen.kt | 242 ++++++++++++++++-- .../loggedIn/wallet/CashuWalletViewModel.kt | 66 +++++ amethyst/src/main/res/values/strings.xml | 12 + 3 files changed, 302 insertions(+), 18 deletions(-) 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 c1d5f0d9d5..0bd1784621 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 @@ -43,6 +43,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -140,6 +141,11 @@ fun CashuWalletScreen( var sendLnOpen by remember { mutableStateOf(false) } var sendTokenOpen by remember { mutableStateOf(false) } var redeemOpen by remember { mutableStateOf(false) } + // The unconfigured mint the user chose to move coins off of, plus the + // source mint to pre-select when the Send dialogs are opened from that + // flow (null = the dialog picks its own default). + var evacuateMint by remember { mutableStateOf(null) } + var sendInitialMint by remember { mutableStateOf(null) } // pendingQuotes drives a non-modal banner in the wallet body (see // PendingQuoteBanner below). Tapping the banner is what opens the @@ -187,6 +193,7 @@ fun CashuWalletScreen( onSendToken = { sendTokenOpen = true }, onRedeem = { redeemOpen = true }, onTopUpMint = { nav.nav(Route.TopUpMint(it)) }, + onMoveCoins = { evacuateMint = it }, onResumePendingQuote = { pendingQuotes.firstOrNull()?.let { viewModel.resumeMintQuote(it) @@ -224,9 +231,13 @@ fun CashuWalletScreen( if (sendLnOpen) { SendLnDialog( viewModel = viewModel, - mints = mints, + // displayMints (not just configured) so an unconfigured mint the + // user is evacuating is a valid source. + mints = displayMints, + initialMint = sendInitialMint, onDismiss = { sendLnOpen = false + sendInitialMint = null viewModel.resetMeltState() }, ) @@ -234,9 +245,11 @@ fun CashuWalletScreen( if (sendTokenOpen) { SendTokenDialog( viewModel = viewModel, - mints = mints, + mints = displayMints, + initialMint = sendInitialMint, onDismiss = { sendTokenOpen = false + sendInitialMint = null viewModel.resetSendTokenState() }, ) @@ -250,6 +263,31 @@ fun CashuWalletScreen( }, ) } + + evacuateMint?.let { source -> + EvacuateMintDialog( + viewModel = viewModel, + sourceMint = source, + sourceBalance = mintBalances[source] ?: 0L, + // Trusted destinations for a rebalance: configured mints other + // than the one we're emptying. + trustedTargets = mints.filter { it != source }, + onWithdrawLightning = { + sendInitialMint = source + evacuateMint = null + sendLnOpen = true + }, + onExportToken = { + sendInitialMint = source + evacuateMint = null + sendTokenOpen = true + }, + onDismiss = { + evacuateMint = null + viewModel.resetRebalanceState() + }, + ) + } } @Composable @@ -326,6 +364,7 @@ private fun CashuWalletContent( onSendToken: () -> Unit, onRedeem: () -> Unit, onTopUpMint: (String) -> Unit, + onMoveCoins: (String) -> Unit, onResumePendingQuote: () -> Unit, ) { LazyColumn( @@ -345,7 +384,12 @@ private fun CashuWalletContent( } if (unconfiguredMints.isNotEmpty()) { - item { UntrustedMintBanner(count = unconfiguredMints.size) } + item { + UntrustedMintBanner( + count = unconfiguredMints.size, + onClick = { unconfiguredMints.firstOrNull()?.let(onMoveCoins) }, + ) + } } item { @@ -366,11 +410,13 @@ private fun CashuWalletContent( ) } items(mints, key = { it }) { mint -> + val isUntrusted = mint in unconfiguredMints MintRow( mint = mint, balanceSats = mintBalances[mint] ?: 0L, - untrusted = mint in unconfiguredMints, + untrusted = isUntrusted, onTopUp = { onTopUpMint(mint) }, + onMoveCoins = if (isUntrusted) ({ onMoveCoins(mint) }) else null, ) } @@ -455,14 +501,20 @@ private fun PendingQuoteBanner( /** * Surfaced above the mint list when we hold a balance at one or more mints * the user never configured — almost always coins auto-redeemed from a - * NIP-61 nutzap sent on a mint outside our kind:10019. Informational for - * now (it explains the situation and recommends moving the funds); the - * per-mint "move coins off this mint" action lands in a follow-up. + * NIP-61 nutzap sent on a mint outside our kind:10019. Tapping it opens + * [EvacuateMintDialog] for the first such mint so the user can move the + * funds somewhere they trust. */ @Composable -private fun UntrustedMintBanner(count: Int) { +private fun UntrustedMintBanner( + count: Int, + onClick: () -> Unit, +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), ) { @@ -495,10 +547,149 @@ private fun UntrustedMintBanner(count: Int) { color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), ) } + Text( + text = stringRes(R.string.cashu_untrusted_mint_move), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) } } } +/** + * Helps the user get coins OFF a mint they never configured (almost always + * a nutzap redeemed on an untrusted mint), offering the three exits whose + * backends already exist: + * - rebalance to a mint the user trusts (no new Lightning sats), + * - withdraw via Lightning (hands off to the Send-LN dialog), + * - export as a Cashu token (hands off to the Send-token dialog). + * Only the rebalance runs inline; the Lightning / token paths reuse the + * existing, tested Send dialogs pre-pointed at this mint. + */ +@Composable +private fun EvacuateMintDialog( + viewModel: CashuWalletViewModel, + sourceMint: String, + sourceBalance: Long, + trustedTargets: List, + onWithdrawLightning: () -> Unit, + onExportToken: () -> Unit, + onDismiss: () -> Unit, +) { + val state by viewModel.rebalanceState.collectAsState() + var target by remember { mutableStateOf(trustedTargets.firstOrNull() ?: "") } + // Defaults to the whole balance, but rebalance deducts a Lightning fee + // from the source, so the user may have to shave a little off — the fee + // is only known once the mint returns a melt quote. + var amount by remember { mutableStateOf(sourceBalance.toString()) } + val busy = state is CashuRebalanceFlowState.Working + val done = state is CashuRebalanceFlowState.Completed + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { Text(stringRes(R.string.cashu_move_coins_title)) }, + text = { + Column { + Text( + text = stringRes(R.string.cashu_move_coins_body, sourceMint, sourceBalance.toString()), + style = MaterialTheme.typography.bodySmall, + ) + Spacer(modifier = Modifier.height(12.dp)) + + when (val s = state) { + is CashuRebalanceFlowState.Working -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringRes(R.string.cashu_move_coins_working)) + } + } + + is CashuRebalanceFlowState.Completed -> { + Text( + text = + stringRes( + R.string.cashu_move_coins_done, + s.movedSats.toString(), + s.targetMintUrl, + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + } + + is CashuRebalanceFlowState.Error -> { + Text( + text = s.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + CashuRebalanceFlowState.Idle -> {} + } + + if (!busy && !done) { + Spacer(modifier = Modifier.height(8.dp)) + if (trustedTargets.isNotEmpty()) { + Text( + text = stringRes(R.string.cashu_move_coins_to_mint), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = amount, + onValueChange = { v -> amount = v.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.cashu_amount_sats)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringRes(R.string.cashu_move_coins_fee_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + MintPicker(trustedTargets, target, { target = it }) + TextButton( + onClick = { + amount.toLongOrNull()?.let { viewModel.rebalanceOut(sourceMint, target, it) } + }, + enabled = target.isNotBlank() && (amount.toLongOrNull() ?: 0L) > 0L, + ) { Text(stringRes(R.string.cashu_move_coins_move)) } + } else { + Text( + text = stringRes(R.string.cashu_move_coins_no_trusted), + style = MaterialTheme.typography.bodySmall, + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + TextButton(onClick = onWithdrawLightning) { + Text(stringRes(R.string.cashu_move_coins_withdraw_ln)) + } + TextButton(onClick = onExportToken) { + Text(stringRes(R.string.cashu_move_coins_export_token)) + } + } + } + }, + confirmButton = { + if (done) { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cashu_done)) } + } + }, + dismissButton = { + if (!busy && !done) { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + } + }, + ) +} + @Composable private fun BalanceCard(balanceSats: Long) { val formatted = @@ -607,6 +798,7 @@ private fun MintRow( balanceSats: Long, untrusted: Boolean, onTopUp: () -> Unit, + onMoveCoins: (() -> Unit)?, ) { val formattedBalance = remember(balanceSats) { @@ -651,13 +843,25 @@ private fun MintRow( fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) - IconButton(onClick = onTopUp) { - Icon( - symbol = MaterialSymbols.AddCircle, - contentDescription = stringRes(R.string.topup_mint_action), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) + if (onMoveCoins != null) { + // Untrusted mint: lead with "move coins out", not "top up". + IconButton(onClick = onMoveCoins) { + Icon( + symbol = MaterialSymbols.AutoMirrored.Send, + contentDescription = stringRes(R.string.cashu_untrusted_mint_move), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + IconButton(onClick = onTopUp) { + Icon( + symbol = MaterialSymbols.AddCircle, + contentDescription = stringRes(R.string.topup_mint_action), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } } } @@ -1049,11 +1253,12 @@ private fun MintPicker( private fun SendLnDialog( viewModel: CashuWalletViewModel, mints: List, + initialMint: String?, onDismiss: () -> Unit, ) { val state by viewModel.meltState.collectAsState() var invoice by remember { mutableStateOf("") } - var pickedMint by remember { mutableStateOf(mints.firstOrNull() ?: "") } + var pickedMint by remember { mutableStateOf(initialMint ?: mints.firstOrNull() ?: "") } val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() @@ -1186,12 +1391,13 @@ private fun InvoiceForm( private fun SendTokenDialog( viewModel: CashuWalletViewModel, mints: List, + initialMint: String?, onDismiss: () -> Unit, ) { val state by viewModel.sendTokenState.collectAsState() var amount by remember { mutableStateOf("") } var memo by remember { mutableStateOf("") } - var pickedMint by remember { mutableStateOf(mints.firstOrNull() ?: "") } + var pickedMint by remember { mutableStateOf(initialMint ?: mints.firstOrNull() ?: "") } val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() 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 4b682f1ac6..77b7fda99f 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 @@ -124,6 +124,24 @@ sealed class CashuSendTokenFlowState { ) : CashuSendTokenFlowState() } +sealed class CashuRebalanceFlowState { + data object Idle : CashuRebalanceFlowState() + + /** Funds are moving — progress in [0f, 1f] from CashuWalletState.rebalance. */ + data class Working( + val progress: Float, + ) : CashuRebalanceFlowState() + + data class Completed( + val movedSats: Long, + val targetMintUrl: String, + ) : CashuRebalanceFlowState() + + data class Error( + val message: String, + ) : CashuRebalanceFlowState() +} + sealed class CashuRedeemFlowState { data object Idle : CashuRedeemFlowState() @@ -176,6 +194,9 @@ class CashuWalletViewModel : ViewModel() { private val _sendTokenState = MutableStateFlow(CashuSendTokenFlowState.Idle) val sendTokenState = _sendTokenState.asStateFlow() + private val _rebalanceState = MutableStateFlow(CashuRebalanceFlowState.Idle) + val rebalanceState = _rebalanceState.asStateFlow() + private val _redeemState = MutableStateFlow(CashuRedeemFlowState.Idle) val redeemState = _redeemState.asStateFlow() @@ -759,6 +780,51 @@ class CashuWalletViewModel : ViewModel() { _sendTokenState.value = CashuSendTokenFlowState.Idle } + // -------- Move coins between mints (rebalance) -------- + + /** + * Move [sats] from [sourceMintUrl] to [targetMintUrl] with no new + * Lightning sats entering the wallet — the evacuation path for coins + * sitting at a mint the user doesn't trust. Backed by the tested + * [CashuWalletState.rebalance], which fetches its own melt quote and + * refuses to spend if the source can't cover amount + fees. + */ + fun rebalanceOut( + sourceMintUrl: String, + targetMintUrl: String, + sats: Long, + ) { + val vm = accountViewModel ?: return + if (sats <= 0) { + _rebalanceState.value = CashuRebalanceFlowState.Error("Amount must be positive") + return + } + if (sourceMintUrl == targetMintUrl) { + _rebalanceState.value = CashuRebalanceFlowState.Error("Pick a different destination mint") + return + } + _rebalanceState.value = CashuRebalanceFlowState.Working(0f) + vm.launchSigner { + try { + val result = + state.rebalance( + sourceMintUrl = sourceMintUrl, + targetMintUrl = targetMintUrl, + sats = sats, + onProgress = { p -> _rebalanceState.value = CashuRebalanceFlowState.Working(p) }, + ) + _rebalanceState.value = + CashuRebalanceFlowState.Completed(result.movedSats, targetMintUrl) + } catch (e: Exception) { + _rebalanceState.value = CashuRebalanceFlowState.Error(describeMintError(e)) + } + } + } + + fun resetRebalanceState() { + _rebalanceState.value = CashuRebalanceFlowState.Idle + } + // -------- Redeem inbound cashuB / cashuA -------- fun redeemToken(rawToken: String) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 142238422c..6c1ee7fc3f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3300,6 +3300,18 @@ These coins arrived from a nutzap. Move them to a mint you trust or withdraw to Lightning to keep them safe. Not in your wallet + Move + + Move coins out + %2$s sat at a mint you don\'t use (%1$s). Move them somewhere you trust. + Move to a mint you trust + Move + A small Lightning fee is taken from this mint, so you may need to send a little less than the full balance. + Add a mint to your wallet first to move these coins between mints, or use the options below. + Withdraw via Lightning… + Export as Cashu token… + Moving coins… + Moved %1$s sat to %2$s. <%1$s Connecting From cca371c1f671fa668e68bb582a727f02eacf5bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:00:25 +0000 Subject: [PATCH 4/4] fix(cashu): recover from seed across held mints, not just configured NUT-09 "Recover from seed" iterated only the configured kind:17375 mint list, so funds at a mint dropped from the wallet config (while still holding tokens) or auto-redeemed from a nutzap on an unconfigured mint were silently skipped by recovery. Scan displayMints (configured plus any mint we currently hold tokens at) instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR --- .../loggedIn/wallet/CashuWalletViewModel.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 77b7fda99f..dda54daa4b 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 @@ -399,10 +399,16 @@ class CashuWalletViewModel : ViewModel() { val restoreState = _restoreState.asStateFlow() /** - * NUT-09 wallet restore — scans every mint in the wallet's mint list - * for proofs the user previously minted but whose kind:7375 events - * have been lost. Recovered unspent proofs are republished as fresh - * kind:7375 + kind:7376 IN history rows. + * NUT-09 wallet restore — scans every mint we know of for proofs the + * user previously minted but whose kind:7375 events have been lost. + * Recovered unspent proofs are republished as fresh kind:7375 + kind:7376 + * IN history rows. + * + * Scans [CashuWalletState.displayMints] (configured kind:17375 mints plus + * any mint we currently hold tokens at), not just the configured list — + * so a mint dropped from the wallet config while it still holds tokens, + * or one a nutzap was auto-redeemed on, is still recovered rather than + * silently skipped. * * Best-effort across mints: a failure on one mint logs and moves to * the next. The total reported in [RestoreFlowState.Completed] @@ -414,7 +420,7 @@ class CashuWalletViewModel : ViewModel() { _restoreState.value = RestoreFlowState.Running vm.launchSigner { try { - val mintsToScan = state.mints.value + val mintsToScan = state.displayMints.value var totalSats = 0L var totalProofs = 0 for (mint in mintsToScan) {