From 40ccffee35dc52dfae9108b7642e812bf94d75ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:29:31 +0000 Subject: [PATCH] feat: NIP-BC onchain zap send flow (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the Phase A.2 quartz send-side foundation into a working end-to-end send: build → sign → broadcast → publish kind:8333. commons/onchain/OnchainZapSender — stateless orchestrator. Loads the sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend, then publishes the kind:8333 receipt through an injected publish callback. Per-stage failures surface as OnchainZapSendResult.Failure (with the broadcast txid preserved when only the receipt publish failed) instead of throwing; CancellationException always propagates. Account.sendOnchainZap — thin wrapper binding the account's signer, the LocalCache.onchainBackend, and signAndComputeBroadcast into the orchestrator. amethyst wallet UI: - OnchainZapSendDialog — collects recipient npub (or a fixed recipient when launched from a note's zap menu), amount, fee tier (slow/normal/fast from the backend's fee estimates), and an optional comment; runs the send and shows progress + success/failure. - OnchainSection — the Bitcoin card on the wallet screen gains a "Send" button next to "Copy address" that opens the dialog for a profile zap. Tests: OnchainZapSenderTest covers the happy path (receipt references the broadcast txid, recipient, and amount), insufficient-funds failure at the build stage, broadcast failure (no txid leaked), and publish failure (txid preserved for retry). Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still throw UnsupportedMethodException; note-zap-menu entry point can reuse OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired. --- .../vitorpamplona/amethyst/model/Account.kt | 34 ++ .../screen/loggedIn/wallet/OnchainSection.kt | 21 +- .../loggedIn/wallet/OnchainZapSendDialog.kt | 313 ++++++++++++++++++ .../commons/onchain/OnchainZapSender.kt | 208 ++++++++++++ .../commons/onchain/OnchainZapSenderTest.kt | 178 ++++++++++ 5 files changed, 751 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt 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 396eee5ce..2d1721287 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,9 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListS import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction +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.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator @@ -754,6 +757,37 @@ class Account( return zapRequest } + /** + * 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 OnchainZapSendResult.Failure( + OnchainZapSendStage.LOADING_UTXOS, + "Bitcoin chain backend is not configured", + ) + return OnchainZapSender.send( + backend = backend, + signer = signer, + senderPubKey = signer.pubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + 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/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index 087c62556..49c804e26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme @@ -133,7 +134,7 @@ fun OnchainSection( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - CopyAddressRow(address) + ActionRow(address = address, accountViewModel = accountViewModel) } } } @@ -173,13 +174,17 @@ private fun BalanceRow( } @Composable -private fun CopyAddressRow(address: String) { +private fun ActionRow( + address: String, + accountViewModel: AccountViewModel, +) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + var showSendDialog by remember { mutableStateOf(false) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { OutlinedButton(onClick = { @@ -187,5 +192,15 @@ private fun CopyAddressRow(address: String) { }) { Text("Copy address") } + Button(onClick = { showSendDialog = true }) { + Text("Send") + } + } + + if (showSendDialog) { + OnchainZapSendDialog( + accountViewModel = accountViewModel, + onDismiss = { showSendDialog = false }, + ) } } 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 new file mode 100644 index 000000000..744c65498 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainZapSendDialog.kt @@ -0,0 +1,313 @@ +/* + * 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.ui.screen.loggedIn.wallet + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.compose.material3.ExperimentalMaterial3Api as ExpM3 + +private enum class FeeTier( + val label: String, +) { + SLOW("Slow"), + NORMAL("Normal"), + FAST("Fast"), +} + +private fun FeeEstimates.rateFor(tier: FeeTier): Double = + when (tier) { + FeeTier.SLOW -> slowSatPerVbyte + FeeTier.NORMAL -> normalSatPerVbyte + FeeTier.FAST -> fastSatPerVbyte + } + +/** + * Dialog that drives a NIP-BC onchain zap: collect recipient / amount / fee + * tier / comment, then run [com.vitorpamplona.amethyst.model.Account.sendOnchainZap] + * and show progress + the result. + * + * When [recipientPubKey] is null the user enters a recipient npub and the zap + * targets that profile. When provided (e.g. from a note's zap menu) the + * recipient is fixed and [zappedEvent] attributes the zap to that event. + */ +@OptIn(ExpM3::class) +@Composable +fun OnchainZapSendDialog( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + recipientPubKey: HexKey? = null, + zappedEvent: EventHintBundle? = null, +) { + val scope = rememberCoroutineScope() + + var npubInput by remember { mutableStateOf("") } + var amountInput by remember { mutableStateOf("") } + var comment by remember { mutableStateOf("") } + var feeTier by remember { mutableStateOf(FeeTier.NORMAL) } + var fees by remember { mutableStateOf(null) } + + var sending by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + + // Fetch recommended fee rates once when the dialog opens. + LaunchedEffect(Unit) { + val backend = LocalCache.onchainBackend ?: return@LaunchedEffect + fees = + runCatching { withContext(Dispatchers.IO) { backend.feeEstimates() } }.getOrNull() + } + + val resolvedRecipient: HexKey? = + recipientPubKey ?: npubInput.trim().takeIf { it.isNotEmpty() }?.let { decodePublicKeyAsHexOrNull(it) } + val amountSats = amountInput.trim().toLongOrNull() + val canSend = + !sending && + result == null && + resolvedRecipient != null && + amountSats != null && + amountSats > 0 && + fees != null + + AlertDialog( + onDismissRequest = { if (!sending) onDismiss() }, + title = { Text("Onchain zap") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + when (val r = result) { + is OnchainZapSendResult.Success -> { + SuccessBody(r) + } + + is OnchainZapSendResult.Failure -> { + FailureBody(r) + } + + null -> { + if (sending) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + Text(" Sending onchain zap…") + } + } else { + SendForm( + recipientPubKey = recipientPubKey, + npubInput = npubInput, + onNpubChange = { npubInput = it }, + amountInput = amountInput, + onAmountChange = { amountInput = it }, + comment = comment, + onCommentChange = { comment = it }, + feeTier = feeTier, + onFeeTierChange = { feeTier = it }, + fees = fees, + ) + } + } + } + } + }, + confirmButton = { + if (result != null) { + TextButton(onClick = onDismiss) { Text("Close") } + } else { + TextButton( + enabled = canSend, + onClick = { + val recipient = resolvedRecipient ?: return@TextButton + val amount = amountSats ?: return@TextButton + val feeRate = fees?.rateFor(feeTier) ?: return@TextButton + sending = true + scope.launch { + val r = + accountViewModel.account.sendOnchainZap( + recipientPubKey = recipient, + amountSats = amount, + feeRateSatPerVByte = feeRate, + comment = comment.trim(), + zappedEvent = zappedEvent, + ) + sending = false + result = r + } + }, + ) { + Text("Send") + } + } + }, + dismissButton = { + if (result == null) { + TextButton(onClick = onDismiss, enabled = !sending) { Text("Cancel") } + } + }, + ) +} + +@OptIn(ExpM3::class) +@Composable +private fun SendForm( + recipientPubKey: HexKey?, + npubInput: String, + onNpubChange: (String) -> Unit, + amountInput: String, + onAmountChange: (String) -> Unit, + comment: String, + onCommentChange: (String) -> Unit, + feeTier: FeeTier, + onFeeTierChange: (FeeTier) -> Unit, + fees: FeeEstimates?, +) { + if (recipientPubKey == null) { + OutlinedTextField( + value = npubInput, + onValueChange = onNpubChange, + label = { Text("Recipient npub") }, + singleLine = true, + isError = npubInput.isNotBlank() && decodePublicKeyAsHexOrNull(npubInput.trim()) == null, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text( + text = "Zapping the post author", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = amountInput, + onValueChange = { onAmountChange(it.filter(Char::isDigit)) }, + label = { Text("Amount (sats)") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = comment, + onValueChange = onCommentChange, + label = { Text("Comment (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + + Text( + text = "Fee priority", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FeeTier.entries.forEach { tier -> + val rate = fees?.rateFor(tier) + FilterChip( + selected = feeTier == tier, + onClick = { onFeeTierChange(tier) }, + label = { + Text( + if (rate != null) { + "${tier.label} · ${formatRate(rate)} sat/vB" + } else { + tier.label + }, + ) + }, + ) + } + } + if (fees == null) { + Text( + text = "Loading fee estimates…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SuccessBody(result: OnchainZapSendResult.Success) { + Text("Onchain zap sent.", style = MaterialTheme.typography.bodyLarge) + Text( + text = "Transaction: ${result.txid}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = + "Fee: ${result.feeSats} sats" + + if (result.changeSats > 0) " · change: ${result.changeSats} sats" else "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun FailureBody(result: OnchainZapSendResult.Failure) { + Text( + text = result.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + result.broadcastTxid?.let { + Text( + text = "The payment was broadcast (tx $it) but the receipt was not published.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "Failed at: ${result.stage.name.lowercase().replace('_', ' ')}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +private fun formatRate(rate: Double): String = if (rate == rate.toLong().toDouble()) rate.toLong().toString() else ((rate * 10).toLong() / 10.0).toString() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt new file mode 100644 index 000000000..7bba078c7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -0,0 +1,208 @@ +/* + * 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.commons.onchain + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import kotlin.coroutines.cancellation.CancellationException + +/** The stage a NIP-BC onchain zap send reached before it finished or failed. */ +enum class OnchainZapSendStage { + /** Querying the chain backend for the sender's spendable UTXOs. */ + LOADING_UTXOS, + + /** Selecting coins and assembling the unsigned PSBT. */ + BUILDING, + + /** Signing the PSBT inputs and finalizing the transaction. */ + SIGNING, + + /** Broadcasting the signed transaction to the network. */ + BROADCASTING, + + /** Publishing the kind:8333 zap receipt to relays. */ + PUBLISHING, +} + +/** Outcome of an [OnchainZapSender.send] attempt. */ +sealed interface OnchainZapSendResult { + /** + * The transaction was broadcast and the zap receipt was published. + * + * @property txid The broadcast Bitcoin transaction id. + * @property receiptEventId The id of the published kind:8333 event. + * @property feeSats Miner fee paid. + * @property changeSats Change returned to the sender (0 if none). + */ + data class Success( + val txid: String, + val receiptEventId: HexKey, + val feeSats: Long, + val changeSats: Long, + ) : OnchainZapSendResult + + /** + * The send failed at [stage]. The transaction was NOT broadcast unless + * [stage] is [OnchainZapSendStage.PUBLISHING], in which case the payment + * went through but the receipt could not be published. + */ + data class Failure( + val stage: OnchainZapSendStage, + val message: String, + val cause: Throwable? = null, + /** Non-null when the payment was broadcast but a later stage failed. */ + val broadcastTxid: String? = null, + ) : OnchainZapSendResult +} + +/** + * Orchestrates a NIP-BC onchain zap end to end: + * load UTXOs → build PSBT → sign → finalize → broadcast → publish kind:8333. + * + * Stateless and platform-agnostic — it takes the chain backend, the signer, + * and a publish callback, so the same pipeline works from the Android app, the + * CLI, or tests. Per-stage failures are reported as + * [OnchainZapSendResult.Failure] rather than thrown, except [CancellationException] + * which always propagates. + */ +object OnchainZapSender { + /** + * @param backend Chain data source (UTXOs + broadcast). + * @param signer The sender's signer; must support `signPsbt`. + * @param senderPubKey The sender's x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param comment Optional human-readable comment for the receipt's content. + * @param zappedEvent The event being zapped, or null for a profile zap. + * @param publish Publishes the signed kind:8333 receipt and returns the event. + */ + suspend fun send( + backend: OnchainBackend, + signer: NostrSigner, + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + comment: String, + zappedEvent: EventHintBundle?, + publish: suspend (EventTemplate) -> Event, + ): OnchainZapSendResult { + // 1. Load the sender's UTXOs. + val utxos = + try { + val address = TaprootAddress.fromPubKey(senderPubKey) + backend.getUtxosForAddress(address) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.LOADING_UTXOS, "Could not load your Bitcoin balance", e) + } + + // 2. Coin-select and assemble the unsigned PSBT. + val built = + try { + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = amountSats, + feeRateSatPerVByte = feeRateSatPerVByte, + availableUtxos = utxos, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e) + } + + // 3. Sign and finalize. + val rawTxHex = + try { + val signedHex = signer.signPsbt(built.psbt.toHex()) + val signedPsbt = Psbt.parse(signedHex) + if (!PsbtFinalizer.isFullySigned(signedPsbt)) { + return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + } + PsbtFinalizer.finalizeToHex(signedPsbt) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.SIGNING, e.message ?: "Could not sign the transaction", e) + } + + // 4. Broadcast. + val txid = + try { + backend.broadcast(rawTxHex) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return fail(OnchainZapSendStage.BROADCASTING, "Could not broadcast the transaction", e) + } + + // 5. Publish the kind:8333 receipt. The payment is already on-chain at + // this point, so a failure here keeps the txid for retry/diagnostics. + val receiptId = + try { + val template = + if (zappedEvent != null) { + OnchainZapEvent.build(txid, recipientPubKey, amountSats, zappedEvent, comment) + } else { + OnchainZapEvent.buildProfileZap(txid, recipientPubKey, amountSats, comment) + } + publish(template).id + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + return OnchainZapSendResult.Failure( + stage = OnchainZapSendStage.PUBLISHING, + message = "Payment sent, but the zap receipt could not be published", + cause = e, + broadcastTxid = txid, + ) + } + + return OnchainZapSendResult.Success( + txid = txid, + receiptEventId = receiptId, + feeSats = built.feeSats, + changeSats = built.changeSats, + ) + } + + private fun fail( + stage: OnchainZapSendStage, + message: String, + cause: Throwable? = null, + ) = OnchainZapSendResult.Failure(stage, message, cause) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt new file mode 100644 index 000000000..ac9cf016c --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -0,0 +1,178 @@ +/* + * 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.commons.onchain + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class OnchainZapSenderTest { + private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val recipientPriv = "000000000000000000000000000000000000000000000000000000000000000d" + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderSigner = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray())) + private val senderPubKey = xOnly(senderPriv) + private val recipientPubKey = xOnly(recipientPriv) + + /** Records the broadcast tx and hands back its real txid. */ + private class FakeBackend( + private val utxos: List, + private val broadcastFails: Boolean = false, + ) : OnchainBackend { + var broadcastedHex: String? = null + + override suspend fun getTx(txid: String): BitcoinTx? = null + + override suspend fun getUtxosForAddress(address: String): List = utxos + + override suspend fun broadcast(rawTxHex: String): String { + if (broadcastFails) throw RuntimeException("relay rejected tx") + broadcastedHex = rawTxHex + return BitcoinTransaction.parse(rawTxHex).txid() + } + + override suspend fun tipHeight(): Long = 800_000L + + override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0) + } + + @Test + fun profileZapSuccess() = + runTest { + val backend = FakeBackend(listOf(Utxo("1".repeat(64), 0, 250_000L, 6))) + var publishedTemplate: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate? = null + + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "thanks!", + zappedEvent = null, + ) { template -> + publishedTemplate = template + senderSigner.sign(template) + } + + assertIs(result) + assertTrue(result.feeSats > 0) + + // The broadcast transaction's txid must match what the receipt references. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.txid) + + // The published kind:8333 receipt must reference the same txid, recipient, amount. + val receipt = senderSigner.sign(publishedTemplate!!) + assertEquals(OnchainZapEvent.KIND, receipt.kind) + assertEquals(broadcastTxid, receipt.txid()) + assertEquals(recipientPubKey, receipt.recipient()) + assertEquals(50_000L, receipt.claimedAmountInSats()) + assertTrue(receipt.isProfileZap()) + } + + @Test + fun insufficientFundsFailsAtBuilding() = + runTest { + val backend = FakeBackend(listOf(Utxo("2".repeat(64), 0, 10_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 1_000_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BUILDING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun broadcastFailureKeepsNoTxid() = + runTest { + val backend = + FakeBackend(listOf(Utxo("3".repeat(64), 0, 250_000L, 6)), broadcastFails = true) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { senderSigner.sign(it) } + + assertIs(result) + assertEquals(OnchainZapSendStage.BROADCASTING, result.stage) + assertEquals(null, result.broadcastTxid) + } + + @Test + fun publishFailureReportsBroadcastTxid() = + runTest { + val backend = FakeBackend(listOf(Utxo("4".repeat(64), 0, 250_000L, 6))) + val result = + OnchainZapSender.send( + backend = backend, + signer = senderSigner, + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 50_000L, + feeRateSatPerVByte = 5.0, + comment = "", + zappedEvent = null, + ) { throw RuntimeException("relay down") } + + assertIs(result) + assertEquals(OnchainZapSendStage.PUBLISHING, result.stage) + // The payment went through — the txid is preserved for retry/diagnostics. + val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid() + assertEquals(broadcastTxid, result.broadcastTxid) + } +}