Skip to content

Commit 3ec4af7

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 fa985da commit 3ec4af7

File tree

4 files changed

+194
-12
lines changed

4 files changed

+194
-12
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: 23 additions & 10 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.
@@ -415,21 +415,34 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
415415

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

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] = {
@@ -623,6 +713,10 @@ object BitcoinCoreClient {
623713

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

716+
case class TransactionInfo(tx: Transaction, confirmations: Int, blockId: Option[ByteVector32])
717+
718+
case class BlockHeaderInfo(header: BlockHeader, confirmation: Long, height: Long, nextBlockHash: Option[ByteVector32])
719+
626720
def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)
627721

628722
}

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, randomBytes32, randomKey}
@@ -1321,4 +1321,78 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
13211321
assert(sender.expectMsgType[Transaction].txid == tx.txid)
13221322
}
13231323

1324+
test("get block header info") {
1325+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
1326+
val sender = TestProbe()
1327+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1328+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
1329+
val height = sender.expectMsgType[BlockHeight]
1330+
bitcoinClient.getBlockHash(height.toInt).pipeTo(sender.ref)
1331+
val lastBlockId = sender.expectMsgType[ByteVector32]
1332+
bitcoinClient.getBlockHeaderInfo(lastBlockId).pipeTo(sender.ref)
1333+
val lastBlockInfo = sender.expectMsgType[BlockHeaderInfo]
1334+
assert(lastBlockInfo.nextBlockHash.isEmpty)
1335+
1336+
bitcoinClient.getBlockHash(height.toInt - 1).pipeTo(sender.ref)
1337+
val blockId = sender.expectMsgType[ByteVector32]
1338+
bitcoinClient.getBlockHeaderInfo(blockId).pipeTo(sender.ref)
1339+
val blockInfo = sender.expectMsgType[BlockHeaderInfo]
1340+
assert(lastBlockInfo.header.hashPreviousBlock == blockInfo.header.hash)
1341+
assert(blockInfo.nextBlockHash.contains(kmp2scala(lastBlockInfo.header.hash)))
1342+
}
1343+
1344+
test("get chains of block headers") {
1345+
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
1346+
val sender = TestProbe()
1347+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1348+
1349+
bitcoinClient.getBlockHash(140).pipeTo(sender.ref)
1350+
val blockId = sender.expectMsgType[ByteVector32]
1351+
bitcoinClient.getBlockInfos(blockId, 5).pipeTo(sender.ref)
1352+
val blockInfos = sender.expectMsgType[List[BlockHeaderInfo]]
1353+
for (i <- 0 until blockInfos.size - 1) {
1354+
require(blockInfos(i).nextBlockHash.contains(kmp2scala(blockInfos(i + 1).header.hash)))
1355+
require(blockInfos(i + 1).header.hashPreviousBlock == blockInfos(i).header.hash)
1356+
}
1357+
}
1358+
1359+
test("verify tx publication proofs") {
1360+
val sender = TestProbe()
1361+
val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient)
1362+
val address = getNewAddress(sender)
1363+
1364+
// we create a dummy confirmed tx, we'll use its txout proof later
1365+
val dummyTx = sendToAddress(address, 5 btc, sender)
1366+
1367+
val tx = sendToAddress(address, 5 btc, sender)
1368+
// transaction is not confirmed yet
1369+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
1370+
sender.expectMsg(Some(0))
1371+
1372+
// let's confirm our transaction.
1373+
generateBlocks(6)
1374+
bitcoinClient.getTxConfirmations(tx.txid).pipeTo(sender.ref)
1375+
sender.expectMsg(Some(6))
1376+
1377+
bitcoinClient.getTxOutProof(tx.txid).pipeTo(sender.ref)
1378+
val proof = sender.expectMsgType[ByteVector]
1379+
val check = fr.acinq.bitcoin.Block.verifyTxOutProof(proof.toArray)
1380+
val header = check.getFirst
1381+
bitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
1382+
val headerInfos = sender.expectMsgType[List[BlockHeaderInfo]]
1383+
assert(header == headerInfos.head.header)
1384+
1385+
// 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
1386+
bitcoinClient.getTxOutProof(dummyTx.txid).pipeTo(sender.ref)
1387+
val dumyProof = sender.expectMsgType[ByteVector]
1388+
val evilBitcoinClient = new BitcoinCoreClient(new BitcoinJsonRPCClient {
1389+
override def invoke(method: String, params: Any*)(implicit ec: ExecutionContext): Future[JValue] = method match {
1390+
case "gettxoutproof" => Future.successful(JString(dumyProof.toHex))
1391+
case _ => bitcoinrpcclient.invoke(method, params: _*)(ec)
1392+
}
1393+
})
1394+
evilBitcoinClient.getTxConfirmationProof(tx.txid).pipeTo(sender.ref)
1395+
val error = sender.expectMsgType[Failure]
1396+
assert(error.cause.getMessage.contains("txid not found"))
1397+
}
13241398
}

0 commit comments

Comments
 (0)