mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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() },
|
||||
)
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+8
-63
@@ -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)}"
|
||||
-79
@@ -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>,
|
||||
-4
@@ -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 } }
|
||||
|
||||
|
||||
+77
-21
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+70
@@ -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>
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user