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:
+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