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:
@@ -100,6 +100,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
|||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
@@ -334,14 +335,18 @@ class AppModules(
|
|||||||
|
|
||||||
// NIP-BC onchain zap verification backend. Wired up once at app init so
|
// NIP-BC onchain zap verification backend. Wired up once at app init so
|
||||||
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
|
// LocalCache.consume(OnchainZapEvent) can sum the on-chain output values
|
||||||
// that pay the recipient's derived Taproot address. Endpoint is currently
|
// that pay the recipient's derived Taproot address. Wrapped in a caching
|
||||||
// a fixed default; per-account override (AccountSettings.onchainEsploraEndpoint)
|
// decorator so a feed full of onchain zaps doesn't fan out into one HTTP
|
||||||
// is plumbed for a future Settings UI.
|
// request per event. Endpoint is currently a fixed default; per-account
|
||||||
|
// override (AccountSettings.onchainEsploraEndpoint) is plumbed for a future
|
||||||
|
// Settings UI.
|
||||||
init {
|
init {
|
||||||
cache.onchainBackend =
|
cache.onchainBackend =
|
||||||
EsploraBackend(
|
CachingOnchainBackend(
|
||||||
baseUrl = { DEFAULT_ESPLORA_ENDPOINT },
|
EsploraBackend(
|
||||||
client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT),
|
baseUrl = { DEFAULT_ESPLORA_ENDPOINT },
|
||||||
|
client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nipBCOnchainZaps.build.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.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
|
||||||
@@ -142,17 +143,38 @@ object OnchainZapSender {
|
|||||||
return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e)
|
return fail(OnchainZapSendStage.BUILDING, e.message ?: "Could not build the transaction", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Sign and finalize.
|
// 3. Sign, verify the signer didn't tamper, and finalize.
|
||||||
val rawTxHex =
|
val rawTxHex =
|
||||||
try {
|
try {
|
||||||
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
|
||||||
|
// different transaction (with valid signatures over IT), finalizing
|
||||||
|
// and broadcasting would send funds wherever that tx says. Reject
|
||||||
|
// anything whose unsigned transaction isn't byte-identical to ours.
|
||||||
|
val expectedTx = built.psbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX)
|
||||||
|
val returnedTx = signedPsbt.global.get(Psbt.PSBT_GLOBAL_UNSIGNED_TX)
|
||||||
|
if (expectedTx == null || returnedTx == null || !expectedTx.contentEquals(returnedTx)) {
|
||||||
|
return fail(
|
||||||
|
OnchainZapSendStage.SIGNING,
|
||||||
|
"The signer returned a different transaction than the one it was asked to sign",
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!PsbtFinalizer.isFullySigned(signedPsbt)) {
|
if (!PsbtFinalizer.isFullySigned(signedPsbt)) {
|
||||||
return fail(
|
return fail(
|
||||||
OnchainZapSendStage.SIGNING,
|
OnchainZapSendStage.SIGNING,
|
||||||
"The signer did not sign every input",
|
"The signer did not sign every input",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Verify every signature is actually valid before money moves —
|
||||||
|
// catches a broken signer up front instead of a doomed broadcast.
|
||||||
|
if (!PsbtSignatureVerifier.verifyAllKeyPathInputs(signedPsbt)) {
|
||||||
|
return fail(
|
||||||
|
OnchainZapSendStage.SIGNING,
|
||||||
|
"The signed transaction has invalid signatures",
|
||||||
|
)
|
||||||
|
}
|
||||||
PsbtFinalizer.finalizeToHex(signedPsbt)
|
PsbtFinalizer.finalizeToHex(signedPsbt)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
+91
@@ -20,15 +20,22 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.onchain
|
package com.vitorpamplona.amethyst.commons.onchain
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
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.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.LnZapRequestEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.build.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
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.Utxo
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.BitcoinTransaction
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.psbt.PsbtSigner
|
||||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -175,4 +182,88 @@ class OnchainZapSenderTest {
|
|||||||
val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid()
|
val broadcastTxid = BitcoinTransaction.parse(backend.broadcastedHex!!).txid()
|
||||||
assertEquals(broadcastTxid, result.broadcastTxid)
|
assertEquals(broadcastTxid, result.broadcastTxid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signer that ignores the PSBT it was handed and instead signs a
|
||||||
|
* completely different transaction of its own — the substitution attack.
|
||||||
|
*/
|
||||||
|
private class TamperingSigner(
|
||||||
|
private val inner: NostrSignerInternal,
|
||||||
|
private val attackerControlledPubKey: HexKey,
|
||||||
|
) : NostrSigner(inner.pubKey) {
|
||||||
|
override fun isWriteable() = inner.isWriteable()
|
||||||
|
|
||||||
|
override fun hasForegroundSupport() = inner.hasForegroundSupport()
|
||||||
|
|
||||||
|
override suspend fun <T : Event> sign(
|
||||||
|
createdAt: Long,
|
||||||
|
kind: Int,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
): T = inner.sign(createdAt, kind, tags, content)
|
||||||
|
|
||||||
|
override suspend fun nip04Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
toPublicKey: HexKey,
|
||||||
|
) = inner.nip04Encrypt(plaintext, toPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip04Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
fromPublicKey: HexKey,
|
||||||
|
) = inner.nip04Decrypt(ciphertext, fromPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip44Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
toPublicKey: HexKey,
|
||||||
|
) = inner.nip44Encrypt(plaintext, toPublicKey)
|
||||||
|
|
||||||
|
override suspend fun nip44Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
fromPublicKey: HexKey,
|
||||||
|
) = inner.nip44Decrypt(ciphertext, fromPublicKey)
|
||||||
|
|
||||||
|
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = inner.decryptZapEvent(event)
|
||||||
|
|
||||||
|
override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce)
|
||||||
|
|
||||||
|
override suspend fun signPsbt(psbtHex: String): String {
|
||||||
|
// Discard the requested PSBT entirely; build and sign one that pays
|
||||||
|
// the attacker instead, then hand it back as if it were the answer.
|
||||||
|
val malicious =
|
||||||
|
OnchainZapBuilder.build(
|
||||||
|
senderPubKey = inner.pubKey,
|
||||||
|
recipientPubKey = attackerControlledPubKey,
|
||||||
|
amountSats = 200_000L,
|
||||||
|
feeRateSatPerVByte = 1.0,
|
||||||
|
availableUtxos = listOf(Utxo("9".repeat(64), 0, 250_000L, 6)),
|
||||||
|
)
|
||||||
|
PsbtSigner.signKeyPathInputs(malicious.psbt, inner.keyPair.privKey!!)
|
||||||
|
return malicious.psbt.toHex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsASignerThatReturnsADifferentTransaction() =
|
||||||
|
runTest {
|
||||||
|
val backend = FakeBackend(listOf(Utxo("5".repeat(64), 0, 250_000L, 6)))
|
||||||
|
val tamperingSigner = TamperingSigner(senderSigner, attackerControlledPubKey = recipientPubKey)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
OnchainZapSender.send(
|
||||||
|
backend = backend,
|
||||||
|
signer = tamperingSigner,
|
||||||
|
senderPubKey = senderPubKey,
|
||||||
|
recipientPubKey = recipientPubKey,
|
||||||
|
amountSats = 50_000L,
|
||||||
|
feeRateSatPerVByte = 5.0,
|
||||||
|
comment = "",
|
||||||
|
zappedEvent = null,
|
||||||
|
) { senderSigner.sign(it) }
|
||||||
|
|
||||||
|
// The substituted transaction must be rejected at the signing stage,
|
||||||
|
// and nothing must have been broadcast.
|
||||||
|
assertIs<OnchainZapSendResult.Failure>(result)
|
||||||
|
assertEquals(OnchainZapSendStage.SIGNING, result.stage)
|
||||||
|
assertEquals(null, backend.broadcastedHex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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()
|
fun txid(): String = hash256(serializeForId()).reversedArray().toHexKey()
|
||||||
|
|
||||||
companion object {
|
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(rawHex: String): BitcoinTransaction = parse(rawHex.hexToByteArray())
|
||||||
|
|
||||||
fun parse(bytes: ByteArray): BitcoinTransaction {
|
fun parse(bytes: ByteArray): BitcoinTransaction {
|
||||||
@@ -210,6 +218,7 @@ data class BitcoinTransaction(
|
|||||||
else -> firstByte.toLong()
|
else -> firstByte.toLong()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
requireCount(inputCount, "input")
|
||||||
|
|
||||||
val inputs = ArrayList<TxIn>(inputCount.toInt())
|
val inputs = ArrayList<TxIn>(inputCount.toInt())
|
||||||
for (i in 0 until inputCount) {
|
for (i in 0 until inputCount) {
|
||||||
@@ -217,6 +226,7 @@ data class BitcoinTransaction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val outputCount = reader.readVarInt()
|
val outputCount = reader.readVarInt()
|
||||||
|
requireCount(outputCount, "output")
|
||||||
val outputs = ArrayList<TxOut>(outputCount.toInt())
|
val outputs = ArrayList<TxOut>(outputCount.toInt())
|
||||||
for (i in 0 until outputCount) {
|
for (i in 0 until outputCount) {
|
||||||
outputs.add(TxOut.read(reader))
|
outputs.add(TxOut.read(reader))
|
||||||
@@ -225,6 +235,7 @@ data class BitcoinTransaction(
|
|||||||
if (isSegwit) {
|
if (isSegwit) {
|
||||||
for (i in inputs.indices) {
|
for (i in inputs.indices) {
|
||||||
val itemCount = reader.readVarInt()
|
val itemCount = reader.readVarInt()
|
||||||
|
requireCount(itemCount, "witness item")
|
||||||
val items = ArrayList<ByteArray>(itemCount.toInt())
|
val items = ArrayList<ByteArray>(itemCount.toInt())
|
||||||
for (j in 0 until itemCount) {
|
for (j in 0 until itemCount) {
|
||||||
items.add(reader.readVarBytes())
|
items.add(reader.readVarBytes())
|
||||||
@@ -236,5 +247,14 @@ data class BitcoinTransaction(
|
|||||||
val lockTime = reader.readUInt32LE()
|
val lockTime = reader.readUInt32LE()
|
||||||
return BitcoinTransaction(version, inputs, outputs, lockTime)
|
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)
|
backend.getTx(txid)
|
||||||
?: return VerifiedOnchainZap.Rejected(txid, VerifiedOnchainZap.Rejected.Reason.TX_NOT_FOUND)
|
?: 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 recipientScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(recipientPubKey).lowercase()
|
||||||
val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase()
|
val senderScriptHex = TaprootAddress.scriptPubKeyHexForRecipient(event.pubKey).lowercase()
|
||||||
|
|
||||||
@@ -81,7 +86,7 @@ class OnchainZapVerifier(
|
|||||||
txid = txid,
|
txid = txid,
|
||||||
recipientPubKey = recipientPubKey,
|
recipientPubKey = recipientPubKey,
|
||||||
verifiedSats = verifiedSats,
|
verifiedSats = verifiedSats,
|
||||||
confirmations = tx.confirmations,
|
confirmations = realConfirmations(tx),
|
||||||
blockHeight = tx.blockHeight,
|
blockHeight = tx.blockHeight,
|
||||||
blockHashHex = tx.blockHashHex,
|
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
|
* Sum the value of outputs paying [recipientScriptHex]. Outputs paying
|
||||||
* back to [senderScriptHex] are change and MUST NOT be counted.
|
* back to [senderScriptHex] are change and MUST NOT be counted.
|
||||||
|
|||||||
+5
-3
@@ -97,7 +97,9 @@ class OnchainZapEvent(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KIND = 8333
|
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.
|
* Build an onchain zap that targets a specific event.
|
||||||
@@ -111,7 +113,7 @@ class OnchainZapEvent(
|
|||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
||||||
) = eventTemplate(KIND, content, createdAt) {
|
) = eventTemplate(KIND, content, createdAt) {
|
||||||
alt(ALT_DESCRIPTION)
|
alt(altDescription(amountInSats))
|
||||||
txid(txid)
|
txid(txid)
|
||||||
recipient(recipientPubKey)
|
recipient(recipientPubKey)
|
||||||
amountInSats(amountInSats)
|
amountInSats(amountInSats)
|
||||||
@@ -134,7 +136,7 @@ class OnchainZapEvent(
|
|||||||
createdAt: Long = TimeUtils.now(),
|
createdAt: Long = TimeUtils.now(),
|
||||||
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
initializer: TagArrayBuilder<OnchainZapEvent>.() -> Unit = {},
|
||||||
) = eventTemplate(KIND, content, createdAt) {
|
) = eventTemplate(KIND, content, createdAt) {
|
||||||
alt(ALT_DESCRIPTION)
|
alt(altDescription(amountInSats))
|
||||||
txid(txid)
|
txid(txid)
|
||||||
recipient(recipientPubKey)
|
recipient(recipientPubKey)
|
||||||
amountInSats(amountInSats)
|
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 outputKey0 = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343"
|
||||||
private val sigHashSingle0 = "2514a6272f85cfa0f45eb907fcb0d121b808ed37c6ea160a5a9046ed5526d555"
|
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 {
|
private fun psbtForVectorTx(): Psbt {
|
||||||
val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex))
|
val psbt = Psbt.fromUnsignedTx(BitcoinTransaction.parse(unsignedTxHex))
|
||||||
spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) }
|
spentOutputs.forEachIndexed { i, utxo -> psbt.setInputWitnessUtxo(i, utxo) }
|
||||||
|
|||||||
+25
-7
@@ -53,13 +53,31 @@ class SegwitAddressTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun encodedTaprootIsLowercaseAndCorrectLength() {
|
fun encodesP2trAgainstBip341WalletTestVectors() {
|
||||||
// P2TR encoded address must be 62 chars, lowercase, with 'bc1p' prefix.
|
// All seven `scriptPubKey` entries from the BIP-341 wallet-test-vectors:
|
||||||
val outputKey = "53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343".hexToByteArray()
|
// (tweaked output key, expected bip350Address).
|
||||||
val address = SegwitAddress.encodeP2TR(outputKey)
|
val vectors =
|
||||||
assertEquals(62, address.length)
|
listOf(
|
||||||
assertEquals(address, address.lowercase())
|
"53a1f6e454df1aa2776a2814a721372d6258050de330b3c6d10ee8f4e0dda343" to
|
||||||
assertEquals("bc1p", address.substring(0, 4))
|
"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())
|
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
|
@Test
|
||||||
fun derivedAddressRoundTripsToOutputKey() {
|
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 address = TaprootAddress.fromPubKey(internalKey)
|
||||||
val decoded = SegwitAddress.decode(address)
|
val decoded = SegwitAddress.decode(address)
|
||||||
assertEquals(1, decoded.witnessVersion)
|
assertEquals(1, decoded.witnessVersion)
|
||||||
|
|||||||
+6
-3
@@ -77,6 +77,7 @@ class OnchainZapVerifierTest {
|
|||||||
|
|
||||||
private class FakeBackend(
|
private class FakeBackend(
|
||||||
private val tx: BitcoinTx?,
|
private val tx: BitcoinTx?,
|
||||||
|
private val tip: Long = 800_006L,
|
||||||
) : OnchainBackend {
|
) : OnchainBackend {
|
||||||
override suspend fun getTx(txid: String): BitcoinTx? = if (tx?.txid == txid) tx else null
|
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 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)
|
override suspend fun feeEstimates(): FeeEstimates = FeeEstimates(20.0, 10.0, 5.0)
|
||||||
}
|
}
|
||||||
@@ -100,17 +101,19 @@ class OnchainZapVerifierTest {
|
|||||||
BitcoinTxOutput(0, 25000L, recipientScriptHex),
|
BitcoinTxOutput(0, 25000L, recipientScriptHex),
|
||||||
BitcoinTxOutput(1, 99000L, senderScriptHex), // change
|
BitcoinTxOutput(1, 99000L, senderScriptHex), // change
|
||||||
),
|
),
|
||||||
confirmations = 3,
|
confirmations = 1,
|
||||||
blockHashHex = "f".repeat(64),
|
blockHashHex = "f".repeat(64),
|
||||||
blockHeight = 800_000L,
|
blockHeight = 800_000L,
|
||||||
)
|
)
|
||||||
val verifier = OnchainZapVerifier(FakeBackend(tx))
|
val verifier = OnchainZapVerifier(FakeBackend(tx, tip = 800_006L))
|
||||||
val result = verifier.verify(mkEvent())
|
val result = verifier.verify(mkEvent())
|
||||||
|
|
||||||
assertIs<VerifiedOnchainZap.Confirmed>(result)
|
assertIs<VerifiedOnchainZap.Confirmed>(result)
|
||||||
assertEquals(25000L, result.verifiedSats)
|
assertEquals(25000L, result.verifiedSats)
|
||||||
assertEquals(recipientHex, result.recipientPubKey)
|
assertEquals(recipientHex, result.recipientPubKey)
|
||||||
assertEquals(800_000L, result.blockHeight)
|
assertEquals(800_000L, result.blockHeight)
|
||||||
|
// Real depth computed from the chain tip: 800006 - 800000 + 1 = 7.
|
||||||
|
assertEquals(7, result.confirmations)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
* - `GET /address/{addr}/utxo` — UTXO list for a derived address
|
||||||
* - `POST /tx` — broadcast (body = raw tx hex)
|
* - `POST /tx` — broadcast (body = raw tx hex)
|
||||||
* - `GET /blocks/tip/height` — chain tip
|
* - `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
|
* The `baseUrl` is supplied as a function so callers can hot-swap endpoints
|
||||||
* from `AccountSettings` without rebuilding the backend.
|
* from `AccountSettings` without rebuilding the backend.
|
||||||
@@ -134,26 +135,54 @@ class EsploraBackend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun feeEstimates(): FeeEstimates {
|
override suspend fun feeEstimates(): FeeEstimates {
|
||||||
// mempool.space-style recommended fees endpoint.
|
// mempool.space-style recommended-fees endpoint.
|
||||||
val url = "${baseUrl()}/v1/fees/recommended"
|
val recommendedUrl = "${baseUrl()}/v1/fees/recommended"
|
||||||
val request =
|
val recommended =
|
||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.url(url)
|
.url(recommendedUrl)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.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) {
|
if (!response.isSuccessful) {
|
||||||
throw OnchainBackendException(
|
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 node = JacksonMapper.mapper.readTree(json)
|
||||||
val txid = node["txid"].asText()
|
val txid = node["txid"].asText()
|
||||||
val status = node["status"]
|
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)
|
val node = JacksonMapper.mapper.readTree(json)
|
||||||
return node.map { utxo ->
|
return node.map { utxo ->
|
||||||
val confirmed = utxo["status"]?.get("confirmed")?.asBoolean() == true
|
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)
|
val node = JacksonMapper.mapper.readTree(json)
|
||||||
// mempool.space exposes fastestFee / halfHourFee / hourFee / minimumFee.
|
|
||||||
val fast = node["fastestFee"]?.asDouble()
|
val fast = node["fastestFee"]?.asDouble()
|
||||||
val normal = node["halfHourFee"]?.asDouble() ?: fast
|
val normal = node["halfHourFee"]?.asDouble() ?: fast
|
||||||
val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal
|
val slow = node["hourFee"]?.asDouble() ?: node["minimumFee"]?.asDouble() ?: normal
|
||||||
|
|
||||||
if (fast == null) {
|
if (fast == null) {
|
||||||
Log.w(logTag) { "fee response missing 'fastestFee': $json" }
|
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(20.0, 10.0, 5.0)
|
||||||
}
|
}
|
||||||
return FeeEstimates(
|
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 {
|
companion object {
|
||||||
const val MEMPOOL_API_URL = "https://mempool.space/api"
|
const val MEMPOOL_API_URL = "https://mempool.space/api"
|
||||||
const val BLOCKSTREAM_API_URL = "https://blockstream.info/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