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.hints.EventHintBundle
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
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.chain.OnchainBackend
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSignatureVerifier
|
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.taproot.TaprootAddress
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
@@ -149,10 +151,9 @@ object OnchainZapSender {
|
|||||||
val signedHex = signer.signPsbt(built.psbt.toHex())
|
val signedHex = signer.signPsbt(built.psbt.toHex())
|
||||||
val signedPsbt = Psbt.parse(signedHex)
|
val signedPsbt = Psbt.parse(signedHex)
|
||||||
|
|
||||||
// Fund-safety: the signer must ONLY add signatures. If it returns a
|
// Fund-safety: the signer must ONLY contribute signatures. First
|
||||||
// different transaction (with valid signatures over IT), finalizing
|
// reject anything whose unsigned transaction isn't byte-identical
|
||||||
// and broadcasting would send funds wherever that tx says. Reject
|
// to ours — that gives a clear error for the substitution attack.
|
||||||
// anything whose unsigned transaction isn't byte-identical to ours.
|
|
||||||
val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX)
|
val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX)
|
||||||
val returnedTx = signedPsbt.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)) {
|
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",
|
"The signer returned a different transaction than the one it was asked to sign",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!PsbtFinalizer.isFullySigned(signedPsbt)) {
|
|
||||||
return fail(
|
// Copy ONLY the signatures back onto the PSBT we built. Everything
|
||||||
OnchainZapSendStage.SIGNING,
|
// else used downstream (witness UTXOs, tap internal keys) stays the
|
||||||
"The signer did not sign every input",
|
// 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 —
|
// Verify every signature is actually valid before money moves —
|
||||||
// catches a broken signer up front instead of a doomed broadcast.
|
// catches a broken signer up front instead of a doomed broadcast.
|
||||||
if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt)) {
|
if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(built.psbt)) {
|
||||||
return fail(
|
return fail(
|
||||||
OnchainZapSendStage.SIGNING,
|
OnchainZapSendStage.SIGNING,
|
||||||
"The signed transaction has invalid signatures",
|
"The signed transaction has invalid signatures",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PsbtFinalizer.finalizeToHex(signedPsbt)
|
PsbtFinalizer.finalizeToHex(built.psbt)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Throwable) {
|
} 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.nip01Core.signers.NostrSignerInternal
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
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.BitcoinTx
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.FeeEstimates
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend
|
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
|
* - `getTx`: a **confirmed** transaction is immutable, so it's cached
|
||||||
* indefinitely; an unconfirmed one is cached only briefly (its confirmation
|
* indefinitely; an unconfirmed one is cached only briefly (its confirmation
|
||||||
* status will change). `null` (not-found) is never cached — the tx may
|
* 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.
|
* - `tipHeight` / `feeEstimates`: cached with a short TTL.
|
||||||
* - `getUtxosForAddress`: never cached — wallet balance must be fresh.
|
* - `getUtxosForAddress`: never cached — wallet balance must be fresh.
|
||||||
* - `broadcast`: never cached.
|
* - `broadcast`: never cached.
|
||||||
@@ -46,6 +47,7 @@ class CachingOnchainBackend(
|
|||||||
private val unconfirmedTxTtlSeconds: Long = 60,
|
private val unconfirmedTxTtlSeconds: Long = 60,
|
||||||
private val tipHeightTtlSeconds: Long = 60,
|
private val tipHeightTtlSeconds: Long = 60,
|
||||||
private val feeEstimatesTtlSeconds: Long = 60,
|
private val feeEstimatesTtlSeconds: Long = 60,
|
||||||
|
private val maxCachedTxs: Int = 512,
|
||||||
private val nowSeconds: () -> Long = { TimeUtils.now() },
|
private val nowSeconds: () -> Long = { TimeUtils.now() },
|
||||||
) : OnchainBackend {
|
) : OnchainBackend {
|
||||||
private class Stamped<T>(
|
private class Stamped<T>(
|
||||||
@@ -71,7 +73,13 @@ class CachingOnchainBackend(
|
|||||||
|
|
||||||
val fetched = delegate.getTx(txid) ?: return null
|
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
|
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.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
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.chain.Utxo
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.Psbt
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtFinalizer
|
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.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
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.nipBCOnchainZaps.chain.Utxo
|
||||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
|
|||||||
Reference in New Issue
Block a user