diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt index 55cde865e..2b57828fc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSender.kt @@ -25,11 +25,13 @@ 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.builder.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.psbt.PsbtSignatureVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.inputTapKeySig +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapKeySig import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlin.coroutines.cancellation.CancellationException @@ -149,10 +151,9 @@ object OnchainZapSender { val signedHex = signer.signPsbt(built.psbt.toHex()) val signedPsbt = Psbt.parse(signedHex) - // Fund-safety: the signer must ONLY add signatures. If it returns a - // different transaction (with valid signatures over IT), finalizing - // and broadcasting would send funds wherever that tx says. Reject - // anything whose unsigned transaction isn't byte-identical to ours. + // Fund-safety: the signer must ONLY contribute signatures. First + // reject anything whose unsigned transaction isn't byte-identical + // to ours — that gives a clear error for the substitution attack. val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX) if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) { @@ -161,21 +162,30 @@ object OnchainZapSender { "The signer returned a different transaction than the one it was asked to sign", ) } - if (!PsbtFinalizer.isFullySigned(signedPsbt)) { - return fail( - OnchainZapSendStage.SIGNING, - "The signer did not sign every input", - ) + + // Copy ONLY the signatures back onto the PSBT we built. Everything + // else used downstream (witness UTXOs, tap internal keys) stays the + // values WE chose, so a signer can never influence the sighash, the + // verified output keys, or where funds go. + built.psbt.unsignedTx.inputs.indices.forEach { i -> + val sig = + signedPsbt.inputTapKeySig(i) + ?: return fail( + OnchainZapSendStage.SIGNING, + "The signer did not sign every input", + ) + built.psbt.setInputTapKeySig(i, sig) } + // Verify every signature is actually valid before money moves — // catches a broken signer up front instead of a doomed broadcast. - if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt)) { + if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(built.psbt)) { return fail( OnchainZapSendStage.SIGNING, "The signed transaction has invalid signatures", ) } - PsbtFinalizer.finalizeToHex(signedPsbt) + PsbtFinalizer.finalizeToHex(built.psbt) } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt index 397ccfac3..3f00933cd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/onchain/OnchainZapSenderTest.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.BitcoinTx import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt new file mode 100644 index 000000000..64d969cdd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilder.kt @@ -0,0 +1,220 @@ +/* + * 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.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.OutPoint +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxIn +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setInputWitnessUtxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.setOutputTapInternalKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlin.math.ceil + +/** + * Assembles the unsigned [Psbt] for a NIP-BC onchain zap. + * + * The sender's whole "wallet" is the single Taproot address derived from their + * Nostr pubkey, so every input spends from — and any change returns to — that + * one address. The recipient output pays the recipient's derived Taproot + * address. + * + * Coin selection is a simple largest-first greedy fill: correct and + * predictable, not privacy- or fee-optimal. The result is an unsigned PSBT + * with `PSBT_IN_WITNESS_UTXO` and `PSBT_IN_TAP_INTERNAL_KEY` populated on + * every input, ready for `NostrSigner.signPsbt`. + */ +object OnchainZapBuilder { + /** P2TR outputs below this are unspendable dust and must not be created. */ + const val DUST_THRESHOLD_SATS = 330L + + /** + * nSequence that opts the transaction into BIP-125 replace-by-fee, so a + * zap stuck at a low fee rate can be bumped instead of being stuck forever. + */ + const val RBF_SEQUENCE = 0xFFFFFFFDL + + // Virtual-size estimates for an all-P2TR-key-path transaction. + private const val OVERHEAD_VBYTES = 10.5 + private const val P2TR_INPUT_VBYTES = 57.5 + private const val P2TR_OUTPUT_VBYTES = 43.0 + + /** + * @property psbt The unsigned PSBT, ready to sign. + * @property selectedUtxos The UTXOs chosen as inputs. + * @property recipientSats Amount paid to the recipient. + * @property changeSats Amount returned to the sender (0 if no change output). + * @property feeSats The miner fee. + */ + data class Result( + val psbt: Psbt, + val selectedUtxos: List, + val recipientSats: Long, + val changeSats: Long, + val feeSats: Long, + ) + + fun estimateVsize( + inputCount: Int, + outputCount: Int, + ): Double = OVERHEAD_VBYTES + inputCount * P2TR_INPUT_VBYTES + outputCount * P2TR_OUTPUT_VBYTES + + fun estimateFee( + inputCount: Int, + outputCount: Int, + feeRateSatPerVByte: Double, + ): Long = ceil(estimateVsize(inputCount, outputCount) * feeRateSatPerVByte).toLong() + + /** + * Build the unsigned onchain-zap PSBT. + * + * @param senderPubKey The sender's 32-byte x-only Nostr pubkey (hex). + * @param recipientPubKey The recipient's 32-byte x-only Nostr pubkey (hex). + * @param amountSats Amount to pay the recipient. + * @param feeRateSatPerVByte Target fee rate. + * @param availableUtxos UTXOs spendable from the sender's Taproot address. + * @param allowUnconfirmed When false (the default), 0-confirmation UTXOs are + * excluded — chaining off an unconfirmed parent risks the whole zap + * being invalidated if that parent is dropped or replaced. + * @throws InsufficientFundsException when the UTXOs can't cover amount + fee. + */ + fun build( + senderPubKey: HexKey, + recipientPubKey: HexKey, + amountSats: Long, + feeRateSatPerVByte: Double, + availableUtxos: List, + allowUnconfirmed: Boolean = false, + ): Result { + require(amountSats > 0) { "amount must be positive" } + require(amountSats >= DUST_THRESHOLD_SATS) { "amount is below the dust threshold" } + require(feeRateSatPerVByte > 0) { "fee rate must be positive" } + require(senderPubKey != recipientPubKey) { "cannot zap yourself" } + + val senderXOnly = senderPubKey.hexToByteArray() + require(senderXOnly.size == 32) { "sender pubkey must be 32 bytes" } + val senderScript = TaprootAddress.scriptPubKeyForRecipient(senderPubKey) + val recipientScript = TaprootAddress.scriptPubKeyForRecipient(recipientPubKey) + + // Only spend confirmed UTXOs unless the caller explicitly opts in. + val spendableUtxos = + if (allowUnconfirmed) availableUtxos else availableUtxos.filter { it.confirmations > 0 } + + // Largest-first greedy selection. + val sorted = spendableUtxos.sortedByDescending { it.valueSats } + val selected = ArrayList() + var selectedSum = 0L + var cursor = 0 + + while (true) { + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeWithChange) break + + if (cursor >= sorted.size) { + // Last chance: maybe it fits without a change output. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + if (selected.isNotEmpty() && selectedSum >= amountSats + feeNoChange) break + throw InsufficientFundsException( + needed = amountSats + estimateFee(selected.size.coerceAtLeast(1), 2, feeRateSatPerVByte), + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + selected.add(sorted[cursor]) + selectedSum += sorted[cursor].valueSats + cursor++ + } + + // Decide whether a change output is worth creating. + val feeWithChange = estimateFee(selected.size, 2, feeRateSatPerVByte) + val candidateChange = selectedSum - amountSats - feeWithChange + + val feeSats: Long + val changeSats: Long + if (candidateChange >= DUST_THRESHOLD_SATS) { + feeSats = feeWithChange + changeSats = candidateChange + } else { + // Drop the change output; the leftover (dust + would-be change) is + // absorbed into the fee. + val feeNoChange = estimateFee(selected.size, 1, feeRateSatPerVByte) + val leftover = selectedSum - amountSats + if (leftover < feeNoChange) { + throw InsufficientFundsException( + needed = amountSats + feeNoChange, + available = spendableUtxos.sumOf { it.valueSats }, + ) + } + feeSats = leftover + changeSats = 0L + } + + // Assemble the unsigned transaction. + val inputs = + selected.map { utxo -> + TxIn( + outPoint = OutPoint(utxo.txid, utxo.vout.toLong()), + scriptSig = ByteArray(0), + sequence = RBF_SEQUENCE, + ) + } + val outputs = ArrayList(2) + outputs.add(TxOut(amountSats, recipientScript)) + if (changeSats > 0) { + outputs.add(TxOut(changeSats, senderScript)) + } + + val tx = BitcoinTransaction(version = 2L, inputs = inputs, outputs = outputs, lockTime = 0L) + + // Wrap into a PSBT and populate the signing metadata. + val psbt = Psbt.fromUnsignedTx(tx) + selected.forEachIndexed { i, utxo -> + psbt.setInputWitnessUtxo(i, TxOut(utxo.valueSats, senderScript)) + psbt.setInputTapInternalKey(i, senderXOnly) + } + if (changeSats > 0) { + psbt.setOutputTapInternalKey(1, senderXOnly) + } + + return Result( + psbt = psbt, + selectedUtxos = selected, + recipientSats = amountSats, + changeSats = changeSats, + feeSats = feeSats, + ) + } +} + +/** + * Thrown when the available UTXOs cannot cover the requested amount plus fee. + * + * @property needed Total satoshis required (amount + estimated fee). + * @property available Total satoshis available across all UTXOs. + */ +class InsufficientFundsException( + val needed: Long, + val available: Long, +) : RuntimeException("Insufficient funds: need $needed sats, have $available sats") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt index 077bb3bdb..3ca48b80e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackend.kt @@ -32,7 +32,8 @@ import kotlinx.coroutines.sync.withLock * - `getTx`: a **confirmed** transaction is immutable, so it's cached * indefinitely; an unconfirmed one is cached only briefly (its confirmation * status will change). `null` (not-found) is never cached — the tx may - * appear later. + * appear later. The tx cache is bounded ([maxCachedTxs]); when full, the + * oldest entry is evicted so a long session can't leak memory. * - `tipHeight` / `feeEstimates`: cached with a short TTL. * - `getUtxosForAddress`: never cached — wallet balance must be fresh. * - `broadcast`: never cached. @@ -46,6 +47,7 @@ class CachingOnchainBackend( private val unconfirmedTxTtlSeconds: Long = 60, private val tipHeightTtlSeconds: Long = 60, private val feeEstimatesTtlSeconds: Long = 60, + private val maxCachedTxs: Int = 512, private val nowSeconds: () -> Long = { TimeUtils.now() }, ) : OnchainBackend { private class Stamped( @@ -71,7 +73,13 @@ class CachingOnchainBackend( val fetched = delegate.getTx(txid) ?: return null - mutex.withLock { txCache[txid] = Stamped(fetched, nowSeconds()) } + mutex.withLock { + if (txCache.size >= maxCachedTxs && !txCache.containsKey(txid)) { + // Evict the oldest entry to keep the cache bounded. + txCache.minByOrNull { it.value.fetchedAt }?.key?.let { txCache.remove(it) } + } + txCache[txid] = Stamped(fetched, nowSeconds()) + } return fetched } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt index 0a1aa8a16..776b99b10 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSignerPsbtTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.signers 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.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt new file mode 100644 index 000000000..3de8ede56 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/builder/OnchainZapBuilderTest.kt @@ -0,0 +1,199 @@ +/* + * 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.quartz.nipBCOnchainZaps.builder + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TaprootSigHash +import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.TxOut +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class OnchainZapBuilderTest { + private val senderPrivKey = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray() + private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPrivKey).copyOfRange(1, 33).toHexKey() + private val recipientPubKey = + Secp256k1Instance + .compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + private val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(senderPubKey).lowercase() + private val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase() + + private fun utxo( + valueSats: Long, + index: Int, + confirmations: Int = 6, + ) = Utxo(txid = index.toString().padStart(64, '0'), vout = 0, valueSats = valueSats, confirmations = confirmations) + + @Test + fun buildsZapWithChangeAndBalances() { + val utxos = listOf(utxo(100_000L, 1), utxo(50_000L, 2)) + val result = + OnchainZapBuilder.build( + senderPubKey = senderPubKey, + recipientPubKey = recipientPubKey, + amountSats = 25_000L, + feeRateSatPerVByte = 5.0, + availableUtxos = utxos, + ) + + // One UTXO of 100k covers 25k + change + fee — greedy picks the largest first. + assertEquals(1, result.selectedUtxos.size) + assertEquals(25_000L, result.recipientSats) + assertTrue(result.changeSats > 0, "should have a change output") + assertTrue(result.feeSats > 0) + + // inputs == outputs + fee + val inputSum = result.selectedUtxos.sumOf { it.valueSats } + assertEquals(inputSum, result.recipientSats + result.changeSats + result.feeSats) + + val tx = result.psbt.unsignedTx + assertEquals(2, tx.outputs.size) + assertEquals(25_000L, tx.outputs[0].valueSats) + assertEquals(recipientScriptHex, tx.outputs[0].scriptPubKey.toHexKey()) + assertEquals(result.changeSats, tx.outputs[1].valueSats) + assertEquals(senderScriptHex, tx.outputs[1].scriptPubKey.toHexKey()) + } + + @Test + fun signedTransactionVerifies() { + val utxos = listOf(utxo(200_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 40_000L, 8.0, utxos) + + val signed = PsbtSigner.signKeyPathInputs(result.psbt, senderPrivKey) + assertEquals(result.selectedUtxos.size, signed) + assertTrue(PsbtFinalizer.isFullySigned(result.psbt)) + + val finalTx = PsbtFinalizer.finalize(result.psbt) + + // Every input's witness signature must verify against the sender's + // taproot output key over the BIP-341 sighash. + val senderOutputKey = TaprootAddress.tweakOutputKey(senderPubKey.hexToByteArray()) + val spentOutputs = result.selectedUtxos.map { TxOut(it.valueSats, senderScriptHex.hexToByteArray()) } + finalTx.inputs.forEachIndexed { i, input -> + assertEquals(1, input.witness.size) + val sigHash = TaprootSigHash.compute(finalTx, i, spentOutputs, TaprootSigHash.SIGHASH_DEFAULT) + assertTrue( + Secp256k1Instance.verifySchnorr(input.witness[0], sigHash, senderOutputKey), + "input $i signature must verify", + ) + } + + // Broadcast hex parses back to the same txid. + val rebroadcast = + com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction + .parse(PsbtFinalizer.finalizeToHex(result.psbt)) + assertEquals(finalTx.txid(), rebroadcast.txid()) + } + + @Test + fun dropsChangeWhenItWouldBeDust() { + // 30_000 utxo, pay 29_800: after the ~154-sat with-change fee the + // leftover change (~46 sats) is below the 330-sat dust threshold, so + // the builder must fold it into the fee instead of creating dust. + val utxos = listOf(utxo(30_000L, 1)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 29_800L, 1.0, utxos) + assertEquals(0L, result.changeSats, "tiny leftover must be absorbed into the fee") + assertEquals(1, result.psbt.unsignedTx.outputs.size, "no change output") + assertEquals(30_000L, result.recipientSats + result.feeSats) + } + + @Test + fun combinesMultipleUtxosWhenNeeded() { + val utxos = listOf(utxo(20_000L, 1), utxo(20_000L, 2), utxo(20_000L, 3)) + val result = + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 45_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.size >= 3, "needs all three 20k UTXOs to cover 45k + fee") + } + + @Test + fun throwsOnInsufficientFunds() { + val utxos = listOf(utxo(10_000L, 1)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 1_000_000L, 5.0, utxos) + } + } + + @Test + fun rejectsSelfZap() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, senderPubKey, 25_000L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun rejectsDustAmount() { + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 100L, 5.0, listOf(utxo(100_000L, 1))) + } + } + + @Test + fun excludesUnconfirmedUtxosByDefault() { + // Only a 0-conf UTXO is available — by default it must not be spent, + // so there are effectively no funds. + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + assertFailsWith { + OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 5.0, unconfirmed) + } + } + + @Test + fun spendsUnconfirmedUtxosOnlyWhenOptedIn() { + val unconfirmed = listOf(utxo(250_000L, 1, confirmations = 0)) + val result = + OnchainZapBuilder.build( + senderPubKey, + recipientPubKey, + 25_000L, + 5.0, + unconfirmed, + allowUnconfirmed = true, + ) + assertEquals(1, result.selectedUtxos.size) + assertEquals(0, result.selectedUtxos[0].confirmations) + } + + @Test + fun prefersConfirmedUtxosAndSkipsUnconfirmedOnes() { + // A large unconfirmed UTXO is ignored; the build falls back to the + // smaller confirmed ones. + val utxos = + listOf( + utxo(1_000_000L, 1, confirmations = 0), + utxo(30_000L, 2, confirmations = 3), + utxo(30_000L, 3, confirmations = 3), + ) + val result = OnchainZapBuilder.build(senderPubKey, recipientPubKey, 25_000L, 2.0, utxos) + assertTrue(result.selectedUtxos.all { it.confirmations > 0 }, "must not select the 0-conf UTXO") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt new file mode 100644 index 000000000..e7ca67e7c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/chain/CachingOnchainBackendTest.kt @@ -0,0 +1,150 @@ +/* + * 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.quartz.nipBCOnchainZaps.chain + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CachingOnchainBackendTest { + /** Counts every delegate call so the cache's hit/miss behaviour is observable. */ + private class CountingBackend( + var txByTxid: (String) -> BitcoinTx? = { null }, + ) : OnchainBackend { + var getTxCalls = 0 + var tipCalls = 0 + var feeCalls = 0 + + override suspend fun getTx(txid: String): BitcoinTx? { + getTxCalls++ + return txByTxid(txid) + } + + override suspend fun getUtxosForAddress(address: String): List = emptyList() + + override suspend fun broadcast(rawTxHex: String): String = "broadcast" + + override suspend fun tipHeight(): Long { + tipCalls++ + return 800_000L + } + + override suspend fun feeEstimates(): FeeEstimates { + feeCalls++ + return FeeEstimates(20.0, 10.0, 5.0) + } + } + + private fun confirmedTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 1, blockHeight = 799_000L) + + private fun mempoolTx(txid: String) = BitcoinTx(txid = txid, outputs = emptyList(), confirmations = 0) + + @Test + fun confirmedTransactionIsCachedIndefinitely() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, nowSeconds = { clock }) + + cache.getTx("aa") + clock += 100_000 // way past any TTL + cache.getTx("aa") + + assertEquals(1, delegate.getTxCalls, "a confirmed tx must be served from cache forever") + } + + @Test + fun unconfirmedTransactionIsRefetchedAfterTtl() = + runTest { + val delegate = CountingBackend { mempoolTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, unconfirmedTxTtlSeconds = 60, nowSeconds = { clock }) + + cache.getTx("bb") + clock += 30 // within TTL + cache.getTx("bb") + assertEquals(1, delegate.getTxCalls, "still fresh — served from cache") + + clock += 60 // now past TTL + cache.getTx("bb") + assertEquals(2, delegate.getTxCalls, "stale unconfirmed tx must be re-fetched") + } + + @Test + fun notFoundIsNeverCached() = + runTest { + val delegate = CountingBackend { null } + val cache = CachingOnchainBackend(delegate, nowSeconds = { 1_000L }) + + assertNull(cache.getTx("cc")) + assertNull(cache.getTx("cc")) + assertEquals(2, delegate.getTxCalls, "a not-found result must not be cached") + } + + @Test + fun tipHeightAndFeesAreCachedWithinTtl() = + runTest { + val delegate = CountingBackend() + var clock = 1_000L + val cache = + CachingOnchainBackend( + delegate, + tipHeightTtlSeconds = 60, + feeEstimatesTtlSeconds = 60, + nowSeconds = { clock }, + ) + + cache.tipHeight() + cache.feeEstimates() + cache.tipHeight() + cache.feeEstimates() + assertEquals(1, delegate.tipCalls) + assertEquals(1, delegate.feeCalls) + + clock += 61 + cache.tipHeight() + cache.feeEstimates() + assertEquals(2, delegate.tipCalls, "tip height must be re-fetched after its TTL") + assertEquals(2, delegate.feeCalls, "fee estimates must be re-fetched after its TTL") + } + + @Test + fun txCacheIsBoundedAndEvictsTheOldest() = + runTest { + val delegate = CountingBackend { confirmedTx(it) } + var clock = 1_000L + val cache = CachingOnchainBackend(delegate, maxCachedTxs = 2, nowSeconds = { clock }) + + cache.getTx("t1") // fetched at 1000 + clock += 1 + cache.getTx("t2") // fetched at 1001 + clock += 1 + cache.getTx("t3") // fetched at 1002 → cache full (2), evicts oldest (t1) + + // t1 was evicted → re-fetch. + cache.getTx("t1") + // t3 is still cached → no re-fetch. + cache.getTx("t3") + + assertEquals(4, delegate.getTxCalls, "t1/t2/t3 + a re-fetch of evicted t1") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt index e53f2a040..767e54bc6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBCOnchainZaps/psbt/PsbtSignatureVerifierTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nipBCOnchainZaps.psbt import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nipBCOnchainZaps.build.OnchainZapBuilder +import com.vitorpamplona.quartz.nipBCOnchainZaps.builder.OnchainZapBuilder import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlin.test.Test