Don't scan the blockchain for spent external channels (#3226)
What changed, and why it matters
This commit fixes a performance and usability bug in the Eclair Lightning node. When the node learned about an old public channel that had already been closed, it would wastefully scan the entire Bitcoin blockchain looking for the closing transaction, which could be very slow and produced scary 'funds at risk' log messages even though no user funds were actually in danger. The fix makes the node simply check whether the channel output is already gone and, if so, remove the channel from its routing map without hunting for the closing transaction.
No immediate security action required; this is a defensive hardening/performance fix. Operators should upgrade to avoid unnecessary bitcoind load and false-positive risk alerts. Reviewers may want to confirm that isTransactionOutputSpent correctly distinguishes mempool-spent vs confirmed-spent, since the new path only triggers on confirmed spends.
Security signals we found
Avoids expensive blockchain rescan triggered by untrusted public channel_announcement messages
Eliminates misleading 'funds are at risk' log lines for external channels where no funds are at risk
Changes WatchExternalChannelSpentTriggered to carry Option[Transaction] rather than Transaction
Adds Kamon metrics for active watches and watched UTXOs
Immediate pruning of already-spent external channels could briefly affect routing during splice confirmation window
Evidence from the diff
The change modifies ZmqWatcher’s checkSpent logic so that WatchExternalChannelSpent watches use a new, cheap isTransactionOutputSpent RPC and immediately trigger WatchExternalChannelSpentTriggered with spendingTx_opt=None when the output is already spent by a confirmed transaction. Other WatchSpent variants (funding/output) keep the existing mempool-hints-and-blockchain-rescan behavior because funds may actually be at risk. Router and Validation are updated to handle the None spending transaction case by pruning the channel from the graph immediately instead of waiting for a splice confirmation delay. Tests and metrics are updated accordingly.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scalaeclair-core/src/main/scala/fr/acinq/eclair/router/Router.scalaeclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scalaeclair-core/src/main/scala/fr/acinq/eclair/blockchain/Monitoring.scalaInspect captured patch +105 / −82
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/Monitoring.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/Monitoring.scala
index f91a6a8..24713a4 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/Monitoring.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/Monitoring.scala
@@ -24,6 +24,8 @@ object Monitoring {
object Metrics {
val NewBlockCheckConfirmedDuration: Metric.Timer = Kamon.timer("bitcoin.watcher.newblock.checkconfirmed")
+ val Watches: Metric.Gauge = Kamon.gauge("bitcoin.watcher.watches")
+ val WatchedUtxos: Metric.Gauge = Kamon.gauge("bitcoin.watcher.utxos")
val RpcBasicInvokeCount: Metric.Counter = Kamon.counter("bitcoin.rpc.basic.invoke.count")
val RpcBasicInvokeDuration: Metric.Timer = Kamon.timer("bitcoin.rpc.basic.invoke.duration")
val RpcBatchInvokeDuration: Metric.Timer = Kamon.timer("bitcoin.rpc.batch.invoke.duration")
@@ -45,6 +47,7 @@ object Monitoring {
val Wallet = "wallet"
val Priority = "priority"
val Provider = "provider"
+ val WatchType = "watch-type"
object Priorities {
val Minimum = "0-minimum"
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
index 3d9d766..d85057e 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala
@@ -121,13 +121,10 @@ object ZmqWatcher {
}
/** This event is sent when a [[WatchSpent]] condition is met. */
- sealed trait WatchSpentTriggered extends WatchTriggered {
- /** Transaction spending the watched outpoint. */
- def spendingTx: Transaction
- }
+ sealed trait WatchSpentTriggered extends WatchTriggered
case class WatchExternalChannelSpent(replyTo: ActorRef[WatchExternalChannelSpentTriggered], txId: TxId, outputIndex: Int, shortChannelId: RealShortChannelId) extends WatchSpent[WatchExternalChannelSpentTriggered] { override def hints: Set[TxId] = Set.empty }
- case class WatchExternalChannelSpentTriggered(shortChannelId: RealShortChannelId, spendingTx: Transaction) extends WatchSpentTriggered
+ case class WatchExternalChannelSpentTriggered(shortChannelId: RealShortChannelId, spendingTx_opt: Option[Transaction]) extends WatchSpentTriggered
case class UnwatchExternalChannelSpent(txId: TxId, outputIndex: Int) extends Command
case class WatchFundingSpent(replyTo: ActorRef[WatchFundingSpentTriggered], txId: TxId, outputIndex: Int, hints: Set[TxId]) extends WatchSpent[WatchFundingSpentTriggered]
@@ -231,7 +228,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
.flatMap(watchedUtxos.get)
.flatten
.foreach {
- case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId, tx))
+ case w: WatchExternalChannelSpent => context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId, Some(tx)))
case w: WatchFundingSpent => context.self ! TriggerEvent(w.replyTo, w, WatchFundingSpentTriggered(tx))
case w: WatchOutputSpent => context.self ! TriggerEvent(w.replyTo, w, WatchOutputSpentTriggered(w.amount, tx))
case _: WatchPublished => // nothing to do
@@ -322,12 +319,14 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
blockHeight.set(currentHeight.toLong)
context.system.eventStream ! EventStream.Publish(CurrentBlockHeight(currentHeight))
// TODO: should we try to mitigate the herd effect and not check all watches immediately?
- val watchExternalChannelCount = watches.keySet.count(_.isInstanceOf[WatchExternalChannelSpent])
- val watchFundingSpentCount = watches.keySet.count(_.isInstanceOf[WatchFundingSpent])
- val watchOutputSpentCount = watches.keySet.count(_.isInstanceOf[WatchOutputSpent])
- val watchPublishedCount = watches.keySet.count(_.isInstanceOf[WatchPublished])
- val watchConfirmedCount = watches.keySet.count(_.isInstanceOf[WatchConfirmed[_]])
- log.info("{} watched utxos: external-channels={}, funding-spent={}, output-spent={}, tx-published={}, tx-confirmed={}", watchedUtxos.size, watchExternalChannelCount, watchFundingSpentCount, watchOutputSpentCount, watchPublishedCount, watchConfirmedCount)
+ Metrics.WatchedUtxos.withoutTags().update(watchedUtxos.size)
+ log.info("currently watching {} utxos with {} watches", watchedUtxos.size, watches.size)
+ watches.keys
+ .groupBy(_.getClass.getSimpleName)
+ .foreach { case (t, list) =>
+ Metrics.Watches.withTag(Monitoring.Tags.WatchType, t).update(list.size)
+ log.info("we have {} {} currently registered", list.size, t)
+ }
KamonExt.timeFuture(Metrics.NewBlockCheckConfirmedDuration.withoutTags()) {
Future.sequence(watches.collect {
case (w: WatchPublished, _) => checkPublished(w)
@@ -429,41 +428,54 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
}
private def checkSpent(w: WatchSpent[_ <: WatchSpentTriggered]): Future[Unit] = {
- // first let's see if the parent tx was published or not
+ // First let's see if the parent tx was published or not before checking whether it has been spent.
client.getTxConfirmations(w.txId).collect {
- case Some(_) =>
- // parent tx was published, we need to make sure this particular output has not been spent
- client.isTransactionOutputSpendable(w.txId, w.outputIndex, includeMempool = true).collect {
- case false =>
- // the output has been spent, let's find the spending tx
- // if we know some potential spending txs, we try to fetch them directly
- Future.sequence(w.hints.map(txid => client.getTransaction(txid).map(Some(_)).recover { case _ => None }))
- .map(_.flatten) // filter out errors and hint transactions that can't be found
- .map(hintTxs => {
- hintTxs.find(tx => tx.txIn.exists(i => i.outPoint.txid == w.txId && i.outPoint.index == w.outputIndex)) match {
- case Some(spendingTx) =>
- log.info(s"${w.txId}:${w.outputIndex} has already been spent by a tx provided in hints: txid=${spendingTx.txid}")
- context.self ! ProcessNewTransaction(spendingTx)
- case None =>
- // The hints didn't help us, let's search for the spending transaction.
- log.info(s"${w.txId}:${w.outputIndex} has already been spent, looking for the spending tx in the mempool")
- client.lookForMempoolSpendingTx(w.txId, w.outputIndex).map(Some(_)).recover { case _ => None }.map {
- case Some(spendingTx) =>
- log.info(s"found tx spending ${w.txId}:${w.outputIndex} in the mempool: txid=${spendingTx.txid}")
- context.self ! ProcessNewTransaction(spendingTx)
- case None =>
- // no luck, we have to do it the hard way...
- log.warn(s"${w.txId}:${w.outputIndex} has already been spent, spending tx not in the mempool, looking in the blockchain...")
- client.lookForSpendingTx(None, w.txId, w.outputIndex, nodeParams.channelConf.maxChannelSpentRescanBlocks).map { spendingTx =>
- log.warn(s"found the spending tx of ${w.txId}:${w.outputIndex} in the blockchain: txid=${spendingTx.txid}")
+ case Some(_) => w match {
+ case w: WatchExternalChannelSpent =>
+ // This is an external channels: funds are not at risk, so we don't need to scan the blockchain to find the
+ // spending transaction, it is costly and unnecessary. We simply check whether the output has already been
+ // spent by a confirmed transaction.
+ client.isTransactionOutputSpent(w.txId, w.outputIndex).collect {
+ case true =>
+ // The output has been spent, so we trigger the watch without including the spending transaction.
+ context.self ! TriggerEvent(w.replyTo, w, WatchExternalChannelSpentTriggered(w.shortChannelId, None))
+ }
+ case _ =>
+ // The parent tx was published, we need to make sure this particular output has not been spent.
+ client.isTransactionOutputSpendable(w.txId, w.outputIndex, includeMempool = true).collect {
+ case false =>
+ // The output has been spent, let's find the spending tx.
+ // If we know some potential spending txs, we try to fetch them directly.
+ Future.sequence(w.hints.map(txid => client.getTransaction(txid).map(Some(_)).recover { case _ => None }))
+ .map(_.flatten) // filter out errors and hint transactions that can't be found
+ .map(hintTxs => {
+ hintTxs.find(tx => tx.txIn.exists(i => i.outPoint.txid == w.txId && i.outPoint.index == w.outputIndex)) match {
+ case Some(spendingTx) =>
+ log.info("{}:{} has already been spent by a tx provided in hints: txid={}", w.txId, w.outputIndex, spendingTx.txid)
+ context.self ! ProcessNewTransaction(spendingTx)
+ case None =>
+ // The hints didn't help us, let's search for the spending transaction in the mempool.
+ log.info("{}:{} has already been spent, looking for the spending tx in the mempool", w.txId, w.outputIndex)
+ client.lookForMempoolSpendingTx(w.txId, w.outputIndex).map(Some(_)).recover { case _ => None }.map {
+ case Some(spendingTx) =>
+ log.info("found tx spending {}:{} in the mempool: txid={}", w.txId, w.outputIndex, spendingTx.txid)
context.self ! ProcessNewTransaction(spendingTx)
- }.recover {
- case _ => log.warn(s"could not find the spending tx of ${w.txId}:${w.outputIndex} in the blockchain, funds are at risk")
- }
- }
- }
- })
- }
+ case None =>
+ // The spending transaction isn't in the mempool, so it must be a transaction that confirmed
+ // before we set the watch. We have to scan the blockchain to find it, which is expensive
+ // since bitcoind doesn't provide indexes for this scenario.
+ log.warn("{}:{} has already been spent, spending tx not in the mempool, looking in the blockchain...", w.txId, w.outputIndex)
+ client.lookForSpendingTx(None, w.txId, w.outputIndex, nodeParams.channelConf.maxChannelSpentRescanBlocks).map { spendingTx =>
+ log.warn("found the spending tx of {}:{} in the blockchain: txid={}", w.txId, w.outputIndex, spendingTx.txid)
+ context.self ! ProcessNewTransaction(spendingTx)
+ }.recover {
+ case _ => log.warn("could not find the spending tx of {}:{} in the blockchain, funds are at risk", w.txId, w.outputIndex)
+ }
+ }
+ }
+ })
+ }
+ }
}
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala
index dc5a133..2609dbd 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Router.scala
@@ -263,15 +263,24 @@ class Router(val nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Comm
case Event(r: ValidateResult, d) =>
stay() using Validation.handleChannelValidationResponse(d, nodeParams, watcher, r)
- case Event(WatchExternalChannelSpentTriggered(shortChannelId, spendingTx), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
+ case Event(WatchExternalChannelSpentTriggered(shortChannelId, spendingTx_opt), d) if d.channels.contains(shortChannelId) || d.prunedChannels.contains(shortChannelId) =>
val fundingTxId = d.channels.get(shortChannelId).orElse(d.prunedChannels.get(shortChannelId)).get.fundingTxId
- log.info("funding tx txId={} of channelId={} has been spent by txId={}: waiting for the spending tx to have enough confirmations before removing the channel from the graph", fundingTxId, shortChannelId, spendingTx.txid)
- watcher ! WatchTxConfirmed(self, spendingTx.txid, nodeParams.routerConf.channelSpentSpliceDelay)
- stay() using d.copy(spentChannels = d.spentChannels.updated(spendingTx.txid, d.spentChannels.getOrElse(spendingTx.txid, Set.empty) + shortChannelId))
+ spendingTx_opt match {
+ case Some(spendingTx) =>
+ log.info("funding tx txId={} of channelId={} has been spent by txId={}: waiting for the spending tx to have enough confirmations before removing the channel from the graph", fundingTxId, shortChannelId, spendingTx.txid)
+ watcher ! WatchTxConfirmed(self, spendingTx.txid, nodeParams.routerConf.channelSpentSpliceDelay)
+ stay() using d.copy(spentChannels = d.spentChannels.updated(spendingTx.txid, d.spentChannels.getOrElse(spendingTx.txid, Set.empty) + shortChannelId))
+ case None =>
+ // If the channel was spent by a transaction that is already confirmed, it would be very inefficient to scan
+ // the blockchain for the spending transaction (which could have confirmed a long time ago), just to forget
+ // that channel. We skip scanning the blockchain and immediately forget the channel.
+ log.info("funding tx txId={} of channelId={} has already been spent by a confirmed transaction: removing the channel from the graph immediately", fundingTxId, shortChannelId)
+ stay() using Validation.handleChannelSpent(d, watcher, nodeParams.db.network, None, Set(shortChannelId))
+ }
case Event(WatchTxConfirmedTriggered(_, _, spendingTx), d) =>
d.spentChannels.get(spendingTx.txid) match {
- case Some(shortChannelIds) => stay() using Validation.handleChannelSpent(d, watcher, nodeParams.db.network, spendingTx.txid, shortChannelIds)
+ case Some(shortChannelIds) => stay() using Validation.handleChannelSpent(d, watcher, nodeParams.db.network, Some(spendingTx.txid), shortChannelIds)
case None => stay()
}
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala b/eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala
index 0e5ecb9..fe429a8 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/router/Validation.scala
@@ -268,7 +268,7 @@ object Validation {
} else d1
}
- def handleChannelSpent(d: Data, watcher: typed.ActorRef[ZmqWatcher.Command], db: NetworkDb, spendingTxId: TxId, shortChannelIds: Set[RealShortChannelId])(implicit ctx: ActorContext, log: LoggingAdapter): Data = {
+ def handleChannelSpent(d: Data, watcher: typed.ActorRef[ZmqWatcher.Command], db: NetworkDb, spendingTxId_opt: Option[TxId], shortChannelIds: Set[RealShortChannelId])(implicit ctx: ActorContext, log: LoggingAdapter): Data = {
implicit val sender: ActorRef = ctx.self // necessary to preserve origin when sending messages to other actors
val lostChannels = shortChannelIds.flatMap(shortChannelId => d.channels.get(shortChannelId).orElse(d.prunedChannels.get(shortChannelId)))
log.info("funding tx for channelIds={} was spent", shortChannelIds.mkString(","))
@@ -277,7 +277,7 @@ object Validation {
val prunedChannels1 = d.prunedChannels -- shortChannelIds
val lostNodes = lostChannels.flatMap(lostChannel => Seq(lostChannel.nodeId1, lostChannel.nodeId2).filterNot(nodeId => hasChannels(nodeId, channels1.values)))
// let's clean the db and send the events
- log.info("pruning shortChannelIds={} (spent)", shortChannelIds.mkString(","))
+ log.info("pruning shortChannelIds={} (spent)", shortChannelIds.mkString(","))
shortChannelIds.foreach(db.removeChannel(_)) // NB: this also removes channel updates
// we also need to remove updates from the graph
val graphWithBalances1 = lostChannels.foldLeft(d.graphWithBalances) { (graph, lostChannel) =>
@@ -298,13 +298,12 @@ object Validation {
// we will re-add a spliced channel as a new channel later when we receive the announcement
watcher ! UnwatchExternalChannelSpent(lostChannel.fundingTxId, outputIndex(lostChannel.ann.shortChannelId))
}
-
// We may have received RBF candidates for this splice: we can find them by looking at transactions that spend one
// of the channels we're removing (note that they may spend a slightly different set of channels).
// Those transactions cannot confirm anymore (they have been double-spent by the current one), so we should stop
// watching them.
val spendingTxs = d.spentChannels.filter(_._2.intersect(shortChannelIds).nonEmpty).keySet
- (spendingTxs - spendingTxId).foreach(txId => watcher ! UnwatchTxConfirmed(txId))
+ (spendingTxs -- spendingTxId_opt.toSet).foreach(txId => watcher ! UnwatchTxConfirmed(txId))
val spentChannels1 = d.spentChannels -- spendingTxs
d.copy(nodes = d.nodes -- lostNodes, channels = channels1, prunedChannels = prunedChannels1, graphWithBalances = graphWithBalances1, spentChannels = spentChannels1)
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
index 54ce8d6..f55b3cb 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala
@@ -264,7 +264,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
bitcoinClient.publishTransaction(tx1)
// tx and tx1 aren't confirmed yet, but we trigger the WatchSpentTriggered event when we see tx1 in the mempool.
probe.expectMsgAllOf(
- WatchExternalChannelSpentTriggered(RealShortChannelId(5), tx1),
+ WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
WatchFundingSpentTriggered(tx1)
)
// Let's confirm tx and tx1: seeing tx1 in a block should trigger both WatchSpentTriggered events again.
@@ -272,7 +272,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
val initialBlockHeight = probe.expectMsgType[BlockHeight]
generateBlocks(1)
probe.expectMsgAllOf(
- WatchExternalChannelSpentTriggered(RealShortChannelId(5), tx1),
+ WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(tx1)),
WatchFundingSpentTriggered(tx1)
)
probe.expectNoMessage(100 millis)
@@ -311,8 +311,10 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
probe.fishForMessage() { case m: WatchOutputSpentTriggered => m.spendingTx.txid == tx1.txid }
watcher ! StopWatching(probe.ref)
+ // If we watch after being spent by a confirmed transaction, we immediately trigger the watch without fetching
+ // the spending transaction.
watcher ! WatchExternalChannelSpent(probe.ref, tx1.txid, 0, RealShortChannelId(1))
- probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(1), tx2))
+ probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(1), None))
watcher ! StopWatching(probe.ref)
watcher ! WatchFundingSpent(probe.ref, tx1.txid, 0, Set.empty)
probe.expectMsg(WatchFundingSpentTriggered(tx2))
@@ -340,7 +342,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
// When publishing the transaction, the watch triggers immediately.
bitcoinClient.publishTransaction(spendingTx1).pipeTo(sender.ref)
sender.expectMsg(spendingTx1.txid)
- probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(3), spendingTx1))
+ probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(3), Some(spendingTx1)))
probe.expectNoMessage(100 millis)
// If we unwatch the transaction, we will ignore when it's published.
@@ -351,7 +353,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
// If we watch again, this will trigger immediately because the transaction is in the mempool.
watcher ! WatchExternalChannelSpent(probe.ref, tx2.txid, outputIndex2, RealShortChannelId(5))
- probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(5), spendingTx2))
+ probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(5), Some(spendingTx2)))
probe.expectNoMessage(100 millis)
// We make the transactions confirm while we're not watching.
@@ -365,7 +367,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
// If we watch again after confirmation, the watch instantly triggers.
watcher ! WatchExternalChannelSpent(probe.ref, tx1.txid, outputIndex1, RealShortChannelId(3))
- probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(3), spendingTx1))
+ probe.expectMsg(WatchExternalChannelSpentTriggered(RealShortChannelId(3), None))
probe.expectNoMessage(100 millis)
})
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala
index e49f8cc..b90df36 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/basic/fixtures/MinimalNodeFixture.scala
@@ -302,7 +302,7 @@ object MinimalNodeFixture extends Assertions with Eventually with IntegrationPat
case watch: ZmqWatcher.WatchExternalChannelSpent =>
knownFundingTxs().find(_.txIn.exists(_.outPoint == OutPoint(watch.txId, watch.outputIndex))) match {
case Some(nextFundingTx) =>
- watch.replyTo ! ZmqWatcher.WatchExternalChannelSpentTriggered(watch.shortChannelId, nextFundingTx)
+ watch.replyTo ! ZmqWatcher.WatchExternalChannelSpentTriggered(watch.shortChannelId, Some(nextFundingTx))
case None => timers.startSingleTimer(watch, 10 millis)
}
Behaviors.same
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/ChannelRouterIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/ChannelRouterIntegrationSpec.scala
index 51dee1e..d4d12c2 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/router/ChannelRouterIntegrationSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/ChannelRouterIntegrationSpec.scala
@@ -182,7 +182,7 @@ class ChannelRouterIntegrationSpec extends TestKitBaseClass with FixtureAnyFunSu
if (testTags.contains(ChannelStateTestsTags.ChannelsPublic)) {
val closingTx = Transaction(2, Nil, Seq(TxOut(100_000 sat, Script.pay2wpkh(randomKey().publicKey))), 0)
val watchSpent = channels.alice2blockchain.expectMsgType[WatchExternalChannelSpent]
- watchSpent.replyTo ! WatchExternalChannelSpentTriggered(watchSpent.shortChannelId, closingTx)
+ watchSpent.replyTo ! WatchExternalChannelSpentTriggered(watchSpent.shortChannelId, Some(closingTx))
val watchConfirmed = channels.alice2blockchain.expectMsgType[WatchTxConfirmed]
assert(watchConfirmed.txId == closingTx.txid)
watchConfirmed.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400_000), 42, closingTx)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
index 8ed41ee..a90bd33 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/RouterSpec.scala
@@ -341,7 +341,7 @@ class RouterSpec extends BaseRouterSpec {
probe.send(router, GetRouterData)
val channels = probe.expectMsgType[Data].channels
- router ! WatchExternalChannelSpentTriggered(scid_ab, spendingTx(funding_a, funding_b))
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(spendingTx(funding_a, funding_b)))
watcher.expectMsgType[WatchTxConfirmed]
router ! WatchTxConfirmedTriggered(BlockHeight(0), 0, spendingTx(funding_a, funding_b))
watcher.expectMsg(UnwatchExternalChannelSpent(channels(scid_ab).fundingTxId, ShortChannelId.outputIndex(scid_ab)))
@@ -351,11 +351,9 @@ class RouterSpec extends BaseRouterSpec {
eventListener.expectMsg(NodeLost(a))
assert(nodeParams.db.network.getNode(a).isEmpty)
assert(nodeParams.db.network.getNode(b).nonEmpty)
- eventListener.expectNoMessage(200 milliseconds)
+ eventListener.expectNoMessage(100 milliseconds)
- router ! WatchExternalChannelSpentTriggered(scid_cd, spendingTx(funding_c, funding_d))
- watcher.expectMsgType[WatchTxConfirmed]
- router ! WatchTxConfirmedTriggered(BlockHeight(0), 0, spendingTx(funding_c, funding_d))
+ router ! WatchExternalChannelSpentTriggered(scid_cd, None)
watcher.expectMsg(UnwatchExternalChannelSpent(channels(scid_cd).fundingTxId, ShortChannelId.outputIndex(scid_cd)))
eventListener.expectMsg(ChannelLost(scid_cd))
assert(nodeParams.db.network.getChannel(scid_cd).isEmpty)
@@ -363,9 +361,9 @@ class RouterSpec extends BaseRouterSpec {
eventListener.expectMsg(NodeLost(d))
assert(nodeParams.db.network.getNode(d).isEmpty)
assert(nodeParams.db.network.getNode(c).nonEmpty)
- eventListener.expectNoMessage(200 milliseconds)
+ eventListener.expectNoMessage(100 milliseconds)
- router ! WatchExternalChannelSpentTriggered(scid_bc, spendingTx(funding_b, funding_c))
+ router ! WatchExternalChannelSpentTriggered(scid_bc, Some(spendingTx(funding_b, funding_c)))
watcher.expectMsgType[WatchTxConfirmed]
router ! WatchTxConfirmedTriggered(BlockHeight(0), 0, spendingTx(funding_b, funding_c))
watcher.expectMsg(UnwatchExternalChannelSpent(channels(scid_bc).fundingTxId, ShortChannelId.outputIndex(scid_bc)))
@@ -375,7 +373,7 @@ class RouterSpec extends BaseRouterSpec {
eventListener.expectMsgAllOf(NodeLost(b), NodeLost(c))
assert(nodeParams.db.network.getNode(b).isEmpty)
assert(nodeParams.db.network.getNode(c).isEmpty)
- eventListener.expectNoMessage(200 milliseconds)
+ eventListener.expectNoMessage(100 milliseconds)
}
test("properly announce lost pruned channels and nodes") { fixture =>
@@ -406,7 +404,7 @@ class RouterSpec extends BaseRouterSpec {
awaitAssert(assert(nodeParams.db.network.getChannel(scid_au).nonEmpty))
// The channel is closed, now we can remove it from the DB.
- router ! WatchExternalChannelSpentTriggered(scid_au, spendingTx(funding_a, priv_funding_u.publicKey))
+ router ! WatchExternalChannelSpentTriggered(scid_au, Some(spendingTx(funding_a, priv_funding_u.publicKey)))
assert(watcher.expectMsgType[WatchTxConfirmed].txId == spendingTx(funding_a, priv_funding_u.publicKey).txid)
router ! WatchTxConfirmedTriggered(BlockHeight(0), 0, spendingTx(funding_a, priv_funding_u.publicKey))
watcher.expectMsg(UnwatchExternalChannelSpent(channels(scid_au).fundingTxId, ShortChannelId.outputIndex(scid_au)))
@@ -1193,7 +1191,7 @@ class RouterSpec extends BaseRouterSpec {
// Channel ab is spent by a splice tx.
val capacity1 = publicChannelCapacity - 100_000.sat
val spliceTx1 = spendingTx(funding_a, funding_b, capacity1)
- router ! WatchExternalChannelSpentTriggered(scid_ab, spliceTx1)
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(spliceTx1))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == spliceTx1.txid)
assert(w.minDepth == 12)
@@ -1203,7 +1201,7 @@ class RouterSpec extends BaseRouterSpec {
// Channel ab is spent and confirmed by an RBF of splice tx.
val newCapacity = publicChannelCapacity - 100_000.sat - 1000.sat
val spliceTx2 = spendingTx(funding_a, funding_b, newCapacity)
- router ! WatchExternalChannelSpentTriggered(scid_ab, spliceTx2)
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(spliceTx2))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == spliceTx2.txid)
assert(w.minDepth == 12)
@@ -1243,38 +1241,38 @@ class RouterSpec extends BaseRouterSpec {
val batchSpliceTx = batchSpendingTx(Seq(spliceTx_ab, spliceTx_bc, spliceTx_bc2))
// Channel ab is spent by a splice tx.
- router ! WatchExternalChannelSpentTriggered(scid_ab, spliceTx_ab)
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(spliceTx_ab))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == spliceTx_ab.txid)
assert(w.minDepth == 12)
}
// Channel bc is spent by a splice tx.
- router ! WatchExternalChannelSpentTriggered(scid_bc, spliceTx_bc)
+ router ! WatchExternalChannelSpentTriggered(scid_bc, Some(spliceTx_bc))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == spliceTx_bc.txid)
assert(w.minDepth == 12)
}
// Channel bc2 is spent by a splice tx.
- router ! WatchExternalChannelSpentTriggered(scid_bc2, spliceTx_bc2)
+ router ! WatchExternalChannelSpentTriggered(scid_bc2, Some(spliceTx_bc2))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == spliceTx_bc2.txid)
assert(w.minDepth == 12)
}
// Channels ab, bc and bc2 are all spent by the same batch splice tx.
- router ! WatchExternalChannelSpentTriggered(scid_ab, batchSpliceTx)
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(batchSpliceTx))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx.txid)
assert(w.minDepth == 12)
}
- router ! WatchExternalChannelSpentTriggered(scid_bc, batchSpliceTx)
+ router ! WatchExternalChannelSpentTriggered(scid_bc, Some(batchSpliceTx))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx.txid)
assert(w.minDepth == 12)
}
- router ! WatchExternalChannelSpentTriggered(scid_bc2, batchSpliceTx)
+ router ! WatchExternalChannelSpentTriggered(scid_bc2, Some(batchSpliceTx))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx.txid)
assert(w.minDepth == 12)
@@ -1283,17 +1281,17 @@ class RouterSpec extends BaseRouterSpec {
// Channels ab, bc and bc2 are also all spent by an RBF of the batch splice tx.
val newCapacity_ab_RBF = newCapacity_ab - 1000.sat
val batchSpliceTx_RBF = batchSpendingTx(Seq(spendingTx(funding_a, funding_b, newCapacity_ab_RBF), spliceTx_bc, spliceTx_bc2))
- router ! WatchExternalChannelSpentTriggered(scid_ab, batchSpliceTx_RBF)
+ router ! WatchExternalChannelSpentTriggered(scid_ab, Some(batchSpliceTx_RBF))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx_RBF.txid)
assert(w.minDepth == 12)
}
- router ! WatchExternalChannelSpentTriggered(scid_bc, batchSpliceTx_RBF)
+ router ! WatchExternalChannelSpentTriggered(scid_bc, Some(batchSpliceTx_RBF))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx_RBF.txid)
assert(w.minDepth == 12)
}
- router ! WatchExternalChannelSpentTriggered(scid_bc2, batchSpliceTx_RBF)
+ router ! WatchExternalChannelSpentTriggered(scid_bc2, Some(batchSpliceTx_RBF))
inside(watcher.expectMsgType[WatchTxConfirmed]) { w =>
assert(w.txId == batchSpliceTx_RBF.txid)
assert(w.minDepth == 12)
Why this scored 30/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.