fix(onchain-zaps): address audit findings — fund-safety, interop, robustness
CRITICAL
- PsbtSignatureVerifier: independently verifies every key-path signature in a
PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key).
- OnchainZapSender now rejects a signer that returns a different transaction
than it was asked to sign (byte-compares the unsigned tx) and verifies all
signatures before broadcasting — closes a substitution attack where a
malicious/buggy external signer could redirect funds.
HIGH — break test circularity
- Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector
sighash with the vector tweaked key reproduces the vector signature
byte-for-byte (also pins BIP-340 nonce determinism).
- Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven
BIP-341 wallet-test-vector P2TR mainnet addresses.
- EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the
mempool.space and blockstream fee formats, and schema-fallback.
MEDIUM
- OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed
opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD).
- CachingOnchainBackend: TTL-caching decorator (confirmed tx forever,
unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out
into one HTTP request per event. Wired in AppModules.
- OnchainZapVerifier computes real confirmation depth from the chain tip and
asserts the backend echoed the requested txid.
- EsploraBackend falls back to the standard Esplora /fee-estimates endpoint
(blockstream.info) when /v1/fees/recommended 404s.
LOW
- BitcoinTransaction.parse caps input/output/witness-item counts to stop a
hostile varint from triggering a giant pre-allocation.
- OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the
NIP-BC spec.
- alt tag now includes the amount ("Onchain zap: N sats"), matching the spec
example.
All quartz / commons / androidHostTest suites pass; amethyst compiles.
This commit is contained in:
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* An [OnchainBackend] decorator that caches read-only lookups so a feed full
|
||||
* of NIP-BC zaps doesn't fan out into one HTTP request per event.
|
||||
*
|
||||
* Caching policy:
|
||||
* - `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.
|
||||
* - `tipHeight` / `feeEstimates`: cached with a short TTL.
|
||||
* - `getUtxosForAddress`: never cached — wallet balance must be fresh.
|
||||
* - `broadcast`: never cached.
|
||||
*
|
||||
* The delegate call is made **outside** the lock, so concurrent lookups still
|
||||
* run in parallel; a brief window where two callers fetch the same txid is
|
||||
* accepted (it only wastes a request, never returns wrong data).
|
||||
*/
|
||||
class CachingOnchainBackend(
|
||||
private val delegate: OnchainBackend,
|
||||
private val unconfirmedTxTtlSeconds: Long = 60,
|
||||
private val tipHeightTtlSeconds: Long = 60,
|
||||
private val feeEstimatesTtlSeconds: Long = 60,
|
||||
private val nowSeconds: () -> Long = { TimeUtils.now() },
|
||||
) : OnchainBackend {
|
||||
private class Stamped<T>(
|
||||
val value: T,
|
||||
val fetchedAt: Long,
|
||||
)
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val txCache = mutableMapOf<String, Stamped<BitcoinTx>>()
|
||||
private var tipCache: Stamped<Long>? = null
|
||||
private var feeCache: Stamped<FeeEstimates>? = null
|
||||
|
||||
override suspend fun getTx(txid: String): BitcoinTx? {
|
||||
mutex.withLock {
|
||||
val cached = txCache[txid]
|
||||
if (cached != null) {
|
||||
val stillFresh =
|
||||
cached.value.confirmations > 0 ||
|
||||
nowSeconds() - cached.fetchedAt < unconfirmedTxTtlSeconds
|
||||
if (stillFresh) return cached.value
|
||||
}
|
||||
}
|
||||
|
||||
val fetched = delegate.getTx(txid) ?: return null
|
||||
|
||||
mutex.withLock { txCache[txid] = Stamped(fetched, nowSeconds()) }
|
||||
return fetched
|
||||
}
|
||||
|
||||
override suspend fun getUtxosForAddress(address: String): List<Utxo> = delegate.getUtxosForAddress(address)
|
||||
|
||||
override suspend fun broadcast(rawTxHex: String): String = delegate.broadcast(rawTxHex)
|
||||
|
||||
override suspend fun tipHeight(): Long {
|
||||
mutex.withLock {
|
||||
tipCache?.let {
|
||||
if (nowSeconds() - it.fetchedAt < tipHeightTtlSeconds) return it.value
|
||||
}
|
||||
}
|
||||
val fetched = delegate.tipHeight()
|
||||
mutex.withLock { tipCache = Stamped(fetched, nowSeconds()) }
|
||||
return fetched
|
||||
}
|
||||
|
||||
override suspend fun feeEstimates(): FeeEstimates {
|
||||
mutex.withLock {
|
||||
feeCache?.let {
|
||||
if (nowSeconds() - it.fetchedAt < feeEstimatesTtlSeconds) return it.value
|
||||
}
|
||||
}
|
||||
val fetched = delegate.feeEstimates()
|
||||
mutex.withLock { feeCache = Stamped(fetched, nowSeconds()) }
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
+20
@@ -185,6 +185,14 @@ data class BitcoinTransaction(
|
||||
fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Sanity cap on input/output/witness-item counts while parsing. A real
|
||||
* Bitcoin transaction is bounded by the 4 MWU block weight (~100k
|
||||
* minimal inputs); this generous limit just stops an attacker-supplied
|
||||
* varint from triggering a giant pre-allocation or a long spin.
|
||||
*/
|
||||
const val MAX_PARSE_ITEMS = 1_000_000L
|
||||
|
||||
fun parse(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray())
|
||||
|
||||
fun parse(bytes: ByteArray): BitcoinTransaction {
|
||||
@@ -210,6 +218,7 @@ data class BitcoinTransaction(
|
||||
else -> firstByte.toLong()
|
||||
}
|
||||
}
|
||||
requireCount(inputCount, "input")
|
||||
|
||||
val inputs = ArrayList<TxIn>(inputCount.toInt())
|
||||
for (i in 0 until inputCount) {
|
||||
@@ -217,6 +226,7 @@ data class BitcoinTransaction(
|
||||
}
|
||||
|
||||
val outputCount = reader.readVarInt()
|
||||
requireCount(outputCount, "output")
|
||||
val outputs = ArrayList<TxOut>(outputCount.toInt())
|
||||
for (i in 0 until outputCount) {
|
||||
outputs.add(TxOut.read(reader))
|
||||
@@ -225,6 +235,7 @@ data class BitcoinTransaction(
|
||||
if (isSegwit) {
|
||||
for (i in inputs.indices) {
|
||||
val itemCount = reader.readVarInt()
|
||||
requireCount(itemCount, "witness item")
|
||||
val items = ArrayList<ByteArray>(itemCount.toInt())
|
||||
for (j in 0 until itemCount) {
|
||||
items.add(reader.readVarBytes())
|
||||
@@ -236,5 +247,14 @@ data class BitcoinTransaction(
|
||||
val lockTime = reader.readUInt32LE()
|
||||
return BitcoinTransaction(version, inputs, outputs, lockTime)
|
||||
}
|
||||
|
||||
private fun requireCount(
|
||||
count: Long,
|
||||
label: String,
|
||||
) {
|
||||
if (count < 0 || count > MAX_PARSE_ITEMS) {
|
||||
throw PsbtParseException("$label count out of range: $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.psbt
|
||||
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
|
||||
/**
|
||||
* Independently verifies the key-path P2TR signatures inside a [Psbt].
|
||||
*
|
||||
* [PsbtFinalizer.isFullySigned] only checks that a `PSBT_IN_TAP_KEY_SIG`
|
||||
* record is *present*; it does not check the signature is *valid* nor that it
|
||||
* commits to *this* transaction. Before broadcasting a transaction that came
|
||||
* back from an external signer, the client MUST also confirm every signature
|
||||
* actually verifies — otherwise a buggy or malicious signer could substitute a
|
||||
* different transaction (with valid signatures over it) and redirect funds.
|
||||
*
|
||||
* Each signature is checked as a BIP-340 Schnorr signature over the BIP-341
|
||||
* sighash, against the output key derived from the input's
|
||||
* `PSBT_IN_TAP_INTERNAL_KEY`. Both anchors — the sighash and the tweak — are
|
||||
* validated against the BIP-341 wallet test vectors, so this check is not
|
||||
* circular with the signer.
|
||||
*/
|
||||
object PsbtSignatureVerifier {
|
||||
/**
|
||||
* True iff every input of [psbt] carries a key-path tap signature, every
|
||||
* input has the witness-UTXO data needed to compute its sighash, and every
|
||||
* signature is a valid BIP-340 signature over the BIP-341 sighash.
|
||||
*/
|
||||
fun verifyAllKeyPathInputs(psbt: Psbt): Boolean {
|
||||
val tx = psbt.unsignedTx
|
||||
if (tx.inputs.isEmpty()) return false
|
||||
|
||||
val spentOutputs =
|
||||
tx.inputs.indices.map { psbt.inputWitnessUtxo(it) ?: return false }
|
||||
|
||||
for (index in tx.inputs.indices) {
|
||||
val internalKey = psbt.inputTapInternalKey(index) ?: return false
|
||||
val sig = psbt.inputTapKeySig(index) ?: return false
|
||||
|
||||
// BIP-341: a 64-byte signature implies SIGHASH_DEFAULT; a 65-byte
|
||||
// signature carries the (non-default) sighash type as its last byte.
|
||||
val sighashType: Int
|
||||
val sig64: ByteArray
|
||||
when (sig.size) {
|
||||
64 -> {
|
||||
sighashType = TaprootSigHash.SIGHASH_DEFAULT
|
||||
sig64 = sig
|
||||
}
|
||||
|
||||
65 -> {
|
||||
sighashType = sig[64].toInt() and 0xFF
|
||||
// A 65-byte signature must not encode SIGHASH_DEFAULT.
|
||||
if (sighashType == TaprootSigHash.SIGHASH_DEFAULT) return false
|
||||
sig64 = sig.copyOfRange(0, 64)
|
||||
}
|
||||
|
||||
else -> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val sigHash =
|
||||
runCatching { TaprootSigHash.compute(tx, index, spentOutputs, sighashType) }
|
||||
.getOrElse { return false }
|
||||
val outputKey =
|
||||
runCatching { TaprootAddress.tweakOutputKey(internalKey) }
|
||||
.getOrElse { return false }
|
||||
|
||||
if (!Secp256k1Instance.verifySchnorr(sig64, sigHash, outputKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -64,6 +64,11 @@ class OnchainZapVerifier(
|
||||
backend.getTx(txid)
|
||||
?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND)
|
||||
|
||||
// Defensive: the backend must have returned the transaction we asked for.
|
||||
if (!tx.txid.equals(txid, ignoreCase = true)) {
|
||||
return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND)
|
||||
}
|
||||
|
||||
val recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase()
|
||||
val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase()
|
||||
|
||||
@@ -81,7 +86,7 @@ class OnchainZapVerifier(
|
||||
txid = txid,
|
||||
recipientPubKey = recipientPubKey,
|
||||
verifiedSats = verifiedSats,
|
||||
confirmations = tx.confirmations,
|
||||
confirmations = realConfirmations(tx),
|
||||
blockHeight = tx.blockHeight,
|
||||
blockHashHex = tx.blockHashHex,
|
||||
)
|
||||
@@ -94,6 +99,18 @@ class OnchainZapVerifier(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the real confirmation depth. Backends often report only a binary
|
||||
* confirmed/unconfirmed flag (as `confirmations` 1 or 0), so when a block
|
||||
* height is available we compute `tip - height + 1` against the chain tip.
|
||||
* Falls back to the backend-reported value if the tip can't be fetched.
|
||||
*/
|
||||
private suspend fun realConfirmations(tx: BitcoinTx): Int {
|
||||
val height = tx.blockHeight ?: return tx.confirmations
|
||||
val tip = runCatching { backend.tipHeight() }.getOrNull() ?: return tx.confirmations
|
||||
return (tip - height + 1).coerceAtLeast(1).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the value of outputs paying [recipientScriptHex]. Outputs paying
|
||||
* back to [senderScriptHex] are change and MUST NOT be counted.
|
||||
|
||||
+5
-3
@@ -97,7 +97,9 @@ class OnchainZapEvent(
|
||||
|
||||
companion object {
|
||||
const val KIND = 8333
|
||||
const val ALT_DESCRIPTION = "Onchain Zap"
|
||||
|
||||
/** NIP-31 human-readable fallback. Includes the amount, as in the NIP-BC example. */
|
||||
fun altDescription(amountInSats: Long) = "Onchain zap: $amountInSats sats"
|
||||
|
||||
/**
|
||||
* Build an onchain zap that targets a specific event.
|
||||
@@ -111,7 +113,7 @@ class OnchainZapEvent(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, content, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
alt(altDescription(amountInSats))
|
||||
txid(txid)
|
||||
recipient(recipientPubKey)
|
||||
amountInSats(amountInSats)
|
||||
@@ -134,7 +136,7 @@ class OnchainZapEvent(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, content, createdAt) {
|
||||
alt(ALT_DESCRIPTION)
|
||||
alt(altDescription(amountInSats))
|
||||
txid(txid)
|
||||
recipient(recipientPubKey)
|
||||
amountInSats(amountInSats)
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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.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.chain.Utxo
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PsbtSignatureVerifierTest {
|
||||
private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray()
|
||||
private val senderPubKey = Secp256k1Instance.compressedPubKeyFor(senderPriv).copyOfRange(1, 33).toHexKey()
|
||||
private val recipientPubKey =
|
||||
Secp256k1Instance
|
||||
.compressedPubKeyFor("000000000000000000000000000000000000000000000000000000000000000b".hexToByteArray())
|
||||
.copyOfRange(1, 33)
|
||||
.toHexKey()
|
||||
|
||||
private fun signedPsbt(): Psbt {
|
||||
val built =
|
||||
OnchainZapBuilder.build(
|
||||
senderPubKey,
|
||||
recipientPubKey,
|
||||
40_000L,
|
||||
5.0,
|
||||
listOf(Utxo("1".repeat(64), 0, 250_000L, 6)),
|
||||
)
|
||||
PsbtSigner.signKeyPathInputs(built.psbt, senderPriv)
|
||||
return built.psbt
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsAProperlySignedPsbt() {
|
||||
assertTrue(PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenAnInputIsUnsigned() {
|
||||
val psbt = signedPsbt()
|
||||
psbt.inputs[0].remove(Psbt.PSBT_IN_TAP_KEY_SIG)
|
||||
assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsAGarbageSignature() {
|
||||
val psbt = signedPsbt()
|
||||
psbt.setInputTapKeySig(0, ByteArray(64) { 0x11 })
|
||||
assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsSignaturesOverATamperedTransaction() {
|
||||
// Sign tx A, then graft A's signed input maps onto a PSBT whose
|
||||
// transaction has a mutated output. The signatures no longer commit to
|
||||
// the transaction being verified — exactly the substitution attack the
|
||||
// verifier exists to catch.
|
||||
val signed = signedPsbt()
|
||||
val original = signed.unsignedTx
|
||||
val tamperedTx =
|
||||
original.copy(
|
||||
outputs =
|
||||
original.outputs.mapIndexed { i, o ->
|
||||
if (i == 0) o.copy(valueSats = o.valueSats + 10_000L) else o
|
||||
},
|
||||
)
|
||||
val tamperedGlobal = PsbtMap()
|
||||
tamperedGlobal.put(Psbt.PSBT_GLOBAL_UNSIGNED_TX, tamperedTx.serializeForId())
|
||||
val tamperedPsbt = Psbt(tamperedGlobal, signed.inputs, signed.outputs)
|
||||
|
||||
assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(tamperedPsbt))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsWhenWitnessUtxoMissing() {
|
||||
val psbt = signedPsbt()
|
||||
psbt.inputs[0].remove(Psbt.PSBT_IN_WITNESS_UTXO)
|
||||
assertFalse(PsbtSignatureVerifier.verifyAllKeyPathInputs(psbt))
|
||||
}
|
||||
}
|
||||
+22
@@ -70,6 +70,28 @@ class PsbtSignerTest {
|
||||
private val outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343"
|
||||
private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555"
|
||||
|
||||
// BIP-341 keyPathSpending input 0 — the full expected witness (64-byte
|
||||
// signature + the SIGHASH_SINGLE 0x03 byte).
|
||||
private val expectedWitness0 =
|
||||
"ed7c1647cb97379e76892be0cacff57ec4a7102aa24296ca39af7541246d8ff1" +
|
||||
"4d38958d4cc1e2e478e4d4a764bbfd835b16d4e314b72937b29833060b87276c03"
|
||||
|
||||
@Test
|
||||
fun producesExactBip341VectorSignature() {
|
||||
// Pins the full BIP-340 signing pipeline (and its nonce determinism) to
|
||||
// the authoritative BIP-341 wallet test vector: signing the vector's
|
||||
// sighash with the vector's tweaked key must reproduce the vector's
|
||||
// witness signature byte-for-byte.
|
||||
val sig =
|
||||
Secp256k1Instance.signSchnorr(
|
||||
sigHashSingle0.hexToByteArray(),
|
||||
tweakedPrivKey0.hexToByteArray(),
|
||||
)
|
||||
// Witness is the 64-byte signature; the trailing 0x03 is the sighash type
|
||||
// appended by the PSBT signer, not part of the BIP-340 signature itself.
|
||||
assertEquals(expectedWitness0.substring(0, 128), sig.toHexKey())
|
||||
}
|
||||
|
||||
private fun psbtForVectorTx(): Psbt {
|
||||
val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex))
|
||||
spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) }
|
||||
|
||||
+25
-7
@@ -53,13 +53,31 @@ class SegwitAddressTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodedTaprootIsLowercaseAndCorrectLength() {
|
||||
// P2TR encoded address must be 62 chars, lowercase, with 'bc1p' prefix.
|
||||
val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()
|
||||
val address = SegwitAddress.encodeP2TR(outputKey)
|
||||
assertEquals(62, address.length)
|
||||
assertEquals(address, address.lowercase())
|
||||
assertEquals("bc1p", address.substring(0, 4))
|
||||
fun encodesP2trAgainstBip341WalletTestVectors() {
|
||||
// All seven `scriptPubKey` entries from the BIP-341 wallet-test-vectors:
|
||||
// (tweaked output key, expected bip350Address).
|
||||
val vectors =
|
||||
listOf(
|
||||
"53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" to
|
||||
"bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5",
|
||||
"147c9c57132f6e7ecddba9800bb0c4449251c92a1e60371ee77557b6620f3ea3" to
|
||||
"bc1pz37fc4cn9ah8anwm4xqqhvxygjf9rjf2resrw8h8w4tmvcs0863sa2e586",
|
||||
"e4d810fd50586274face62b8a807eb9719cef49c04177cc6b76a9a4251d5450e" to
|
||||
"bc1punvppl2stp38f7kwv2u2spltjuvuaayuqsthe34hd2dyy5w4g58qqfuag5",
|
||||
"712447206d7a5238acc7ff53fbe94a3b64539ad291c7cdbc490b7577e4b17df5" to
|
||||
"bc1pwyjywgrd0ffr3tx8laflh6228dj98xkjj8rum0zfpd6h0e930h6saqxrrm",
|
||||
"77e30a5522dd9f894c3f8b8bd4c4b2cf82ca7da8a3ea6a239655c39c050ab220" to
|
||||
"bc1pwl3s54fzmk0cjnpl3w9af39je7pv5ldg504x5guk2hpecpg2kgsqaqstjq",
|
||||
"91b64d5324723a985170e4dc5a0f84c041804f2cd12660fa5dec09fc21783605" to
|
||||
"bc1pjxmy65eywgafs5tsunw95ruycpqcqnev6ynxp7jaasylcgtcxczs6n332e",
|
||||
"75169f4001aa68f15bbed28b218df1d0a62cbbcf1188c6665110c293c907b831" to
|
||||
"bc1pw5tf7sqp4f50zka7629jrr036znzew70zxyvvej3zrpf8jg8hqcssyuewe",
|
||||
)
|
||||
for ((outputKey, address) in vectors) {
|
||||
assertEquals(address, SegwitAddress.encodeP2TR(outputKey.hexToByteArray()))
|
||||
// And it must decode back to the same output key.
|
||||
assertEquals(outputKey, SegwitAddress.decode(address).program.toHexKey())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
+11
-5
@@ -61,13 +61,19 @@ class TaprootAddressTest {
|
||||
assertEquals(expectedScriptPubKey, script.toHexKey())
|
||||
}
|
||||
|
||||
// BIP-341 wallet-test-vectors `scriptPubKey` entry 1 (scriptTree = null):
|
||||
// internalPubkey d6889cb0… → bip350Address.
|
||||
private val expectedAddress = "bc1p2wsldez5mud2yam29q22wgfh9439spgduvct83k3pm50fcxa5dps59h4z5"
|
||||
|
||||
@Test
|
||||
fun derivesExactBip350Address() {
|
||||
// Full key-path-only derivation pinned to the BIP-341 wallet test vector.
|
||||
assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey))
|
||||
assertEquals(expectedAddress, TaprootAddress.fromPubKey(internalKey.hexToByteArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun derivedAddressRoundTripsToOutputKey() {
|
||||
// The address itself is the bech32m encoding of (v1, output_key). We
|
||||
// don't hard-code a specific bech32m string here because some
|
||||
// historical BIP-341 wallet vectors used post-Taproot-Schnorr key
|
||||
// adjustments — instead, verify the address decodes back to the
|
||||
// expected output key.
|
||||
val address = TaprootAddress.fromPubKey(internalKey)
|
||||
val decoded = SegwitAddress.decode(address)
|
||||
assertEquals(1, decoded.witnessVersion)
|
||||
|
||||
+6
-3
@@ -77,6 +77,7 @@ class OnchainZapVerifierTest {
|
||||
|
||||
private class FakeBackend(
|
||||
private val tx: BitcoinTx?,
|
||||
private val tip: Long = 800_006L,
|
||||
) : OnchainBackend {
|
||||
override suspend fun getTx(txid: String): BitcoinTx? = if (tx?.txid == txid) tx else null
|
||||
|
||||
@@ -84,7 +85,7 @@ class OnchainZapVerifierTest {
|
||||
|
||||
override suspend fun broadcast(rawTxHex: String): String = throw UnsupportedOperationException()
|
||||
|
||||
override suspend fun tipHeight(): Long = 0L
|
||||
override suspend fun tipHeight(): Long = tip
|
||||
|
||||
override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0)
|
||||
}
|
||||
@@ -100,17 +101,19 @@ class OnchainZapVerifierTest {
|
||||
BitcoinTxOutput(0, 25000L, recipientScriptHex),
|
||||
BitcoinTxOutput(1, 99000L, senderScriptHex), // change
|
||||
),
|
||||
confirmations = 3,
|
||||
confirmations = 1,
|
||||
blockHashHex = "f".repeat(64),
|
||||
blockHeight = 800_000L,
|
||||
)
|
||||
val verifier = OnchainZapVerifier(FakeBackend(tx))
|
||||
val verifier = OnchainZapVerifier(FakeBackend(tx, tip = 800_006L))
|
||||
val result = verifier.verify(mkEvent())
|
||||
|
||||
assertIs<VerifiedOnchainZap.Confirmed>(result)
|
||||
assertEquals(25000L, result.verifiedSats)
|
||||
assertEquals(recipientHex, result.recipientPubKey)
|
||||
assertEquals(800_000L, result.blockHeight)
|
||||
// Real depth computed from the chain tip: 800006 - 800000 + 1 = 7.
|
||||
assertEquals(7, result.confirmations)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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.zap
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Asserts the on-the-wire tag structure of kind:8333 events against the
|
||||
* NIP-BC spec, so cross-app interoperability doesn't drift.
|
||||
*/
|
||||
class OnchainZapEventTest {
|
||||
private val txid = "a".repeat(64)
|
||||
private val recipient = "8b34731183a85a4fc1a3ea9e8caa14e72ff31716bf6dd0d2f8c93f5b14e44f5d"
|
||||
|
||||
private fun Array<Array<String>>.tag(name: String) = firstOrNull { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
@Test
|
||||
fun profileZapHasExactlyTheSpecTags() {
|
||||
val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L)
|
||||
|
||||
assertEquals(OnchainZapEvent.KIND, template.kind)
|
||||
val tags = template.tags
|
||||
|
||||
// Required NIP-BC tags, exact values.
|
||||
assertEquals(listOf("alt", "Onchain zap: 25000 sats"), tags.tag("alt")?.toList())
|
||||
assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList())
|
||||
assertEquals(listOf("p", recipient), tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "25000"), tags.tag("amount")?.toList())
|
||||
|
||||
// A profile zap targets no event — no e / a / k tags.
|
||||
assertNull(tags.tag("e"), "profile zap must not carry an e tag")
|
||||
assertNull(tags.tag("a"), "profile zap must not carry an a tag")
|
||||
assertNull(tags.tag("k"), "profile zap must not carry a k tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventZapCarriesEventAndKindTags() {
|
||||
val zapped =
|
||||
Event(
|
||||
id = "b".repeat(64),
|
||||
pubKey = "c".repeat(64),
|
||||
createdAt = 1_700_000_000L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "hello",
|
||||
sig = "d".repeat(128),
|
||||
)
|
||||
val template =
|
||||
OnchainZapEvent.build(txid, recipient, 21_000L, EventHintBundle(zapped))
|
||||
val tags = template.tags
|
||||
|
||||
assertEquals(listOf("i", "bitcoin:tx:$txid"), tags.tag("i")?.toList())
|
||||
assertEquals(listOf("p", recipient), tags.tag("p")?.toList())
|
||||
assertEquals(listOf("amount", "21000"), tags.tag("amount")?.toList())
|
||||
assertEquals("Onchain zap: 21000 sats", tags.tag("alt")?.get(1))
|
||||
|
||||
// The zapped event is referenced by an `e` tag and its kind by a `k` tag.
|
||||
val eTag = tags.tag("e")
|
||||
assertTrue(eTag != null && eTag[1] == "b".repeat(64), "e tag must reference the zapped event id")
|
||||
assertEquals(listOf("k", "1"), tags.tag("k")?.toList())
|
||||
// Kind 1 is not addressable — no a tag.
|
||||
assertNull(tags.tag("a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsedBackEventExposesTheSameFields() {
|
||||
// The receipt must round-trip through the event accessors used by the
|
||||
// verifier and feed code.
|
||||
val template = OnchainZapEvent.buildProfileZap(txid, recipient, 25_000L)
|
||||
val event =
|
||||
OnchainZapEvent(
|
||||
id = "e".repeat(64),
|
||||
pubKey = "f".repeat(64),
|
||||
createdAt = template.createdAt,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
assertEquals(txid, event.txid())
|
||||
assertEquals(recipient, event.recipient())
|
||||
assertEquals(25_000L, event.claimedAmountInSats())
|
||||
assertTrue(event.isProfileZap())
|
||||
}
|
||||
}
|
||||
+66
-13
@@ -37,7 +37,8 @@ import okhttp3.coroutines.executeAsync
|
||||
* - `GET /address/{addr}/utxo` — UTXO list for a derived address
|
||||
* - `POST /tx` — broadcast (body = raw tx hex)
|
||||
* - `GET /blocks/tip/height` — chain tip
|
||||
* - `GET /v1/fees/recommended` — fee tier suggestions (mempool.space variant)
|
||||
* - `GET /v1/fees/recommended` — fee tiers (mempool.space); falls back to
|
||||
* `GET /fee-estimates` (the standard Esplora target→rate map) on 404
|
||||
*
|
||||
* The `baseUrl` is supplied as a function so callers can hot-swap endpoints
|
||||
* from `AccountSettings` without rebuilding the backend.
|
||||
@@ -134,26 +135,54 @@ class EsploraBackend(
|
||||
}
|
||||
|
||||
override suspend fun feeEstimates(): FeeEstimates {
|
||||
// mempool.space-style recommended fees endpoint.
|
||||
val url = "${baseUrl()}/v1/fees/recommended"
|
||||
val request =
|
||||
// mempool.space-style recommended-fees endpoint.
|
||||
val recommendedUrl = "${baseUrl()}/v1/fees/recommended"
|
||||
val recommended =
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/json")
|
||||
.url(url)
|
||||
.url(recommendedUrl)
|
||||
.get()
|
||||
.build()
|
||||
return client.newCall(request).executeAsync().use { response ->
|
||||
client.newCall(recommended).executeAsync().use { response ->
|
||||
when {
|
||||
response.isSuccessful -> {
|
||||
return parseRecommendedFees(response.body.string())
|
||||
}
|
||||
|
||||
// Standard Esplora servers (blockstream.info, self-hosted) don't
|
||||
// expose /v1/fees/recommended — fall back to /fee-estimates.
|
||||
response.code == 404 -> {
|
||||
Unit
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw OnchainBackendException(
|
||||
"GET $recommendedUrl failed: ${response.code} ${response.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val estimatesUrl = "${baseUrl()}/fee-estimates"
|
||||
val estimates =
|
||||
Request
|
||||
.Builder()
|
||||
.header("Accept", "application/json")
|
||||
.url(estimatesUrl)
|
||||
.get()
|
||||
.build()
|
||||
return client.newCall(estimates).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw OnchainBackendException(
|
||||
"GET $url failed: ${response.code} ${response.message}",
|
||||
"GET $estimatesUrl failed: ${response.code} ${response.message}",
|
||||
)
|
||||
}
|
||||
parseFees(response.body.string())
|
||||
parseFeeEstimates(response.body.string())
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTx(json: String): BitcoinTx {
|
||||
internal fun parseTx(json: String): BitcoinTx {
|
||||
val node = JacksonMapper.mapper.readTree(json)
|
||||
val txid = node["txid"].asText()
|
||||
val status = node["status"]
|
||||
@@ -189,7 +218,7 @@ class EsploraBackend(
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUtxoList(json: String): List<Utxo> {
|
||||
internal fun parseUtxoList(json: String): List<Utxo> {
|
||||
val node = JacksonMapper.mapper.readTree(json)
|
||||
return node.map { utxo ->
|
||||
val confirmed = utxo["status"]?.get("confirmed")?.asBoolean() == true
|
||||
@@ -202,16 +231,15 @@ class EsploraBackend(
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFees(json: String): FeeEstimates {
|
||||
/** mempool.space `/v1/fees/recommended`: `{ fastestFee, halfHourFee, hourFee, minimumFee }`. */
|
||||
internal fun parseRecommendedFees(json: String): FeeEstimates {
|
||||
val node = JacksonMapper.mapper.readTree(json)
|
||||
// mempool.space exposes fastestFee / halfHourFee / hourFee / minimumFee.
|
||||
val fast = node["fastestFee"]?.asDouble()
|
||||
val normal = node["halfHourFee"]?.asDouble() ?: fast
|
||||
val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal
|
||||
|
||||
if (fast == null) {
|
||||
Log.w(logTag) { "fee response missing 'fastestFee': $json" }
|
||||
// Sensible default if the endpoint disagrees with our schema.
|
||||
return FeeEstimates(20.0, 10.0, 5.0)
|
||||
}
|
||||
return FeeEstimates(
|
||||
@@ -221,6 +249,31 @@ class EsploraBackend(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Esplora `/fee-estimates`: a JSON object mapping a confirmation
|
||||
* target (in blocks, as a string key) to the estimated sat/vB. We map the
|
||||
* 2-block / 6-block / 144-block targets to the fast / normal / slow tiers.
|
||||
*/
|
||||
internal fun parseFeeEstimates(json: String): FeeEstimates {
|
||||
val node = JacksonMapper.mapper.readTree(json)
|
||||
|
||||
fun rate(vararg targets: String): Double? = targets.firstNotNullOfOrNull { node[it]?.asDouble() }
|
||||
|
||||
val fast = rate("1", "2")
|
||||
val normal = rate("4", "6", "3")
|
||||
val slow = rate("144", "1008", "10")
|
||||
|
||||
if (fast == null) {
|
||||
Log.w(logTag) { "fee-estimates response missing block targets: $json" }
|
||||
return FeeEstimates(20.0, 10.0, 5.0)
|
||||
}
|
||||
return FeeEstimates(
|
||||
fastSatPerVbyte = fast,
|
||||
normalSatPerVbyte = normal ?: fast,
|
||||
slowSatPerVbyte = slow ?: normal ?: fast,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MEMPOOL_API_URL = "https://mempool.space/api"
|
||||
const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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 okhttp3.OkHttpClient
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Tests the JSON-parsing layer of [EsploraBackend] against representative
|
||||
* mempool.space / blockstream.info response bodies. The parse functions are
|
||||
* pure (string in, model out) so no HTTP client is exercised.
|
||||
*/
|
||||
class EsploraBackendTest {
|
||||
private val backend = EsploraBackend({ "https://example.test/api" }, OkHttpClient())
|
||||
|
||||
@Test
|
||||
fun parsesConfirmedTransaction() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"txid": "7e3ab0f...not validated here",
|
||||
"version": 2,
|
||||
"locktime": 0,
|
||||
"vin": [],
|
||||
"vout": [
|
||||
{ "scriptpubkey": "512053A1F6E454DF1AA2776A2814A721372D6258050DE330B3C6D10EE8F4E0DDA343",
|
||||
"scriptpubkey_type": "v1_p2tr", "value": 25000 },
|
||||
{ "scriptpubkey": "0014abababababababababababababababababababab", "value": 99000 }
|
||||
],
|
||||
"status": {
|
||||
"confirmed": true,
|
||||
"block_height": 800000,
|
||||
"block_hash": "00000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"block_time": 1690000000
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val tx = backend.parseTx(json)
|
||||
assertEquals(2, tx.outputs.size)
|
||||
assertEquals(25000L, tx.outputs[0].valueSats)
|
||||
// scriptPubKey is normalized to lowercase for byte-comparison with our own.
|
||||
assertEquals(
|
||||
"512053a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343",
|
||||
tx.outputs[0].scriptPubKeyHex,
|
||||
)
|
||||
assertEquals(0, tx.outputs[0].index)
|
||||
assertEquals(1, tx.outputs[1].index)
|
||||
assertEquals(99000L, tx.outputs[1].valueSats)
|
||||
assertEquals(800000L, tx.blockHeight)
|
||||
assertEquals(1, tx.confirmations, "a confirmed tx must report ≥1 confirmation")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesUnconfirmedTransaction() {
|
||||
val json =
|
||||
"""
|
||||
{ "txid": "abc", "version": 2, "locktime": 0, "vin": [],
|
||||
"vout": [ { "scriptpubkey": "5120ff", "value": 1000 } ],
|
||||
"status": { "confirmed": false } }
|
||||
""".trimIndent()
|
||||
val tx = backend.parseTx(json)
|
||||
assertEquals(0, tx.confirmations, "an unconfirmed tx must report 0 confirmations")
|
||||
assertNull(tx.blockHeight)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesUtxoList() {
|
||||
val json =
|
||||
"""
|
||||
[
|
||||
{ "txid": "1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"vout": 0, "value": 100000,
|
||||
"status": { "confirmed": true, "block_height": 799000 } },
|
||||
{ "txid": "2222222222222222222222222222222222222222222222222222222222222222",
|
||||
"vout": 3, "value": 50000,
|
||||
"status": { "confirmed": false } }
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val utxos = backend.parseUtxoList(json)
|
||||
assertEquals(2, utxos.size)
|
||||
assertEquals(100000L, utxos[0].valueSats)
|
||||
assertEquals(0, utxos[0].vout)
|
||||
assertEquals(1, utxos[0].confirmations)
|
||||
assertEquals(50000L, utxos[1].valueSats)
|
||||
assertEquals(3, utxos[1].vout)
|
||||
assertEquals(0, utxos[1].confirmations, "unconfirmed UTXO must report 0 confirmations")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesMempoolSpaceRecommendedFees() {
|
||||
// mempool.space /v1/fees/recommended
|
||||
val json =
|
||||
"""{ "fastestFee": 25, "halfHourFee": 15, "hourFee": 10, "economyFee": 5, "minimumFee": 1 }"""
|
||||
val fees = backend.parseRecommendedFees(json)
|
||||
assertEquals(25.0, fees.fastSatPerVbyte)
|
||||
assertEquals(15.0, fees.normalSatPerVbyte)
|
||||
assertEquals(10.0, fees.slowSatPerVbyte)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesBlockstreamFeeEstimates() {
|
||||
// blockstream.info / standard Esplora /fee-estimates: block target → sat/vB.
|
||||
val json =
|
||||
"""
|
||||
{ "1": 87.0, "2": 87.0, "3": 81.0, "4": 76.0, "6": 68.0,
|
||||
"10": 50.0, "144": 1.027, "504": 1.0, "1008": 1.0 }
|
||||
""".trimIndent()
|
||||
val fees = backend.parseFeeEstimates(json)
|
||||
assertEquals(87.0, fees.fastSatPerVbyte, "fast = 1-block target")
|
||||
assertEquals(76.0, fees.normalSatPerVbyte, "normal = 4-block target")
|
||||
assertEquals(1.027, fees.slowSatPerVbyte, "slow = 144-block target")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feeParsersFallBackWhenSchemaUnexpected() {
|
||||
val recommended = backend.parseRecommendedFees("""{ "unexpected": true }""")
|
||||
assertEquals(20.0, recommended.fastSatPerVbyte)
|
||||
val estimates = backend.parseFeeEstimates("""{ "unexpected": true }""")
|
||||
assertEquals(20.0, estimates.fastSatPerVbyte)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user