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 c9331c651a..3a62a18f26 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 @@ -401,20 +401,31 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client 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 => { + require(headerInfos.forall(hi => fr.acinq.bitcoin.BlockHeader.checkProofOfWork(hi.header)), s"invalid proof of work for txid=${w.txId}") + // FIXME: this should not be hardcoded. 0x1715a35c is the difficulty of block 600000 + if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) require(headerInfos.forall(hi => hi.header.bits < 0x1715a35c)) + }) + } + // 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 9d92ab761f..1a19efb109 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,41 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5) } + def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = { + import KotlinUtils._ + 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 + check = Block.verifyTxOutProof(proof.toArray) + // check that the block hash included in the proof matches the block in which the tx was published + Some(blockHash) <- getTxBlockHash(txid) + _ = require(check.getFirst.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 = check.getSecond.asScala.map(_.getFirst).map(kmp2scala).map(_.reverse) + _ = require(txids.contains(txid)) + // get the block in which our tx was confirmed and all following blocks + headerInfos <- getBlockInfos(blockHash, confirmations_opt.get) + } 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))) + } + /** 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 +242,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] = { @@ -511,6 +572,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 26d2b19a9b..2a1ce34257 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 @@ -806,4 +806,63 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash) == Script.pay2wpkh(receiveKey)) } + 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") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val address = getNewAddress(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) + } } \ No newline at end of file