From 6d34d3d9f9467bd02c54b0b669e3f897f7111d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 16:33:56 +0000 Subject: [PATCH 1/5] feat(profile): show BOLT12 offers as payment pills, drop the header wallet buttons BOLT12 offers saved in Settings were only reachable through a small bolt button in the profile header action row, next to a second NIP-A3 wallet button, while every other way to pay a profile (Lightning, CLINK, on-chain, Cashu, NIP-A3 targets) rendered as a chip in the payment rail below the bio. Render one chip per NIP-B1 offer in that rail: tap opens the existing copy / pay-with-wallet / pay-via-intent dialog for that offer, long-press copies the raw lno1 string. Remove both header buttons, since the rail already lists every NIP-A3 target with the same tap-to-pay and long-press copy behaviour the dialog rows have. The two dialogs stay (the reaction row still opens the NIP-A3 one), so their files are renamed after what is left. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq --- ...lt12PayButton.kt => Bolt12OffersDialog.kt} | 62 ------------ ...ymentButton.kt => PaymentTargetsDialog.kt} | 79 --------------- .../loggedIn/profile/header/ProfileActions.kt | 4 - .../profile/header/ProfilePaymentRailChips.kt | 98 +++++++++++++++---- .../composeResources/values/strings.xml | 1 + 5 files changed, 78 insertions(+), 166 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/{Bolt12PayButton.kt => Bolt12OffersDialog.kt} (82%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/{PaymentButton.kt => PaymentTargetsDialog.kt} (76%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt index f0a50ef5a5..e6ff960a77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -55,80 +54,19 @@ import androidx.compose.ui.window.Dialog 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.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.bolt12_pay_with_wallet import com.vitorpamplona.amethyst.commons.resources.bolt12_payment_amount_sats -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.payViaBolt12Intent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import kotlinx.coroutines.launch -@Composable -fun Bolt12PayButton( - user: User, - accountViewModel: AccountViewModel, -) { - val address = - remember(user.pubkeyHex) { - Bolt12OfferListEvent.createAddress(user.pubkeyHex) - } - - LoadAddressableNote(address, accountViewModel) { note -> - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) - val offers = - remember(event) { - event?.offers() ?: emptyList() - } - if (offers.isNotEmpty()) { - Bolt12PayButtonWithOffers(offers, accountViewModel) - } - } - } -} - -@Composable -fun Bolt12PayButtonWithOffers( - offers: List, - accountViewModel: AccountViewModel, -) { - var expanded by remember { mutableStateOf(false) } - - FilledTonalButton( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { expanded = true }, - contentPadding = ZeroPadding, - ) { - Icon( - symbol = MaterialSymbols.Bolt, - contentDescription = stringRes(R.string.bolt12_offers), - ) - } - - if (expanded) { - Bolt12OffersDialog( - offers = offers, - accountViewModel = accountViewModel, - onDismiss = { expanded = false }, - ) - } -} - @Composable fun Bolt12OffersDialog( offers: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt similarity index 76% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt index c35e73e5c4..86bea970f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentTargetsDialog.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -54,98 +53,20 @@ import androidx.compose.ui.window.Dialog 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.commons.model.User import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.no_payment_targets_message import com.vitorpamplona.amethyst.commons.resources.show_qr -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog -import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import kotlinx.coroutines.launch -@Composable -fun PaymentButton( - user: User, - accountViewModel: AccountViewModel, - nav: INav, -) { - val address = - remember(user.pubkeyHex) { - PaymentTargetsEvent.createAddress(user.pubkeyHex) - } - - LoadAddressableNote(address, accountViewModel) { note -> - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) - val targets = - remember(event) { - event?.paymentTargets() ?: emptyList() - } - if (targets.isNotEmpty()) { - PaymentButtonWithTargets(user, targets, nav) - } - } - } -} - -@Composable -fun PaymentButtonWithTargets( - user: User, - targets: List, - nav: INav, -) { - var expanded by remember { mutableStateOf(false) } - - FilledTonalButton( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { expanded = true }, - contentPadding = ZeroPadding, - ) { - Icon( - symbol = MaterialSymbols.AccountBalanceWallet, - contentDescription = stringRes(R.string.payment_targets), - ) - } - - if (expanded) { - PaymentTargetsDialog( - targets = targets, - onDismiss = { expanded = false }, - payInApp = { target -> - // Targets one of the user's wallets can pay (lightning, bitcoin) - // go to the Send Payment screen, which collects the amount and - // confirms in place — no extra dialog. Returns false when no - // in-app wallet applies so the dialog falls back to payto://. - val route = inAppPaymentRouteFor(user.pubkeyHex, target) - if (route != null) { - expanded = false - nav.nav(route) - true - } else { - false - } - }, - ) - } -} - @Composable fun PaymentTargetsDialog( targets: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 3501530049..5a1a29969b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -40,10 +40,6 @@ fun ProfileActions( ) { MessageButton(baseUser, accountViewModel, nav) - PaymentButton(baseUser, accountViewModel, nav) - - Bolt12PayButton(baseUser, accountViewModel) - val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index eeda4bccf3..61c6fbda35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -36,8 +36,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.graphics.Color @@ -56,6 +58,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.bolt12_lightning_offer import com.vitorpamplona.amethyst.commons.resources.clink_lightning_offer import com.vitorpamplona.amethyst.commons.resources.send_payment_method_cashu import com.vitorpamplona.amethyst.commons.resources.send_payment_method_lightning @@ -76,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.quartz.experimental.clink.pointers.NOffer import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import kotlinx.coroutines.launch import androidx.compose.material3.Icon as M3Icon @@ -84,9 +88,10 @@ private val CashuPurple = Color(0xFFA855F7) /** * One FlowRow of tappable chips for every way to pay this profile: Lightning - * (lud16, long-press copies the address), the CLINK offer, the NIP-BC on-chain - * wallet, Cashu nutzaps (shown only when the logged-in user's cashu wallet - * shares a mint the recipient accepts), and the NIP-A3 payment-target chips. + * (lud16, long-press copies the address), the CLINK offer, the NIP-B1 BOLT12 + * offers (one chip each), the NIP-BC on-chain wallet, Cashu nutzaps (shown only + * when the logged-in user's cashu wallet shares a mint the recipient accepts), + * and the NIP-A3 payment-target chips. * A single FlowRow so the chips wrap together with uniform spacing instead of * stacking as separately padded rows. */ @@ -113,35 +118,57 @@ fun DisplayPaymentRailChips( .collectAsStateWithLifecycle() val onchainAvailable = showOnchainWallet && LocalCache.onchainBackend != null - val address = + val targetsAddress = remember(baseUser.pubkeyHex) { PaymentTargetsEvent.createAddress(baseUser.pubkeyHex) } + val bolt12Address = + remember(baseUser.pubkeyHex) { + Bolt12OfferListEvent.createAddress(baseUser.pubkeyHex) + } - LoadAddressableNote(address, accountViewModel) { note -> + LoadAddressableNote(targetsAddress, accountViewModel) { targetsNote -> val targets = - if (note != null) { - EventFinderFilterAssemblerSubscription(note, accountViewModel) - val event by observeNoteEvent(note, accountViewModel) + if (targetsNote != null) { + EventFinderFilterAssemblerSubscription(targetsNote, accountViewModel) + val event by observeNoteEvent(targetsNote, accountViewModel) remember(event) { event?.paymentTargets() ?: emptyList() } } else { emptyList() } - if (lud16.isNullOrEmpty() && clinkOffer == null && !onchainAvailable && cashuMintUrl == null && targets.isEmpty()) { - return@LoadAddressableNote - } + LoadAddressableNote(bolt12Address, accountViewModel) { bolt12Note -> + val bolt12Offers = + if (bolt12Note != null) { + EventFinderFilterAssemblerSubscription(bolt12Note, accountViewModel) + val event by observeNoteEvent(bolt12Note, accountViewModel) + remember(event) { event?.offers() ?: emptyList() } + } else { + emptyList() + } - RailAndTargetChips( - baseUser = baseUser, - lud16 = lud16, - clinkOffer = clinkOffer, - onchainAvailable = onchainAvailable, - cashuMintUrl = cashuMintUrl, - targets = targets, - accountViewModel = accountViewModel, - nav = nav, - ) + if (lud16.isNullOrEmpty() && + clinkOffer == null && + bolt12Offers.isEmpty() && + !onchainAvailable && + cashuMintUrl == null && + targets.isEmpty() + ) { + return@LoadAddressableNote + } + + RailAndTargetChips( + baseUser = baseUser, + lud16 = lud16, + clinkOffer = clinkOffer, + bolt12Offers = bolt12Offers, + onchainAvailable = onchainAvailable, + cashuMintUrl = cashuMintUrl, + targets = targets, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } @@ -151,6 +178,7 @@ private fun RailAndTargetChips( baseUser: User, lud16: String?, clinkOffer: NOffer?, + bolt12Offers: List, onchainAvailable: Boolean, cashuMintUrl: String?, targets: List, @@ -161,6 +189,9 @@ private fun RailAndTargetChips( nav.nav(Route.SendPayment(baseUser.pubkeyHex, method.routeKey)) } + // The BOLT12 offer whose pay/copy dialog is open, if any. + var bolt12DialogOffer by remember { mutableStateOf(null) } + FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -199,6 +230,23 @@ private fun RailAndTargetChips( } } + bolt12Offers.forEach { offer -> + ProfilePaymentChip( + color = BitcoinOrange, + label = stringRes(Res.string.bolt12_lightning_offer), + detail = remember(offer) { "${offer.take(14)}\u2026${offer.takeLast(6)}" }, + copyValue = offer, + onClick = { bolt12DialogOffer = offer }, + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + tint = BitcoinOrange, + modifier = Size16Modifier, + ) + } + } + if (onchainAvailable) { ProfilePaymentChip( color = BitcoinOrange, @@ -239,6 +287,14 @@ private fun RailAndTargetChips( PaymentTargetChip(baseUser, target, accountViewModel, nav) } } + + bolt12DialogOffer?.let { offer -> + Bolt12OffersDialog( + offers = listOf(offer), + accountViewModel = accountViewModel, + onDismiss = { bolt12DialogOffer = null }, + ) + } } /** diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 6cee50140c..cdb9318316 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -258,6 +258,7 @@ Lightning Invoice Expired CLINK Offer + BOLT12 Offer To Confirm payment Amount (sats) From 5e8fdedb839005e98e534479f8f1b9eb8fef78f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 18:49:03 +0000 Subject: [PATCH 2/5] fix(zaps): offer the Lightning rail to BOLT12-only recipients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zap picker gated its Lightning bolt on the recipient's lud16/lud06, but the send path has routed a recipient with a kind:10058 offer over BOLT12 for a while (when our default NWC wallet advertises `pay`). A recipient who published only an offer therefore had no bolt in the popup and no one-tap zap, even though ZapPaymentHandler could pay them. Teach RailCapabilityResolver about the BOLT12 route: hasLightning is now true for a recipient with an offer when our wallet can pay offers. The rail keeps its single bolt — which flavour is used stays a send-time decision. The popup observes the recipient's offer list and the default wallet URI so the bolt appears as those load, and the one-tap fast path uses the same check. The sender-side test moves into AccountZapActions.canZapViaBolt12 so the handler, the picker and the profile dialog share it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq --- .../amethyst/model/AccountZapActions.kt | 10 +++++++ .../amethyst/model/zap/RailCapability.kt | 16 +++++++++- .../amethyst/service/ZapPaymentHandler.kt | 5 +--- .../amethyst/ui/note/ReactionsRow.kt | 30 +++++++++++++++---- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index ac39759a4c..3f9bd894c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -162,6 +162,16 @@ class AccountZapActions( ?.supportsMethod(NwcMethod.PAY) == true } + /** + * True when this account can settle a BOLT12 zap at all: an NWC wallet is + * configured and the default one advertises `pay`. The sender-side half of the + * BOLT12 route; the recipient-side half is a published kind:10058 offer. + */ + fun canZapViaBolt12(): Boolean = + account.settings.nwcWallets.value + .isNotEmpty() && + defaultWalletSupportsBolt12Pay() + /** * Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index f53cc2144a..0c3b1593c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -46,6 +46,12 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup * through that rail — matching the existing best-effort behaviour of the * actual send paths (Lightning skips pubkeys with no `lnAddress`; on-chain * separately warns about lnAddress-only splits that can't be paid on-chain). + * + * [hasLightning] is the whole Lightning rail, not just BOLT11: a recipient with + * no `lnAddress` but a published kind:10058 BOLT12 offer counts when our own + * NWC wallet can pay offers, because the zap send path routes them over BOLT12 + * (see `ZapPaymentHandler`). The chip stays one bolt either way — which flavour + * gets used is decided at send time, not in the picker. */ @Immutable data class RailCapability( @@ -143,6 +149,12 @@ object RailCapabilityResolver { baseNote: Note, cashuState: CashuWalletState, payToEnabled: Boolean = false, + /** + * Whether our default NWC wallet can pay BOLT12 offers + * (`AccountZapActions.canZapViaBolt12`). When true, a recipient's published + * offer makes them payable on the Lightning rail even without an lnAddress. + */ + bolt12Payable: Boolean = false, ): RailCapability { val author = baseNote.author?.pubkeyHex val splits = baseNote.event?.zapSplitSetup().orEmpty() @@ -170,7 +182,9 @@ object RailCapabilityResolver { val hasLightning = lnAddressOnlySplits.isNotEmpty() || pubKeyRecipients.any { pk -> - LocalCache.getUserIfExists(pk)?.lnAddress() != null + val user = LocalCache.getUserIfExists(pk) + user?.lnAddress() != null || + (bolt12Payable && user?.bolt12Offers()?.isNotEmpty() == true) } // On-chain pays the pubkey directly; an event with only lnAddress diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index cd44293231..cdec670729 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -167,10 +167,7 @@ class ZapPaymentHandler( // BOLT12 when our default NWC wallet advertises the nwc#2 `pay` method (needed for // the payer proof). Otherwise — no wallet, or a wallet without `pay` — the recipient // stays on lightning, so an unsupported wallet degrades gracefully instead of erroring. - val canBolt12 = - account.settings.nwcWallets.value - .isNotEmpty() && - account.zaps.defaultWalletSupportsBolt12Pay() + val canBolt12 = account.zaps.canZapViaBolt12() val bolt12Recipients = unverifiedZapsToSend.mapNotNull { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a394481d99..40642ee2cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -216,6 +216,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipB1Bolt12Zaps.offer.Bolt12OfferListEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet @@ -1480,10 +1481,15 @@ fun zapClick( choices.size == 1 -> { // One-tap fast path is Lightning-only. If the recipient can't - // receive Lightning (no lud16/lud06), firing a zap here would just - // fail — open the picker instead so the rail-aware chip can route to - // cashu / on-chain / reload. - val caps = RailCapabilityResolver.peek(baseNote, accountViewModel.account.cashuWalletState) + // receive Lightning (no lud16/lud06, and no BOLT12 offer our wallet + // can pay), firing a zap here would just fail — open the picker + // instead so the rail-aware chip can route to cashu / on-chain / reload. + val caps = + RailCapabilityResolver.peek( + baseNote, + accountViewModel.account.cashuWalletState, + bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(), + ) if (caps.hasLightning) { onZapStarts() accountViewModel.zap( @@ -2134,6 +2140,12 @@ fun observeZapRailCapability( val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle() val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value } val nutzapInfo = author?.let { observeNoteEvent(it.nutzapInfoNote, accountViewModel).value } + // BOLT12 route inputs, same contract: the recipient's kind:10058 offer list + // (rides in UserMetadataForKeyKinds beside kind:0) and our default NWC wallet, + // whose `pay` support decides whether that offer makes them Lightning-payable. + val bolt12OfferList = author?.let { observeNoteEvent(it.bolt12OfferListNote, accountViewModel).value } + val defaultWalletUri by accountViewModel.account.nip47SignerState.defaultWalletUri + .collectAsStateWithLifecycle() // Honors the user's "show on-chain wallet" preference: off hides the on-chain // rail from the zap chips too, matching the wallet screen, profile chips, and // Send Payment screen. @@ -2180,11 +2192,19 @@ fun observeZapRailCapability( cashuEntries, recipientInfo, nutzapInfo, + bolt12OfferList, + defaultWalletUri, showPayToChip, recipientPayTo, payToApps, ) { - val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip) + val rc = + RailCapabilityResolver.peek( + baseNote, + cashuState, + showPayToChip, + bolt12Payable = accountViewModel.account.zaps.canZapViaBolt12(), + ) if (onchainEnabled) { rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cda7e35f72..d3fa91fa09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1196,7 +1196,7 @@ class AccountViewModel( .isNotEmpty() /** True when a BOLT12 offer can be paid in-app: an NWC wallet is set and advertises `pay` (nwc#2). */ - fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay() + fun canPayBolt12ViaNwc(): Boolean = account.zaps.canZapViaBolt12() /** * Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2 From 6a4ddfa9747c6e39c620b1f04619f16f013c6555 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 19:21:27 +0000 Subject: [PATCH 3/5] fix(blossom): re-check the cache after winning the read-auth in-flight slot aFastSignerStillSharesOneSignature still failed about two runs in five: a straggler that read the in-flight map and the cache as empty could then win putIfAbsent because the leader had already signed, cached and retired its entry in between, and would sign a second token. The leader's cache put happens-before its removal of the same key, so once a caller has claimed the slot a cached token, if any, is visible. Look once more there: hand the cached token to this caller and to any follower that already picked up the fresh deferred, retire the entry, and skip the signature. Ten reruns of the class pass where two in five failed before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq --- .../service/http/BlossomReadAuthTokenProvider.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt index 7c635ce442..80814a484b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/http/BlossomReadAuthTokenProvider.kt @@ -129,6 +129,18 @@ class BlossomReadAuthTokenProvider( val fresh = CompletableDeferred() inFlight.putIfAbsent(host, fresh)?.let { return it } + // Third look, now that this caller holds the entry. Between the second look and + // the `putIfAbsent` a leader can run its whole cycle — sign, cache, retire — and + // that retirement is exactly what let this caller claim the slot. The leader's + // put to [cache] happens-before its removal of the same key, so any token it + // minted is visible here: hand it out (to this caller and to any follower that + // already picked up [fresh]) and retire the entry, instead of signing again. + cachedHeader(host)?.let { + inFlight.remove(host, fresh) + fresh.complete(it) + return fresh + } + scope .launch { val header = From 3887b033e00a773c3c8c2de2e94bb6b57fb080e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 20:17:09 +0000 Subject: [PATCH 4/5] feat(zaps): fall back to a BOLT11 zap when the wallet refuses a BOLT12 offer The wallet resolves the offer itself, so a stale or unreachable kind:10058 offer surfaces as a NIP-47 error reply, which by the spec means nothing was paid. When that recipient also publishes a lightning address, re-send the same share as a regular zap through the BOLT11 lane instead of toasting the BOLT12 error; the toast stays for a recipient with no other route. Bolt12LightningFallback keeps the decision pure and tested: every refusal retries except the ones about our own wallet (insufficient balance, quota, rate limit, restricted, unauthorized, unsupported encryption), which would fail the same way over BOLT11. A paid-but-no-receipt outcome is never retried, and neither is a wallet that never answers: sendBolt12Zap now passes a timeout handler, so a silent wallet reports a timeout and steps the progress instead of leaving the zap hanging. The BOLT11 lane moves into zapOverLightning so the main zap and the fallback share one path, and Bolt12Recipient carries the lnAddress and relay hint the retry needs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq --- .../amethyst/model/AccountZapActions.kt | 34 +++- .../service/Bolt12LightningFallback.kt | 51 +++++ .../amethyst/service/ZapPaymentHandler.kt | 192 +++++++++++++----- .../service/Bolt12LightningFallbackTest.kt | 64 ++++++ 4 files changed, 289 insertions(+), 52 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index 3f9bd894c3..560ccee5ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -37,7 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request @@ -183,6 +186,13 @@ class AccountZapActions( * still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for * a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no * external-wallet or LNURL fallback because only NWC returns the proof. + * + * Outcomes are split by what they say about the money: + * - [onNotPaid]: the wallet answered with an error, so nothing was paid. The + * wallet does the offer → invoice exchange itself, so a stale or dead offer + * lands here too. The caller may safely retry over another rail. + * - [onError]: paid but no valid receipt, or nothing conclusive. Never retry. + * - [onTimeout]: the wallet never answered. Unknown state — never retry. */ suspend fun sendBolt12Zap( zappedEvent: Event?, @@ -193,15 +203,21 @@ class AccountZapActions( zapType: LnZapEvent.ZapType, // (messageResId, detail) — the caller localizes; detail carries a wallet error, if any. onError: (Int, String?) -> Unit, + // (code, detail) — the wallet refused or failed the payment; no funds moved. + onNotPaid: suspend (NwcErrorCode?, String?) -> Unit, + onTimeout: () -> Unit, onProcessed: () -> Unit, ) { // NONZAP means "pay, but publish no receipt" — settle the offer without binding // a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP. if (zapType == LnZapEvent.ZapType.NONZAP) { - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response -> + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats), onTimeout) { response -> account.scope.launch { - if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage()) - onProcessed() + try { + if (response is IErrorResponseLike) onNotPaid(response.nwcErrorCode(), response.errorMessage()) + } finally { + onProcessed() + } } } return @@ -221,7 +237,7 @@ class AccountZapActions( val payerNote = Bolt12ZapBuilder.payerNote(intent) - sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response -> + sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote), onTimeout) { response -> account.scope.launch { // try/finally so a failure while assembling/publishing the receipt (e.g. a // remote signer error) still steps progress and surfaces an error, instead @@ -244,7 +260,7 @@ class AccountZapActions( } } - is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage()) + is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage()) else -> onError(R.string.bolt12_zap_paid_no_receipt, null) } @@ -384,3 +400,11 @@ class AccountZapActions( return this } } + +/** The NIP-47 error code on a failed reply, whichever error shape the wallet used. */ +private fun Response.nwcErrorCode(): NwcErrorCode? = + when (this) { + is NwcErrorResponse -> error?.code + is PayInvoiceErrorResponse -> error?.code + else -> null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt new file mode 100644 index 0000000000..60815ca778 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt @@ -0,0 +1,51 @@ +/* + * 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.service + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode + +/** + * Decides whether a BOLT12 zap the wallet refused should be re-sent as a BOLT11 zap. + * + * Only ever consulted for a NIP-47 *error* reply, which by the spec means the wallet + * did not pay — so the retry can never double-spend. The question left is whether a + * second attempt over a different instruction has a chance: a refusal about the + * offer (the wallet could not resolve it, the recipient's node is unreachable, the + * offer expired, or our wallet does not handle `lno` at all) is worth retrying over + * the recipient's lightning address; a refusal about *our* wallet — no balance, a + * quota or rate limit, a permission the connection lacks — would fail the same way + * on BOLT11 and would only produce a second error. + */ +object Bolt12LightningFallback { + /** Refusals that describe the sender's wallet, not the offer. */ + private val senderSideCodes = + setOf( + NwcErrorCode.INSUFFICIENT_BALANCE, + NwcErrorCode.QUOTA_EXCEEDED, + NwcErrorCode.RATE_LIMITED, + NwcErrorCode.RESTRICTED, + NwcErrorCode.UNAUTHORIZED, + NwcErrorCode.UNSUPPORTED_ENCRYPTION, + ) + + /** True when a refusal with [code] (null when the wallet sent none) should be retried over BOLT11. */ + fun shouldRetry(code: NwcErrorCode?): Boolean = code !in senderSideCodes +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index cdec670729..df1b584156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -84,11 +85,17 @@ class ZapPaymentHandler( val user: User? = null, ) - /** A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we hold an NWC wallet. */ + /** + * A recipient routed over BOLT12 (NIP-B1): they publish a kind:10058 [offer] and we + * hold an NWC wallet. [lnAddress] is their BOLT11 route, kept so a refused offer can + * fall back to a regular zap (see [payViaBolt12]); null when they publish none. + */ data class Bolt12Recipient( val user: User, val offer: String, val weight: Double = 1.0, + val lnAddress: String? = null, + val relay: NormalizedRelayUrl? = null, ) suspend fun zap( @@ -173,7 +180,7 @@ class ZapPaymentHandler( unverifiedZapsToSend.mapNotNull { val user = it.user if (canBolt12 && it.bolt12Offer != null && user != null) { - Bolt12Recipient(user, it.bolt12Offer, it.weight) + Bolt12Recipient(user, it.bolt12Offer, it.weight, it.lnAddress, it.relay) } else { null } @@ -230,48 +237,20 @@ class ZapPaymentHandler( // --- Lightning lane ----------------------------------------------------------- if (zapsToSend.isNotEmpty()) { - val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats, totalWeight) - - if (splitZapRequests.isNotEmpty()) { - onProgress(0.05f) - - val payables = - assembleAllInvoices( - requests = splitZapRequests, - totalAmountMilliSats = amountMilliSats, - message = message, - okHttpClient = okHttpClient, - onError = onError, - onProgress = { onProgress(it * 0.7f + 0.05f) }, - context = context, - totalWeight = totalWeight, - ) - - if (payables.isNotEmpty()) { - onProgress(0.75f) - - // Route through the user's selected default payment source. A CLINK debit takes - // precedence over NWC when it is the chosen default; NWC-only users are unaffected - // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. - when (val source = account.settings.defaultPaymentSource()) { - is PaymentSource.ClinkDebit -> { - payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) - }, context) - } - - is PaymentSource.Nwc -> { - payViaNWC(payables, note, onError = onError, onProgress = { - onProgress(it * 0.25f + 0.75f) // keeps within range. - }, context) - } - - null -> { - onPayViaIntent(payables.toImmutableList()) - } - } - } - } + zapOverLightning( + zapsToSend = zapsToSend, + note = note, + pollOption = pollOption, + message = message, + zapType = zapType, + totalAmountMilliSats = amountMilliSats, + totalWeight = totalWeight, + okHttpClient = okHttpClient, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + context = context, + ) } // --- BOLT12 lane -------------------------------------------------------------- @@ -279,12 +258,15 @@ class ZapPaymentHandler( payViaBolt12( recipients = bolt12Recipients, note = note, + pollOption = pollOption, totalAmountMilliSats = amountMilliSats, totalWeight = totalWeight, message = message, zapType = zapType, + okHttpClient = okHttpClient, onError = onError, onProgress = { onProgress(it * 0.25f + 0.75f) }, + onPayViaIntent = onPayViaIntent, context = context, ) } @@ -292,6 +274,68 @@ class ZapPaymentHandler( onProgress(1f) } + /** + * The BOLT11 lane: signs one kind 9734 per recipient, fetches each invoice from + * the recipient's LNURL, then settles through the default payment source. Used + * for every lnAddress recipient of a zap, and again by [payViaBolt12] for a + * recipient whose offer the wallet refused. [onProgress] spans 0.05..1.0. + */ + private suspend fun zapOverLightning( + zapsToSend: List, + note: Note, + pollOption: Int?, + message: String, + zapType: LnZapEvent.ZapType, + totalAmountMilliSats: Long, + totalWeight: Double, + okHttpClient: (String) -> OkHttpClient, + onError: (String, String, User?) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, + context: Context, + ) { + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, totalAmountMilliSats, totalWeight) + if (splitZapRequests.isEmpty()) return + + onProgress(0.05f) + + val payables = + assembleAllInvoices( + requests = splitZapRequests, + totalAmountMilliSats = totalAmountMilliSats, + message = message, + okHttpClient = okHttpClient, + onError = onError, + onProgress = { onProgress(it * 0.7f + 0.05f) }, + context = context, + totalWeight = totalWeight, + ) + if (payables.isEmpty()) return + + onProgress(0.75f) + + // Route through the user's selected default payment source. A CLINK debit takes + // precedence over NWC when it is the chosen default; NWC-only users are unaffected + // (defaultPaymentSource() resolves to their NWC wallet). No source -> wallet app. + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.ClinkDebit -> { + payViaClinkDebit(payables, source.wallet.pointer, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) + }, context) + } + + is PaymentSource.Nwc -> { + payViaNWC(payables, note, onError = onError, onProgress = { + onProgress(it * 0.25f + 0.75f) // keeps within range. + }, context) + } + + null -> { + onPayViaIntent(payables.toImmutableList()) + } + } + } + private fun calculateZapValue( amountMilliSats: Long, weight: Double, @@ -460,21 +504,40 @@ class ZapPaymentHandler( * and (if the returned proof validates) publishes a 9736 zap — see * [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic * and settlement/errors surface later through the async NWC response. + * + * When the wallet answers that it did **not** pay — it resolves the offer itself, + * so a stale or unreachable offer fails there — and the recipient also publishes a + * lightning address, the same share is re-sent as a regular BOLT11 zap through + * [zapOverLightning], silently: the BOLT12 error is only shown when there is no + * BOLT11 route, or when the refusal is about our wallet rather than the offer + * ([Bolt12LightningFallback]). A paid-but-no-receipt outcome and a wallet that + * never answers are never retried, since funds may already have moved. */ suspend fun payViaBolt12( recipients: List, note: Note, + pollOption: Int?, totalAmountMilliSats: Long, totalWeight: Double, message: String, zapType: LnZapEvent.ZapType, + okHttpClient: (String) -> OkHttpClient, onError: (String, String, User?) -> Unit, onProgress: (percent: Float) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, context: Context, ) { val progress = PaymentProgress(recipients.size, onProgress) mapNotNullAsync(recipients) { recipient: Bolt12Recipient -> + fun reportBolt12Error( + msgRes: Int, + detail: String?, + ) { + val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes) + onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user) + } + account.zaps.sendBolt12Zap( zappedEvent = note.event, recipientPubKey = recipient.user.pubkeyHex, @@ -482,9 +545,44 @@ class ZapPaymentHandler( amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight), message = message, zapType = zapType, - onError = { msgRes, detail -> - val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes) - onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user) + onError = ::reportBolt12Error, + onNotPaid = { code, detail -> + val lnAddress = recipient.lnAddress + if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) { + Log.i("ZapPaymentHandler") { "BOLT12 offer refused ($code: $detail); re-sending over BOLT11 to $lnAddress" } + try { + zapOverLightning( + zapsToSend = listOf(MyZapSplitSetup(lnAddress, recipient.weight, recipient.relay, recipient.user)), + note = note, + pollOption = pollOption, + message = message, + zapType = zapType, + totalAmountMilliSats = totalAmountMilliSats, + totalWeight = totalWeight, + okHttpClient = okHttpClient, + onError = onError, + // The zap's own progress finished when the BOLT12 request was + // dispatched; the retry settles in the background like NWC does. + onProgress = {}, + onPayViaIntent = onPayViaIntent, + context = context, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Nothing was paid on either rail. Report it as the lightning failure it + // is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt". + Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e) + onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user) + } + } else { + reportBolt12Error(R.string.bolt12_payment_failed, detail) + } + }, + onTimeout = { + // No response callback will fire, so account for the settlement step here. + reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context)) + progress.step() }, onProcessed = { progress.step() }, ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt new file mode 100644 index 0000000000..059085c571 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt @@ -0,0 +1,64 @@ +/* + * 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.service + +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class Bolt12LightningFallbackTest { + @Test + fun offerSideRefusalsRetryOverLightning() { + listOf( + NwcErrorCode.PAYMENT_FAILED, + NwcErrorCode.EXPIRED, + NwcErrorCode.NOT_FOUND, + NwcErrorCode.BAD_REQUEST, + NwcErrorCode.NOT_IMPLEMENTED, + NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, + NwcErrorCode.UNSUPPORTED_NETWORK, + NwcErrorCode.INTERNAL, + NwcErrorCode.OTHER, + ).forEach { code -> + assertTrue("$code should fall back to BOLT11", Bolt12LightningFallback.shouldRetry(code)) + } + } + + @Test + fun aReplyWithoutACodeStillRetries() { + assertTrue(Bolt12LightningFallback.shouldRetry(null)) + } + + @Test + fun senderSideRefusalsDoNotRetry() { + listOf( + NwcErrorCode.INSUFFICIENT_BALANCE, + NwcErrorCode.QUOTA_EXCEEDED, + NwcErrorCode.RATE_LIMITED, + NwcErrorCode.RESTRICTED, + NwcErrorCode.UNAUTHORIZED, + NwcErrorCode.UNSUPPORTED_ENCRYPTION, + ).forEach { code -> + assertFalse("$code is about our wallet, BOLT11 would fail the same way", Bolt12LightningFallback.shouldRetry(code)) + } + } +} From d4ac5149f75249fce257a27ea40426fb01208aca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 20:47:05 +0000 Subject: [PATCH 5/5] fix(zaps): audit follow-ups on the BOLT12 fallback and profile chips - Only retry a refused BOLT12 offer over BOLT11 when the wallet rejected it before attempting a payment (EXPIRED, NOT_FOUND, BAD_REQUEST, NOT_IMPLEMENTED, UNSUPPORTED_PAYMENT_INSTRUCTION, UNSUPPORTED_NETWORK). NIP-47 defines PAYMENT_FAILED as possibly "due to a timeout", so an HTLC can still settle after that reply; retrying on it, or on INTERNAL / OTHER / a missing code, could pay the recipient twice. The decision is now an allowlist and the test locks the non-retry set. - NwcInfoCache exposes an `updates` counter bumped on every stored entry. The zap picker keys its rail recompute on it, so a BOLT12-only recipient's bolt appears when the wallet's kind:13194 info lands after the popup opened, instead of only after closing and reopening it. - A BOLT12 refusal with neither message nor code no longer toasts the raw "%1$s" placeholder; the code name (or OTHER) fills the detail. - One abbreviateBolt12Offer() replaces three copies of the lno1 truncation in the profile chip, the offers dialog and the settings screen. - Reword the "these four" recompute-key comment so it covers the BOLT12 reads added alongside it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LCxV2YvpegwUEKUhN13Mvq --- .../amethyst/model/AccountZapActions.kt | 7 ++-- .../service/Bolt12LightningFallback.kt | 37 ++++++++++--------- .../amethyst/service/ZapPaymentHandler.kt | 20 ++++++---- .../bolt12Offers/Bolt12OffersScreen.kt | 3 +- .../amethyst/ui/note/ReactionsRow.kt | 20 ++++++---- .../profile/header/Bolt12OffersDialog.kt | 9 ++++- .../profile/header/ProfilePaymentRailChips.kt | 2 +- .../service/Bolt12LightningFallbackTest.kt | 16 +++++--- .../model/nip47WalletConnect/NwcInfoCache.kt | 15 ++++++++ 9 files changed, 86 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt index 560ccee5ce..eb854749ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -188,9 +188,10 @@ class AccountZapActions( * external-wallet or LNURL fallback because only NWC returns the proof. * * Outcomes are split by what they say about the money: - * - [onNotPaid]: the wallet answered with an error, so nothing was paid. The - * wallet does the offer → invoice exchange itself, so a stale or dead offer - * lands here too. The caller may safely retry over another rail. + * - [onNotPaid]: the wallet answered with an error. The wallet does the offer → + * invoice exchange itself, so a stale or dead offer lands here too. Whether a + * retry is safe depends on the code — `PAYMENT_FAILED` may be a timeout with the + * HTLC still in flight — see `Bolt12LightningFallback`. * - [onError]: paid but no valid receipt, or nothing conclusive. Never retry. * - [onTimeout]: the wallet never answered. Unknown state — never retry. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt index 60815ca778..4f73a3b2c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt @@ -25,27 +25,30 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode /** * Decides whether a BOLT12 zap the wallet refused should be re-sent as a BOLT11 zap. * - * Only ever consulted for a NIP-47 *error* reply, which by the spec means the wallet - * did not pay — so the retry can never double-spend. The question left is whether a - * second attempt over a different instruction has a chance: a refusal about the - * offer (the wallet could not resolve it, the recipient's node is unreachable, the - * offer expired, or our wallet does not handle `lno` at all) is worth retrying over - * the recipient's lightning address; a refusal about *our* wallet — no balance, a - * quota or rate limit, a permission the connection lacks — would fail the same way - * on BOLT11 and would only produce a second error. + * Only ever consulted for a NIP-47 *error* reply. An allowlist, because not every + * error means no money moved: NIP-47 defines `PAYMENT_FAILED` as "may be due to a + * timeout, exhausting all routes, insufficient capacity or similar", and a wallet + * that gave up on a payment whose HTLC is still in flight can see it settle later. + * Retrying on that, or on the catch-all `INTERNAL` / `OTHER` / no-code replies, + * could pay the recipient twice. Only refusals the wallet raises *before* it + * attempts a payment qualify — the offer could not be resolved or has expired, the + * request was rejected as malformed, or our wallet does not handle `lno` at all. + * Those are the "recipient's configuration is stale" cases the fallback exists for. + * Refusals about our own wallet (balance, quota, permissions) are out too: BOLT11 + * through the same wallet would fail identically and only add a second error. */ object Bolt12LightningFallback { - /** Refusals that describe the sender's wallet, not the offer. */ - private val senderSideCodes = + /** Refusals raised before any payment attempt, about the offer or the instruction. */ + private val offerSideCodes = setOf( - NwcErrorCode.INSUFFICIENT_BALANCE, - NwcErrorCode.QUOTA_EXCEEDED, - NwcErrorCode.RATE_LIMITED, - NwcErrorCode.RESTRICTED, - NwcErrorCode.UNAUTHORIZED, - NwcErrorCode.UNSUPPORTED_ENCRYPTION, + NwcErrorCode.EXPIRED, + NwcErrorCode.NOT_FOUND, + NwcErrorCode.BAD_REQUEST, + NwcErrorCode.NOT_IMPLEMENTED, + NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, + NwcErrorCode.UNSUPPORTED_NETWORK, ) /** True when a refusal with [code] (null when the wallet sent none) should be retried over BOLT11. */ - fun shouldRetry(code: NwcErrorCode?): Boolean = code !in senderSideCodes + fun shouldRetry(code: NwcErrorCode?): Boolean = code in offerSideCodes } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index df1b584156..0d08176f3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionMetadata import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -505,13 +506,14 @@ class ZapPaymentHandler( * [Account.sendBolt12Zap]. Fire-and-forget like [payViaNWC]: dispatch is optimistic * and settlement/errors surface later through the async NWC response. * - * When the wallet answers that it did **not** pay — it resolves the offer itself, - * so a stale or unreachable offer fails there — and the recipient also publishes a - * lightning address, the same share is re-sent as a regular BOLT11 zap through - * [zapOverLightning], silently: the BOLT12 error is only shown when there is no - * BOLT11 route, or when the refusal is about our wallet rather than the offer - * ([Bolt12LightningFallback]). A paid-but-no-receipt outcome and a wallet that - * never answers are never retried, since funds may already have moved. + * When the wallet refuses the offer before attempting a payment — it resolves the + * offer itself, so a stale, expired or unsupported offer fails there — and the + * recipient also publishes a lightning address, the same share is re-sent as a + * regular BOLT11 zap through [zapOverLightning], silently. The BOLT12 error is + * shown when there is no BOLT11 route or when the refusal does not qualify + * ([Bolt12LightningFallback]: a failed payment attempt may still settle, and a + * refusal about our own wallet would repeat on BOLT11). A paid-but-no-receipt + * outcome and a wallet that never answers are never retried either. */ suspend fun payViaBolt12( recipients: List, @@ -576,7 +578,9 @@ class ZapPaymentHandler( onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user) } } else { - reportBolt12Error(R.string.bolt12_payment_failed, detail) + // bolt12_payment_failed always formats a detail; a wallet may send neither + // message nor a recognised code. + reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name) } }, onTimeout = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt index 354d0a435e..0a0ef7d197 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/bolt12Offers/Bolt12OffersScreen.kt @@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.abbreviateBolt12Offer import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder @@ -191,7 +192,7 @@ fun Bolt12OfferEntry( horizontalArrangement = Arrangement.SpaceAround, ) { Text( - text = "${offer.take(14)}…${offer.takeLast(6)}", + text = abbreviateBolt12Offer(offer), style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 40642ee2cb..b469b4091e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -226,6 +226,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -2130,12 +2131,12 @@ fun observeZapRailCapability( ): RailCapability { val cashuState = accountViewModel.account.cashuWalletState val author = baseNote.author - // These four are deliberately read only to drive the recompute below — do NOT - // delete them as "unused". Each observe* call ALSO subscribes the relay fetch - // (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is - // open), and each value is a remember() key so railCapability recomputes when - // it arrives. RailCapabilityResolver.peek re-reads everything itself; these - // just say *when* to re-run it. + // Every value below up to `showOnchainWallet` is deliberately read only to drive + // the recompute — do NOT delete them as "unused". Each observe* call ALSO + // subscribes the relay fetch (so a not-yet-seen kind:0 / kind:10019 / kind:10058 + // gets pulled in while the popup is open), and each value is a remember() key so + // railCapability recomputes when it arrives. RailCapabilityResolver.peek re-reads + // everything itself; these just say *when* to re-run it. val cashuMints by cashuState.mints.collectAsStateWithLifecycle() val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle() val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value } @@ -2144,7 +2145,11 @@ fun observeZapRailCapability( // (rides in UserMetadataForKeyKinds beside kind:0) and our default NWC wallet, // whose `pay` support decides whether that offer makes them Lightning-payable. val bolt12OfferList = author?.let { observeNoteEvent(it.bolt12OfferListNote, accountViewModel).value } - val defaultWalletUri by accountViewModel.account.nip47SignerState.defaultWalletUri + val nip47State = accountViewModel.account.nip47SignerState + val defaultWalletUri by nip47State.defaultWalletUri.collectAsStateWithLifecycle() + // The wallet's kind:13194 info (its `pay` support) is a plain cache read inside + // canZapViaBolt12(); this counter is what recomputes when it lands after opening. + val walletInfoUpdates by remember(nip47State) { nip47State.infoCache?.updates ?: MutableStateFlow(0) } .collectAsStateWithLifecycle() // Honors the user's "show on-chain wallet" preference: off hides the on-chain // rail from the zap chips too, matching the wallet screen, profile chips, and @@ -2194,6 +2199,7 @@ fun observeZapRailCapability( nutzapInfo, bolt12OfferList, defaultWalletUri, + walletInfoUpdates, showPayToChip, recipientPayTo, payToApps, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt index e6ff960a77..e377c20473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12OffersDialog.kt @@ -148,7 +148,7 @@ private fun Bolt12OfferRow( .padding(horizontal = 16.dp, vertical = 10.dp), ) { Text( - text = "${offer.take(14)}…${offer.takeLast(6)}", + text = abbreviateBolt12Offer(offer), style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurface, @@ -233,3 +233,10 @@ private fun Bolt12NwcAmountDialog( } } } + +/** + * The short form every BOLT12 surface shows for an `lno1…` offer: enough of the + * head to recognise the prefix, the tail to tell two offers apart. Shared by the + * profile chip, this dialog and the offers settings screen so they agree. + */ +fun abbreviateBolt12Offer(offer: String): String = "${offer.take(14)}\u2026${offer.takeLast(6)}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt index 61c6fbda35..43e0949b2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfilePaymentRailChips.kt @@ -234,7 +234,7 @@ private fun RailAndTargetChips( ProfilePaymentChip( color = BitcoinOrange, label = stringRes(Res.string.bolt12_lightning_offer), - detail = remember(offer) { "${offer.take(14)}\u2026${offer.takeLast(6)}" }, + detail = remember(offer) { abbreviateBolt12Offer(offer) }, copyValue = offer, onClick = { bolt12DialogOffer = offer }, ) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt index 059085c571..98fe9c90fc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt @@ -29,23 +29,29 @@ class Bolt12LightningFallbackTest { @Test fun offerSideRefusalsRetryOverLightning() { listOf( - NwcErrorCode.PAYMENT_FAILED, NwcErrorCode.EXPIRED, NwcErrorCode.NOT_FOUND, NwcErrorCode.BAD_REQUEST, NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, NwcErrorCode.UNSUPPORTED_NETWORK, - NwcErrorCode.INTERNAL, - NwcErrorCode.OTHER, ).forEach { code -> assertTrue("$code should fall back to BOLT11", Bolt12LightningFallback.shouldRetry(code)) } } @Test - fun aReplyWithoutACodeStillRetries() { - assertTrue(Bolt12LightningFallback.shouldRetry(null)) + fun aFailedOrAmbiguousPaymentNeverRetries() { + // PAYMENT_FAILED "may be due to a timeout" (NIP-47): the HTLC can still settle. + // The catch-alls and a reply with no code say nothing about whether funds moved. + listOf( + NwcErrorCode.PAYMENT_FAILED, + NwcErrorCode.INTERNAL, + NwcErrorCode.OTHER, + null, + ).forEach { code -> + assertFalse("$code may already have paid; a retry could double-spend", Bolt12LightningFallback.shouldRetry(code)) + } } @Test diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt index 51c1c1e6df..3782bdfe66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip47WalletConnect/NwcInfoCache.kt @@ -30,6 +30,10 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -75,6 +79,16 @@ class NwcInfoCache( private val cache = ConcurrentMap() + private val updatesState = MutableStateFlow(0) + + /** + * Bumps every time a fetch stores an entry. [current] is a plain map read, so a + * composable that derives state from it (the zap picker's "can our wallet pay a + * BOLT12 offer" check) has nothing to recompose on when the info arrives after + * it opened; keying on this flow closes that gap. + */ + val updates: StateFlow = updatesState.asStateFlow() + // Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce]. private val inFlight = ConcurrentMap>() @@ -182,6 +196,7 @@ class NwcInfoCache( } cache[uri.pubKeyHex] = Entry(info, now()) + updatesState.update { it + 1 } return info }