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()
|
||||
Reference in New Issue
Block a user