feat: NIP-BC onchain zap send flow (Phase D)

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.
This commit is contained in:
Claude
2026-05-14 12:29:31 +00:00
parent f5e7240425
commit 40ccffee35
5 changed files with 751 additions and 3 deletions
@@ -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<out Event>? = 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,
@@ -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 },
)
}
}
@@ -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<out Event>? = 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<FeeEstimates?>(null) }
var sending by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<OnchainZapSendResult?>(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()
@@ -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<out Event>?,
publish: suspend (EventTemplate<OnchainZapEvent>) -> 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)
}
@@ -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<Utxo>,
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<Utxo> = 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<OnchainZapEvent>? = 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<OnchainZapSendResult.Success>(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<OnchainZapEvent>(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<OnchainZapSendResult.Failure>(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<OnchainZapSendResult.Failure>(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<OnchainZapSendResult.Failure>(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)
}
}