diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d9c5c66860..70afe7bbfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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? = 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? = 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, - feeRateSatPerVByte: Double, - comment: String = "", - zappedEvent: EventHintBundle? = 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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt new file mode 100644 index 0000000000..1c9f6ae8bf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountZapActions.kt @@ -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? = 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? = 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, + feeRateSatPerVByte: Double, + comment: String = "", + zappedEvent: EventHintBundle? = 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) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index e7fd4f0d67..51f482ba94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -279,7 +279,7 @@ class AccountNappletGateways( } val result = CompletableDeferred() - 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.")) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt index 6fa95cfe3f..3f7657ca83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 6fa29d1b5e..0d5ad13cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt index 5deaa6928f..7bcd89d292 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -166,7 +166,7 @@ object BlossomPaymentHandler { val preimageResult = CompletableDeferred() 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt index 77d435e736..f8546909b0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt index c3e621001b..005e1ba19e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Goal.kt @@ -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 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 3a24635536..7963b56c27 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 @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt index d9a2fc3da0..629f595cb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/SendPaymentScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt index f5c2947bc5..51ca681e33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt index 51db1320fc..c887fd980a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/ReloadMintViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt index cd6e902cfa..6fde099208 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TopUpMintViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index e988daeed4..15e1150248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -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, diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt index 717c519036..f02f086b07 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/appfunctions/AmethystAppFunctions.kt @@ -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() // 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 =