Merge pull request #4112 from vitorpamplona/claude/intelligent-newton-bw5dmf

BOLT12 offers in the profile payment rail and zap picker, with BOLT11 fallback on refused offers
This commit is contained in:
Vitor Pamplona
2026-09-12 20:40:50 -04:00
committed by GitHub
14 changed files with 470 additions and 237 deletions
@@ -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
}
@@ -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
@@ -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
}
@@ -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<MyZapSplitSetup>,
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<Payable>) -> 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<Bolt12Recipient>,
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<Payable>) -> 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() },
)
@@ -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,
@@ -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<NutzapInfoEvent>(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<Bolt12OfferListEvent>(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 {
@@ -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
@@ -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<Bolt12OfferListEvent>(note, accountViewModel)
val offers =
remember(event) {
event?.offers() ?: emptyList()
}
if (offers.isNotEmpty()) {
Bolt12PayButtonWithOffers(offers, accountViewModel)
}
}
}
}
@Composable
fun Bolt12PayButtonWithOffers(
offers: List<String>,
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<String>,
@@ -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)}"
@@ -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<PaymentTargetsEvent>(note, accountViewModel)
val targets =
remember(event) {
event?.paymentTargets() ?: emptyList()
}
if (targets.isNotEmpty()) {
PaymentButtonWithTargets(user, targets, nav)
}
}
}
}
@Composable
fun PaymentButtonWithTargets(
user: User,
targets: List<PaymentTarget>,
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<PaymentTarget>,
@@ -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 } }
@@ -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<PaymentTargetsEvent>(note, accountViewModel)
if (targetsNote != null) {
EventFinderFilterAssemblerSubscription(targetsNote, accountViewModel)
val event by observeNoteEvent<PaymentTargetsEvent>(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<Bolt12OfferListEvent>(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<String>,
onchainAvailable: Boolean,
cashuMintUrl: String?,
targets: List<PaymentTarget>,
@@ -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<String?>(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 },
)
}
}
/**
@@ -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))
}
}
}
@@ -258,6 +258,7 @@
<string name="lightning_invoice">Lightning Invoice</string>
<string name="invoice_expired">Expired</string>
<string name="clink_lightning_offer">CLINK Offer</string>
<string name="bolt12_lightning_offer">BOLT12 Offer</string>
<string name="clink_offer_pay_to">To</string>
<string name="clink_confirm_payment_title">Confirm payment</string>
<string name="clink_offer_amount_sats">Amount (sats)</string>
@@ -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<HexKey, Entry>()
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<Int> = updatesState.asStateFlow()
// Fetches in progress, keyed like [cache]. Every fetching path goes through [fetchOnce].
private val inFlight = ConcurrentMap<HexKey, CompletableDeferred<NwcInfoEvent?>>()
@@ -182,6 +196,7 @@ class NwcInfoCache(
}
cache[uri.pubKeyHex] = Entry(info, now())
updatesState.update { it + 1 }
return info
}