Skip to content

Commit 00d357b

Browse files
committed
Use bitcoin core's txospenderindex if available
If bitcoin core's txospenderindex is enabled and sync, we'll automatically use it to find transactions that spent our channels.
1 parent c6769f1 commit 00d357b

8 files changed

Lines changed: 239 additions & 111 deletions

File tree

docs/release-notes/eclair-vnext.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,11 @@ eclair.relay.peer-reputation.enabled = false
231231
eclair.relay.reserved-for-accountable = 0.0
232232
```
233233

234+
### Faster scanning for spending transactions with Bitcoin Core's txospenderindex
235+
236+
If Bitcoin Core's `txospenderindex` (available in Bitcoin Core 31.0 and newer) is available and synced, Eclair will use it to find channel spending transactions, which is much faster and less expensive than scanning blocks.
237+
To enable this index, start Bitcoin Core with `-txospenderindex` or add `txospenderindex=1` to your `bitcoin.conf`.
238+
234239
### Configuration changes
235240

236241
<insert changes>

eclair-core/pom.xml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@
8787
<activeByDefault>true</activeByDefault>
8888
</activation>
8989
<properties>
90-
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-linux-gnu.tar.gz</bitcoind.url>
91-
<bitcoind.sha256>6aa7bb4feb699c4c6262dd23e4004191f6df7f373b5d5978b5bcdd4bb72f75d8</bitcoind.sha256>
90+
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-31.0/bitcoin-31.0-x86_64-linux-gnu.tar.gz</bitcoind.url>
91+
<bitcoind.sha256>d3e4c58a35b1d0a97a457462c94f55501ad167c660c245cb1ffa565641c65074</bitcoind.sha256>
9292
</properties>
9393
</profile>
9494
<profile>
@@ -99,8 +99,8 @@
9999
</os>
100100
</activation>
101101
<properties>
102-
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-x86_64-apple-darwin.tar.gz</bitcoind.url>
103-
<bitcoind.sha256>99d5cee9b9c37be506396c30837a4b98e320bfea71c474d6120a7e8eb6075c7b</bitcoind.sha256>
102+
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-31.0/bitcoin-31.0-x86_64-apple-darwin.tar.gz</bitcoind.url>
103+
<bitcoind.sha256>56824dd705bc2a3b22d42e8aa02ed53498d491ff7c2c8aa96831333871887ead</bitcoind.sha256>
104104
</properties>
105105
</profile>
106106
<profile>
@@ -111,8 +111,8 @@
111111
</os>
112112
</activation>
113113
<properties>
114-
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-30.2/bitcoin-30.2-win64.zip</bitcoind.url>
115-
<bitcoind.sha256>0d7e1f16f8823aa26d29b44855ff6dbac11c03d75631a6c1d2ea5fab3a84fdf8</bitcoind.sha256>
114+
<bitcoind.url>https://bitcoincore.org/bin/bitcoin-core-31.0/bitcoin-31.0-win64.zip</bitcoind.url>
115+
<bitcoind.sha256>82fd2c504a0f20a31d4d13bd407783d6fc7bf17622d0ce85228a9b92694e03f0</bitcoind.sha256>
116116
</properties>
117117
</profile>
118118
</profiles>

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

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
429429
}
430430
}
431431

432+
private def canUseTxoSpenderIndex: Future[Boolean] = client.getIndexInfo().map(_.get("txospenderindex").exists(_.synced))
433+
432434
private def checkSpent(w: WatchSpent[_ <: WatchSpentTriggered]): Future[Unit] = {
433435
// First let's see if the parent tx was published or not before checking whether it has been spent.
434436
client.getTxConfirmations(w.txId).collect {
@@ -445,37 +447,52 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
445447
case _ =>
446448
// The parent tx was published, we need to make sure this particular output has not been spent.
447449
client.isTransactionOutputSpendable(w.outPoint, includeMempool = true).collect {
448-
case false =>
449-
// The output has been spent, let's find the spending tx.
450-
// If we know some potential spending txs, we try to fetch them directly.
451-
Future.sequence(w.hints.map(txid => client.getTransaction(txid).map(Some(_)).recover { case _ => None }))
452-
.map(_.flatten) // filter out errors and hint transactions that can't be found
453-
.map(hintTxs => {
454-
hintTxs.find(tx => tx.txIn.exists(i => i.outPoint.txid == w.txId && i.outPoint.index == w.outputIndex)) match {
455-
case Some(spendingTx) =>
456-
log.info("{}:{} has already been spent by a tx provided in hints: txid={}", w.txId, w.outputIndex, spendingTx.txid)
457-
context.self ! ProcessNewTransaction(spendingTx)
458-
case None =>
459-
// The hints didn't help us, let's search for the spending transaction in the mempool.
460-
log.info("{}:{} has already been spent, looking for the spending tx in the mempool", w.txId, w.outputIndex)
461-
client.lookForMempoolSpendingTx(w.outPoint).map(Some(_)).recover { case _ => None }.map {
462-
case Some(spendingTx) =>
463-
log.info("found tx spending {}:{} in the mempool: txid={}", w.txId, w.outputIndex, spendingTx.txid)
464-
context.self ! ProcessNewTransaction(spendingTx)
465-
case None =>
466-
// The spending transaction isn't in the mempool, so it must be a transaction that confirmed
467-
// before we set the watch. We have to scan the blockchain to find it, which is expensive
468-
// since bitcoind doesn't provide indexes for this scenario.
469-
log.warn("{}:{} has already been spent, spending tx not in the mempool, looking in the blockchain...", w.txId, w.outputIndex)
470-
client.lookForSpendingTx(None, w.outPoint, nodeParams.channelConf.maxChannelSpentRescanBlocks).map { spendingTx =>
471-
log.warn("found the spending tx of {}:{} in the blockchain: txid={}", w.txId, w.outputIndex, spendingTx.txid)
450+
case false => canUseTxoSpenderIndex.foreach {
451+
case true =>
452+
// The output has been spent, let's find the spending tx in the txospenderindex.
453+
log.info("{} has already been spent, looking for the spending tx", w.outPoint)
454+
client.findSpendingTx(w.outPoint) map {
455+
case Some((spendingTx, _)) =>
456+
log.info("found tx spending {} txid={}", w.outPoint, spendingTx.txid)
457+
context.self ! ProcessNewTransaction(spendingTx)
458+
case None =>
459+
log.warn("could not find the spending tx of {}, funds are at risk", w.outPoint)
460+
} recover {
461+
case error =>
462+
log.warn("error finding the spending tx of {}, funds are at risk", w.outPoint, error)
463+
}
464+
case false =>
465+
// txospenderindex is not available, scan the mempool and the blockchain
466+
// If we know some potential spending txs, we try to fetch them directly.
467+
Future.sequence(w.hints.map(txid => client.getTransaction(txid).map(Some(_)).recover { case _ => None }))
468+
.map(_.flatten) // filter out errors and hint transactions that can't be found
469+
.map(hintTxs => {
470+
hintTxs.find(tx => tx.txIn.exists(i => i.outPoint.txid == w.txId && i.outPoint.index == w.outputIndex)) match {
471+
case Some(spendingTx) =>
472+
log.info("{}:{} has already been spent by a tx provided in hints: txid={}", w.txId, w.outputIndex, spendingTx.txid)
473+
context.self ! ProcessNewTransaction(spendingTx)
474+
case None =>
475+
// The hints didn't help us, let's search for the spending transaction in the mempool.
476+
log.info("{}:{} has already been spent, looking for the spending tx in the mempool", w.txId, w.outputIndex)
477+
client.lookForMempoolSpendingTx(w.outPoint).map(Some(_)).recover { case _ => None }.map {
478+
case Some(spendingTx) =>
479+
log.info("found tx spending {}:{} in the mempool: txid={}", w.txId, w.outputIndex, spendingTx.txid)
472480
context.self ! ProcessNewTransaction(spendingTx)
473-
}.recover {
474-
case _ => log.warn("could not find the spending tx of {}:{} in the blockchain, funds are at risk", w.txId, w.outputIndex)
475-
}
476-
}
477-
}
478-
})
481+
case None =>
482+
// The spending transaction isn't in the mempool, so it must be a transaction that confirmed
483+
// before we set the watch. We have to scan the blockchain to find it, which is expensive
484+
// since bitcoind doesn't provide indexes for this scenario.
485+
log.warn("{}:{} has already been spent, spending tx not in the mempool, looking in the blockchain...", w.txId, w.outputIndex)
486+
client.lookForSpendingTx(None, w.outPoint, nodeParams.channelConf.maxChannelSpentRescanBlocks).map { spendingTx =>
487+
log.warn("found the spending tx of {}:{} in the blockchain: txid={}", w.txId, w.outputIndex, spendingTx.txid)
488+
context.self ! ProcessNewTransaction(spendingTx)
489+
}.recover {
490+
case _ => log.warn("could not find the spending tx of {}:{} in the blockchain, funds are at risk", w.txId, w.outputIndex)
491+
}
492+
}
493+
}
494+
})
495+
}
479496
}
480497
}
481498
}

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,8 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
172172

173173
/** Search for mempool transaction spending a given output. */
174174
def lookForMempoolSpendingTx(outPoint: OutPoint)(implicit ec: ExecutionContext): Future[Transaction] = {
175-
rpcClient.invoke("gettxspendingprevout", Seq(OutpointArg(outPoint.txid, outPoint.index))).collect {
175+
val options = JObject(List("return_spending_tx" -> JBool(true), "mempool_only" -> JBool(true)))
176+
rpcClient.invoke("gettxspendingprevout", Seq(OutpointArg(outPoint.txid, outPoint.index)), options).collect {
176177
case JArray(results) => results.flatMap(result => (result \ "spendingtxid").extractOpt[String].map(TxId.fromValidHex))
177178
}.flatMap { spendingTxIds =>
178179
spendingTxIds.headOption match {
@@ -182,6 +183,22 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
182183
}
183184
}
184185

186+
/**
187+
* Find the transaction spending a given output. Requires `txospenderindex` on the bitcoin code node we're connecting to.
188+
*
189+
* @param outPoint transaction output
190+
* @return the transaction that spent this output along with the id of the block it was published in if any, or None if no spending transaction was found
191+
*/
192+
def findSpendingTx(outPoint: OutPoint)(implicit ec: ExecutionContext): Future[Option[(Transaction, Option[BlockId])]] = {
193+
val options = JObject(List("return_spending_tx" -> JBool(true)))
194+
rpcClient.invoke("gettxspendingprevout", Seq(OutpointArg(outPoint.txid, outPoint.index)), options).collect {
195+
case JArray(results) => results.flatMap(result => {
196+
val tx_opt = (result \ "spendingtx").extractOpt[String].map(Transaction.read)
197+
tx_opt.map(tx => tx -> (result \ "blockhash").extractOpt[String].map(s => BlockId(ByteVector32.fromValidHex(s))))
198+
}).headOption
199+
}
200+
}
201+
185202
/**
186203
* Iterate over blocks to find the transaction that has spent a given output.
187204
* It isn't useful to look at the whole blockchain history: if the transaction was confirmed long ago, an attacker
@@ -794,6 +811,15 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
794811
})
795812
}
796813

814+
//------------------------- MISC -------------------------//
815+
816+
/**
817+
*
818+
* @return information about enabled bitcoin core indexes, in a map where the key is the index name
819+
*/
820+
def getIndexInfo()(implicit ec: ExecutionContext): Future[Map[String, IndexInfo]] = rpcClient.invoke("getindexinfo").collect {
821+
case JObject(results) => results.map { case (name, o) => name -> BitcoinCoreClient.IndexInfo((o \ "synced").extract[Boolean], (o \ "best_block_height").extract[Int]) }.toMap
822+
}
797823
}
798824

799825
object BitcoinCoreClient {
@@ -871,4 +897,11 @@ object BitcoinCoreClient {
871897
// @formatter:on
872898
}
873899

900+
/**
901+
* Information about a bitcoin core inedx
902+
*
903+
* @param synced true if the index is synced
904+
* @param bestBlockHeight height of the last indexed block
905+
*/
906+
case class IndexInfo(synced: Boolean, bestBlockHeight: Int)
874907
}

eclair-core/src/test/resources/integration/bitcoin.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ server=1
44
rpcuser=foo
55
rpcpassword=bar
66
txindex=1
7+
txospenderindex=1
78
zmqpubhashblock=tcp://127.0.0.1:28334
89
zmqpubrawtx=tcp://127.0.0.1:28335
910
rpcworkqueue=64

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,71 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
17621762
sender.expectMsg(tx1)
17631763
}
17641764

1765+
test("get index information") {
1766+
val sender = TestProbe()
1767+
val bitcoinClient = makeBitcoinCoreClient()
1768+
bitcoinClient.getIndexInfo().pipeTo(sender.ref)
1769+
val indexInfos = sender.expectMsgType[Map[String, BitcoinCoreClient.IndexInfo]]
1770+
assert(indexInfos("txindex").synced)
1771+
assert(indexInfos("txospenderindex").synced)
1772+
}
1773+
1774+
test("find spending transaction of a given output using txospenderindex") {
1775+
val sender = TestProbe()
1776+
val bitcoinClient = makeBitcoinCoreClient()
1777+
1778+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
1779+
val blockHeight = sender.expectMsgType[BlockHeight]
1780+
1781+
bitcoinClient.getIndexInfo().pipeTo(sender.ref)
1782+
val indexInfos = sender.expectMsgType[Map[String, BitcoinCoreClient.IndexInfo]]
1783+
assert(indexInfos("txindex").synced)
1784+
1785+
val address = getNewAddress(sender)
1786+
val tx1 = sendToAddress(address, 5 btc)
1787+
1788+
// Transaction is still in the mempool at that point
1789+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
1790+
sender.expectMsg(Some(0))
1791+
// If we omit the mempool, tx1's input is still considered unspent.
1792+
bitcoinClient.isTransactionOutputSpendable(tx1.txIn.head.outPoint, includeMempool = false).pipeTo(sender.ref)
1793+
sender.expectMsg(true)
1794+
// If we include the mempool, we see that tx1's input is now spent.
1795+
bitcoinClient.isTransactionOutputSpendable(tx1.txIn.head.outPoint, includeMempool = true).pipeTo(sender.ref)
1796+
sender.expectMsg(false)
1797+
// If we omit the mempool, tx1's output is not considered spendable because we can't even find that output.
1798+
bitcoinClient.isTransactionOutputSpendable(OutPoint(tx1.txid, 0), includeMempool = false).pipeTo(sender.ref)
1799+
sender.expectMsg(false)
1800+
// If we include the mempool, we see that tx1 produces an output that is still unspent.
1801+
bitcoinClient.isTransactionOutputSpendable(OutPoint(tx1.txid, 0), includeMempool = true).pipeTo(sender.ref)
1802+
sender.expectMsg(true)
1803+
// We're able to find the spending transaction in the mempool.
1804+
bitcoinClient.findSpendingTx(tx1.txIn.head.outPoint).pipeTo(sender.ref)
1805+
sender.expectMsg(Some(tx1, None))
1806+
1807+
// Let's confirm our transaction.
1808+
generateBlocks(1)
1809+
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
1810+
val blockHeight1 = sender.expectMsgType[BlockHeight]
1811+
assert(blockHeight1 == blockHeight + 1)
1812+
bitcoinClient.getBlockId(blockHeight1.toInt).pipeTo(sender.ref)
1813+
val tip = sender.expectMsgType[BlockId]
1814+
bitcoinClient.findSpendingTx(tx1.txIn.head.outPoint).pipeTo(sender.ref)
1815+
sender.expectMsg(Some(tx1, Some(tip)))
1816+
1817+
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
1818+
sender.expectMsg(Some(1))
1819+
bitcoinClient.isTransactionOutputSpendable(OutPoint(tx1.txid, 0), includeMempool = false).pipeTo(sender.ref)
1820+
sender.expectMsg(true)
1821+
bitcoinClient.isTransactionOutputSpendable(OutPoint(tx1.txid, 0), includeMempool = true).pipeTo(sender.ref)
1822+
sender.expectMsg(true)
1823+
1824+
// we still fid the spending tx evn if it has been confirmed many times
1825+
generateBlocks(10)
1826+
bitcoinClient.findSpendingTx(tx1.txIn.head.outPoint).pipeTo(sender.ref)
1827+
sender.expectMsg(Some(tx1, Some(tip)))
1828+
}
1829+
17651830
test("get pubkey for p2wpkh receive address") {
17661831
val sender = TestProbe()
17671832
val bitcoinClient = makeBitcoinCoreClient()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ trait BitcoindService extends Logging {
6262

6363
val PATH_BITCOIND = sys.env.get("BITCOIND_DIR") match {
6464
case Some(customBitcoinDir) => new File(customBitcoinDir, "bitcoind")
65-
case None => new File(TestUtils.BUILD_DIRECTORY, "bitcoin-30.2/bin/bitcoind")
65+
case None => new File(TestUtils.BUILD_DIRECTORY, "bitcoin-31.0/bin/bitcoind")
6666
}
6767
logger.info(s"using bitcoind: $PATH_BITCOIND")
6868
val PATH_BITCOIND_DATADIR = new File(INTEGRATION_TMP_DIR, "datadir-bitcoin")

0 commit comments

Comments
 (0)