feat(bolt12): pay a recipient's offer in-app over NWC

Adds a "pay with connected wallet" action to the BOLT12 offer dialog on a
profile: when the account has a NIP-47 wallet configured, an amount sheet
collects sats and settles the offer over the wallet via the nwc#2 `pay`
method (BIP321 bitcoin:?lno=). Falls back to the external bitcoin: intent
otherwise. Outcome is surfaced as a toast. This is a plain payment, not a
NIP-XX zap (no receipt) — that's the deferred Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
This commit is contained in:
Claude
2026-07-24 21:45:56 +00:00
parent 7d3f7f7ca4
commit d58bca4177
3 changed files with 132 additions and 5 deletions
@@ -163,6 +163,9 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -1201,6 +1204,31 @@ class AccountViewModel(
)
}
/** True when the account has at least one NIP-47 wallet configured. */
fun hasNwcWallet(): Boolean =
account.settings.nwcWallets.value
.isNotEmpty()
/**
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
* `pay` method, wrapping it as a BIP321 `bitcoin:?lno=` instruction. This is a
* plain payment, not a NIP-XX zap (no Nostr receipt); the outcome is surfaced as
* a toast. Callers should gate on [hasNwcWallet].
*/
fun payBolt12OfferViaNwc(
offer: String,
amountMillisats: Long,
) = launchSigner {
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
when (response) {
is PaySuccessResponse -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_sent)
is IErrorResponseLike ->
toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, response.errorMessage() ?: "")
else -> toastManager.toast(R.string.bolt12_offers, R.string.bolt12_payment_failed, "")
}
}
}
/**
* Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in
* the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via
@@ -21,14 +21,21 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -41,8 +48,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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
@@ -56,6 +65,7 @@ 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.nipXXBolt12Zaps.offer.Bolt12OfferListEvent
@@ -80,14 +90,17 @@ fun Bolt12PayButton(
event?.offers() ?: emptyList()
}
if (offers.isNotEmpty()) {
Bolt12PayButtonWithOffers(offers)
Bolt12PayButtonWithOffers(offers, accountViewModel)
}
}
}
}
@Composable
fun Bolt12PayButtonWithOffers(offers: List<String>) {
fun Bolt12PayButtonWithOffers(
offers: List<String>,
accountViewModel: AccountViewModel,
) {
var expanded by remember { mutableStateOf(false) }
FilledTonalButton(
@@ -107,6 +120,7 @@ fun Bolt12PayButtonWithOffers(offers: List<String>) {
if (expanded) {
Bolt12OffersDialog(
offers = offers,
accountViewModel = accountViewModel,
onDismiss = { expanded = false },
)
}
@@ -115,12 +129,20 @@ fun Bolt12PayButtonWithOffers(offers: List<String>) {
@Composable
fun Bolt12OffersDialog(
offers: List<String>,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
// Whether we can settle in-app over the user's NIP-47 wallet (nwc#2 `pay`), or
// must hand off to an external wallet via a bitcoin: intent.
val canPayInApp = remember { accountViewModel.hasNwcWallet() }
// The offer the user chose to pay over NWC; drives the amount-entry dialog.
var nwcPayOffer by remember { mutableStateOf<String?>(null) }
M3ActionDialog(
title = stringRes(R.string.bolt12_offers),
onDismiss = onDismiss,
@@ -129,6 +151,7 @@ fun Bolt12OffersDialog(
offers.forEach { offer ->
Bolt12OfferRow(
offer = offer,
canPayInApp = canPayInApp,
onCopy = {
scope.launch {
clipboardManager.setText(offer)
@@ -140,7 +163,7 @@ fun Bolt12OffersDialog(
).show()
}
},
onPay = {
onPayViaIntent = {
payViaBolt12Intent(
offer = offer,
context = context,
@@ -150,17 +173,31 @@ fun Bolt12OffersDialog(
},
)
},
onPayInApp = { nwcPayOffer = offer },
)
}
}
}
nwcPayOffer?.let { offer ->
Bolt12NwcAmountDialog(
onDismiss = { nwcPayOffer = null },
onPay = { amountMillisats ->
accountViewModel.payBolt12OfferViaNwc(offer, amountMillisats)
nwcPayOffer = null
onDismiss()
},
)
}
}
@Composable
private fun Bolt12OfferRow(
offer: String,
canPayInApp: Boolean,
onCopy: () -> Unit,
onPay: () -> Unit,
onPayViaIntent: () -> Unit,
onPayInApp: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -187,7 +224,17 @@ private fun Bolt12OfferRow(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onPay) {
if (canPayInApp) {
IconButton(onClick = onPayInApp) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = stringRes(R.string.bolt12_pay_with_wallet),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = onPayViaIntent) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.bolt12_offers),
@@ -197,3 +244,51 @@ private fun Bolt12OfferRow(
}
}
}
/** Collects an amount (in sats) and returns it to [onPay] in millisats. */
@Composable
private fun Bolt12NwcAmountDialog(
onDismiss: () -> Unit,
onPay: (amountMillisats: Long) -> Unit,
) {
var sats by remember { mutableStateOf("") }
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringRes(R.string.bolt12_pay_with_wallet),
style = MaterialTheme.typography.titleMedium,
)
OutlinedTextField(
value = sats,
onValueChange = { new -> sats = new.filter { it.isDigit() } },
label = { Text(text = stringRes(R.string.bolt12_payment_amount_sats)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
Button(
onClick = {
val amountSats = sats.toLongOrNull()
if (amountSats != null && amountSats > 0) {
onPay(amountSats * 1000)
}
},
shape = ButtonBorder,
enabled = (sats.toLongOrNull() ?: 0) > 0,
) {
Text(text = stringRes(R.string.bolt12_pay_with_wallet))
}
}
}
}
}
}
+4
View File
@@ -1725,6 +1725,10 @@
<string name="bolt12_offer">BOLT12 offer (lno1…)</string>
<string name="invalid_bolt12_offer">Not a valid BOLT12 offer</string>
<string name="delete_bolt12_offer">Delete BOLT12 offer</string>
<string name="bolt12_pay_with_wallet">Pay with connected wallet</string>
<string name="bolt12_payment_amount_sats">Amount (sats)</string>
<string name="bolt12_payment_sent">BOLT12 payment sent</string>
<string name="bolt12_payment_failed">BOLT12 payment failed: %1$s</string>
<string name="uploading_state_ready">Not Started</string>
<string name="uploading_state_compressing">Compressing</string>