feat(zap): unify zap picker into one chip per amount with per-rail logos

Replace the three parallel chip rows (cashu / lightning / on-chain) in the
zap amount popup with a single pill per amount that shows a tappable logo
for each rail that can actually pay it. Tapping the amount fires the
cheapest/fastest available rail (cashu funded -> lightning -> on-chain);
tapping a specific logo forces that rail.

Rail gating:
- Cashu: precise per-amount status (FUNDED / NEEDS_RELOAD / IMPOSSIBLE /
  UNAVAILABLE) computed from in-memory proofs — the one balance that is free
  to read synchronously. Only FUNDED is offered for now.
- Lightning: optimistic — an external wallet can pay any invoice, so it's
  offered whenever the recipient can receive, with no sender-balance fetch.
- On-chain: offered when a backend is configured and the amount clears the
  on-chain minimum; UTXO sufficiency stays a send-time check.

Also fixes the original "No proofs available at <mint>" failure: nutzap mint
selection now picks the shared mint where we hold the most balance instead of
the first one the recipient lists (peekNutzapFunding), so a zap no longer
fails when funds sit in a different shared mint. Cashu capability is now
resolved against the note author (who sendNutzap actually pays) rather than
split recipients.

Amount presets from the (still separate) lightning and on-chain settings are
merged into one sorted set for display.

https://claude.ai/code/session_01HNE2z7CSYZ2G8KwC5fziJn
This commit is contained in:
Claude
2026-05-29 18:09:22 +00:00
parent c3b40c4369
commit c1c1a69148
5 changed files with 336 additions and 168 deletions
@@ -760,22 +760,55 @@ class CashuWalletState(
* - we share no mint with them, or
* - their kind:10019 has no P2PK pubkey.
*/
fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? {
fun peekNutzapTarget(recipientPubKey: HexKey): NutzapTarget? = peekNutzapFunding(recipientPubKey)?.target
/**
* Resolve nutzap funding for [recipientPubKey]: the best shared mint to
* spend from plus the balance figures the zap picker needs to classify
* each amount as funded, reloadable, or out of reach.
*
* The chosen [NutzapFunding.target] is the shared mint where we hold the
* **most** balance — not merely the first one the recipient lists. The
* previous `firstOrNull` could pick an empty shared mint and make a zap
* fail with "No proofs available at <mint>" even though another shared
* mint was funded.
*
* All reads are synchronous from in-memory state (`_mints`,
* `_tokenEntries`, `LocalCache`), so this is safe to call from a
* composable `remember {}`. The User pins the recipient's kind:10019
* addressable note for its own lifetime, so the chip no longer races the
* notes.LargeSoftCache eviction.
*
* Returns null when a nutzap is structurally impossible: we have no mints,
* the recipient has no kind:10019, no P2PK pubkey, or shares no mint with
* us. A non-null result still does not guarantee a single mint can cover a
* given amount — compare against [NutzapFunding.bestSingleMintSats].
*/
fun peekNutzapFunding(recipientPubKey: HexKey): NutzapFunding? {
val ourMints = _mints.value.toSet()
if (ourMints.isEmpty()) return null
// Read the recipient's kind:10019 via their User — User pins the
// addressable note for its own lifetime, so the previous race
// (notes.LargeSoftCache evicts the WeakReference even though the
// event was delivered) no longer drops the chip.
val info = cache.getOrCreateUser(recipientPubKey).nutzapInfo() ?: return null
val recipientPubkeyHex = info.p2pkPubkey() ?: return null
val shared = info.mints().firstOrNull { it.mintUrl in ourMints } ?: return null
val sharedMints = info.mints().map { it.mintUrl }.filter { it in ourMints }
if (sharedMints.isEmpty()) return null
return NutzapTarget(
mintUrl = shared.mintUrl,
recipientP2pkPubkeyHex = recipientPubkeyHex,
val entries = _tokenEntries.value
var bestMint = sharedMints.first()
var bestMintSats = 0L
for (mint in sharedMints) {
val balance = entries.filter { it.content.mint == mint }.sumOf { it.content.totalAmount() }
if (balance > bestMintSats) {
bestMintSats = balance
bestMint = mint
}
}
return NutzapFunding(
target = NutzapTarget(mintUrl = bestMint, recipientP2pkPubkeyHex = recipientPubkeyHex),
bestSingleMintSats = bestMintSats,
totalWalletSats = entries.sumOf { it.content.totalAmount() },
)
}
@@ -1133,3 +1166,16 @@ data class NutzapTarget(
val mintUrl: String,
val recipientP2pkPubkeyHex: String,
)
/**
* Nutzap funding snapshot for a recipient — the [target] mint to spend from
* plus the balance figures the zap picker uses to classify a given amount:
* - `amount <= bestSingleMintSats` → fundable instantly from one shared mint
* - `amount <= totalWalletSats` → fundable only after a mint reload/rebalance
* - otherwise → out of reach with the current balance
*/
data class NutzapFunding(
val target: NutzapTarget,
val bestSingleMintSats: Long,
val totalWalletSats: Long,
)
@@ -46,12 +46,41 @@ data class RailCapability(
val hasCashu: Boolean,
val hasLightning: Boolean,
val hasOnchain: Boolean,
/** Largest balance held in any single mint shared with the recipient. */
val cashuBestSingleMintSats: Long = 0L,
/** Total cashu balance across all our mints (reachable via reload/rebalance). */
val cashuTotalWalletSats: Long = 0L,
) {
/**
* Classify a cashu nutzap of [amountSats] for the unified amount chip.
* Cashu is the one rail whose balance is free to read synchronously, so
* this is a precise per-amount status rather than the optimistic
* recipient-only gating used for Lightning and on-chain.
*/
fun cashuStatus(amountSats: Long): CashuRailStatus =
when {
!hasCashu -> CashuRailStatus.UNAVAILABLE
amountSats <= cashuBestSingleMintSats -> CashuRailStatus.FUNDED
amountSats <= cashuTotalWalletSats -> CashuRailStatus.NEEDS_RELOAD
else -> CashuRailStatus.IMPOSSIBLE
}
companion object {
val NONE = RailCapability(hasCashu = false, hasLightning = false, hasOnchain = false)
}
}
/**
* Per-amount cashu spendability for the unified zap chip.
* - [FUNDED]: a single shared mint already covers the amount — instant, no fee.
* - [NEEDS_RELOAD]: total wallet balance covers it, but no single shared mint
* does — needs a mint reload/rebalance first.
* - [IMPOSSIBLE]: not enough cashu anywhere — a reload can't help.
* - [UNAVAILABLE]: the recipient can't receive cashu at all (no kind:10019 /
* no shared mint).
*/
enum class CashuRailStatus { FUNDED, NEEDS_RELOAD, IMPOSSIBLE, UNAVAILABLE }
object RailCapabilityResolver {
/**
* Resolve [RailCapability] for [baseNote]. Reads are synchronous from
@@ -59,10 +88,11 @@ object RailCapabilityResolver {
* inside a composable.
*
* Rules:
* - **Cashu**: any pubkey-based recipient (author + `ZapSplitSetup`
* splits) has a kind:10019 with a P2PK pubkey AND shares at least one
* mint with our wallet. Delegates to [CashuWalletState.peekNutzapTarget]
* which already enforces all three conditions.
* - **Cashu**: the note **author** has a kind:10019 with a P2PK pubkey
* AND shares at least one mint with our wallet. Computed against the
* author only (not `zap` splits) because [CashuWalletState.sendNutzap]
* pays the author. Delegates to [CashuWalletState.peekNutzapFunding],
* which also reports per-mint balances for amount-level gating.
* - **Lightning**: any pubkey recipient has lud16/lud06 in their kind:0,
* OR the note has at least one direct `ZapSplitSetupLnAddress` split.
* - **On-chain**: at least one pubkey-based recipient exists. NIP-BC
@@ -92,7 +122,12 @@ object RailCapabilityResolver {
return RailCapability.NONE
}
val hasCashu = pubKeyRecipients.any { cashuState.peekNutzapTarget(it) != null }
// Cashu is computed against the author only: sendNutzap pays
// baseNote.author (it does not fan a nutzap across `zap` splits), so
// gating on a split recipient's wallet would offer a chip the send
// path can't honor.
val cashuFunding = author?.let { cashuState.peekNutzapFunding(it) }
val hasCashu = cashuFunding != null
val hasLightning =
lnAddressOnlySplits.isNotEmpty() ||
@@ -108,6 +143,8 @@ object RailCapabilityResolver {
hasCashu = hasCashu,
hasLightning = hasLightning,
hasOnchain = hasOnchain,
cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L,
cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L,
)
}
}
@@ -207,10 +207,6 @@ fun ReusableZapButton(
onchainZapAmount = amount
showOnchainDialog = true
},
nutzapEnabled =
baseNote.author?.pubkeyHex?.let { recipient ->
accountViewModel.account.cashuWalletState.peekNutzapTarget(recipient) != null
} ?: false,
)
}
@@ -38,6 +38,7 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -52,6 +53,7 @@ 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.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
@@ -83,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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
@@ -111,6 +114,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ReactionRowAction
import com.vitorpamplona.amethyst.model.ReactionRowItem
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
import com.vitorpamplona.amethyst.model.zap.RailCapability
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -1887,21 +1892,9 @@ fun ZapAmountChoicePopup(
accountViewModel.account.settings.syncedSettings.zaps.onchainZapAmountChoices
.collectAsStateWithLifecycle()
// Hide chips for rails the recipient(s) can't actually receive on. For a
// single-author note this gates against the author's kind:0 / kind:10019;
// for a note with `zap` split tags it considers every split recipient so
// splits where (e.g.) only one of three has a lud16 still surface the LN
// chips. Recomputed only when the note id changes — the underlying flows
// (CashuWalletState, LocalCache user metadata) are stable for the life of
// the popup, which is usually open for under a second.
val railCapability =
remember(baseNote) {
RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
}
ZapAmountChoicePopup(
baseNote = baseNote,
zapAmountChoices = if (railCapability.hasLightning) zapAmountChoices else persistentListOf(),
zapAmountChoices = zapAmountChoices,
accountViewModel = accountViewModel,
popupYOffset = popupYOffset,
onZapStarts = onZapStarts,
@@ -1910,14 +1903,9 @@ fun ZapAmountChoicePopup(
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
onchainZapAmountChoices =
if (onOnchainAmount != null && railCapability.hasOnchain) {
onchainZapAmountChoices
} else {
persistentListOf()
},
onchainZapAmountChoices = onchainZapAmountChoices,
onOnchainAmount = onOnchainAmount ?: {},
nutzapEnabled = railCapability.hasCashu,
onchainSupported = onOnchainAmount != null,
)
}
@@ -1935,13 +1923,33 @@ fun ZapAmountChoicePopup(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
nutzapEnabled: Boolean = false,
onchainSupported: Boolean = true,
) {
// One chip per amount; the chip itself shows which rails can pay it. Merge
// the (historically separate) Lightning and on-chain amount presets into a
// single sorted set of choices. Rail availability per amount is decided in
// [UnifiedZapAmountChip] from [railCapability], so the lists no longer need
// to be pre-emptied per rail.
//
// [railCapability] gates which rails the recipient can receive on (plus the
// sender's cashu balance, which is free to read). It's recomputed only when
// the note changes — the underlying cache/wallet flows are stable for the
// ~1s the popup is open. A caller that can't drive the on-chain dialog
// (onchainSupported == false) masks that rail off here.
val railCapability =
remember(baseNote, onchainSupported) {
val rc = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState)
if (onchainSupported) rc else rc.copy(hasOnchain = false)
}
val amountChoices =
remember(zapAmountChoices, onchainZapAmountChoices) {
(zapAmountChoices + onchainZapAmountChoices).distinct().sorted().toImmutableList()
}
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(
baseNote,
zapAmountChoices,
onchainZapAmountChoices,
amountChoices,
railCapability,
accountViewModel,
popupYOffset,
visibilityState,
@@ -1951,7 +1959,6 @@ fun ZapAmountChoicePopup(
onError,
onProgress,
onPayViaIntent,
nutzapEnabled,
)
}
@@ -1959,8 +1966,8 @@ fun ZapAmountChoicePopup(
@Composable
fun ZapAmountChoicePopup(
baseNote: Note,
zapAmountChoices: ImmutableList<Long>,
onchainZapAmountChoices: ImmutableList<Long>,
amountChoices: ImmutableList<Long>,
railCapability: RailCapability,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
@@ -1970,7 +1977,6 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
nutzapEnabled: Boolean = false,
) {
val context = LocalContext.current
val yOffset = with(LocalDensity.current) { -popupYOffset.toPx().toInt() }
@@ -1987,9 +1993,9 @@ fun ZapAmountChoicePopup(
exit = popupAnimationExit,
) {
ZapAmountChoicePopupContent(
zapAmountChoices = zapAmountChoices,
onchainZapAmountChoices = onchainZapAmountChoices,
onZap = { amountInSats ->
amountChoices = amountChoices,
railCapability = railCapability,
onLightningZap = { amountInSats ->
onZapStarts()
accountViewModel.zap(
baseNote,
@@ -2004,12 +2010,6 @@ fun ZapAmountChoicePopup(
)
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
onOnchainAmount = { amount ->
onOnchainAmount(amount)
visibilityState.targetState = false
},
nutzapAmountChoices = if (nutzapEnabled) zapAmountChoices else persistentListOf(),
onNutzap = { amountInSats ->
onZapStarts()
// Instant click feedback. Without this initial nudge
@@ -2025,6 +2025,11 @@ fun ZapAmountChoicePopup(
)
visibilityState.targetState = false
},
onOnchainAmount = { amount ->
onOnchainAmount(amount)
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
)
}
}
@@ -2033,13 +2038,12 @@ fun ZapAmountChoicePopup(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopupContent(
zapAmountChoices: ImmutableList<Long>,
onZap: (Long) -> Unit,
amountChoices: ImmutableList<Long>,
railCapability: RailCapability,
onLightningZap: (Long) -> Unit,
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onchainZapAmountChoices: ImmutableList<Long> = persistentListOf(),
onOnchainAmount: (Long?) -> Unit = {},
nutzapAmountChoices: ImmutableList<Long> = persistentListOf(),
onNutzap: (Long) -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
@@ -2053,29 +2057,14 @@ fun ZapAmountChoicePopupContent(
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = CenterVertically,
) {
// Order: Cashu first (instant, no fees), then Lightning,
// then on-chain — matches the recipient-capability fallback
// order used elsewhere (RailCapabilityResolver) so the
// "happiest path" rail surfaces first for the eye.
nutzapAmountChoices.forEach { amountInSats ->
NutzapAmountChip(
amountChoices.forEach { amountInSats ->
UnifiedZapAmountChip(
amountInSats = amountInSats,
onClick = { onNutzap(amountInSats) },
onLongClick = onChangeAmount,
)
}
zapAmountChoices.forEach { amountInSats ->
ZapAmountChip(
amountInSats = amountInSats,
onClick = { onZap(amountInSats) },
onLongClick = onChangeAmount,
)
}
onchainZapAmountChoices.forEach { amountInSats ->
OnchainZapAmountChip(
amountInSats = amountInSats,
onClick = { onOnchainAmount(amountInSats) },
onLongClick = onChangeAmount,
railCapability = railCapability,
onLightningZap = onLightningZap,
onNutzap = onNutzap,
onOnchainAmount = onOnchainAmount,
onChangeAmount = onChangeAmount,
)
}
ClickableBox(
@@ -2098,115 +2087,128 @@ fun ZapAmountChoicePopupContent(
}
}
/**
* Floor for the on-chain rail. Mirrors `MIN_ONCHAIN_ZAP_SATS` in
* `OnchainZapSendDialog` (stricter than the protocol dust threshold); kept in
* sync by hand because that one is private to the dialog. Below this the
* on-chain logo is not offered for an amount.
*/
private const val MIN_ONCHAIN_ZAP_SATS = 1_000L
/**
* 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.
* - **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.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NutzapAmountChip(
private fun UnifiedZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
railCapability: RailCapability,
onLightningZap: (Long) -> Unit,
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
) {
val cashuReady = railCapability.cashuStatus(amountInSats) == CashuRailStatus.FUNDED
val lightningReady = railCapability.hasLightning
val onchainReady = railCapability.hasOnchain && amountInSats >= MIN_ONCHAIN_ZAP_SATS
if (!cashuReady && !lightningReady && !onchainReady) return
val defaultAction: () -> Unit =
when {
cashuReady -> {
{ onNutzap(amountInSats) }
}
lightningReady -> {
{ onLightningZap(amountInSats) }
}
else -> {
{ onOnchainAmount(amountInSats) }
}
}
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.tertiary,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
.combinedClickable(onClick = defaultAction, onLongClick = onChangeAmount)
.padding(start = 12.dp, end = 6.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = CenterVertically,
) {
// CustomHashTagIcons.Cashu ships a multi-tone Cashu logo; using
// tint=Unspecified preserves it instead of flattening to onTertiary
// (which would lose the cashu-orange brand cue that distinguishes
// this chip from the orange Lightning bolt next to it).
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(R.string.nutzap),
modifier = Size18Modifier,
tint = Color.Unspecified,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = MaterialTheme.colorScheme.onTertiary,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.width(6.dp))
if (cashuReady) {
RailLogo(onClick = { onNutzap(amountInSats) }) {
// CustomHashTagIcons.Cashu is a multi-tone logo; tint
// Unspecified preserves its cashu-orange brand cue.
Material3Icon(
imageVector = CustomHashTagIcons.Cashu,
contentDescription = stringRes(R.string.nutzap),
modifier = Size18Modifier,
tint = Color.Unspecified,
)
}
}
if (lightningReady) {
RailLogo(onClick = { onLightningZap(amountInSats) }) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Size18Modifier,
tint = BitcoinOrange,
)
}
}
if (onchainReady) {
RailLogo(onClick = { onOnchainAmount(amountInSats) }) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
modifier = Size18Modifier,
tint = BitcoinOrange,
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ZapAmountChip(
amountInSats: Long,
private fun RailLogo(
onClick: () -> Unit,
onLongClick: () -> Unit,
content: @Composable () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
Box(
modifier =
Modifier
.padding(horizontal = 3.dp)
.clip(CircleShape)
.clickable(onClick = onClick)
.padding(2.dp),
contentAlignment = Center,
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun OnchainZapAmountChip(
amountInSats: Long,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = BitcoinOrange,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier =
Modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = CenterVertically,
) {
Icon(
symbol = MaterialSymbols.CurrencyBitcoin,
contentDescription = null,
modifier = Size18Modifier,
tint = Color.White,
)
Spacer(Modifier.width(2.dp))
Text(
text = showAmount(amountInSats.toBigDecimal().setScale(1)),
color = Color.White,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
}
content()
}
}
@@ -2215,11 +2217,19 @@ private fun OnchainZapAmountChip(
fun ZapAmountChoicePopupPreview() {
ThemeComparisonColumn {
ZapAmountChoicePopupContent(
zapAmountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
onchainZapAmountChoices = persistentListOf(10_000L, 50_000L, 250_000L),
onZap = {},
onChangeAmount = {},
amountChoices = persistentListOf(50L, 100L, 500L, 1_000L, 5_000L, 10_000L, 100_000L),
railCapability =
RailCapability(
hasCashu = true,
hasLightning = true,
hasOnchain = true,
cashuBestSingleMintSats = 1_000L,
cashuTotalWalletSats = 10_000L,
),
onLightningZap = {},
onNutzap = {},
onOnchainAmount = {},
onChangeAmount = {},
)
}
}
@@ -0,0 +1,79 @@
/*
* 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.model.zap
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Locks in how the unified zap chip classifies a cashu nutzap per amount.
* This is the precise (free-to-read) gating the chip relies on, so the
* boundaries between funded / reload / impossible matter.
*/
class RailCapabilityCashuStatusTest {
private fun caps(
hasCashu: Boolean = true,
bestSingleMint: Long = 0L,
totalWallet: Long = 0L,
) = RailCapability(
hasCashu = hasCashu,
hasLightning = false,
hasOnchain = false,
cashuBestSingleMintSats = bestSingleMint,
cashuTotalWalletSats = totalWallet,
)
@Test
fun unavailableWhenRecipientCannotReceiveCashu() {
assertEquals(
CashuRailStatus.UNAVAILABLE,
caps(hasCashu = false, bestSingleMint = 10_000L, totalWallet = 10_000L).cashuStatus(100L),
)
}
@Test
fun fundedWhenSingleMintCoversAmount() {
val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L)
assertEquals(CashuRailStatus.FUNDED, c.cashuStatus(500L))
// Exact boundary is funded.
assertEquals(CashuRailStatus.FUNDED, c.cashuStatus(1_000L))
}
@Test
fun needsReloadWhenOnlyTotalCoversAmount() {
// Funds exist, but spread across mints so no single shared mint covers it.
val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L)
assertEquals(CashuRailStatus.NEEDS_RELOAD, c.cashuStatus(1_001L))
assertEquals(CashuRailStatus.NEEDS_RELOAD, c.cashuStatus(5_000L))
}
@Test
fun impossibleWhenTotalCannotCoverAmount() {
val c = caps(bestSingleMint = 1_000L, totalWallet = 5_000L)
assertEquals(CashuRailStatus.IMPOSSIBLE, c.cashuStatus(5_001L))
}
@Test
fun emptyWalletWithReceivingRecipientIsImpossibleNotUnavailable() {
// Recipient can receive (hasCashu) but we hold nothing anywhere.
assertEquals(CashuRailStatus.IMPOSSIBLE, caps().cashuStatus(1L))
}
}