fix(onchain-zaps): recover the OnchainZapBuilder package + re-audit fixes

The big one: the source package was named `nipBCOnchainZaps/build/`, which
the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt
(the main source, not just its test) was NEVER committed. The pushed branch
did not compile; the pre-commit hook only checks the working tree, so this
went unnoticed. Renamed the package `build` -> `builder` (a source package
must never be named `build`) and updated all references; the recovered files
are now actually tracked.

Re-audit findings on the previous fix commit:
- CachingOnchainBackend.txCache was unbounded -> memory leak in a long
  session. Added a bounded cache (maxCachedTxs, oldest-entry eviction).
- OnchainZapSender still trusted the signer's returned PSBT for witness
  UTXOs and tap internal keys (used to compute the sighash + the verified
  output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto
  the PSBT we built, so a signer can contribute signatures and nothing else;
  verification and finalization run entirely on our own PSBT.

Test coverage added:
- OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded
  by default, spendable only with allowUnconfirmed, confirmed preferred.
- CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed
  re-fetched after TTL, not-found never cached, tip/fee TTL, bounded
  eviction.

All quartz / commons jvmTest suites pass; quartz android + amethyst compile.
This commit is contained in:
Claude
2026-05-14 13:55:18 +00:00
parent 1d7be1d444
commit db6254872e
8 changed files with 604 additions and 17 deletions
@@ -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) {
@@ -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
@@ -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<Utxo>,
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<Utxo>,
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<Utxo>()
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<TxOut>(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")
@@ -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<T>(
@@ -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
}
@@ -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
@@ -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<InsufficientFundsException> {
OnchainZapBuilder.build(senderPubKey, recipientPubKey, 1_000_000L, 5.0, utxos)
}
}
@Test
fun rejectsSelfZap() {
assertFailsWith<IllegalArgumentException> {
OnchainZapBuilder.build(senderPubKey, senderPubKey, 25_000L, 5.0, listOf(utxo(100_000L, 1)))
}
}
@Test
fun rejectsDustAmount() {
assertFailsWith<IllegalArgumentException> {
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<InsufficientFundsException> {
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")
}
}
@@ -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<Utxo> = 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")
}
}
@@ -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