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:
+22
-12
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+220
@@ -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")
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+199
@@ -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")
|
||||
}
|
||||
}
|
||||
+150
@@ -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")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user