From 3ec4af79ce6a216443d5c4cf8023aa22080bc662 Mon Sep 17 00:00:00 2001 From: sstone Date: Mon, 8 Aug 2022 18:38:17 +0200 Subject: [PATCH 1/3] Check tx confirmation proofs When we're notified that a tx has been confirmed, we: - ask bitcoind for a "txout" proof i.e a proof that the tx was included in a block - verify this proof - verify the proof of work of the block in which it was published and its descendants by checking that the block hash matches the block difficulty and (only on mainnet) that the diffculty is above a given target --- eclair-core/src/main/resources/reference.conf | 1 + .../blockchain/bitcoind/ZmqWatcher.scala | 33 +++++-- .../bitcoind/rpc/BitcoinCoreClient.scala | 96 ++++++++++++++++++- .../bitcoind/BitcoinCoreClientSpec.scala | 76 ++++++++++++++- 4 files changed, 194 insertions(+), 12 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 11e93b9ca8..4a0cdb368a 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -40,6 +40,7 @@ eclair { // - ignore: eclair will leave these utxos locked and start startup-locked-utxos-behavior = "stop" final-pubkey-refresh-delay = 3 seconds + min-difficulty = 387294044 // difficulty of block 600000 } node-alias = "eclair" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 5455a06a70..4cd18fc347 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -30,7 +30,7 @@ import fr.acinq.eclair.{BlockHeight, KamonExt, NodeParams, RealShortChannelId, T import java.util.concurrent.atomic.AtomicLong import scala.concurrent.duration._ import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success} +import scala.util.{Failure, Success, Try} /** * Created by PM on 21/02/2016. @@ -415,21 +415,34 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = { log.debug("checking confirmations of txid={}", w.txId) + + def checkConfirmationProof(): Future[Unit] = { + client.getTxConfirmationProof(w.txId).map(headerInfos => { + if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) { + // 0x1715a35cL = 387294044 = difficulty of block 600000 + val minDiff = Try(context.system.settings.config.getLong("eclair.bitcoind.min-difficulty")).getOrElse(0x1715a35cL) + require(headerInfos.forall(hi => hi.header.bits < minDiff)) + } + }) + } + // NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really // matter because this only happens once, when the watched transaction has reached min_depth client.getTxConfirmations(w.txId).flatMap { case Some(confirmations) if confirmations >= w.minDepth => - client.getTransaction(w.txId).flatMap { tx => - client.getTransactionShortId(w.txId).map { - case (height, index) => w match { - case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx)) - case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx)) - case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx)) - case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx)) - case w: WatchAlternativeCommitTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchAlternativeCommitTxConfirmedTriggered(height, index, tx)) + checkConfirmationProof().andThen(_ => + client.getTransaction(w.txId).flatMap { tx => + client.getTransactionShortId(w.txId).map { + case (height, index) => w match { + case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx)) + case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx)) + case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx)) + case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx)) + case w: WatchAlternativeCommitTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchAlternativeCommitTxConfirmedTriggered(height, index, tx)) + } } } - } + ) case _ => Future.successful((): Unit) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 476fd8a777..6461d049b0 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat._ -import fr.acinq.bitcoin.{Bech32, Block} +import fr.acinq.bitcoin.{Bech32, Block, BlockHeader} import fr.acinq.eclair.ShortChannelId.coordinates import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} @@ -74,6 +74,70 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5) } + /** + * + * @param txid transaction id + * @return a list of block header information, starting from the block in which the transaction was published, up to the current tip + */ + def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = { + import KotlinUtils._ + + /** + * Scala wrapper for Block.verifyTxOutProof + * + * @param proof tx output proof, as provided by bitcoind + * @return a (Header, List(txhash, position)) tuple. Header is the header of the block used to compute the input proof, and + * (txhash, position) is a list of transaction ids that were verified, and their position in the block + */ + def verifyTxOutProof(proof: ByteVector): (BlockHeader, List[(ByteVector32, Int)]) = { + val check = Block.verifyTxOutProof(proof.toArray) + (check.getFirst, check.getSecond.asScala.toList.map(p => (kmp2scala(p.getFirst), p.getSecond.intValue()))) + } + + for { + confirmations_opt <- getTxConfirmations(txid) + if (confirmations_opt.isDefined && confirmations_opt.get > 0) + // get the merkle proof for our txid + proof <- getTxOutProof(txid) + // verify this merkle proof. if valid, we get the header for the block the tx was published in, and the tx hashes + // that can be used to rebuild the block's merkle root + (header, txHashesAndPos) = verifyTxOutProof(proof) + // inclusionData contains a header and a list of (txid, position) that can be used to re-build the header's merkle root + // check that the block hash included in the proof matches the block in which the tx was published + Some(blockHash) <- getTxBlockHash(txid) + _ = require(header.blockId.contentEquals(blockHash.toArray), "confirmation proof is not valid (block id mismatch)") + // check that our txid is included in the merkle root of the block it was published in + txids = txHashesAndPos.map { case (txhash, _) => txhash.reverse } + _ = require(txids.contains(txid), "confirmation proof is not valid (txid not found)") + // get the block in which our tx was confirmed and all following blocks + headerInfos <- getBlockInfos(blockHash, confirmations_opt.get) + _ = require(headerInfos.head.header.blockId.contentEquals(blockHash.toArray), "block header id mismatch") + } yield headerInfos + } + + def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] = + rpcClient.invoke("gettxoutproof", Array(txid)).collect { case JString(raw) => ByteVector.fromValidHex(raw) } + + // returns a chain a blocks of a given size starting at `blockId` + def getBlockInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = { + import KotlinUtils._ + + def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else { + getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info)) + } + + getBlockHeaderInfo(blockId).flatMap(info => loop(List(info))).map(blocks => { + for (i <- 0 until blocks.size - 1) { + require(BlockHeader.checkProofOfWork(blocks(i).header)) + require(blocks(i).height == blocks(0).height + i) + require(blocks(i).confirmation == blocks(0).confirmation - i) + require(blocks(i).nextBlockHash.contains(kmp2scala(blocks(i + 1).header.hash))) + require(blocks(i + 1).header.hashPreviousBlock == blocks(i).header.hash) + } + blocks + }) + } + /** Get the hash of the block containing a given transaction. */ private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] = rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the block hash */) @@ -207,6 +271,32 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall case _ => Nil } + //------------------------- BLOCKS -------------------------// + def getBlockHash(height: Int)(implicit ec: ExecutionContext): Future[ByteVector32] = { + rpcClient.invoke("getblockhash", height).map(json => { + val JString(hash) = json + ByteVector32.fromValidHex(hash) + }) + } + + def getBlockHeaderInfo(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[BlockHeaderInfo] = { + import fr.acinq.bitcoin.{ByteVector32 => ByteVector32Kt} + rpcClient.invoke("getblockheader", blockId.toString()).map(json => { + val JInt(confirmations) = json \ "confirmations" + val JInt(height) = json \ "height" + val JInt(time) = json \ "time" + val JInt(version) = json \ "version" + val JInt(nonce) = json \ "nonce" + val JString(bits) = json \ "bits" + val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed() + val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed() + val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse) + val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue) + require(header.blockId == KotlinUtils.scala2kmp(blockId)) + BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash) + }) + } + //------------------------- FUNDING -------------------------// def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { @@ -623,6 +713,10 @@ object BitcoinCoreClient { case class Utxo(txid: ByteVector32, amount: MilliBtc, confirmations: Long, safe: Boolean, label_opt: Option[String]) + case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32]) + + case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32]) + def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue) } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index ed7e8b526d..fee0920670 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -28,7 +28,7 @@ import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpen import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword -import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, JsonRPCError} +import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient, JsonRPCError} import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw} import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomBytes32, randomKey} @@ -1321,4 +1321,78 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(sender.expectMsgType[Transaction].txid == tx.txid) } + test("get block header info") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + bitcoinClient.getBlockHeight().pipeTo(sender.ref) + val height = sender.expectMsgType[BlockHeight] + bitcoinClient.getBlockHash(height.toInt).pipeTo(sender.ref) + val lastBlockId = sender.expectMsgType[ByteVector32] + bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref) + val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo] + assert(lastBlockInfo.nextBlockHash.isEmpty) + + bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref) + val blockId = sender.expectMsgType[ByteVector32] + bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref) + val blockInfo = sender.expectMsgType[BlockHeaderInfo] + assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash) + assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash))) + } + + test("get chains of block headers") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + + bitcoinClient.getBlockHash(140).pipeTo(sender.ref) + val blockId = sender.expectMsgType[ByteVector32] + bitcoinClient.getBlockInfos(blockId, 5).pipeTo(sender.ref) + val blockInfos = sender.expectMsgType[List[BlockHeaderInfo]] + for (i <- 0 until blockInfos.size - 1) { + require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash))) + require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash) + } + } + + test("verify tx publication proofs") { + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val address = getNewAddress(sender) + + // we create a dummy confirmed tx, we'll use its txout proof later + val dummyTx = sendToAddress(address, 5 btc, sender) + + val tx = sendToAddress(address, 5 btc, sender) + // transaction is not confirmed yet + bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref) + sender.expectMsg(Some(0)) + + // let's confirm our transaction. + generateBlocks(6) + bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref) + sender.expectMsg(Some(6)) + + bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref) + val proof = sender.expectMsgType[ByteVector] + val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray) + val header = check.getFirst + bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref) + val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]] + assert(header == headerInfos.head.header) + + // try again with a bitcoin client that returns a proof that is not valid for our tx but from the same block where it was confirmed + bitcoinClient.getTxOutProof(dummyTx.txid).pipeTo(sender.ref) + val dumyProof = sender.expectMsgType[ByteVector] + val evilBitcoinClient = new BitcoinCoreClient(new BitcoinJsonRPCClient { + override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match { + case "gettxoutproof" => Future.successful(JString(dumyProof.toHex)) + case _ => bitcoinrpcclient.invoke(method, params: _*)(ec) + } + }) + evilBitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref) + val error = sender.expectMsgType[Failure] + assert(error.cause.getMessage.contains("txid not found")) + } } \ No newline at end of file From 949424ac2a3eb1ebec3e33062bdb21d56cb12fe9 Mon Sep 17 00:00:00 2001 From: sstone Date: Mon, 22 May 2023 15:00:09 +0200 Subject: [PATCH 2/3] Rework and simplify tx inclusion proofs We can use the tx position that we compute when we verify the inclusion proof instead of explicitly asking Bitcoin Core for it. We also check that the id of the tx that is returned match what we asked for, and add min-difficulty checks for regtest and testnet. --- eclair-core/src/main/resources/reference.conf | 2 +- .../blockchain/bitcoind/ZmqWatcher.scala | 43 ++++++++----------- .../bitcoind/rpc/BitcoinCoreClient.scala | 28 +++++++----- .../bitcoind/BitcoinCoreClientSpec.scala | 4 +- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 4a0cdb368a..cbc574fc82 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -40,7 +40,7 @@ eclair { // - ignore: eclair will leave these utxos locked and start startup-locked-utxos-behavior = "stop" final-pubkey-refresh-delay = 3 seconds - min-difficulty = 387294044 // difficulty of block 600000 + min-difficulty-target = 387294044 // difficulty of block 600000 } node-alias = "eclair" diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 4cd18fc347..86c58780ea 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -416,35 +416,30 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = { log.debug("checking confirmations of txid={}", w.txId) - def checkConfirmationProof(): Future[Unit] = { - client.getTxConfirmationProof(w.txId).map(headerInfos => { - if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) { - // 0x1715a35cL = 387294044 = difficulty of block 600000 - val minDiff = Try(context.system.settings.config.getLong("eclair.bitcoind.min-difficulty")).getOrElse(0x1715a35cL) - require(headerInfos.forall(hi => hi.header.bits < minDiff)) - } - }) + val minDifficultyTarget = nodeParams.chainHash match { + case Block.LivenetGenesisBlock.hash => Try(context.system.settings.config.getLong("eclair.bitcoind.min-difficulty-target")).getOrElse(0x1715a35cL) // 0x1715a35cL = 387294044 = difficulty target of block 600000 + case Block.TestnetGenesisBlock.hash => 0x1d00ffffL + case _ => 0x207fffffL } - // NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really - // matter because this only happens once, when the watched transaction has reached min_depth client.getTxConfirmations(w.txId).flatMap { case Some(confirmations) if confirmations >= w.minDepth => - checkConfirmationProof().andThen(_ => - client.getTransaction(w.txId).flatMap { tx => - client.getTransactionShortId(w.txId).map { - case (height, index) => w match { - case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx)) - case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx)) - case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx)) - case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx)) - case w: WatchAlternativeCommitTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchAlternativeCommitTxConfirmedTriggered(height, index, tx)) - } - } + for { + proof <- client.getTxConfirmationProof(w.txId) + _ = require(proof.confirmations >= confirmations) + _ = require(proof.headerInfos.forall(hi => hi.header.bits <= minDifficultyTarget)) + height = BlockHeight(proof.height) + tx <- client.getTransaction(w.txId) + } yield { + w match { + case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, proof.txIndex, tx)) + case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, proof.txIndex, tx)) + case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, proof.txIndex, tx)) + case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, proof.txIndex, tx)) + case w: WatchAlternativeCommitTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchAlternativeCommitTxConfirmedTriggered(height, proof.txIndex, tx)) } - ) - case _ => Future.successful((): Unit) + } + case _ => Future.successful(()) } } - } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 6461d049b0..3b0a4b9885 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -52,7 +52,11 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall //------------------------- TRANSACTIONS -------------------------// def getTransaction(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] = - getRawTransaction(txid).map(raw => Transaction.read(raw)) + getRawTransaction(txid).map(raw => { + val tx = Transaction.read(raw) + require(tx.txid == txid, "transaction id mismatch") + tx + }) private def getRawTransaction(txid: ByteVector32)(implicit ec: ExecutionContext): Future[String] = rpcClient.invoke("getrawtransaction", txid).collect { @@ -79,7 +83,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall * @param txid transaction id * @return a list of block header information, starting from the block in which the transaction was published, up to the current tip */ - def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = { + def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[TxConfirmationProof] = { import KotlinUtils._ /** @@ -103,16 +107,13 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall // that can be used to rebuild the block's merkle root (header, txHashesAndPos) = verifyTxOutProof(proof) // inclusionData contains a header and a list of (txid, position) that can be used to re-build the header's merkle root - // check that the block hash included in the proof matches the block in which the tx was published - Some(blockHash) <- getTxBlockHash(txid) - _ = require(header.blockId.contentEquals(blockHash.toArray), "confirmation proof is not valid (block id mismatch)") - // check that our txid is included in the merkle root of the block it was published in - txids = txHashesAndPos.map { case (txhash, _) => txhash.reverse } - _ = require(txids.contains(txid), "confirmation proof is not valid (txid not found)") + // find the position of txid in the merkle root of the block it was published in + pos_opt = txHashesAndPos.find { case (hash, _) => hash.reverse == txid } map { case (_, pos) => pos } + _ = require(pos_opt.isDefined, "confirmation proof is not valid (txid not found)") // get the block in which our tx was confirmed and all following blocks - headerInfos <- getBlockInfos(blockHash, confirmations_opt.get) - _ = require(headerInfos.head.header.blockId.contentEquals(blockHash.toArray), "block header id mismatch") - } yield headerInfos + headerInfos <- getBlockInfos(header.blockId, confirmations_opt.get) + _ = require(headerInfos.head.header.blockId == header.blockId, "block header id mismatch") + } yield TxConfirmationProof(txid, headerInfos, pos_opt.get) } def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] = @@ -717,6 +718,11 @@ object BitcoinCoreClient { case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32]) + case class TxConfirmationProof(txid: ByteVector32, headerInfos: List[BlockHeaderInfo], txIndex: Int) { + val confirmations = headerInfos.size + val height = headerInfos.head.height + } + def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue) } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index fee0920670..b40ef6e811 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -1379,8 +1379,8 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray) val header = check.getFirst bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref) - val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]] - assert(header == headerInfos.head.header) + val confirmationProof = sender.expectMsgType[TxConfirmationProof] + assert(header == confirmationProof.headerInfos.head.header) // try again with a bitcoin client that returns a proof that is not valid for our tx but from the same block where it was confirmed bitcoinClient.getTxOutProof(dummyTx.txid).pipeTo(sender.ref) From 8b8294f5a7b7d98f46f6e56edb977f291f3d209b Mon Sep 17 00:00:00 2001 From: sstone Date: Tue, 23 May 2023 21:17:19 +0200 Subject: [PATCH 3/3] Check the header's head when verifying tx inclusion proofs All blocks now are BIP34 compliant commit to their height in the sig script of their coinbase tx. --- .../bitcoind/rpc/BitcoinCoreClient.scala | 100 +++++++++++++----- .../bitcoind/BitcoinCoreClientSpec.scala | 46 ++++++-- 2 files changed, 114 insertions(+), 32 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 3b0a4b9885..b0a8f3a52c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -70,14 +70,22 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall JInt(timestamp) = blockchainInfo \ "mediantime" } yield GetTxWithMetaResponse(txid, tx_opt, TimestampSecond(timestamp.toLong)) - /** Get the number of confirmations of a given transaction. */ - def getTxConfirmations(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[Int]] = - rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the number of confirmations */) - .map(json => Some((json \ "confirmations").extractOrElse[Int](0))) + + def getTxInfo(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[TransactionInfo]] = + rpcClient.invoke("getrawtransaction", txid, 1).map(json => { + val confirmations = Some((json \ "confirmations").extractOrElse[Int](0)) + val blockHash = (json \ "blockhash").extractOpt[String].map(ByteVector32.fromValidHex) + val hex = (json \ "hex").extract[String] + val tx = Transaction.read(hex) + require(tx.txid == txid, "transaction id mismatch") + Some(TransactionInfo(tx, confirmations, blockHash)) + }) .recover { case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5) } + def getTxConfirmations(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[Int]] = getTxInfo(txid).map(_.flatMap(_.confirmations)) + /** * * @param txid transaction id @@ -99,32 +107,54 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall } for { - confirmations_opt <- getTxConfirmations(txid) - if (confirmations_opt.isDefined && confirmations_opt.get > 0) + txinfo <- getTxInfo(txid) + confirmations_opt = txinfo.flatMap(_.confirmations) + if confirmations_opt.isDefined && confirmations_opt.get > 0 + blockId = txinfo.flatMap(_.blockHash).get + // get the coinbase tx for this block, we'll use it to check the block's height + blockTxids <- getTransactionIds(blockId) + coinbaseTxid = blockTxids.head + coinbaseTx <- getTransaction(coinbaseTxid) // get the merkle proof for our txid - proof <- getTxOutProof(txid) + proof <- getTxOutProof(Seq(txid, coinbaseTxid), Some(blockId)) // verify this merkle proof. if valid, we get the header for the block the tx was published in, and the tx hashes // that can be used to rebuild the block's merkle root (header, txHashesAndPos) = verifyTxOutProof(proof) + _ = require(txHashesAndPos.exists { case (hash, _) => hash.reverse == coinbaseTxid }, "confirmation proof is not valid for coinbase tx") // inclusionData contains a header and a list of (txid, position) that can be used to re-build the header's merkle root // find the position of txid in the merkle root of the block it was published in pos_opt = txHashesAndPos.find { case (hash, _) => hash.reverse == txid } map { case (_, pos) => pos } _ = require(pos_opt.isDefined, "confirmation proof is not valid (txid not found)") // get the block in which our tx was confirmed and all following blocks + height = decodeBlockHeight(coinbaseTx) headerInfos <- getBlockInfos(header.blockId, confirmations_opt.get) _ = require(headerInfos.head.header.blockId == header.blockId, "block header id mismatch") + _ = require(headerInfos.head.height == height, "block header height mismatch") } yield TxConfirmationProof(txid, headerInfos, pos_opt.get) } - def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] = - rpcClient.invoke("gettxoutproof", Array(txid)).collect { case JString(raw) => ByteVector.fromValidHex(raw) } + def getTxOutProof(txids: Seq[ByteVector32], blockHash_opt: Option[ByteVector32])(implicit ec: ExecutionContext): Future[ByteVector] = (blockHash_opt match { + case Some(blockHash) => rpcClient.invoke("gettxoutproof", txids.toArray, blockHash) + case None => rpcClient.invoke("gettxoutproof", txids.toArray) + }).collect { case JString(raw) => ByteVector.fromValidHex(raw) } + + def getTxOutProof(txid: ByteVector32, blockHash_opt: Option[ByteVector32] = None)(implicit ec: ExecutionContext): Future[ByteVector] = getTxOutProof(Seq(txid), blockHash_opt) + + def getBlock(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[Block] = { + rpcClient.invoke("getblock", blockId.toString(), 0).collect { + case JString(raw) => + val block = Block.read(raw) + require(block.blockId.contentEquals(blockId.toArray)) + block + } + } // returns a chain a blocks of a given size starting at `blockId` def getBlockInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = { import KotlinUtils._ def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else { - getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info)) + getBlockHeaderInfo(blocks.last.nextBlockId.get).flatMap(info => loop(blocks :+ info)) } getBlockHeaderInfo(blockId).flatMap(info => loop(List(info))).map(blocks => { @@ -132,20 +162,15 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall require(BlockHeader.checkProofOfWork(blocks(i).header)) require(blocks(i).height == blocks(0).height + i) require(blocks(i).confirmation == blocks(0).confirmation - i) - require(blocks(i).nextBlockHash.contains(kmp2scala(blocks(i + 1).header.hash))) - require(blocks(i + 1).header.hashPreviousBlock == blocks(i).header.hash) + require(blocks(i).nextBlockId.contains(kmp2scala(blocks(i + 1).header.blockId)), "next block id mismatch") + require(blocks(i + 1).header.hashPreviousBlock == blocks(i).header.hash, "previous block id mismatch") } blocks }) } /** Get the hash of the block containing a given transaction. */ - private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] = - rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the block hash */) - .map(json => (json \ "blockhash").extractOpt[String].map(ByteVector32.fromValidHex)) - .recover { - case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5) - } + private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] = getTxInfo(txid).map(i => i.flatMap(_.blockHash)) /** * @return a Future[height, index] where height is the height of the block where this transaction was published, and @@ -290,11 +315,18 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall val JInt(nonce) = json \ "nonce" val JString(bits) = json \ "bits" val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed() - val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed() - val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse) - val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue) + val previousblockId = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]) + val nextblockId = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h)) + val header = new BlockHeader(version.longValue, previousblockId.reversed(), merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue) require(header.blockId == KotlinUtils.scala2kmp(blockId)) - BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash) + BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockId) + }) + } + + def getTransactionIds(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[Seq[ByteVector32]] = { + rpcClient.invoke("getblock", blockId.toString(), 1).map(json => { + val JArray(txids) = json \ "tx" + txids.map(txid => ByteVector32.fromValidHex(txid.extract[String])) }) } @@ -714,9 +746,11 @@ object BitcoinCoreClient { case class Utxo(txid: ByteVector32, amount: MilliBtc, confirmations: Long, safe: Boolean, label_opt: Option[String]) - case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32]) + case class TransactionInfo(tx: Transaction, confirmations: Option[Int], blockHash: Option[ByteVector32]) - case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32]) + case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockId: Option[ByteVector32]) + + case class BlockInfo(headerInfo: BlockHeaderInfo, txids: Seq[ByteVector32]) case class TxConfirmationProof(txid: ByteVector32, headerInfos: List[BlockHeaderInfo], txIndex: Int) { val confirmations = headerInfos.size @@ -725,4 +759,22 @@ object BitcoinCoreClient { def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue) + /** + * TODO: move this into bitcoin-kmp + * Extract the block height embedded in a block's coinbase tx (see BIP34) + * + * @param signatureScript coinbase transaction input signature script + * @return the block height committed to in the coinbase tx + */ + def decodeBlockHeight(signatureScript: ByteVector): Long = { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + + val it = fr.acinq.bitcoin.Script.INSTANCE.scriptIterator(signatureScript.toArray) + val op: ScriptElt = it.next() + require(op.isPush, "signature script does not match BIP34 rules") + Script.decodeNumber(op.asInstanceOf[OP_PUSHDATA].data, false, 4) + } + + def decodeBlockHeight(coinbaseTx: Transaction): Long = decodeBlockHeight(coinbaseTx.txIn(0).signatureScript) + } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index b40ef6e811..0d2cc7ada8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -21,8 +21,8 @@ import akka.pattern.pipe import akka.testkit.TestProbe import fr.acinq.bitcoin import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Block, Btc, BtcDouble, ByteVector32, Crypto, MilliBtcDouble, OP_DROP, OP_PUSHDATA, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, computeP2PkhAddress, computeP2WpkhAddress} -import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} +import fr.acinq.bitcoin.scalacompat.{Btc, BtcDouble, ByteVector32, Crypto, MilliBtcDouble, OP_DROP, OP_PUSHDATA, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, computeP2PkhAddress, computeP2WpkhAddress} +import fr.acinq.bitcoin.{Bech32, Block, SigHash, SigVersion} import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq @@ -308,6 +308,8 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } test("create/commit/rollback funding txs") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) @@ -372,6 +374,8 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } test("unlock failed funding txs") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) @@ -1224,6 +1228,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } test("get pubkey for p2wpkh receive address") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) @@ -1247,6 +1252,7 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } test("generate segwit change outputs") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) @@ -1331,14 +1337,14 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val lastBlockId = sender.expectMsgType[ByteVector32] bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref) val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo] - assert(lastBlockInfo.nextBlockHash.isEmpty) + assert(lastBlockInfo.nextBlockId.isEmpty) bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref) val blockId = sender.expectMsgType[ByteVector32] bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref) val blockInfo = sender.expectMsgType[BlockHeaderInfo] assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash) - assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash))) + assert(blockInfo.nextBlockId.contains(kmp2scala(lastBlockInfo.header.blockId))) } test("get chains of block headers") { @@ -1351,11 +1357,34 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.getBlockInfos(blockId, 5).pipeTo(sender.ref) val blockInfos = sender.expectMsgType[List[BlockHeaderInfo]] for (i <- 0 until blockInfos.size - 1) { - require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash))) + require(blockInfos(i).nextBlockId.contains(kmp2scala(blockInfos(i + 1).header.blockId))) require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash) } } + test("get transaction ids") { + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val address = getNewAddress(sender) + + generateBlocks(1) + val txs = (0 until 10).map(_ => sendToAddress(address, 10000.sat)) + generateBlocks(1) + bitcoinClient.getBlockHeight().flatMap(height => bitcoinClient.getBlockHash(height.toInt)).pipeTo(sender.ref) + val blockHash = sender.expectMsgType[ByteVector32] + bitcoinClient.getBlock(blockHash).pipeTo(sender.ref) + val block = sender.expectMsgType[Block] + assert(block.blockId.contentEquals(blockHash.toArray)) + bitcoinClient.getTransactionIds(blockHash).pipeTo(sender.ref) + val txids = sender.expectMsgType[Seq[ByteVector32]] + assert((txs.map(_.txid).toSet -- txids).isEmpty) + } + + test("decode embedded block height") { + val coinbaseTx = Transaction.read("010000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff580399de0a1b4d696e656420627920416e74506f6f6c373436a10015037d15afd8fabe6d6d84ec36731b79c697ddf94ce60c369686671fddcfa1d89bab89445908e0acf31f0200000000000000514900009520020000000000ffffffff04d0ee6425000000001976a91411dbe48cc6b617f9c6adaf4d9ed5f625b1c7cb5988ac0000000000000000266a24aa21a9ed271daeb7b480018dd1dc9c53180c1a7eb89d004e7eb4ec2937e69909f3782a030000000000000000266a24b9e11b6d68d1fd4e697128e0b3901d0229ae7cd7f14124d9678aae7cb716beebe65fb1b800000000000000002b6a2952534b424c4f434b3af5be02402ea85e79a5198ef3a6988aded3fa928a6609120f27905328003b6f7b0120000000000000000000000000000000000000000000000000000000000000000000000000") + require(decodeBlockHeight(coinbaseTx) == 712345) + } + test("verify tx publication proofs") { val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) @@ -1374,7 +1403,9 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref) sender.expectMsg(Some(6)) - bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref) + bitcoinClient.getBlockHeight().flatMap(height => bitcoinClient.getBlockHash(height.toInt - 5)).pipeTo(sender.ref) + val blockHash = sender.expectMsgType[ByteVector32] + bitcoinClient.getTxOutProof(tx.txid, Some(blockHash)).pipeTo(sender.ref) val proof = sender.expectMsgType[ByteVector] val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray) val header = check.getFirst @@ -1392,7 +1423,6 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } }) evilBitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref) - val error = sender.expectMsgType[Failure] - assert(error.cause.getMessage.contains("txid not found")) + sender.expectMsgType[Failure] } } \ No newline at end of file