mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
feat(cashu): highlight balances held at unconfigured mints
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TK5eNfhkNR1svcQxjY1JvR
This commit is contained in:
+16
@@ -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<Map<String, Long>> =
|
||||
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<List<CashuSpendingHistoryEvent>>(emptyList())
|
||||
val history: StateFlow<List<CashuSpendingHistoryEvent>> = _history.asStateFlow()
|
||||
|
||||
|
||||
+83
-3
@@ -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<String>,
|
||||
mintBalances: Map<String, Long>,
|
||||
unconfiguredMints: Set<String>,
|
||||
history: List<CashuSpendingHistoryEvent>,
|
||||
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)}",
|
||||
|
||||
+1
@@ -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<List<TokenEntry>> get() = state.tokenEntries
|
||||
|
||||
@@ -3294,6 +3294,13 @@
|
||||
<string name="cashu_pending_quotes_subtitle">Tap to resume and check status.</string>
|
||||
<string name="cashu_pending_quotes_resume">Resume</string>
|
||||
|
||||
<plurals name="cashu_untrusted_mint_title">
|
||||
<item quantity="one">Funds held at %1$d mint you don\'t use</item>
|
||||
<item quantity="other">Funds held at %1$d mints you don\'t use</item>
|
||||
</plurals>
|
||||
<string name="cashu_untrusted_mint_subtitle">These coins arrived from a nutzap. Move them to a mint you trust or withdraw to Lightning to keep them safe.</string>
|
||||
<string name="cashu_untrusted_mint_badge">Not in your wallet</string>
|
||||
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_status_connecting">Connecting</string>
|
||||
<string name="event_sync_status_downloading">Downloading</string>
|
||||
|
||||
Reference in New Issue
Block a user