refactor: extract AccountZapActions from Account

Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.

Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
This commit is contained in:
Claude
2026-08-01 17:16:51 +00:00
parent d2c9593919
commit 1dba215d4d
15 changed files with 376 additions and 324 deletions
@@ -24,7 +24,6 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
@@ -60,11 +59,6 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -254,12 +248,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -319,8 +307,6 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
@@ -347,8 +333,6 @@ import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -726,6 +710,9 @@ class Account(
/** NIP-29 relay-group + Buzz workspace orchestration. */
val relayGroups = AccountRelayGroupActions(this)
/** Zap/payment orchestration (NIP-57, NWC, BOLT12, onchain). */
val zaps = AccountZapActions(this)
/**
* Relay routing + sign-and-publish choke point: computes which relays an event
* should go to (outbox model, hints, channels, broadcast lists) and owns every
@@ -1339,278 +1326,6 @@ class Account(
cache.justConsumeMyOwnEvent(event)
}
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
message: String = "",
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
signer = signer,
pollOption = pollOption,
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(userProfile(), afterTimeInSeconds, this) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
client.publish(event, setOf(relay))
}
/**
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = nip47SignerState.defaultWalletUri.value ?: return false
return nip47SignerState.infoCache?.current(uri)?.supportsMethod(NwcMethod.PAY) == true
}
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
* fails the binding check, so no invalid receipt is ever published (the payment
* 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.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
recipientPubKey: HexKey,
offer: String,
amountMillisats: Long,
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> 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 ->
scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
}
}
return
}
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else signer
val intent =
if (zappedEvent == null) {
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
} else {
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
}
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
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
// of vanishing as an uncaught coroutine exception. The payment already
// settled at this point, so such a failure means "paid, no receipt".
try {
when (response) {
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
cache.justConsumeMyOwnEvent(zap)
client.publish(zap, computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = signer,
senderPubKey = signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> signAndComputeBroadcast(template) }
}
suspend fun report(
note: Note,
type: ReportType,
@@ -0,0 +1,337 @@
/*
* 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.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
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.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
/**
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
* onchain zaps/sends. Event building lives in the commons ZapActions/
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
* routing to the account.
*/
class AccountZapActions(
private val account: Account,
) {
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
message: String = "",
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
account.client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
account.client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.client.publish(event, setOf(relay))
}
/**
* True when the default NWC wallet advertises the nwc#2 `pay` method the rail a
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = account.nip47SignerState.defaultWalletUri.value ?: return false
return account.nip47SignerState.infoCache
?.current(uri)
?.supportsMethod(NwcMethod.PAY) == true
}
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
* intent-bound `payer_note`, then only if the wallet returns a payer proof that
* validates builds, self-consumes, and publishes the kind 9736 zap. Validation
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
* fails the binding check, so no invalid receipt is ever published (the payment
* 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.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
recipientPubKey: HexKey,
offer: String,
amountMillisats: Long,
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> 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 ->
account.scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
}
}
return
}
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
val intent =
if (zappedEvent == null) {
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
} else {
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
}
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { 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
// of vanishing as an uncaught coroutine exception. The payment already
// settled at this point, so such a failure means "paid, no receipt".
try {
when (response) {
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
account.cache.justConsumeMyOwnEvent(zap)
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = account.signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
account.cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
}
@@ -279,7 +279,7 @@ class AccountNappletGateways(
}
val result = CompletableDeferred<String?>()
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
when (response) {
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
account.zaps.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.defaultWalletSupportsBolt12Pay()
account.zaps.defaultWalletSupportsBolt12Pay()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
return mapNotNullAsync(
items = payables,
runRequestFor = { payable: Payable ->
account.sendZapPaymentRequestFor(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
onResponse = { response ->
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
account.sendBolt12Zap(
account.zaps.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
offer = recipient.offer,
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}
@@ -103,7 +103,7 @@ class PollNoteViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0)
wasZappedByLoggedInAccount = account.zaps.calculateIfNoteWasZappedByAccount(pollNote, 0)
canZap.value = checkIfCanZap()
tallies.forEach {
@@ -150,7 +150,7 @@ fun GoalProgressBar(
LaunchedEffect(key1 = zapsState) {
zapsState?.note?.let {
val newZapAmount = accountViewModel.account.calculateZappedAmount(note)
val newZapAmount = accountViewModel.account.zaps.calculateZappedAmount(note)
var percentage = newZapAmount.div(goalAmountSats.toBigDecimal()).toFloat()
if (percentage > 1) percentage = 1f
@@ -843,7 +843,7 @@ class AccountViewModel(
afterTimeInSeconds: Long,
): Boolean =
withContext(Dispatchers.IO) {
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
account.zaps.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
}
suspend fun calculateZapAmount(zappedNote: Note): String {
@@ -854,7 +854,7 @@ class AccountViewModel(
val ownPendingOnchain = zappedNote.extraOwnPendingOnchainSats(account.userProfile().pubkeyHex)
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val nwc = account.calculateZappedAmount(zappedNote)
val nwc = account.zaps.calculateZappedAmount(zappedNote)
showAmount(nwc + java.math.BigDecimal(ownPendingOnchain))
}
} else {
@@ -866,7 +866,7 @@ class AccountViewModel(
val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0
return if (zappedNote.zapPayments.isNotEmpty()) {
withContext(Dispatchers.IO) {
val newZapAmount = account.calculateZappedAmount(zappedNote)
val newZapAmount = account.zaps.calculateZappedAmount(zappedNote)
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat()
if (percentage > 1) {
@@ -1202,7 +1202,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.defaultWalletSupportsBolt12Pay()
fun canPayBolt12ViaNwc(): Boolean = hasNwcWallet() && account.zaps.defaultWalletSupportsBolt12Pay()
/**
* Pays a recipient's BOLT12 [offer] over the default NWC wallet using the nwc#2
@@ -1214,7 +1214,7 @@ class AccountViewModel(
offer: String,
amountMillisats: Long,
) = launchSigner {
account.sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
account.zaps.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 ->
@@ -2745,7 +2745,7 @@ class AccountViewModel(
onSent: () -> Unit = {},
onResponse: (Response?) -> Unit,
) = launchSigner {
account.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
onSent()
}
@@ -2801,7 +2801,7 @@ class AccountViewModel(
if (effectiveZapType != LnZapEvent.ZapType.NONZAP) {
// NIP-57 Appendix F: include amount + lnurl so the receipt can be validated.
val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32)
account.createZapRequestFor(
account.zaps.createZapRequestFor(
user = user,
message = message,
zapType = effectiveZapType,
@@ -509,13 +509,13 @@ private fun SendPaymentLoaded(
if (onchainAddressTarget != null) {
// Pays the profile's announced bitcoin address directly —
// a plain wallet send, no NIP-BC receipt exists for it.
accountViewModel.account.sendOnchainToAddress(
accountViewModel.account.zaps.sendOnchainToAddress(
recipientAddress = onchainAddressTarget,
amountSats = amount,
feeRateSatPerVByte = feeRate,
)
} else {
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = user.pubkeyHex,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -425,7 +425,7 @@ fun OnchainZapSendDialog(
)
return@launch
}
accountViewModel.account.sendOnchainZapWithSplits(
accountViewModel.account.zaps.sendOnchainZapWithSplits(
recipients = shares,
feeRateSatPerVByte = feeRate,
comment = comment.trim(),
@@ -433,7 +433,7 @@ fun OnchainZapSendDialog(
)
} else {
val recipient = resolvedRecipient ?: return@launch
accountViewModel.account.sendOnchainZap(
accountViewModel.account.zaps.sendOnchainZap(
recipientPubKey = recipient,
amountSats = amount,
feeRateSatPerVByte = feeRate,
@@ -347,7 +347,7 @@ class ReloadMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -209,7 +209,7 @@ class TopUpMintViewModel : ViewModel() {
// Fire-and-forget: the mint-quote poll below is the source of truth for
// whether the payment actually landed.
runCatching {
vm.account.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
vm.account.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(flow.invoice)) { }
}
} else {
// No NWC — surface the invoice for an external wallet and keep polling.
@@ -222,7 +222,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
delay(NWC_TIMEOUT_MS)
val requestId = requestIdProvider()
val spoofs = requestId?.let { account?.nwcSpoofAttempts(it) ?: 0 } ?: 0
val spoofs = requestId?.let { account?.zaps?.nwcSpoofAttempts(it) ?: 0 } ?: 0
_error.value =
if (spoofs > 0) {
"Wallet request timed out — $spoofs ${if (spoofs == 1) "reply was" else "replies were"} rejected because " +
@@ -230,7 +230,7 @@ class WalletViewModel : ViewModel() {
} else {
"Wallet request timed out"
}
requestId?.let { account?.cleanupNwcRequest(it) }
requestId?.let { account?.zaps?.cleanupNwcRequest(it) }
onTimeout()
}
@@ -406,7 +406,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
updateWalletInfo(walletId) { it.copy(isLoading = true, error = null) }
try {
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
when (response) {
is GetBalanceSuccessResponse -> {
val sats = (response.result?.balance ?: 0L) / 1000L
@@ -437,7 +437,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
updateWalletInfo(walletId) { it.copy(alias = response.result?.alias) }
@@ -479,7 +479,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetBalanceMethod.create()) { response ->
timeoutJob.cancel()
when (response) {
is GetBalanceSuccessResponse -> {
@@ -512,7 +512,7 @@ class WalletViewModel : ViewModel() {
val walletUri = getWalletUri(walletId) ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
_walletAlias.value = response.result?.alias
@@ -538,7 +538,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoading.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -591,7 +591,7 @@ class WalletViewModel : ViewModel() {
val timeoutJob = launchTimeout({ requestId }) { _isLoadingMore.value = false }
try {
requestId =
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
ListTransactionsMethod.create(
limit = pageSize,
@@ -638,7 +638,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_sendState.value = SendState.Sending
try {
acc.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
acc.zaps.sendNwcRequestToWallet(walletUri, PayInvoiceMethod.create(bolt11)) { response ->
when (response) {
is PayInvoiceSuccessResponse -> {
_sendState.value = SendState.Success(response.result?.preimage)
@@ -676,7 +676,7 @@ class WalletViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.IO) {
_receiveState.value = ReceiveState.Creating
try {
acc.sendNwcRequestToWallet(
acc.zaps.sendNwcRequestToWallet(
walletUri,
MakeInvoiceMethod.create(
amount = amountSats * 1000L,
@@ -1467,7 +1467,7 @@ class AmethystAppFunctions {
}
val result =
account.sendOnchainZap(
account.zaps.sendOnchainZap(
recipientPubKey = recipientPub,
amountSats = sats,
feeRateSatPerVByte = feeRateSatPerVByte,
@@ -1751,7 +1751,7 @@ class AmethystAppFunctions {
val deferred = CompletableDeferred<Response?>()
// sendZapPaymentRequestFor fires onResponse exactly once when the wallet replies
// (success, error, or NwcError). On timeout we discard the late response.
account.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(bolt11, zappedNote) { response ->
if (!deferred.isCompleted) deferred.complete(response)
}
val response =