From acc65a3a25570303391605f5b840de23de4e2f8e Mon Sep 17 00:00:00 2001 From: sstone Date: Mon, 8 Aug 2022 18:38:17 +0200 Subject: [PATCH] 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 | 31 ++++-- .../bitcoind/rpc/BitcoinCoreClient.scala | 96 ++++++++++++++++++- .../bitcoind/BitcoinCoreClientSpec.scala | 76 ++++++++++++++- 4 files changed, 193 insertions(+), 11 deletions(-) diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 24bd556fd4..66d1f27779 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 10db3067c6..9401c9d713 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. @@ -412,20 +412,33 @@ 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)) + 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 _ => 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 5298af0dce..745b87f3a7 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] = { @@ -546,6 +636,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 c7b3aff0df..6a9bc7df8d 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, randomKey} @@ -1083,4 +1083,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