From d58bca417761a3e36361e9b60258d1f622769e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:45:56 +0000 Subject: [PATCH] feat(bolt12): pay a recipient's offer in-app over NWC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3 --- .../ui/screen/loggedIn/AccountViewModel.kt | 28 +++++ .../profile/header/Bolt12PayButton.kt | 105 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 4 + 3 files changed, 132 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ede2817c11..20c295b6ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt index 8b8a84d482..4ac9bcc062 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/Bolt12PayButton.kt @@ -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) { +fun Bolt12PayButtonWithOffers( + offers: List, + accountViewModel: AccountViewModel, +) { var expanded by remember { mutableStateOf(false) } FilledTonalButton( @@ -107,6 +120,7 @@ fun Bolt12PayButtonWithOffers(offers: List) { if (expanded) { Bolt12OffersDialog( offers = offers, + accountViewModel = accountViewModel, onDismiss = { expanded = false }, ) } @@ -115,12 +129,20 @@ fun Bolt12PayButtonWithOffers(offers: List) { @Composable fun Bolt12OffersDialog( offers: List, + 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(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)) + } + } + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bd5521bde7..a86c147f8d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1725,6 +1725,10 @@ BOLT12 offer (lno1…) Not a valid BOLT12 offer Delete BOLT12 offer + Pay with connected wallet + Amount (sats) + BOLT12 payment sent + BOLT12 payment failed: %1$s Not Started Compressing