mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
feat(cashu): add standalone mint top-up screen
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.
This commit is contained in:
@@ -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<Route.ReloadMint> { ReloadMintScreen(it.requestId, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.TopUpMint> { TopUpMintScreen(it.mintUrl, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
|
||||
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -650,6 +650,11 @@ sealed class Route {
|
||||
data class ReloadMint(
|
||||
val requestId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable
|
||||
data class TopUpMint(
|
||||
val mintUrl: String,
|
||||
) : Route()
|
||||
}
|
||||
|
||||
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
|
||||
|
||||
+13
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+224
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+307
@@ -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<TopUpUiState> = _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<String, Long>) {
|
||||
_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<String, Long>,
|
||||
): 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<ReloadSource> =
|
||||
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<String, Long> = 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>): 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<ReloadSource> = persistentListOf(),
|
||||
val selectedSource: ReloadSource? = null,
|
||||
val status: TopUpStatus = TopUpStatus.Configuring,
|
||||
)
|
||||
@@ -904,6 +904,10 @@
|
||||
<string name="reload_mint_needs_more">needs %1$s sat more</string>
|
||||
<string name="reload_mint_funded">funded</string>
|
||||
<string name="reload_mint_copy_invoice">Copy invoice</string>
|
||||
<string name="topup_mint_title">Top up mint</string>
|
||||
<string name="topup_mint_action">Top up this mint</string>
|
||||
<string name="topup_mint_amount_label">Amount to add</string>
|
||||
<string name="topup_mint_confirm">Top up</string>
|
||||
<string name="zap_privacy_section">Zap Privacy</string>
|
||||
<string name="zap_type_section_explainer">Controls how your identity is shown when you send a zap.</string>
|
||||
<string name="wallet_connect_connect_app">Connect Wallet</string>
|
||||
|
||||
Reference in New Issue
Block a user