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
@@ -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)
}
}