Skip to content

Commit 33fff0b

Browse files
committed
[WIP] check tx inclusion 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 id was used to build the block's merkle tree - 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
1 parent c1a925d commit 33fff0b

File tree

3 files changed

+190
-10
lines changed

3 files changed

+190
-10
lines changed

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -401,20 +401,32 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
401401

402402
def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = {
403403
log.debug("checking confirmations of txid={}", w.txId)
404+
405+
def checkConfirmationProof(): Future[Unit] = {
406+
println(s"checking checkConfirmationProof for ${w.txId}")
407+
client.getTxConfirmationProof(w.txId).map(headerInfos => {
408+
require(headerInfos.forall(hi => fr.acinq.bitcoin.BlockHeader.checkProofOfWork(hi.header)), s"invalid proof of work for txid=${w.txId}")
409+
// FIXME: this should not be hardcoded. 0x1715a35c is the difficulty of block 600000
410+
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) require(headerInfos.forall(hi => hi.header.bits < 0x1715a35c))
411+
})
412+
}
413+
404414
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
405415
// matter because this only happens once, when the watched transaction has reached min_depth
406416
client.getTxConfirmations(w.txId).flatMap {
407417
case Some(confirmations) if confirmations >= w.minDepth =>
408-
client.getTransaction(w.txId).flatMap { tx =>
409-
client.getTransactionShortId(w.txId).map {
410-
case (height, index) => w match {
411-
case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx))
412-
case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx))
413-
case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx))
414-
case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx))
418+
checkConfirmationProof().andThen(_ =>
419+
client.getTransaction(w.txId).flatMap { tx =>
420+
client.getTransactionShortId(w.txId).map {
421+
case (height, index) => w match {
422+
case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx))
423+
case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx))
424+
case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx))
425+
case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx))
426+
}
415427
}
416428
}
417-
}
429+
)
418430
case _ => Future.successful((): Unit)
419431
}
420432
}

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

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616

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

19+
import fr.acinq.bitcoin
1920
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2021
import fr.acinq.bitcoin.scalacompat._
21-
import fr.acinq.bitcoin.{Bech32, Block}
22+
import fr.acinq.bitcoin.{Bech32, Block, BlockHeader}
2223
import fr.acinq.eclair.ShortChannelId.coordinates
2324
import fr.acinq.eclair.blockchain.OnChainWallet
2425
import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse}
@@ -28,10 +29,12 @@ import fr.acinq.eclair.transactions.Transactions
2829
import fr.acinq.eclair.wire.protocol.ChannelAnnouncement
2930
import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates}
3031
import grizzled.slf4j.Logging
32+
import kotlin.Pair
3133
import org.json4s.Formats
3234
import org.json4s.JsonAST._
3335
import scodec.bits.ByteVector
3436

37+
import scala.collection.mutable
3538
import scala.concurrent.{ExecutionContext, Future}
3639
import scala.jdk.CollectionConverters.ListHasAsScala
3740
import scala.util.{Failure, Success, Try}
@@ -74,6 +77,55 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
7477
case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5)
7578
}
7679

80+
def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
81+
import KotlinUtils._
82+
83+
/**
84+
* Scala wrapper for Block.verifyTxOutProof
85+
* @param proof tx output proof, as provided by bitcoind
86+
* @return a (Header, List(txhash, position)) tuple
87+
*/
88+
def verifyTxOutProof(proof: ByteVector): (BlockHeader, List[(ByteVector32, Int)]) = {
89+
val check = Block.verifyTxOutProof(proof.toArray)
90+
(check.getFirst, check.getSecond.asScala.toList.map(p => (kmp2scala(p.getFirst), p.getSecond.intValue())))
91+
}
92+
93+
for {
94+
confirmations_opt <- getTxConfirmations(txid)
95+
if (confirmations_opt.isDefined && confirmations_opt.get > 0)
96+
// get the merkle proof for our txid
97+
proof <- getTxOutProof(txid)
98+
// verify this merkle proof. if valid, we get the header for the block the tx was published in, and the tx hashes
99+
// that can be used to rebuild the block's merkle root
100+
(header, txHashesAndPos) = verifyTxOutProof(proof)
101+
// inclusionData contains a header and a list of (txid, position) that can be used to re-build the header's merkle root
102+
// check that the block hash included in the proof matches the block in which the tx was published
103+
Some(blockHash) <- getTxBlockHash(txid)
104+
_ = require(header.blockId.contentEquals(blockHash.toArray), "confirmation proof is not valid (block id mismatch)")
105+
// check that our txid is included in the merkle root of the block it was published in
106+
txids = txHashesAndPos.map { case (txhash, _) => txhash.reverse }
107+
_ = require(txids.contains(txid), "confirmation proof is not valid (txid not found)")
108+
// get the block in which our tx was confirmed and all following blocks
109+
headerInfos <- getBlockInfos(blockHash, confirmations_opt.get)
110+
_ = require(headerInfos.head.header.blockId.contentEquals(blockHash.toArray), "block header id mismatch")
111+
_ = require(headerInfos.forall(hi => BlockHeader.checkProofOfWork(hi.header)), "header's proof of work is not valid")
112+
} yield headerInfos
113+
}
114+
115+
def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] =
116+
rpcClient.invoke("gettxoutproof", Array(txid)).collect { case JString(raw) => ByteVector.fromValidHex(raw) }
117+
118+
// returns a chain a blocks of a given size starting at `blockId`
119+
def getBlockInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
120+
import KotlinUtils._
121+
122+
def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else {
123+
getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info))
124+
}
125+
126+
getBlockHeaderInfo(blockId).flatMap(info => loop(List(info)))
127+
}
128+
77129
/** Get the hash of the block containing a given transaction. */
78130
private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] =
79131
rpcClient.invoke("getrawtransaction", txid, 1 /* verbose output is needed to get the block hash */)
@@ -207,6 +259,32 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
207259
case _ => Nil
208260
}
209261

262+
//------------------------- BLOCKS -------------------------//
263+
def getBlockHash(height: Int)(implicit ec: ExecutionContext): Future[ByteVector32] = {
264+
rpcClient.invoke("getblockhash", height).map(json => {
265+
val JString(hash) = json
266+
ByteVector32.fromValidHex(hash)
267+
})
268+
}
269+
270+
def getBlockHeaderInfo(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[BlockHeaderInfo] = {
271+
import fr.acinq.bitcoin.{ByteVector32 => ByteVector32Kt}
272+
rpcClient.invoke("getblockheader", blockId.toString()).map(json => {
273+
val JInt(confirmations) = json \ "confirmations"
274+
val JInt(height) = json \ "height"
275+
val JInt(time) = json \ "time"
276+
val JInt(version) = json \ "version"
277+
val JInt(nonce) = json \ "nonce"
278+
val JString(bits) = json \ "bits"
279+
val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed()
280+
val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed()
281+
val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse)
282+
val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue)
283+
require(header.blockId == KotlinUtils.scala2kmp(blockId))
284+
BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash)
285+
})
286+
}
287+
210288
//------------------------- FUNDING -------------------------//
211289

212290
def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
@@ -511,6 +589,10 @@ object BitcoinCoreClient {
511589

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

592+
case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32])
593+
594+
case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32])
595+
514596
def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)
515597

516598
}

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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpen
2727
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
2828
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._
2929
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword
30-
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, JsonRPCError}
30+
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient, JsonRPCError}
3131
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
3232
import fr.acinq.eclair.transactions.{Scripts, Transactions}
3333
import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomKey}
@@ -806,4 +806,90 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
806806
assert(addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash) == Script.pay2wpkh(receiveKey))
807807
}
808808

809+
test("get block header info") {
810+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
811+
val sender = TestProbe()
812+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
813+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
814+
val height = sender.expectMsgType[BlockHeight]
815+
bitcoinClient.getBlockHash(height.toInt).pipeTo(sender.ref)
816+
val lastBlockId = sender.expectMsgType[ByteVector32]
817+
bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref)
818+
val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo]
819+
assert(lastBlockInfo.nextBlockHash.isEmpty)
820+
821+
bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref)
822+
val blockId = sender.expectMsgType[ByteVector32]
823+
bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref)
824+
val blockInfo = sender.expectMsgType[BlockHeaderInfo]
825+
assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash)
826+
assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash)))
827+
}
828+
829+
test("get chains of block headers") {
830+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
831+
val sender = TestProbe()
832+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
833+
834+
bitcoinClient.getBlockHash(140).pipeTo(sender.ref)
835+
val blockId = sender.expectMsgType[ByteVector32]
836+
bitcoinClient.getBlockInfos(blockId, 5).pipeTo(sender.ref)
837+
val blockInfos = sender.expectMsgType[List[BlockHeaderInfo]]
838+
for (i <- 0 until blockInfos.size - 1) {
839+
require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash)))
840+
require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash)
841+
}
842+
}
843+
844+
test("verify tx publication proofs") {
845+
val sender = TestProbe()
846+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
847+
val address = getNewAddress(sender)
848+
849+
// we create a dummy confirmed tx, we'll use its txout proof later
850+
val dummyTx = sendToAddress(address, 5 btc, sender)
851+
generateBlocks(1)
852+
853+
val tx = sendToAddress(address, 5 btc, sender)
854+
855+
// transaction is not confirmed yet
856+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
857+
sender.expectMsg(Some(0))
858+
859+
// let's confirm our transaction.
860+
generateBlocks(6)
861+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
862+
sender.expectMsg(Some(6))
863+
864+
bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref)
865+
val proof = sender.expectMsgType[ByteVector]
866+
val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray)
867+
val header = check.getFirst
868+
bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
869+
val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]]
870+
assert(header == headerInfos.head.header)
871+
872+
// try again with a bitcoin client that returns a proof that is not valid for our tx
873+
bitcoinClient.getTxOutProof(dummyTx.txid).pipeTo(sender.ref)
874+
val dumyProof = sender.expectMsgType[ByteVector]
875+
val evilBitcoinClient = new BitcoinCoreClient(new BitcoinJsonRPCClient {
876+
override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match {
877+
case "gettxoutproof" => Future.successful(JString(dumyProof.toHex))
878+
case _ => bitcoinrpcclient.invoke(method, params: _*)(ec)
879+
}
880+
})
881+
evilBitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
882+
val error = sender.expectMsgType[Failure]
883+
assert(error.cause.getMessage.contains("block id mismatch"))
884+
885+
val evilBitcoinClient1 = new BitcoinCoreClient(new BitcoinJsonRPCClient {
886+
override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match {
887+
case "gettxoutproof" => Future.successful(JString(dumyProof.toHex))
888+
case _ => bitcoinrpcclient.invoke(method, params: _*)(ec)
889+
}
890+
})
891+
evilBitcoinClient1.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
892+
val error1 = sender.expectMsgType[Failure]
893+
assert(error1.cause.getMessage.contains("block id mismatch"))
894+
}
809895
}

0 commit comments

Comments
 (0)