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