mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
Merge pull request #3257 from vitorpamplona/claude/nice-maxwell-cb026o
Cashu: surface and help evacuate coins from untrusted mints
This commit is contained in:
+53
@@ -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,39 @@ 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())
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -1143,6 +1177,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
|
||||
|
||||
+314
-15
@@ -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
|
||||
@@ -113,16 +114,38 @@ 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()
|
||||
// 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()
|
||||
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) }
|
||||
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<String?>(null) }
|
||||
var sendInitialMint by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// pendingQuotes drives a non-modal banner in the wallet body (see
|
||||
// PendingQuoteBanner below). Tapping the banner is what opens the
|
||||
@@ -158,8 +181,9 @@ fun CashuWalletScreen(
|
||||
CashuWalletContent(
|
||||
modifier = Modifier.padding(padding),
|
||||
balanceSats = balanceSats,
|
||||
mints = mints,
|
||||
mints = displayMints,
|
||||
mintBalances = mintBalances,
|
||||
unconfiguredMints = unconfiguredMintBalances.keys,
|
||||
history = history,
|
||||
pendingQuoteCount = pendingQuotes.size,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -169,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)
|
||||
@@ -206,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()
|
||||
},
|
||||
)
|
||||
@@ -216,9 +245,11 @@ fun CashuWalletScreen(
|
||||
if (sendTokenOpen) {
|
||||
SendTokenDialog(
|
||||
viewModel = viewModel,
|
||||
mints = mints,
|
||||
mints = displayMints,
|
||||
initialMint = sendInitialMint,
|
||||
onDismiss = {
|
||||
sendTokenOpen = false
|
||||
sendInitialMint = null
|
||||
viewModel.resetSendTokenState()
|
||||
},
|
||||
)
|
||||
@@ -232,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
|
||||
@@ -298,6 +354,7 @@ private fun CashuWalletContent(
|
||||
balanceSats: Long,
|
||||
mints: List<String>,
|
||||
mintBalances: Map<String, Long>,
|
||||
unconfiguredMints: Set<String>,
|
||||
history: List<CashuSpendingHistoryEvent>,
|
||||
pendingQuoteCount: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -307,6 +364,7 @@ private fun CashuWalletContent(
|
||||
onSendToken: () -> Unit,
|
||||
onRedeem: () -> Unit,
|
||||
onTopUpMint: (String) -> Unit,
|
||||
onMoveCoins: (String) -> Unit,
|
||||
onResumePendingQuote: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -325,6 +383,15 @@ private fun CashuWalletContent(
|
||||
item { PendingQuoteBanner(count = pendingQuoteCount, onResume = onResumePendingQuote) }
|
||||
}
|
||||
|
||||
if (unconfiguredMints.isNotEmpty()) {
|
||||
item {
|
||||
UntrustedMintBanner(
|
||||
count = unconfiguredMints.size,
|
||||
onClick = { unconfiguredMints.firstOrNull()?.let(onMoveCoins) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
ActionRow(
|
||||
onReceive = onReceive,
|
||||
@@ -343,7 +410,14 @@ private fun CashuWalletContent(
|
||||
)
|
||||
}
|
||||
items(mints, key = { it }) { mint ->
|
||||
MintRow(mint = mint, balanceSats = mintBalances[mint] ?: 0L, onTopUp = { onTopUpMint(mint) })
|
||||
val isUntrusted = mint in unconfiguredMints
|
||||
MintRow(
|
||||
mint = mint,
|
||||
balanceSats = mintBalances[mint] ?: 0L,
|
||||
untrusted = isUntrusted,
|
||||
onTopUp = { onTopUpMint(mint) },
|
||||
onMoveCoins = if (isUntrusted) ({ onMoveCoins(mint) }) else null,
|
||||
)
|
||||
}
|
||||
|
||||
if (history.isNotEmpty()) {
|
||||
@@ -424,6 +498,198 @@ 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. 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,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
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),
|
||||
)
|
||||
}
|
||||
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<String>,
|
||||
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 =
|
||||
@@ -530,7 +796,9 @@ private fun ActionTile(
|
||||
private fun MintRow(
|
||||
mint: String,
|
||||
balanceSats: Long,
|
||||
untrusted: Boolean,
|
||||
onTopUp: () -> Unit,
|
||||
onMoveCoins: (() -> Unit)?,
|
||||
) {
|
||||
val formattedBalance =
|
||||
remember(balanceSats) {
|
||||
@@ -538,7 +806,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),
|
||||
@@ -550,7 +826,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)}",
|
||||
@@ -558,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,11 +1253,12 @@ private fun MintPicker(
|
||||
private fun SendLnDialog(
|
||||
viewModel: CashuWalletViewModel,
|
||||
mints: List<String>,
|
||||
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()
|
||||
|
||||
@@ -1093,12 +1391,13 @@ private fun InvoiceForm(
|
||||
private fun SendTokenDialog(
|
||||
viewModel: CashuWalletViewModel,
|
||||
mints: List<String>,
|
||||
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()
|
||||
|
||||
|
||||
+98
-5
@@ -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()
|
||||
|
||||
@@ -154,6 +172,8 @@ 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
|
||||
@@ -174,6 +194,9 @@ class CashuWalletViewModel : ViewModel() {
|
||||
private val _sendTokenState = MutableStateFlow<CashuSendTokenFlowState>(CashuSendTokenFlowState.Idle)
|
||||
val sendTokenState = _sendTokenState.asStateFlow()
|
||||
|
||||
private val _rebalanceState = MutableStateFlow<CashuRebalanceFlowState>(CashuRebalanceFlowState.Idle)
|
||||
val rebalanceState = _rebalanceState.asStateFlow()
|
||||
|
||||
private val _redeemState = MutableStateFlow<CashuRedeemFlowState>(CashuRedeemFlowState.Idle)
|
||||
val redeemState = _redeemState.asStateFlow()
|
||||
|
||||
@@ -187,6 +210,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
|
||||
@@ -357,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]
|
||||
@@ -372,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) {
|
||||
@@ -738,6 +786,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) {
|
||||
|
||||
@@ -3294,6 +3294,25 @@
|
||||
<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="cashu_untrusted_mint_move">Move</string>
|
||||
|
||||
<string name="cashu_move_coins_title">Move coins out</string>
|
||||
<string name="cashu_move_coins_body">%2$s sat at a mint you don\'t use (%1$s). Move them somewhere you trust.</string>
|
||||
<string name="cashu_move_coins_to_mint">Move to a mint you trust</string>
|
||||
<string name="cashu_move_coins_move">Move</string>
|
||||
<string name="cashu_move_coins_fee_hint">A small Lightning fee is taken from this mint, so you may need to send a little less than the full balance.</string>
|
||||
<string name="cashu_move_coins_no_trusted">Add a mint to your wallet first to move these coins between mints, or use the options below.</string>
|
||||
<string name="cashu_move_coins_withdraw_ln">Withdraw via Lightning…</string>
|
||||
<string name="cashu_move_coins_export_token">Export as Cashu token…</string>
|
||||
<string name="cashu_move_coins_working">Moving coins…</string>
|
||||
<string name="cashu_move_coins_done">Moved %1$s sat to %2$s.</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