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..eb854749ea 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 @@ -162,6 +165,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. * @@ -173,6 +186,14 @@ 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. 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. */ suspend fun sendBolt12Zap( zappedEvent: Event?, @@ -183,15 +204,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 @@ -211,7 +238,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 @@ -234,7 +261,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) } @@ -374,3 +401,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/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/Bolt12LightningFallback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt new file mode 100644 index 0000000000..4f73a3b2c2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallback.kt @@ -0,0 +1,54 @@ +/* + * 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. 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 raised before any payment attempt, about the offer or the instruction. */ + private val offerSideCodes = + setOf( + 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 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 cd44293231..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 @@ -43,6 +44,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 +86,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( @@ -167,16 +175,13 @@ 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 { 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 } @@ -233,48 +238,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 -------------------------------------------------------------- @@ -282,12 +259,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, ) } @@ -295,6 +275,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, @@ -463,21 +505,41 @@ 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 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, 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, @@ -485,9 +547,46 @@ 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 { + // 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 = { + // 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/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 a394481d99..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 @@ -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 @@ -225,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 @@ -1480,10 +1482,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( @@ -2124,16 +2131,26 @@ 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 } 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 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 // Send Payment screen. @@ -2180,11 +2197,20 @@ fun observeZapRailCapability( cashuEntries, recipientInfo, nutzapInfo, + bolt12OfferList, + defaultWalletUri, + walletInfoUpdates, 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 a1b4069ec7..6422871fab 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 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..e377c20473 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, @@ -210,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, @@ -295,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/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..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 @@ -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) { abbreviateBolt12Offer(offer) }, + 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/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..98fe9c90fc --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/Bolt12LightningFallbackTest.kt @@ -0,0 +1,70 @@ +/* + * 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.EXPIRED, + NwcErrorCode.NOT_FOUND, + NwcErrorCode.BAD_REQUEST, + NwcErrorCode.NOT_IMPLEMENTED, + NwcErrorCode.UNSUPPORTED_PAYMENT_INSTRUCTION, + NwcErrorCode.UNSUPPORTED_NETWORK, + ).forEach { code -> + assertTrue("$code should fall back to BOLT11", Bolt12LightningFallback.shouldRetry(code)) + } + } + + @Test + 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 + 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)) + } + } +} 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) 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 }