From 94dc759f8acbaaeeb3e140a8539aefb7cf404481 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 22:59:08 +0000 Subject: [PATCH] feat(cashu): add standalone mint top-up screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dedicated TopUpMintScreen + TopUpMintViewModel for funding a specific mint outside the zap flow. Each mint row on the wallet screen gets an add-funds icon that opens the screen with that mint as the fixed target. The screen reuses the same funding primitives as the zap-driven Reload screen — CashuWalletState.rebalance for a mint-to-mint move and startMintFromLightning/completeMintFromLightning for an LN top-up (NWC or external invoice) — but drops all zap machinery: no recipient, no shared-mint intersection, no fixed send amount/shortfall, no terminal nutzap, no fund-then-send atomicity. This keeps the double-spend- sensitive zap pipeline untouched. Shares SectionHeader/SourceRow/shortMint/sats from ReloadMintScreen (widened to internal) instead of duplicating them. --- .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 5 + .../loggedIn/wallet/CashuWalletScreen.kt | 15 +- .../loggedIn/wallet/ReloadMintScreen.kt | 8 +- .../screen/loggedIn/wallet/TopUpMintScreen.kt | 224 +++++++++++++ .../loggedIn/wallet/TopUpMintViewModel.kt | 307 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 4 + 7 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8c03c0d26c..67fec8ec37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -201,6 +201,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.TopUpMintScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen @@ -348,6 +349,8 @@ fun BuildNavigation( composableFromBottomArgs { ReloadMintScreen(it.requestId, accountViewModel, nav) } + composableFromBottomArgs { TopUpMintScreen(it.mintUrl, accountViewModel, nav) } + composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 02dcc6c61c..3c8579065a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -650,6 +650,11 @@ sealed class Route { data class ReloadMint( val requestId: String, ) : Route() + + @Serializable + data class TopUpMint( + val mintUrl: String, + ) : Route() } inline fun isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true 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 0d93f9828d..1c700dae95 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 @@ -168,6 +168,7 @@ fun CashuWalletScreen( onSendLn = { sendLnOpen = true }, onSendToken = { sendTokenOpen = true }, onRedeem = { redeemOpen = true }, + onTopUpMint = { nav.nav(Route.TopUpMint(it)) }, onResumePendingQuote = { pendingQuotes.firstOrNull()?.let { viewModel.resumeMintQuote(it) @@ -305,6 +306,7 @@ private fun CashuWalletContent( onSendLn: () -> Unit, onSendToken: () -> Unit, onRedeem: () -> Unit, + onTopUpMint: (String) -> Unit, onResumePendingQuote: () -> Unit, ) { LazyColumn( @@ -341,7 +343,7 @@ private fun CashuWalletContent( ) } items(mints, key = { it }) { mint -> - MintRow(mint = mint, balanceSats = mintBalances[mint] ?: 0L) + MintRow(mint = mint, balanceSats = mintBalances[mint] ?: 0L, onTopUp = { onTopUpMint(mint) }) } if (history.isNotEmpty()) { @@ -528,6 +530,7 @@ private fun ActionTile( private fun MintRow( mint: String, balanceSats: Long, + onTopUp: () -> Unit, ) { val formattedBalance = remember(balanceSats) { @@ -538,7 +541,7 @@ private fun MintRow( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Material3Icon( @@ -555,6 +558,14 @@ 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, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt index 5aef6bdd64..cafdd4c2e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintScreen.kt @@ -99,9 +99,9 @@ fun navigateToReloadMint( nav.nav(Route.ReloadMint(uid)) } -private fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/") +internal fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/") -private fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1)) +internal fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1)) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -359,7 +359,7 @@ fun ReloadMintScreen( } @Composable -private fun SectionHeader(text: String) { +internal fun SectionHeader(text: String) { Text( text = text, color = MaterialTheme.colorScheme.primary, @@ -368,7 +368,7 @@ private fun SectionHeader(text: String) { } @Composable -private fun SourceRow( +internal fun SourceRow( source: ReloadSource, selected: Boolean, onSelect: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintScreen.kt new file mode 100644 index 0000000000..f6770a8e06 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintScreen.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.hashtags.Cashu +import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.launch +import androidx.compose.material3.Icon as Material3Icon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TopUpMintScreen( + mintUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: TopUpMintViewModel = viewModel() + LaunchedEffect(mintUrl) { viewModel.init(accountViewModel, mintUrl) } + + val ui by viewModel.uiState.collectAsStateWithLifecycle() + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + var amountText by remember(mintUrl) { mutableStateOf("") } + + if (ui.status is TopUpStatus.Done) { + LaunchedEffect(Unit) { nav.popBack() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.topup_mint_title)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // ── Header: the mint we're topping up + its current balance ──── + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Material3Icon( + imageVector = CustomHashTagIcons.Cashu, + contentDescription = null, + modifier = Modifier.size(26.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = shortMint(ui.targetMint), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.reload_mint_available, sats(ui.targetBalanceSats)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + + // ── Amount to add ───────────────────────────────────────────── + SectionHeader(stringRes(R.string.topup_mint_amount_label)) + OutlinedTextField( + value = amountText, + onValueChange = { input -> + val digits = input.filter { it.isDigit() }.take(12) + amountText = digits + viewModel.setAmount(digits.toLongOrNull() ?: 0L) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + suffix = { Text(stringRes(R.string.sats)) }, + modifier = Modifier.fillMaxWidth(), + ) + + // ── Funds from ──────────────────────────────────────────────── + SectionHeader(stringRes(R.string.reload_mint_section_from)) + ui.sources.forEach { source -> + SourceRow( + source = source, + selected = source == ui.selectedSource, + onSelect = { viewModel.selectSource(source) }, + ) + } + + Spacer(Modifier.height(4.dp)) + + when (val status = ui.status) { + is TopUpStatus.Working -> { + LinearProgressIndicator(progress = { status.progress }, modifier = Modifier.fillMaxWidth()) + Text(status.step, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText) + } + + is TopUpStatus.AwaitingInvoice -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CircularProgressIndicator(modifier = Modifier.height(20.dp)) + Text(stringRes(R.string.reload_mint_awaiting_payment), style = MaterialTheme.typography.bodyMedium) + } + OutlinedButton( + onClick = { scope.launch { clipboard.setText(status.invoice) } }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.reload_mint_copy_invoice)) + } + } + + is TopUpStatus.Failed -> { + Text(status.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + Button(onClick = { viewModel.confirm() }, modifier = Modifier.fillMaxWidth()) { + Text(stringRes(R.string.reload_mint_retry)) + } + } + + else -> { + val source = ui.selectedSource + val enabled = + ui.amountSats > 0 && + ( + source is ReloadSource.LightningWallet || + source is ReloadSource.LightningExternal || + (source is ReloadSource.Mint && source.canCover) + ) + Button( + onClick = { viewModel.confirm() }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(R.string.topup_mint_confirm)) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt new file mode 100644 index 0000000000..5a73201124 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState +import com.vitorpamplona.amethyst.model.nip60Cashu.describeMintError +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Presenter for the standalone "Top up a mint" screen. + * + * Reuses the same funding primitives as [ReloadMintViewModel] — + * [CashuWalletState.rebalance] for a mint-to-mint move and + * [com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletOps.startMintFromLightning] / + * `completeMintFromLightning` for an LN top-up — but without any of the + * zap-specific machinery (recipient, shared-mint intersection, fixed send + * amount + shortfall, terminal nutzap, fund-then-send atomicity). The user + * picks an amount and a source; we add ecash to a fixed target mint and pop. + */ +class TopUpMintViewModel : ViewModel() { + private var accountViewModel: AccountViewModel? = null + private var targetMint: String = "" + private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState + + /** The in-flight pipeline, cancelled when the screen leaves (it runs on the + * long-lived AccountViewModel scope, not this VM's). */ + private var job: Job? = null + + private val _uiState = MutableStateFlow(TopUpUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun init( + accountViewModel: AccountViewModel, + mintUrl: String, + ) { + if (this.accountViewModel != null) return // already initialized + this.accountViewModel = accountViewModel + this.targetMint = mintUrl + _uiState.update { it.copy(targetMint = mintUrl) } + + // Wallet proofs/mints arrive from relays and can land after the screen + // opens, so keep the source list + target balance in sync as the wallet + // fills in. Project to the per-mint balance map and distinctUntilChanged + // so unrelated wallet churn doesn't re-run the rebuild on every emission. + val st = state ?: return + viewModelScope.launch { + combine(st.tokenEntries, st.mints) { _, _ -> st.peekMintBalances() } + .distinctUntilChanged() + .collect { rebuild(it) } + } + } + + /** Rebuild target balance + funding sources from the current wallet state, + * keeping the user's amount and source pick intact. */ + private fun rebuild(balances: Map) { + _uiState.update { cur -> recompute(cur.copy(targetBalanceSats = balances[targetMint] ?: 0L), balances) } + } + + /** Recompute fee estimate + sources + a default source for the current amount. */ + private fun recompute( + base: TopUpUiState, + balances: Map, + ): TopUpUiState { + val moveSats = base.amountSats + // Rough fee cushion for *enabling* a mint source — the real Lightning + // feeReserve is only known once rebalance() fetches the melt quote, so this + // is a heuristic (1%, min 2 sat). A source that clears the estimate but not + // the real quote fails recoverably without moving funds. + val estFee = if (moveSats <= 0L) 0L else (moveSats / 100L).coerceAtLeast(2L) + val needFromSource = moveSats + estFee + + val mintSources = + balances + .filterKeys { it != targetMint } + .entries + .sortedByDescending { it.value } + .map { ReloadSource.Mint(it.key, it.value, canCover = moveSats <= 0L || it.value >= needFromSource) } + + val lightningSources: List = + nwcWallets() + .map { ReloadSource.LightningWallet(it.id, it.name) } + .ifEmpty { listOf(ReloadSource.LightningExternal) } + val sources = mintSources + lightningSources + + // Keep the current pick if still valid; else default to the richest mint that + // can cover (no new sats), else the configured/first Lightning wallet. + val keep = + when (val prev = base.selectedSource) { + is ReloadSource.Mint -> mintSources.firstOrNull { it.mintUrl == prev.mintUrl && it.canCover } + is ReloadSource.LightningWallet -> lightningSources.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == prev.walletId } + ReloadSource.LightningExternal -> lightningSources.firstOrNull { it is ReloadSource.LightningExternal } + null -> null + } + val selectedSource: ReloadSource? = + keep + ?: mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } + ?: defaultLightningSource(lightningSources) + + return base.copy( + estFeeSats = estFee, + sources = sources.toImmutableList(), + selectedSource = selectedSource, + ) + } + + fun selectSource(source: ReloadSource) { + _uiState.update { it.copy(selectedSource = source) } + } + + fun setAmount(sats: Long) { + _uiState.update { recompute(it.copy(amountSats = sats.coerceAtLeast(0L)), currentBalances()) } + } + + private fun currentBalances(): Map = state?.peekMintBalances().orEmpty() + + fun confirm() { + val vm = accountViewModel ?: return + val s = _uiState.value + val source = s.selectedSource ?: return + val moveSats = s.amountSats + if (moveSats <= 0L) return + + // In-flight guard: only start from a resting state so a double tap (or the + // Failed-state retry) can't launch a second pipeline. + if (s.status !is TopUpStatus.Configuring && s.status !is TopUpStatus.Failed) return + setStatus(TopUpStatus.Working("Starting", 0.05f)) + + job?.cancel() + job = + vm.launchSigner { + try { + when (source) { + is ReloadSource.Mint -> topUpFromMint(source.mintUrl, moveSats) + is ReloadSource.LightningWallet -> topUpFromLightning(moveSats, walletUriFor(source.walletId)) + ReloadSource.LightningExternal -> topUpFromLightning(moveSats, null) + } + setStatus(TopUpStatus.Done) + } catch (e: CancellationException) { + throw e // screen left mid-flow — don't mask as a Failed state + } catch (e: Exception) { + setStatus(TopUpStatus.Failed(describeMintError(e))) + } + } + } + + private suspend fun topUpFromMint( + sourceMint: String, + moveSats: Long, + ) { + val st = state ?: return + setStatus(TopUpStatus.Working("Moving funds", 0.1f)) + st.rebalance( + sourceMintUrl = sourceMint, + targetMintUrl = targetMint, + sats = moveSats, + onProgress = { p -> setStatus(TopUpStatus.Working("Moving funds", p.coerceIn(0.1f, 0.95f))) }, + ) + } + + private suspend fun topUpFromLightning( + moveSats: Long, + walletUri: Nip47WalletConnect.Nip47URINorm?, + ) { + val vm = accountViewModel ?: return + val st = state ?: return + val ops = st.ops + + setStatus(TopUpStatus.Working("Requesting invoice", 0.1f)) + val flow = ops.startMintFromLightning(targetMint, moveSats) + + if (walletUri != null) { + setStatus(TopUpStatus.Working("Paying from your wallet", 0.35f)) + // Fire-and-forget: the mint-quote poll below is the source of truth for + // whether the payment actually landed. + runCatching { + vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { } + } + } else { + // No NWC — surface the invoice for an external wallet and keep polling. + setStatus(TopUpStatus.AwaitingInvoice(flow.invoice, moveSats)) + } + + // External payment can take a while; the poll runs on a job tied to the + // screen (cancelled in onCleared), so leaving stops it. + val attempts = 90 + val delayMs = 2_000L + var paid = false + var attempt = 0 + while (!paid && attempt < attempts) { + paid = ops.checkMintQuote(targetMint, flow.mintQuote.quote).isSettled() + if (!paid) { + delay(delayMs) + attempt++ + } + } + if (!paid) { + setStatus(TopUpStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner")) + return + } + setStatus(TopUpStatus.Working("Issuing ecash", 0.85f)) + ops.completeMintFromLightning(targetMint, flow.quoteEvent, moveSats) + } + + private fun setStatus(status: TopUpStatus) { + _uiState.update { it.copy(status = status) } + } + + private fun nwcWallets() = + accountViewModel + ?.account + ?.settings + ?.nwcWallets + ?.value + .orEmpty() + + private fun walletUriFor(walletId: String) = nwcWallets().firstOrNull { it.id == walletId }?.uri + + /** The configured default NWC wallet's source, else the first Lightning source. */ + private fun defaultLightningSource(lightning: List): ReloadSource? { + val defaultId = + accountViewModel + ?.account + ?.settings + ?.defaultNwcWallet() + ?.id + return lightning.firstOrNull { it is ReloadSource.LightningWallet && it.walletId == defaultId } + ?: lightning.firstOrNull() + } + + override fun onCleared() { + // The pipeline runs on the AccountViewModel scope, not this VM's, so it would + // outlive the screen — cancel it when the screen goes away. + job?.cancel() + super.onCleared() + } +} + +/** Progress of a standalone mint top-up. Mirrors [ReloadStatus] minus the zap step. */ +@Immutable +sealed interface TopUpStatus { + data object Configuring : TopUpStatus + + data class Working( + val step: String, + val progress: Float, + ) : TopUpStatus + + /** External Lightning — the user must pay [invoice]; we keep polling. */ + data class AwaitingInvoice( + val invoice: String, + val sats: Long, + ) : TopUpStatus + + data object Done : TopUpStatus + + data class Failed( + val message: String, + ) : TopUpStatus +} + +@Immutable +data class TopUpUiState( + val targetMint: String = "", + val targetBalanceSats: Long = 0L, + /** The amount to add to the target mint. */ + val amountSats: Long = 0L, + val estFeeSats: Long = 0L, + val sources: ImmutableList = persistentListOf(), + val selectedSource: ReloadSource? = null, + val status: TopUpStatus = TopUpStatus.Configuring, +) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cf1d12b9e4..4630d800e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -904,6 +904,10 @@ needs %1$s sat more funded Copy invoice + Top up mint + Top up this mint + Amount to add + Top up Zap Privacy Controls how your identity is shown when you send a zap. Connect Wallet