Skip to content

Commit e8e7db5

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 e8e7db5

File tree

3 files changed

+144
-9
lines changed

3 files changed

+144
-9
lines changed

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -401,20 +401,31 @@ 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+
client.getTxConfirmationProof(w.txId).map(headerInfos => {
407+
require(headerInfos.forall(hi => fr.acinq.bitcoin.BlockHeader.checkProofOfWork(hi.header)), s"invalid proof of work for txid=${w.txId}")
408+
// FIXME: this should not be hardcoded. 0x1715a35c is the difficulty of block 600000
409+
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) require(headerInfos.forall(hi => hi.header.bits < 0x1715a35c))
410+
})
411+
}
412+
404413
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
405414
// matter because this only happens once, when the watched transaction has reached min_depth
406415
client.getTxConfirmations(w.txId).flatMap {
407416
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))
417+
checkConfirmationProof().andThen(_ =>
418+
client.getTransaction(w.txId).flatMap { tx =>
419+
client.getTransactionShortId(w.txId).map {
420+
case (height, index) => w match {
421+
case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx))
422+
case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx))
423+
case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx))
424+
case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx))
425+
}
415426
}
416427
}
417-
}
428+
)
418429
case _ => Future.successful((): Unit)
419430
}
420431
}

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

Lines changed: 66 additions & 1 deletion
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.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse}
@@ -74,6 +74,41 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
7474
case t: JsonRPCError if t.error.code == -5 => None // Invalid or non-wallet transaction id (code: -5)
7575
}
7676

77+
def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
78+
import KotlinUtils._
79+
for {
80+
confirmations_opt <- getTxConfirmations(txid)
81+
if (confirmations_opt.isDefined && confirmations_opt.get > 0)
82+
// get the merkle proof for our txid
83+
proof <- getTxOutProof(txid)
84+
// verify this merkle proof. if valid, we get the header for the block the tx was published in, and the tx hashes
85+
// that can be used to rebuild the block's merkle root
86+
check = Block.verifyTxOutProof(proof.toArray)
87+
// check that the block hash included in the proof matches the block in which the tx was published
88+
Some(blockHash) <- getTxBlockHash(txid)
89+
_ = require(check.getFirst.blockId.contentEquals(blockHash.toArray), "confirmation proof is not valid (block id mismatch)")
90+
// check that our txid is included in the merkle root of the block it was published in
91+
txids = check.getSecond.asScala.map(_.getFirst).map(kmp2scala).map(_.reverse)
92+
_ = require(txids.contains(txid))
93+
// get the block in which our tx was confirmed and all following blocks
94+
headerInfos <- getBlockInfos(blockHash, confirmations_opt.get)
95+
} yield headerInfos
96+
}
97+
98+
def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] =
99+
rpcClient.invoke("gettxoutproof", Array(txid)).collect { case JString(raw) => ByteVector.fromValidHex(raw) }
100+
101+
// returns a chain a blocks of a given size starting at `blockId`
102+
def getBlockInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
103+
import KotlinUtils._
104+
105+
def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else {
106+
getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info))
107+
}
108+
109+
getBlockHeaderInfo(blockId).flatMap(info => loop(List(info)))
110+
}
111+
77112
/** Get the hash of the block containing a given transaction. */
78113
private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] =
79114
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
207242
case _ => Nil
208243
}
209244

245+
//------------------------- BLOCKS -------------------------//
246+
def getBlockHash(height: Int)(implicit ec: ExecutionContext): Future[ByteVector32] = {
247+
rpcClient.invoke("getblockhash", height).map(json => {
248+
val JString(hash) = json
249+
ByteVector32.fromValidHex(hash)
250+
})
251+
}
252+
253+
def getBlockHeaderInfo(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[BlockHeaderInfo] = {
254+
import fr.acinq.bitcoin.{ByteVector32 => ByteVector32Kt}
255+
rpcClient.invoke("getblockheader", blockId.toString()).map(json => {
256+
val JInt(confirmations) = json \ "confirmations"
257+
val JInt(height) = json \ "height"
258+
val JInt(time) = json \ "time"
259+
val JInt(version) = json \ "version"
260+
val JInt(nonce) = json \ "nonce"
261+
val JString(bits) = json \ "bits"
262+
val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed()
263+
val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed()
264+
val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse)
265+
val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue)
266+
require(header.blockId == KotlinUtils.scala2kmp(blockId))
267+
BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash)
268+
})
269+
}
270+
210271
//------------------------- FUNDING -------------------------//
211272

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

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

575+
case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32])
576+
577+
case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32])
578+
514579
def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)
515580

516581
}

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,4 +806,63 @@ 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+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
846+
val sender = TestProbe()
847+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
848+
val address = getNewAddress(sender)
849+
val tx = sendToAddress(address, 5 btc, sender)
850+
851+
// Transaction is not confirmed yet
852+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
853+
sender.expectMsg(Some(0))
854+
855+
// Let's confirm our transaction.
856+
generateBlocks(6)
857+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
858+
sender.expectMsg(Some(6))
859+
860+
bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref)
861+
val proof = sender.expectMsgType[ByteVector]
862+
val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray)
863+
val header = check.getFirst
864+
bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
865+
val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]]
866+
assert(header == headerInfos.head.header)
867+
}
809868
}

0 commit comments

Comments
 (0)