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:
@@ -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,
|
||||
|
||||
+18
-3
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+313
@@ -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()
|
||||
+208
@@ -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)
|
||||
}
|
||||
+178
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user