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:
Claude
2026-05-14 13:29:48 +00:00
parent 5f16ebc060
commit 1d7be1d444
16 changed files with 844 additions and 39 deletions
@@ -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
}
}
@@ -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")
}
}
}
}
@@ -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
}
}
@@ -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.
@@ -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)
@@ -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))
}
}
@@ -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) }
@@ -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())
}
}
// ============================================================
@@ -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)
@@ -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
@@ -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())
}
}
@@ -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 targetrate 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"
@@ -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)
}
}