Skip to content

Commit acc65a3

Browse files
committed
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
1 parent a52a10a commit acc65a3

File tree

4 files changed

+193
-11
lines changed

4 files changed

+193
-11
lines changed

eclair-core/src/main/resources/reference.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ eclair {
4040
// - ignore: eclair will leave these utxos locked and start
4141
startup-locked-utxos-behavior = "stop"
4242
final-pubkey-refresh-delay = 3 seconds
43+
min-difficulty = 387294044 // difficulty of block 600000
4344
}
4445

4546
node-alias = "eclair"

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import fr.acinq.eclair.{BlockHeight, KamonExt, NodeParams, RealShortChannelId, T
3030
import java.util.concurrent.atomic.AtomicLong
3131
import scala.concurrent.duration._
3232
import scala.concurrent.{ExecutionContext, Future}
33-
import scala.util.{Failure, Success}
33+
import scala.util.{Failure, Success, Try}
3434

3535
/**
3636
* Created by PM on 21/02/2016.
@@ -412,20 +412,33 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
412412

413413
private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = {
414414
log.debug("checking confirmations of txid={}", w.txId)
415+
416+
def checkConfirmationProof(): Future[Unit] = {
417+
client.getTxConfirmationProof(w.txId).map(headerInfos => {
418+
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) {
419+
// 0x1715a35cL = 387294044 = difficulty of block 600000
420+
val minDiff = Try(context.system.settings.config.getLong("eclair.bitcoind.min-difficulty")).getOrElse(0x1715a35cL)
421+
require(headerInfos.forall(hi => hi.header.bits < minDiff))
422+
}
423+
})
424+
}
425+
415426
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
416427
// matter because this only happens once, when the watched transaction has reached min_depth
417428
client.getTxConfirmations(w.txId).flatMap {
418429
case Some(confirmations) if confirmations >= w.minDepth =>
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))
430+
checkConfirmationProof().andThen(_ =>
431+
client.getTransaction(w.txId).flatMap { tx =>
432+
client.getTransactionShortId(w.txId).map {
433+
case (height, index) => w match {
434+
case w: WatchFundingConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchFundingConfirmedTriggered(height, index, tx))
435+
case w: WatchFundingDeeplyBuried => context.self ! TriggerEvent(w.replyTo, w, WatchFundingDeeplyBuriedTriggered(height, index, tx))
436+
case w: WatchTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchTxConfirmedTriggered(height, index, tx))
437+
case w: WatchParentTxConfirmed => context.self ! TriggerEvent(w.replyTo, w, WatchParentTxConfirmedTriggered(height, index, tx))
438+
}
426439
}
427440
}
428-
}
441+
)
429442
case _ => Future.successful((): Unit)
430443
}
431444
}

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

Lines changed: 95 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,70 @@ 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+
/**
78+
*
79+
* @param txid transaction id
80+
* @return a list of block header information, starting from the block in which the transaction was published, up to the current tip
81+
*/
82+
def getTxConfirmationProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
83+
import KotlinUtils._
84+
85+
/**
86+
* Scala wrapper for Block.verifyTxOutProof
87+
*
88+
* @param proof tx output proof, as provided by bitcoind
89+
* @return a (Header, List(txhash, position)) tuple. Header is the header of the block used to compute the input proof, and
90+
* (txhash, position) is a list of transaction ids that were verified, and their position in the block
91+
*/
92+
def verifyTxOutProof(proof: ByteVector): (BlockHeader, List[(ByteVector32, Int)]) = {
93+
val check = Block.verifyTxOutProof(proof.toArray)
94+
(check.getFirst, check.getSecond.asScala.toList.map(p => (kmp2scala(p.getFirst), p.getSecond.intValue())))
95+
}
96+
97+
for {
98+
confirmations_opt <- getTxConfirmations(txid)
99+
if (confirmations_opt.isDefined && confirmations_opt.get > 0)
100+
// get the merkle proof for our txid
101+
proof <- getTxOutProof(txid)
102+
// verify this merkle proof. if valid, we get the header for the block the tx was published in, and the tx hashes
103+
// that can be used to rebuild the block's merkle root
104+
(header, txHashesAndPos) = verifyTxOutProof(proof)
105+
// inclusionData contains a header and a list of (txid, position) that can be used to re-build the header's merkle root
106+
// check that the block hash included in the proof matches the block in which the tx was published
107+
Some(blockHash) <- getTxBlockHash(txid)
108+
_ = require(header.blockId.contentEquals(blockHash.toArray), "confirmation proof is not valid (block id mismatch)")
109+
// check that our txid is included in the merkle root of the block it was published in
110+
txids = txHashesAndPos.map { case (txhash, _) => txhash.reverse }
111+
_ = require(txids.contains(txid), "confirmation proof is not valid (txid not found)")
112+
// get the block in which our tx was confirmed and all following blocks
113+
headerInfos <- getBlockInfos(blockHash, confirmations_opt.get)
114+
_ = require(headerInfos.head.header.blockId.contentEquals(blockHash.toArray), "block header id mismatch")
115+
} yield headerInfos
116+
}
117+
118+
def getTxOutProof(txid: ByteVector32)(implicit ec: ExecutionContext): Future[ByteVector] =
119+
rpcClient.invoke("gettxoutproof", Array(txid)).collect { case JString(raw) => ByteVector.fromValidHex(raw) }
120+
121+
// returns a chain a blocks of a given size starting at `blockId`
122+
def getBlockInfos(blockId: ByteVector32, count: Int)(implicit ec: ExecutionContext): Future[List[BlockHeaderInfo]] = {
123+
import KotlinUtils._
124+
125+
def loop(blocks: List[BlockHeaderInfo]): Future[List[BlockHeaderInfo]] = if (blocks.size == count) Future.successful(blocks) else {
126+
getBlockHeaderInfo(blocks.last.nextBlockHash.get.reverse).flatMap(info => loop(blocks :+ info))
127+
}
128+
129+
getBlockHeaderInfo(blockId).flatMap(info => loop(List(info))).map(blocks => {
130+
for (i <- 0 until blocks.size - 1) {
131+
require(BlockHeader.checkProofOfWork(blocks(i).header))
132+
require(blocks(i).height == blocks(0).height + i)
133+
require(blocks(i).confirmation == blocks(0).confirmation - i)
134+
require(blocks(i).nextBlockHash.contains(kmp2scala(blocks(i + 1).header.hash)))
135+
require(blocks(i + 1).header.hashPreviousBlock == blocks(i).header.hash)
136+
}
137+
blocks
138+
})
139+
}
140+
77141
/** Get the hash of the block containing a given transaction. */
78142
private def getTxBlockHash(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Option[ByteVector32]] =
79143
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
207271
case _ => Nil
208272
}
209273

274+
//------------------------- BLOCKS -------------------------//
275+
def getBlockHash(height: Int)(implicit ec: ExecutionContext): Future[ByteVector32] = {
276+
rpcClient.invoke("getblockhash", height).map(json => {
277+
val JString(hash) = json
278+
ByteVector32.fromValidHex(hash)
279+
})
280+
}
281+
282+
def getBlockHeaderInfo(blockId: ByteVector32)(implicit ec: ExecutionContext): Future[BlockHeaderInfo] = {
283+
import fr.acinq.bitcoin.{ByteVector32 => ByteVector32Kt}
284+
rpcClient.invoke("getblockheader", blockId.toString()).map(json => {
285+
val JInt(confirmations) = json \ "confirmations"
286+
val JInt(height) = json \ "height"
287+
val JInt(time) = json \ "time"
288+
val JInt(version) = json \ "version"
289+
val JInt(nonce) = json \ "nonce"
290+
val JString(bits) = json \ "bits"
291+
val merkleRoot = ByteVector32Kt.fromValidHex((json \ "merkleroot").extract[String]).reversed()
292+
val previousblockhash = ByteVector32Kt.fromValidHex((json \ "previousblockhash").extract[String]).reversed()
293+
val nextblockhash = (json \ "nextblockhash").extractOpt[String].map(h => ByteVector32.fromValidHex(h).reverse)
294+
val header = new BlockHeader(version.longValue, previousblockhash, merkleRoot, time.longValue, java.lang.Long.parseLong(bits, 16), nonce.longValue)
295+
require(header.blockId == KotlinUtils.scala2kmp(blockId))
296+
BlockHeaderInfo(header, confirmations.toLong, height.toLong, nextblockhash)
297+
})
298+
}
299+
210300
//------------------------- FUNDING -------------------------//
211301

212302
def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
@@ -546,6 +636,10 @@ object BitcoinCoreClient {
546636

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

639+
case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32])
640+
641+
case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32])
642+
549643
def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)
550644

551645
}

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpen
2828
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
2929
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient._
3030
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword
31-
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, JsonRPCError}
31+
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, BitcoinJsonRPCClient, JsonRPCError}
3232
import fr.acinq.eclair.blockchain.fee.{FeeratePerByte, FeeratePerKw}
3333
import fr.acinq.eclair.transactions.{Scripts, Transactions}
3434
import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomKey}
@@ -1083,4 +1083,78 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
10831083
assert(sender.expectMsgType[Transaction].txid == tx.txid)
10841084
}
10851085

1086+
test("get block header info") {
1087+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
1088+
val sender = TestProbe()
1089+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1090+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
1091+
val height = sender.expectMsgType[BlockHeight]
1092+
bitcoinClient.getBlockHash(height.toInt).pipeTo(sender.ref)
1093+
val lastBlockId = sender.expectMsgType[ByteVector32]
1094+
bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref)
1095+
val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo]
1096+
assert(lastBlockInfo.nextBlockHash.isEmpty)
1097+
1098+
bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref)
1099+
val blockId = sender.expectMsgType[ByteVector32]
1100+
bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref)
1101+
val blockInfo = sender.expectMsgType[BlockHeaderInfo]
1102+
assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash)
1103+
assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash)))
1104+
}
1105+
1106+
test("get chains of block headers") {
1107+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
1108+
val sender = TestProbe()
1109+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1110+
1111+
bitcoinClient.getBlockHash(140).pipeTo(sender.ref)
1112+
val blockId = sender.expectMsgType[ByteVector32]
1113+
bitcoinClient.getBlockInfos(blockId, 5).pipeTo(sender.ref)
1114+
val blockInfos = sender.expectMsgType[List[BlockHeaderInfo]]
1115+
for (i <- 0 until blockInfos.size - 1) {
1116+
require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash)))
1117+
require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash)
1118+
}
1119+
}
1120+
1121+
test("verify tx publication proofs") {
1122+
val sender = TestProbe()
1123+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1124+
val address = getNewAddress(sender)
1125+
1126+
// we create a dummy confirmed tx, we'll use its txout proof later
1127+
val dummyTx = sendToAddress(address, 5 btc, sender)
1128+
1129+
val tx = sendToAddress(address, 5 btc, sender)
1130+
// transaction is not confirmed yet
1131+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
1132+
sender.expectMsg(Some(0))
1133+
1134+
// let's confirm our transaction.
1135+
generateBlocks(6)
1136+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
1137+
sender.expectMsg(Some(6))
1138+
1139+
bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref)
1140+
val proof = sender.expectMsgType[ByteVector]
1141+
val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray)
1142+
val header = check.getFirst
1143+
bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
1144+
val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]]
1145+
assert(header == headerInfos.head.header)
1146+
1147+
// 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
1148+
bitcoinClient.getTxOutProof(dummyTx.txid).pipeTo(sender.ref)
1149+
val dumyProof = sender.expectMsgType[ByteVector]
1150+
val evilBitcoinClient = new BitcoinCoreClient(new BitcoinJsonRPCClient {
1151+
override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match {
1152+
case "gettxoutproof" => Future.successful(JString(dumyProof.toHex))
1153+
case _ => bitcoinrpcclient.invoke(method, params: _*)(ec)
1154+
}
1155+
})
1156+
evilBitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
1157+
val error = sender.expectMsgType[Failure]
1158+
assert(error.cause.getMessage.contains("txid not found"))
1159+
}
10861160
}

0 commit comments

Comments
 (0)