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.nip30CustomEmojis.EmojiPackState
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
|
import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusAction
|
||||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
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.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.logTime
|
import com.vitorpamplona.amethyst.logTime
|
||||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||||
@@ -754,6 +757,37 @@ class Account(
|
|||||||
return zapRequest
|
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(
|
suspend fun report(
|
||||||
note: Note,
|
note: Note,
|
||||||
type: ReportType,
|
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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -133,7 +134,7 @@ fun OnchainSection(
|
|||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
CopyAddressRow(address)
|
ActionRow(address = address, accountViewModel = accountViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,13 +174,17 @@ private fun BalanceRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CopyAddressRow(address: String) {
|
private fun ActionRow(
|
||||||
|
address: String,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
val clipboard = LocalClipboard.current
|
val clipboard = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showSendDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = {
|
OutlinedButton(onClick = {
|
||||||
@@ -187,5 +192,15 @@ private fun CopyAddressRow(address: String) {
|
|||||||
}) {
|
}) {
|
||||||
Text("Copy address")
|
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