Skip to content

Commit cd31b14

Browse files
committed
Make use of txospender mandatory, remove block scanning code
On startup, eclair will check that both txindex and txospenderindex are enabled and synced, and will stop if they're not. Block scanning code used to find spending transactions is removed, we just use the txospenderindex now.
1 parent d4e7edd commit cd31b14

5 files changed

Lines changed: 81 additions & 145 deletions

File tree

eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ class Setup(val datadir: File,
292292
}
293293
}
294294
_ = if (bitcoinClient.useEclairSigner) logger.info("using eclair to sign bitcoin core transactions")
295+
indexInfos <- bitcoinClient.getIndexInfo()
296+
_ = if (!indexInfos.get("txindex").exists(_.synced)) throw new RuntimeException("txindex is disabled or not synchronized")
297+
_ = if (!indexInfos.get("txospenderindex").exists(_.synced)) throw new RuntimeException("txospenderindex is disabled or not synchronized")
295298
// We use the default address type configured on the Bitcoin Core node.
296299
initialPubkeyScript <- bitcoinClient.getReceivePublicKeyScript(addressType_opt = None)
297300
_ = finalPubkeyScript.set(initialPubkeyScript)

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

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,6 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
429429
}
430430
}
431431

432-
private def canUseTxoSpenderIndex: Future[Boolean] = client.getIndexInfo().map(_.get("txospenderindex").exists(_.synced))
433432

434433
private def checkSpent(w: WatchSpent[_ <: WatchSpentTriggered]): Future[Unit] = {
435434
// First let's see if the parent tx was published or not before checking whether it has been spent.
@@ -447,8 +446,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
447446
case _ =>
448447
// The parent tx was published, we need to make sure this particular output has not been spent.
449448
client.isTransactionOutputSpendable(w.outPoint, includeMempool = true).collect {
450-
case false => canUseTxoSpenderIndex.foreach {
451-
case true =>
449+
case false =>
452450
// The output has been spent, let's find the spending tx in the txospenderindex.
453451
log.info("{} has already been spent, looking for the spending tx", w.outPoint)
454452
client.findSpendingTx(w.outPoint) map {
@@ -461,42 +459,10 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
461459
case error =>
462460
log.warn("error finding the spending tx of {}, funds are at risk", w.outPoint, error)
463461
}
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)
480-
context.self ! ProcessNewTransaction(spendingTx)
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-
})
495462
}
496463
}
497464
}
498465
}
499-
}
500466

501467
private def checkPublished(w: WatchPublished): Future[Unit] = {
502468
log.debug("checking publication of txid={}", w.txId)

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

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -199,32 +199,6 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
199199
}
200200
}
201201

202-
/**
203-
* Iterate over blocks to find the transaction that has spent a given output.
204-
* It isn't useful to look at the whole blockchain history: if the transaction was confirmed long ago, an attacker
205-
* will have already claimed all possible outputs and there's nothing we can do about it.
206-
*
207-
* @param blockHash_opt hash of a block *after* the output has been spent. If not provided, we will use the blockchain tip.
208-
* @param outPoint transaction output that has been spent.
209-
* @param limit maximum number of previous blocks to scan.
210-
* @return the transaction spending the given output.
211-
*/
212-
def lookForSpendingTx(blockHash_opt: Option[BlockHash], outPoint: OutPoint, limit: Int)(implicit ec: ExecutionContext): Future[Transaction] = {
213-
for {
214-
blockId <- blockHash_opt match {
215-
case Some(blockHash) => Future.successful(BlockId(blockHash))
216-
// NB: bitcoind confusingly returns the blockId instead of the blockHash.
217-
case None => rpcClient.invoke("getbestblockhash").collect { case JString(blockId) => BlockId(ByteVector32.fromValidHex(blockId)) }
218-
}
219-
block <- getBlock(blockId)
220-
res <- block.tx.asScala.find(tx => tx.txIn.asScala.exists(i => i.outPoint == KotlinUtils.scala2kmp(outPoint))) match {
221-
case Some(tx) => Future.successful(KotlinUtils.kmp2scala(tx))
222-
case None if limit > 0 => lookForSpendingTx(Some(KotlinUtils.kmp2scala(block.header.hashPreviousBlock)), outPoint, limit - 1)
223-
case None => Future.failed(new RuntimeException(s"couldn't find tx spending $outPoint in the blockchain"))
224-
}
225-
} yield res
226-
}
227-
228202
def listTransactions(count: Int, skip: Int)(implicit ec: ExecutionContext): Future[List[WalletTx]] = rpcClient.invoke("listtransactions", "*", count, skip).map {
229203
case JArray(txs) => txs.map(tx => {
230204
val JString(address) = tx \ "address"

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,6 +1745,8 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
17451745
generateBlocks(1)
17461746
bitcoinClient.getBlockHeight().pipeTo(sender.ref)
17471747
val blockHeight1 = sender.expectMsgType[BlockHeight]
1748+
bitcoinClient.getBlockId(blockHeight1.toInt).pipeTo(sender.ref)
1749+
val blockId = sender.expectMsgType[BlockId]
17481750
assert(blockHeight1 == blockHeight + 1)
17491751
bitcoinClient.getTxConfirmations(tx1.txid).pipeTo(sender.ref)
17501752
sender.expectMsg(Some(1))
@@ -1756,10 +1758,8 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A
17561758
generateBlocks(10)
17571759
bitcoinClient.lookForMempoolSpendingTx(tx1.txIn.head.outPoint).pipeTo(sender.ref)
17581760
sender.expectMsgType[Failure]
1759-
bitcoinClient.lookForSpendingTx(None, tx1.txIn.head.outPoint, limit = 5).pipeTo(sender.ref)
1760-
sender.expectMsgType[Failure]
1761-
bitcoinClient.lookForSpendingTx(None, tx1.txIn.head.outPoint, limit = 15).pipeTo(sender.ref)
1762-
sender.expectMsg(tx1)
1761+
bitcoinClient.findSpendingTx(tx1.txIn.head.outPoint).pipeTo(sender.ref)
1762+
sender.expectMsg(Some(tx1, Some(blockId)))
17631763
}
17641764

17651765
test("get index information") {

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

Lines changed: 73 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
7272
case class Fixture(blockHeight: AtomicLong, bitcoinClient: BitcoinCoreClient, watcher: typed.ActorRef[ZmqWatcher.Command], probe: TestProbe, listener: TestProbe)
7373

7474
// NB: we can't use ScalaTest's fixtures, they would see uninitialized bitcoind fields because they sandbox each test.
75-
private def withWatcher(testFun: Fixture => Any, scanPastBlock: Boolean = false, useTxoSpenderIndex: Boolean = false): Unit = {
75+
private def withWatcher(testFun: Fixture => Any, scanPastBlock: Boolean = false): Unit = {
7676
val blockCount = new AtomicLong()
7777
val probe = TestProbe()
7878
val listener = TestProbe()
@@ -85,7 +85,6 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
8585
// We enable it and use a faster (randomized) delay when requested.
8686
.modify(_.channelConf.scanPreviousBlocksDepth).setToIf(scanPastBlock)(6)
8787
.modify(_.channelConf.maxBlockProcessingDelay).setToIf(scanPastBlock)(10 millis)
88-
//.modify(_.channelConf.useTxoSpenderIndex).setTo(useTxoSpenderIndex)
8988
val watcher = system.spawn(ZmqWatcher(nodeParams, blockCount, bitcoinClient), UUID.randomUUID().toString)
9089
try {
9190
testFun(Fixture(blockCount, bitcoinClient, watcher, probe, listener))
@@ -246,86 +245,80 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
246245
})
247246
}
248247

249-
def watchForSpentTransactions(f: Fixture): Unit = {
250-
import f._
251-
252-
val (priv, address) = createExternalAddress()
253-
val tx = sendToAddress(address, Btc(1), probe)
254-
val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey)))
255-
val (tx1, tx2) = createUnspentTxChain(tx, priv)
256-
257-
watcher ! WatchExternalChannelSpent(probe.ref, tx.txid, outputIndex, RealShortChannelId(5))
258-
watcher ! WatchFundingSpent(probe.ref, tx.txid, outputIndex, Set.empty)
259-
probe.expectNoMessage(100 millis)
260-
261-
watcher ! ListWatches(probe.ref)
262-
assert(probe.expectMsgType[Set[Watch[_]]].size == 2)
263-
264-
bitcoinClient.publishTransaction(tx1)
265-
// tx and tx1 aren't confirmed yet, but we trigger the WatchSpentTriggered event when we see tx1 in the mempool.
266-
probe.expectMsgAllOf(
267-
WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
268-
WatchFundingSpentTriggered(tx1)
269-
)
270-
// Let's confirm tx and tx1: seeing tx1 in a block should trigger both WatchSpentTriggered events again.
271-
bitcoinClient.getBlockHeight().pipeTo(probe.ref)
272-
val initialBlockHeight = probe.expectMsgType[BlockHeight]
273-
generateBlocks(1)
274-
probe.expectMsgAllOf(
275-
WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
276-
WatchFundingSpentTriggered(tx1)
277-
)
278-
probe.expectNoMessage(100 millis)
279-
280-
watcher ! ListWatches(probe.ref)
281-
val watches1 = probe.expectMsgType[Set[Watch[_]]]
282-
assert(watches1.size == 2)
283-
assert(watches1.forall(_.isInstanceOf[WatchSpent[_]]))
284-
285-
// Let's submit tx2, and set a watch after it has been confirmed this time.
286-
bitcoinClient.publishTransaction(tx2)
287-
probe.expectNoMessage(100 millis)
288-
289-
system.eventStream.subscribe(probe.ref, classOf[CurrentBlockHeight])
290-
generateBlocks(1)
291-
awaitCond(probe.expectMsgType[CurrentBlockHeight].blockHeight >= initialBlockHeight + 2)
292-
293-
watcher ! ListWatches(probe.ref)
294-
val watches2 = probe.expectMsgType[Set[Watch[_]]]
295-
assert(watches2.size == 2)
296-
assert(watches2.forall(_.isInstanceOf[WatchSpent[_]]))
297-
watcher ! StopWatching(probe.ref)
298-
299-
// We use hints and see if we can find tx2
300-
watcher ! WatchFundingSpent(probe.ref, tx1.txid, 0, Set(tx2.txid))
301-
probe.expectMsg(WatchFundingSpentTriggered(tx2))
302-
watcher ! StopWatching(probe.ref)
303-
304-
// We should still find tx2 if the provided hint is wrong
305-
watcher ! WatchOutputSpent(probe.ref, tx1.txid, 0, tx1.txOut(0).amount, Set(randomTxId()))
306-
probe.fishForMessage() { case m: WatchOutputSpentTriggered => m.spendingTx.txid == tx2.txid }
307-
watcher ! StopWatching(probe.ref)
308-
309-
// We should find txs that have already been confirmed
310-
watcher ! WatchOutputSpent(probe.ref, tx.txid, outputIndex, tx.txOut(outputIndex).amount, Set.empty)
311-
probe.fishForMessage() { case m: WatchOutputSpentTriggered => m.spendingTx.txid == tx1.txid }
312-
watcher ! StopWatching(probe.ref)
313-
314-
// If we watch after being spent by a confirmed transaction, we immediately trigger the watch without fetching
315-
// the spending transaction.
316-
watcher ! WatchExternalChannelSpent(probe.ref, tx1.txid, 0, RealShortChannelId(1))
317-
probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(1), None))
318-
watcher ! StopWatching(probe.ref)
319-
watcher ! WatchFundingSpent(probe.ref, tx1.txid, 0, Set.empty)
320-
probe.expectMsg(WatchFundingSpentTriggered(tx2))
321-
}
322-
323248
test("watch for spent transactions") {
324-
withWatcher(f => watchForSpentTransactions(f))
325-
}
249+
withWatcher(f => {
250+
import f._
251+
252+
val (priv, address) = createExternalAddress()
253+
val tx = sendToAddress(address, Btc(1), probe)
254+
val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey)))
255+
val (tx1, tx2) = createUnspentTxChain(tx, priv)
256+
257+
watcher ! WatchExternalChannelSpent(probe.ref, tx.txid, outputIndex, RealShortChannelId(5))
258+
watcher ! WatchFundingSpent(probe.ref, tx.txid, outputIndex, Set.empty)
259+
probe.expectNoMessage(100 millis)
260+
261+
watcher ! ListWatches(probe.ref)
262+
assert(probe.expectMsgType[Set[Watch[_]]].size == 2)
263+
264+
bitcoinClient.publishTransaction(tx1)
265+
// tx and tx1 aren't confirmed yet, but we trigger the WatchSpentTriggered event when we see tx1 in the mempool.
266+
probe.expectMsgAllOf(
267+
WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
268+
WatchFundingSpentTriggered(tx1)
269+
)
270+
// Let's confirm tx and tx1: seeing tx1 in a block should trigger both WatchSpentTriggered events again.
271+
bitcoinClient.getBlockHeight().pipeTo(probe.ref)
272+
val initialBlockHeight = probe.expectMsgType[BlockHeight]
273+
generateBlocks(1)
274+
probe.expectMsgAllOf(
275+
WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
276+
WatchFundingSpentTriggered(tx1)
277+
)
278+
probe.expectNoMessage(100 millis)
326279

327-
test("watch for spent transactions with txospenderindex") {
328-
withWatcher(f => watchForSpentTransactions(f), useTxoSpenderIndex = true)
280+
watcher ! ListWatches(probe.ref)
281+
val watches1 = probe.expectMsgType[Set[Watch[_]]]
282+
assert(watches1.size == 2)
283+
assert(watches1.forall(_.isInstanceOf[WatchSpent[_]]))
284+
285+
// Let's submit tx2, and set a watch after it has been confirmed this time.
286+
bitcoinClient.publishTransaction(tx2)
287+
probe.expectNoMessage(100 millis)
288+
289+
system.eventStream.subscribe(probe.ref, classOf[CurrentBlockHeight])
290+
generateBlocks(1)
291+
awaitCond(probe.expectMsgType[CurrentBlockHeight].blockHeight >= initialBlockHeight + 2)
292+
293+
watcher ! ListWatches(probe.ref)
294+
val watches2 = probe.expectMsgType[Set[Watch[_]]]
295+
assert(watches2.size == 2)
296+
assert(watches2.forall(_.isInstanceOf[WatchSpent[_]]))
297+
watcher ! StopWatching(probe.ref)
298+
299+
// We use hints and see if we can find tx2
300+
watcher ! WatchFundingSpent(probe.ref, tx1.txid, 0, Set(tx2.txid))
301+
probe.expectMsg(WatchFundingSpentTriggered(tx2))
302+
watcher ! StopWatching(probe.ref)
303+
304+
// We should still find tx2 if the provided hint is wrong
305+
watcher ! WatchOutputSpent(probe.ref, tx1.txid, 0, tx1.txOut(0).amount, Set(randomTxId()))
306+
probe.fishForMessage() { case m: WatchOutputSpentTriggered => m.spendingTx.txid == tx2.txid }
307+
watcher ! StopWatching(probe.ref)
308+
309+
// We should find txs that have already been confirmed
310+
watcher ! WatchOutputSpent(probe.ref, tx.txid, outputIndex, tx.txOut(outputIndex).amount, Set.empty)
311+
probe.fishForMessage() { case m: WatchOutputSpentTriggered => m.spendingTx.txid == tx1.txid }
312+
watcher ! StopWatching(probe.ref)
313+
314+
// If we watch after being spent by a confirmed transaction, we immediately trigger the watch without fetching
315+
// the spending transaction.
316+
watcher ! WatchExternalChannelSpent(probe.ref, tx1.txid, 0, RealShortChannelId(1))
317+
probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(1), None))
318+
watcher ! StopWatching(probe.ref)
319+
watcher ! WatchFundingSpent(probe.ref, tx1.txid, 0, Set.empty)
320+
probe.expectMsg(WatchFundingSpentTriggered(tx2))
321+
})
329322
}
330323

331324
test("unwatch external channel") {

0 commit comments

Comments
 (0)