feat(zap): Reload Mint screen to fund a recipient's mint, then nutzap

When the cashu logo on an amount chip is NEEDS_RELOAD (you hold enough cashu,
but not in the recipient's mint), it now shows a dimmed logo with a "+" badge
that opens a full Reload Mint screen instead of silently falling back to
Lightning.

The screen tells the whole story and auto-sends the nutzap on success:
- Destination ("mint to reload") with its shortfall; selectable when the
  recipient accepts several mints we hold.
- All cashu balances for context.
- Source ("funds from"): another mint (mint-to-mint rebalance, no new sats) or
  Lightning. Mint rows are disabled when they can't cover shortfall + fee.

Funding paths:
- Rebalance: CashuWalletState.rebalance moves cashu between mints.
- Lightning: mint a fresh quote at the destination, auto-pay via NWC when a
  wallet is configured (else show the invoice to copy), poll, then issue.

Wiring: new Route.ReloadMint + AppNavigation entry; a ReloadMintRequest handed
off via an LruCache on AccountViewModel (mirrors the manual-zap pattern);
onReloadNutzap threaded through the zap popup overloads and the chip; callers
(ReusableZapButton, ReactionsRow, NestActionBar) navigate to the screen.

IMPOSSIBLE (not enough cashu anywhere) keeps falling back to Lightning — the
reload screen is only for moving existing cashu.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
Claude
2026-05-29 19:32:30 +00:00
parent c73e7924dd
commit ce32dc4fc3
9 changed files with 705 additions and 8 deletions
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
@@ -201,6 +202,10 @@ fun ReusableZapButton(
onchainZapAmount = amount
showOnchainDialog = true
},
onReloadNutzap = { amount ->
wantsToZap = null
navigateToReloadMint(accountViewModel, nav, baseNote, amount)
},
)
}
@@ -199,6 +199,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
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.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
@@ -343,6 +344,8 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
composableFromBottomArgs<Route.ReloadMint> { ReloadMintScreen(it.requestId, accountViewModel, nav) }
composableFromBottomArgs<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
@@ -639,6 +639,11 @@ sealed class Route {
data class ManualZapSplitPayment(
val paymentId: String,
) : Route()
@Serializable
data class ReloadMint(
val requestId: String,
) : Route()
}
inline fun <reified T : Route> isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute<T>() == true
@@ -85,6 +85,7 @@ import androidx.compose.ui.Alignment.Companion.Center
import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
@@ -146,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -1257,6 +1259,10 @@ fun ZapReaction(
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onReloadNutzap = { amount ->
wantsToZap = false
navigateToReloadMint(accountViewModel, nav, baseNote, amount)
},
)
}
@@ -1885,6 +1891,7 @@ fun ZapAmountChoicePopup(
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onOnchainAmount: ((Long?) -> Unit)? = null,
onReloadNutzap: (Long) -> Unit = {},
) {
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
@@ -1903,6 +1910,7 @@ fun ZapAmountChoicePopup(
onPayViaIntent = onPayViaIntent,
onOnchainAmount = onOnchainAmount ?: {},
onchainSupported = onOnchainAmount != null,
onReloadNutzap = onReloadNutzap,
)
}
@@ -1920,6 +1928,7 @@ fun ZapAmountChoicePopup(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onOnchainAmount: (Long?) -> Unit = {},
onchainSupported: Boolean = true,
onReloadNutzap: (Long) -> Unit = {},
) {
// One chip per amount; the chip itself shows which rails can pay it.
// [zapAmountChoices] is already the single merged+sorted preset list (the
@@ -1955,6 +1964,7 @@ fun ZapAmountChoicePopup(
onError,
onProgress,
onPayViaIntent,
onReloadNutzap,
)
}
@@ -1973,6 +1983,7 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onReloadNutzap: (Long) -> Unit = {},
) {
val context = LocalContext.current
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
@@ -2025,6 +2036,10 @@ fun ZapAmountChoicePopup(
onOnchainAmount(amount)
visibilityState.targetState = false
},
onReloadNutzap = { amount ->
onReloadNutzap(amount)
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
)
}
@@ -2040,6 +2055,7 @@ fun ZapAmountChoicePopupContent(
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
@@ -2060,6 +2076,7 @@ fun ZapAmountChoicePopupContent(
onLightningZap = onLightningZap,
onNutzap = onNutzap,
onOnchainAmount = onOnchainAmount,
onReloadNutzap = onReloadNutzap,
onChangeAmount = onChangeAmount,
)
}
@@ -2085,17 +2102,19 @@ fun ZapAmountChoicePopupContent(
/**
* One pill per amount, showing a tappable logo for every rail that can pay it:
* - **Cashu** only when a single shared mint is already funded for the amount
* (the one rail whose balance is free to read). Underfunded/reload states
* are intentionally not offered yet.
* - **Cashu** as a solid logo when a single shared mint already covers the
* amount (FUNDED), or as a dimmed logo with a "+" badge when funds exist but
* in the wrong mint (NEEDS_RELOAD) — tapping the latter opens the Reload Mint
* screen. Cashu is the one rail whose balance is free to read.
* - **Lightning** optimistically whenever the recipient can receive — an
* external wallet can pay any invoice, so we don't gate on a sender balance.
* - **On-chain** when a backend is configured and the amount clears
* [MIN_ONCHAIN_ZAP_SATS]; UTXO sufficiency stays a send-time check.
*
* Tapping the amount fires the cheapest/fastest available rail (cashu →
* Lightning → on-chain); tapping a specific logo forces that rail. Long-press
* opens the amount-preset editor. A pill with no payable rail is not rendered.
* Tapping the amount fires the cheapest/fastest available rail (cashu funded
* Lightning → cashu reload → on-chain); tapping a specific logo forces that
* rail. Long-press opens the amount-preset editor. A pill with no payable rail
* is not rendered.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -2105,13 +2124,16 @@ private fun UnifiedZapAmountChip(
onLightningZap: (Long) -> Unit,
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onReloadNutzap: (Long) -> Unit,
onChangeAmount: () -> Unit,
) {
val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED
val cashuStatus = railCapability.cashuStatus(amountInSats)
val cashuReady = cashuStatus == CashuRailStatus.FUNDED
val cashuReloadable = cashuStatus == CashuRailStatus.NEEDS_RELOAD
val lightningReady = railCapability.hasLightning
val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
if (!cashuReady && !lightningReady && !onchainReady) return
if (!cashuReady && !cashuReloadable && !lightningReady && !onchainReady) return
val defaultAction: () -> Unit =
when {
@@ -2121,6 +2143,9 @@ private fun UnifiedZapAmountChip(
lightningReady -> {
{ onLightningZap(amountInSats) }
}
cashuReloadable -> {
{ onReloadNutzap(amountInSats) }
}
else -> {
{ onOnchainAmount(amountInSats) }
}
@@ -2157,6 +2182,26 @@ private fun UnifiedZapAmountChip(
)
}
}
if (cashuReloadable) {
// Funds exist but in the wrong mint — a dimmed cashu logo with a
// "+" badge; tap opens the Reload Mint screen to top it up first.
RailLogo(onClick = { onReloadNutzap(amountInSats) }) {
Row(verticalAlignment = CenterVertically) {
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(R.string.reload_mint_title),
modifier = Size18Modifier.alpha(0.5f),
tint = Color.Unspecified,
)
Icon(
symbol = MaterialSymbols.AddCircle,
contentDescription = null,
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
if (lightningReady) {
RailLogo(onClick = { onLightningZap(amountInSats) }) {
Icon(
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NOTIFICATION_LAST_READ_KEY
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSync
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.ReloadMintRequest
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -320,6 +321,9 @@ class AccountViewModel(
val tempManualPaymentCache = LruCache<String, List<ZapPaymentHandler.Payable>>(5)
/** Hand-off for the Reload Mint screen — keyed by a uid put on the back stack. */
val tempReloadRequestCache = LruCache<String, ReloadMintRequest>(5)
@OptIn(ExperimentalCoroutinesApi::class)
val notificationHasNewItems =
// When split-notifications is on, the badge tracks only the Following feed.
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.note.zapClick
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -565,6 +566,10 @@ private fun NestZapButton(
nav.nav(Route.ManualZapSplitPayment(uid))
}
},
onReloadNutzap = { amount ->
wantsToZap = false
navigateToReloadMint(accountViewModel, nav, roomNote, amount)
},
)
}
@@ -0,0 +1,336 @@
/*
* 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 android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
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.rememberScrollState
import androidx.compose.foundation.selection.selectable
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.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import java.util.UUID
/**
* Stash the pending nutzap and open the Reload Mint screen. Called from the zap
* picker when the user taps an underfunded (NEEDS_RELOAD) cashu logo.
*/
fun navigateToReloadMint(
accountViewModel: AccountViewModel,
nav: INav,
baseNote: Note,
amountSats: Long,
) {
val uid = UUID.randomUUID().toString()
accountViewModel.tempReloadRequestCache.put(uid, ReloadMintRequest(baseNote, amountSats))
nav.nav(Route.ReloadMint(uid))
}
private fun shortMint(url: String): String = url.removePrefix("https://").removePrefix("http://").removeSuffix("/")
private fun sats(value: Long): String = showAmount(value.toBigDecimal().setScale(1))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReloadMintScreen(
requestId: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val request = remember(requestId) { accountViewModel.tempReloadRequestCache.get(requestId) }
// The cache entry can be gone after process death — nothing to fund, pop.
if (request == null) {
LaunchedEffect(Unit) { nav.popBack() }
return
}
val viewModel: ReloadMintViewModel = viewModel()
LaunchedEffect(requestId) { viewModel.init(accountViewModel, request) }
val ui by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
if (ui.status is ReloadStatus.Done) {
LaunchedEffect(Unit) { nav.popBack() }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.reload_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),
) {
Text(
text = stringRes(R.string.reload_mint_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
// ── Destination ───────────────────────────────────────────────
SectionHeader(stringRes(R.string.reload_mint_section_to))
if (ui.targetOptions.size > 1) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ui.targetOptions.forEach { mint ->
FilterChip(
selected = mint == ui.selectedTarget,
onClick = { viewModel.selectTarget(mint) },
label = { Text(shortMint(mint), maxLines = 1, overflow = TextOverflow.Ellipsis) },
)
}
}
}
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = shortMint(ui.selectedTarget),
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringRes(R.string.reload_mint_needs_more, sats(ui.shortfallSats)),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
// ── All balances ──────────────────────────────────────────────
SectionHeader(stringRes(R.string.reload_mint_section_balances))
ui.balances.forEach { bal ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = shortMint(bal.mintUrl),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color =
if (bal.mintUrl == ui.selectedTarget) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
Text(text = "${sats(bal.balanceSats)} sat", color = MaterialTheme.colorScheme.placeholderText)
}
}
// ── Source ────────────────────────────────────────────────────
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))
// ── Summary + action ──────────────────────────────────────────
Text(
text = stringRes(R.string.reload_mint_move_summary, sats(ui.shortfallSats), sats(ui.estFeeSats)),
style = MaterialTheme.typography.bodyMedium,
)
when (val status = ui.status) {
is ReloadStatus.Working -> {
LinearProgressIndicator(progress = { status.progress }, modifier = Modifier.fillMaxWidth())
Text(status.step, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText)
}
is ReloadStatus.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 = { copyToClipboard(context, status.invoice) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.reload_mint_copy_invoice))
}
}
is ReloadStatus.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 = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)
Button(
onClick = { viewModel.confirm() },
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.reload_mint_confirm))
}
}
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
)
}
@Composable
private fun SourceRow(
source: ReloadSource,
selected: Boolean,
onSelect: () -> Unit,
) {
val enabled = source is ReloadSource.Lightning || (source is ReloadSource.Mint && source.canCover)
val title =
when (source) {
is ReloadSource.Mint -> shortMint(source.mintUrl)
ReloadSource.Lightning -> stringRes(R.string.reload_mint_pay_lightning)
}
val trailing =
when (source) {
is ReloadSource.Mint ->
if (source.canCover) "${sats(source.balanceSats)} sat" else stringRes(R.string.reload_mint_not_enough)
ReloadSource.Lightning -> stringRes(R.string.reload_mint_lightning_desc)
}
Card(
colors =
CardDefaults.cardColors(
containerColor =
if (selected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
modifier =
Modifier
.fillMaxWidth()
.selectable(selected = selected, enabled = enabled, onClick = onSelect),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = trailing,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
private fun copyToClipboard(
context: Context,
text: String,
) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("invoice", text))
}
@@ -0,0 +1,280 @@
/*
* 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 com.vitorpamplona.amethyst.model.Note
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.rpc.PayInvoiceMethod
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/** Hand-off payload for the Reload Mint screen — the pending nutzap to fund. */
data class ReloadMintRequest(
val baseNote: Note,
val amountSats: Long,
)
/** Where the reload funds come from. */
@Immutable
sealed interface ReloadSource {
/** Move existing cashu from another mint we hold (no new sats). */
data class Mint(
val mintUrl: String,
val balanceSats: Long,
val canCover: Boolean,
) : ReloadSource
/** Pay a fresh Lightning invoice to mint at the destination. */
data object Lightning : ReloadSource
}
/** A mint we hold a balance at, for the balances list. */
@Immutable
data class MintBalance(
val mintUrl: String,
val balanceSats: Long,
)
/** Progress of the reload + send. */
@Immutable
sealed interface ReloadStatus {
data object Configuring : ReloadStatus
data class Working(
val step: String,
val progress: Float,
) : ReloadStatus
/** No NWC wallet — the user must pay [invoice] externally; we keep polling. */
data class AwaitingInvoice(
val invoice: String,
val sats: Long,
) : ReloadStatus
data object Done : ReloadStatus
data class Failed(
val message: String,
) : ReloadStatus
}
@Immutable
data class ReloadUiState(
val recipient: String = "",
val amountSats: Long = 0L,
val balances: ImmutableList<MintBalance> = persistentListOf(),
val targetOptions: ImmutableList<String> = persistentListOf(),
val selectedTarget: String = "",
val shortfallSats: Long = 0L,
val estFeeSats: Long = 0L,
val sources: ImmutableList<ReloadSource> = persistentListOf(),
val selectedSource: ReloadSource? = null,
val status: ReloadStatus = ReloadStatus.Configuring,
)
class ReloadMintViewModel : ViewModel() {
private var accountViewModel: AccountViewModel? = null
private var baseNote: Note? = null
private val state: CashuWalletState? get() = accountViewModel?.account?.cashuWalletState
private val _uiState = MutableStateFlow(ReloadUiState())
val uiState: StateFlow<ReloadUiState> = _uiState.asStateFlow()
fun init(
accountViewModel: AccountViewModel,
request: ReloadMintRequest,
) {
this.accountViewModel = accountViewModel
this.baseNote = request.baseNote
val st = state ?: return
val recipient = request.baseNote.author?.pubkeyHex ?: return
val balances = st.peekMintBalances()
val targets =
st
.recipientSharedMints(recipient)
.sortedByDescending { balances[it] ?: 0L }
val defaultTarget = targets.firstOrNull() ?: return
_uiState.value =
ReloadUiState(
recipient = recipient,
amountSats = request.amountSats,
balances =
balances.entries
.sortedByDescending { it.value }
.map { MintBalance(it.key, it.value) }
.toImmutableList(),
targetOptions = targets.toImmutableList(),
selectedTarget = defaultTarget,
).let { recomputeFor(it, defaultTarget) }
}
/** Recompute shortfall, fee estimate, sources, and a default source. */
private fun recomputeFor(
base: ReloadUiState,
target: String,
): ReloadUiState {
val balances = base.balances.associate { it.mintUrl to it.balanceSats }
val targetBalance = balances[target] ?: 0L
val shortfall = (base.amountSats - targetBalance).coerceAtLeast(0L)
// Rough fee cushion for enabling a source; the melt quote sets the real
// fee at execution time. 1% (min 1 sat).
val estFee = (shortfall / 100L).coerceAtLeast(1L)
val mintSources =
base.balances
.filter { it.mintUrl != target }
.map { ReloadSource.Mint(it.mintUrl, it.balanceSats, canCover = it.balanceSats >= shortfall + estFee) }
val sources: List<ReloadSource> = mintSources + ReloadSource.Lightning
// Prefer the richest mint that can cover (no new sats); else Lightning.
val defaultSource: ReloadSource =
mintSources.filter { it.canCover }.maxByOrNull { it.balanceSats } ?: ReloadSource.Lightning
return base.copy(
selectedTarget = target,
shortfallSats = shortfall,
estFeeSats = estFee,
sources = sources.toImmutableList(),
selectedSource = defaultSource,
)
}
fun selectTarget(mintUrl: String) {
_uiState.update { recomputeFor(it, mintUrl) }
}
fun selectSource(source: ReloadSource) {
_uiState.update { it.copy(selectedSource = source) }
}
fun confirm() {
val vm = accountViewModel ?: return
val note = baseNote ?: return
val s = _uiState.value
val source = s.selectedSource ?: return
vm.launchSigner {
try {
when (source) {
is ReloadSource.Mint -> rebalanceThenZap(source.mintUrl, s.selectedTarget, s.shortfallSats, note, s.amountSats)
ReloadSource.Lightning -> reloadFromLightningThenZap(s.selectedTarget, s.shortfallSats, note, s.amountSats)
}
} catch (e: Exception) {
setStatus(ReloadStatus.Failed(describeMintError(e)))
}
}
}
private suspend fun rebalanceThenZap(
sourceMint: String,
targetMint: String,
shortfall: Long,
note: Note,
amount: Long,
) {
val st = state ?: return
setStatus(ReloadStatus.Working("Moving funds", 0.1f))
st.rebalance(sourceMint, targetMint, shortfall) { p ->
setStatus(ReloadStatus.Working("Moving funds", p.coerceIn(0.1f, 0.9f)))
}
sendNutzapAndFinish(note, amount)
}
private suspend fun reloadFromLightningThenZap(
targetMint: String,
shortfall: Long,
note: Note,
amount: Long,
) {
val vm = accountViewModel ?: return
val st = state ?: return
val ops = st.ops
setStatus(ReloadStatus.Working("Requesting invoice", 0.1f))
val flow = ops.startMintFromLightning(targetMint, shortfall)
val walletUri = vm.account.settings.defaultZapPaymentRequest()
if (walletUri != null) {
setStatus(ReloadStatus.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(ReloadStatus.AwaitingInvoice(flow.invoice, shortfall))
}
val attempts = 90
val delayMs = 2_000L
var paid = false
var attempt = 0
while (!paid && attempt < attempts) {
val status = ops.checkMintQuote(targetMint, flow.mintQuote.quote)
paid = status.paid == true || status.state == "PAID" || status.state == "ISSUED"
if (!paid) {
delay(delayMs)
attempt++
}
}
if (!paid) {
setStatus(ReloadStatus.Failed("Invoice not paid yet — you can finish it later from the pending quote banner"))
return
}
setStatus(ReloadStatus.Working("Issuing ecash", 0.85f))
ops.completeMintFromLightning(targetMint, flow.quoteEvent, shortfall)
sendNutzapAndFinish(note, amount)
}
private fun sendNutzapAndFinish(
note: Note,
amount: Long,
) {
val vm = accountViewModel ?: return
setStatus(ReloadStatus.Working("Sending zap", 0.95f))
// The destination is now funded; fire the nutzap (its own error path
// toasts) and report Done so the screen can pop.
vm.sendNutzap(
baseNote = note,
amountSats = amount,
message = "",
onError = { _, msg, _ -> setStatus(ReloadStatus.Failed(msg)) },
)
setStatus(ReloadStatus.Done)
}
private fun setStatus(status: ReloadStatus) {
_uiState.update { it.copy(status = status) }
}
}
+14
View File
@@ -871,6 +871,20 @@
<string name="quick_zap_amounts">Quick Zap Amounts</string>
<string name="quick_zap_amounts_explainer">Shown when pressing the zap button. Each amount can be paid by any rail the recipient supports — Lightning, Cashu, or on-chain (on-chain only for larger amounts). Tap an amount to remove it. If you leave it empty, it will open the dialog to insert an amount every time.</string>
<string name="send_onchain_instead">Send on-chain instead</string>
<string name="reload_mint_title">Reload Mint</string>
<string name="reload_mint_subtitle">Top up the recipient\'s mint so you can send this nutzap in ecash.</string>
<string name="reload_mint_section_to">Mint to reload</string>
<string name="reload_mint_section_balances">Your balances</string>
<string name="reload_mint_section_from">Funds from</string>
<string name="reload_mint_pay_lightning">Pay with Lightning</string>
<string name="reload_mint_lightning_desc">Mint new ecash from your Lightning wallet</string>
<string name="reload_mint_not_enough">Not enough here</string>
<string name="reload_mint_confirm">Move funds &amp; send zap</string>
<string name="reload_mint_awaiting_payment">Waiting for payment…</string>
<string name="reload_mint_retry">Try again</string>
<string name="reload_mint_move_summary">Move %1$s sat · fee ≈ %2$s sat</string>
<string name="reload_mint_needs_more">needs %1$s sat more</string>
<string name="reload_mint_copy_invoice">Copy invoice</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>