Skip to content

Commit 3657dfa

Browse files
committed
Check tx inclusion proofs
When bitcoin core tells use that a tx has been confirmed, ask for and verify a tx inclusion proof: check that the tx was actually included in a block, and that this block was followed by at least as many blocks as bicoin core says it was.
1 parent 85fea72 commit 3657dfa

File tree

4 files changed

+202
-4
lines changed

4 files changed

+202
-4
lines changed

eclair-core/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@
188188
<version>4.1.42.Final</version>
189189
</dependency>
190190
<!-- BITCOIN -->
191+
<dependency>
192+
<groupId>fr.acinq.bitcoin</groupId>
193+
<artifactId>bitcoin-kmp-jvm</artifactId>
194+
<version>0.8.5-SNAPSHOT</version>
195+
</dependency>
191196
<dependency>
192197
<groupId>fr.acinq</groupId>
193198
<artifactId>bitcoin-lib_${scala.version.short}</artifactId>

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,29 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
400400
}
401401

402402
def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = {
403+
log.debug("checking confirmations of txid={}", w.txId)
404+
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
405+
// matter because this only happens once, when the watched transaction has reached min_depth
406+
client.getTxConfirmations(w.txId).flatMap {
407+
case Some(confirmations) if confirmations >= w.minDepth =>
408+
client.checkTxConfirmations(w.txId, confirmations).flatMap { isValid =>
409+
require(isValid, s"invalid confirmation proof for ${w.txId}")
410+
client.getTransaction(w.txId).flatMap { tx =>
411+
client.getTransactionShortId(w.txId).map {
412+
case (height, index) => w match {
413+
case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx))
414+
case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx))
415+
case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx))
416+
case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx))
417+
}
418+
}
419+
}
420+
}
421+
case _ => Future.successful((): Unit)
422+
}
423+
}
424+
425+
def checkConfirmedOld(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = {
403426
log.debug("checking confirmations of txid={}", w.txId)
404427
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
405428
// matter because this only happens once, when the watched transaction has reached min_depth

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc
1818

1919
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2020
import fr.acinq.bitcoin.scalacompat._
21-
import fr.acinq.bitcoin.{Bech32, Block}
21+
import fr.acinq.bitcoin.{Bech32, Block, BlockHeader}
2222
import fr.acinq.eclair.ShortChannelId.coordinates
2323
import fr.acinq.eclair.blockchain.OnChainWallet
2424
import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance}
@@ -69,11 +69,47 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
6969
/** Get the number of confirmations of a given transaction. */
7070
def getTxConfirmations(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[Int]] =
7171
rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the number of confirmations */)
72-
.map(json => Some((json \ "confirmations").extractOrElse[Int](0)))
72+
.map(json =>
73+
Some((json \ "confirmations").extractOrElse[Int](0))
74+
)
7375
.recover {
7476
case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5)
7577
}
7678

79+
/**
80+
*
81+
* @param txids list of transactions ids
82+
* @return a (header, matched) tuple where header is the header of the block for which the transactions were published and matched is a list of transactions
83+
* ids that were actually in that block and their positions in that block
84+
*/
85+
def getTxoutProof(txids: Seq[ByteVector32])(implicit ec: ExecutionContext): Future[(BlockHeader, List[(ByteVector32, Int)])] = {
86+
import KotlinUtils._
87+
rpcClient.invoke("gettxoutproof", txids.toArray)
88+
.map(json => {
89+
val proof = ByteVector.fromValidHex(json.extract[String])
90+
val check = Block.verifyTxOutProof(proof.toArray)
91+
(check.getFirst, check.getSecond.asScala.map(p => (kmp2scala(p.getFirst.reversed()), p.getSecond.intValue())).toList)
92+
})
93+
}
94+
95+
def checkTxConfirmations(txid: ByteVector32, confirmations: Int)(implicit ec: ExecutionContext): Future[Boolean] = {
96+
import KotlinUtils._
97+
98+
def validate(headerInfos: List[BlockHeaderInfo]): Boolean = {
99+
for (i <- 0 until headerInfos.size - 1) {
100+
if(!headerInfos(i).nextBlockHash.contains(kmp2scala(headerInfos(i + 1).header.hash))) return false
101+
if (headerInfos(i + 1).header.hashPreviousBlock != headerInfos(i).header.hash) return false
102+
}
103+
true
104+
}
105+
106+
for {
107+
(header, matched) <- getTxoutProof(List(txid))
108+
_ = require(matched.map { case (txid, _) => txid } contains(txid), s"cannot find inclusion proof for $txid")
109+
headerInfos <- getBlockHeaderInfos(header.blockId, confirmations)
110+
} yield validate(headerInfos)
111+
}
112+
77113
/** Get the hash of the block containing a given transaction. */
78114
private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] =
79115
rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the block hash */)
@@ -207,6 +243,42 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
207243
case _ => Nil
208244
}
209245

246+
//------------------------- BLOCKS -------------------------//
247+
def getBlockHash(height: Int)(implicit ec: ExecutionContext): Future[ByteVector32] = {
248+
rpcClient.invoke("getblockhash", height).map(json => {
249+
val JString(hash) = json
250+
ByteVector32.fromValidHex(hash)
251+
})
252+
}
253+
254+
def getBlockHeaderInfo(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[BlockHeaderInfo] = {
255+
import fr.acinq.bitcoin.{ByteVector32 => ByteVector32Kt}
256+
rpcClient.invoke("getblockheader", blockId.toString()).map(json => {
257+
val JInt(confirmations) = json \ "confirmations"
258+
val JInt(height) = json \ "height"
259+
val JInt(time) = json \ "time"
260+
val JInt(version) = json \ "version"
261+
val JInt(nonce) = json \ "nonce"
262+
val JString(bits) = json \ "bits"
263+
val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed()
264+
val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed()
265+
val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse)
266+
val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue)
267+
require(header.blockId == KotlinUtils.scala2kmp(blockId))
268+
BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash)
269+
})
270+
}
271+
272+
// returns a chain a blocks of a given size starting at `blockId`
273+
def getBlockHeaderInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
274+
275+
def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else {
276+
getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info))
277+
}
278+
279+
getBlockHeaderInfo(blockId).flatMap(info => loop(List(info)))
280+
}
281+
210282
//------------------------- FUNDING -------------------------//
211283

212284
def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
@@ -513,6 +585,8 @@ object BitcoinCoreClient {
513585

514586
case class Utxo(txid: ByteVector32, amount: MilliBtc, confirmations: Long, safe: Boolean, label_opt: Option[String])
515587

588+
case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32])
589+
516590
def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)
517591

518592
}

eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,20 @@
1616

1717
package fr.acinq.eclair.blockchain.bitcoind
1818

19+
import akka.actor.ActorRef
1920
import akka.actor.Status.Failure
2021
import akka.pattern.pipe
21-
import akka.testkit.TestProbe
22+
import akka.testkit.{TestActor, TestProbe}
2223
import fr.acinq.bitcoin
24+
import fr.acinq.bitcoin.BlockHeader
2325
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2426
import fr.acinq.bitcoin.scalacompat.{Block, BtcDouble, ByteVector32, MilliBtcDouble, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut}
2527
import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance}
2628
import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH}
2729
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
2830
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._
2931
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword
30-
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, JsonRPCError}
32+
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient, JsonRPCError}
3133
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
3234
import fr.acinq.eclair.transactions.{Scripts, Transactions}
3335
import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomKey}
@@ -806,4 +808,98 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
806808
assert(addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash) == Script.pay2wpkh(receiveKey))
807809
}
808810

811+
test("get block header info") {
812+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
813+
val sender = TestProbe()
814+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
815+
816+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
817+
val height = sender.expectMsgType[BlockHeight]
818+
bitcoinClient.getBlockHash(height.toInt).pipeTo(sender.ref)
819+
val lastBlockId = sender.expectMsgType[ByteVector32]
820+
bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref)
821+
val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo]
822+
assert(lastBlockInfo.nextBlockHash.isEmpty)
823+
824+
bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref)
825+
val blockId = sender.expectMsgType[ByteVector32]
826+
bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref)
827+
val blockInfo = sender.expectMsgType[BlockHeaderInfo]
828+
assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash)
829+
assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash)))
830+
}
831+
832+
test("get chains of block header infos") {
833+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
834+
val sender = TestProbe()
835+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
836+
837+
def getHeaderInfos(height: Int, count: Int): List[BlockHeaderInfo] = {
838+
bitcoinClient.getBlockHash(height).pipeTo(sender.ref)
839+
val blockId = sender.expectMsgType[ByteVector32]
840+
bitcoinClient.getBlockHeaderInfos(blockId, count).pipeTo(sender.ref)
841+
sender.expectMsgType[List[BlockHeaderInfo]]
842+
}
843+
844+
def check(blockInfos: List[BlockHeaderInfo]): Unit = {
845+
for (i <- 0 until blockInfos.size - 1) {
846+
require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash)))
847+
require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash)
848+
}
849+
}
850+
851+
check(getHeaderInfos(140, 5))
852+
check(getHeaderInfos(146, 5))
853+
}
854+
855+
test("get tx confirmation proof") {
856+
val sender = TestProbe()
857+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
858+
859+
val address = getNewAddress(sender)
860+
val tx1 = sendToAddress(address, 5 btc, sender)
861+
862+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
863+
sender.expectMsg(Some(0))
864+
865+
bitcoinClient.checkTxConfirmations(tx1.txid, 1).pipeTo(sender.ref)
866+
sender.expectMsgType[Failure]
867+
868+
generateBlocks(3)
869+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
870+
sender.expectMsg(Some(3))
871+
872+
bitcoinClient.checkTxConfirmations(tx1.txid, 3).pipeTo(sender.ref)
873+
assert(sender.expectMsgType[Boolean])
874+
}
875+
876+
test("verify tx confirmation proof") {
877+
val sender = TestProbe()
878+
// this is a valid proof for a random tx on testnet
879+
val validProof = "0000c020d80e8834189edc6f67bb6683e0f1fc21608fb30f0c7148432b0000000000000078d01cf841039ee0d10e9e43cae12d54f08251fd96ac13688991a9af155f1c93b4f0d762f1da381903a82037140000000653f44c07bbd9acc8a9f9efa4ffaef176e0be7cfd7125b4985dc63b516191f81384d408a276c856d1816ffaf66f33dd17d4601a8e7c702ad98db859bd2c9d03ee2d982aad9cf8956835baa2011888a30a759548b3702b98c2266f4dd6a42b3dacefa0c54813f90ee2c7629f7600fcb782503c98a1df30368c568cc8c780c9d97b3c99c68fffea99cf505a02138e52df20e51c77e5784460f95bf3660f302b7046ceaa968b4e007fab9a717a9be9403ba5d866ec3ef81c5155e919aa27da49fa17025f00"
880+
val bitcoinClient = new BitcoinCoreClient(new BitcoinJsonRPCClient {
881+
override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match {
882+
case "gettxoutproof" => Future.successful(new JString(validProof)) // bitcoin core is lying to us
883+
case _ => bitcoinrpcclient.invoke(method, params: _*)(ec)
884+
}
885+
})
886+
887+
val address = getNewAddress(sender)
888+
val tx1 = sendToAddress(address, 5 btc, sender)
889+
890+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
891+
sender.expectMsg(Some(0))
892+
893+
bitcoinClient.checkTxConfirmations(tx1.txid, 1).pipeTo(sender.ref)
894+
sender.expectMsgType[Failure]
895+
896+
generateBlocks(3)
897+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
898+
sender.expectMsg(Some(3))
899+
900+
bitcoinClient.checkTxConfirmations(tx1.txid, 3).pipeTo(sender.ref)
901+
val failure = sender.expectMsgType[Failure]
902+
assert(failure.cause.getMessage.contains(s"cannot find inclusion proof for ${tx1.txid}"))
903+
}
904+
809905
}

0 commit comments

Comments
 (0)