Skip to content

Commit 8370fa2

Browse files
authored
Reduce the number of RPC calls to bitcoind during force-close (#2902)
* Don't spawn anchor tx publisher if commit is confirmed It is inefficient to spawn a tx publisher for anchor txs if we already know that the commit tx is confirmed: we will make calls to our bitcoin node that can easily be avoided. This can matter when force-closing a large number of channels with frequent disconnections (e.g. wallets). * Improve `TxTimeLocksMonitor` performance When publishing a transaction that has CSV delays, we previously used the watcher and set a `minDepth` on the parent transaction matching the CSV delay of the child transaction. While this was very simple, it was unnecessarily expensive for large CSV delays: the watcher would check for tx confirmations at every block, even when the CSV delay is very large. When we force-close a large number of channels, it results in a very large number of RPC calls to our `bitcoind` node. We don't use the watcher in the `TxTimeLocksMonitor` anymore: instead we check the parent confirmations once, and then we check again after the CSV delay. * Add relative delay hints to `ZmqWatcher` When we tell the `ZmqWatcher` to watch for confirmations on transactions that have a relative delay, it is highly inefficient to call our bitcoin node at every new block to check for confirmations (especially when the parent transaction isn't even confirmed). We now tell the watcher about the relative delay, which lets it check for confirmations only at block heights where we expect the transaction to reach its minimum depth. This is especially useful to improve performance for delayed transactions that usually use a CSV of at least 720 blocks.
1 parent fcd88b0 commit 8370fa2

File tree

15 files changed

+314
-194
lines changed

15 files changed

+314
-194
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ class Setup(val datadir: File,
367367
// we want to make sure the handler for post-restart broken HTLCs has finished initializing.
368368
_ <- postRestartCleanUpInitialized.future
369369

370-
txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, watcher, bitcoinClient)
370+
txPublisherFactory = Channel.SimpleTxPublisherFactory(nodeParams, bitcoinClient)
371371
channelFactory = Peer.SimpleChannelFactory(nodeParams, watcher, relayer, bitcoinClient, txPublisherFactory)
372372
pendingChannelsRateLimiter = system.spawn(Behaviors.supervise(PendingChannelsRateLimiter(nodeParams, router.toTyped, channels)).onFailure(typed.SupervisorStrategy.resume), name = "pending-channels-rate-limiter")
373373
peerFactory = Switchboard.SimplePeerFactory(nodeParams, bitcoinClient, channelFactory, pendingChannelsRateLimiter, register, router.toTyped)

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

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ object ZmqWatcher {
6363
private case class PublishBlockHeight(current: BlockHeight) extends Command
6464
private case class ProcessNewBlock(blockId: BlockId) extends Command
6565
private case class ProcessNewTransaction(tx: Transaction) extends Command
66+
private case class SetWatchHint(w: GenericWatch, hint: WatchHint) extends Command
6667

6768
final case class ValidateRequest(replyTo: ActorRef[ValidateResult], ann: ChannelAnnouncement) extends Command
6869
final case class ValidateResult(c: ChannelAnnouncement, fundingTx: Either[Throwable, (Transaction, UtxoStatus)])
@@ -155,7 +156,8 @@ object ZmqWatcher {
155156
case class WatchFundingDeeplyBuried(replyTo: ActorRef[WatchFundingDeeplyBuriedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchFundingDeeplyBuriedTriggered]
156157
case class WatchFundingDeeplyBuriedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
157158

158-
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchTxConfirmedTriggered]
159+
case class RelativeDelay(parentTxId: TxId, delay: Long)
160+
case class WatchTxConfirmed(replyTo: ActorRef[WatchTxConfirmedTriggered], txId: TxId, minDepth: Long, delay_opt: Option[RelativeDelay] = None) extends WatchConfirmed[WatchTxConfirmedTriggered]
159161
case class WatchTxConfirmedTriggered(blockHeight: BlockHeight, txIndex: Int, tx: Transaction) extends WatchConfirmedTriggered
160162

161163
case class WatchParentTxConfirmed(replyTo: ActorRef[WatchParentTxConfirmedTriggered], txId: TxId, minDepth: Long) extends WatchConfirmed[WatchParentTxConfirmedTriggered]
@@ -167,6 +169,13 @@ object ZmqWatcher {
167169
private sealed trait AddWatchResult
168170
private case object Keep extends AddWatchResult
169171
private case object Ignore extends AddWatchResult
172+
173+
sealed trait WatchHint
174+
/**
175+
* In some cases we don't need to check watches every time a block is found and only need to check again after we
176+
* reach a specific block height. This is for example the case for transactions with a CSV delay.
177+
*/
178+
private case class CheckAfterBlock(blockHeight: BlockHeight) extends WatchHint
170179
// @formatter:on
171180

172181
def apply(nodeParams: NodeParams, blockCount: AtomicLong, client: BitcoinCoreClient): Behavior[Command] =
@@ -178,7 +187,7 @@ object ZmqWatcher {
178187
timers.startSingleTimer(TickNewBlock, 1 second)
179188
// we start a timer in case we don't receive ZMQ block events
180189
timers.startSingleTimer(TickBlockTimeout, blockTimeout)
181-
new ZmqWatcher(nodeParams, blockCount, client, context, timers).watching(Set.empty[GenericWatch], Map.empty[OutPoint, Set[GenericWatch]])
190+
new ZmqWatcher(nodeParams, blockCount, client, context, timers).watching(Map.empty[GenericWatch, Option[WatchHint]], Map.empty[OutPoint, Set[GenericWatch]])
182191
}
183192
}
184193

@@ -224,7 +233,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
224233

225234
private val watchdog = context.spawn(Behaviors.supervise(BlockchainWatchdog(nodeParams, 150 seconds)).onFailure(SupervisorStrategy.resume), "blockchain-watchdog")
226235

227-
private def watching(watches: Set[GenericWatch], watchedUtxos: Map[OutPoint, Set[GenericWatch]]): Behavior[Command] = {
236+
private def watching(watches: Map[GenericWatch, Option[WatchHint]], watchedUtxos: Map[OutPoint, Set[GenericWatch]]): Behavior[Command] = {
228237
Behaviors.receiveMessage {
229238
case ProcessNewTransaction(tx) =>
230239
log.debug("analyzing txid={} tx={}", tx.txid, tx)
@@ -239,7 +248,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
239248
case _: WatchPublished => // nothing to do
240249
case _: WatchConfirmed[_] => // nothing to do
241250
}
242-
watches.collect {
251+
watches.keySet.collect {
243252
case w: WatchPublished if w.txId == tx.txid => context.self ! TriggerEvent(w.replyTo, w, WatchPublishedTriggered(tx))
244253
}
245254
Behaviors.same
@@ -279,21 +288,32 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
279288
case Failure(t) => GetBlockCountFailed(t)
280289
case Success(currentHeight) => PublishBlockHeight(currentHeight)
281290
}
282-
// TODO: beware of the herd effect
283-
KamonExt.timeFuture(Metrics.NewBlockCheckConfirmedDuration.withoutTags()) {
284-
Future.sequence(watches.collect {
285-
case w: WatchPublished => checkPublished(w)
286-
case w: WatchConfirmed[_] => checkConfirmed(w)
287-
})
288-
}
289291
Behaviors.same
290292

291293
case PublishBlockHeight(currentHeight) =>
292294
log.debug("setting blockHeight={}", currentHeight)
293295
blockHeight.set(currentHeight.toLong)
294296
context.system.eventStream ! EventStream.Publish(CurrentBlockHeight(currentHeight))
297+
// TODO: should we try to mitigate the herd effect and not check all watches immediately?
298+
KamonExt.timeFuture(Metrics.NewBlockCheckConfirmedDuration.withoutTags()) {
299+
Future.sequence(watches.collect {
300+
case (w: WatchPublished, _) => checkPublished(w)
301+
case (w: WatchConfirmed[_], hint) =>
302+
hint match {
303+
case Some(CheckAfterBlock(delayUntilBlock)) if currentHeight < delayUntilBlock => Future.successful(())
304+
case _ => checkConfirmed(w, currentHeight)
305+
}
306+
})
307+
}
295308
Behaviors.same
296309

310+
case SetWatchHint(w, hint) =>
311+
val watches1 = watches.get(w) match {
312+
case Some(_) => watches + (w -> Some(hint))
313+
case None => watches
314+
}
315+
watching(watches1, watchedUtxos)
316+
297317
case TriggerEvent(replyTo, watch, event) =>
298318
if (watches.contains(watch)) {
299319
log.debug("triggering {}", watch)
@@ -323,7 +343,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
323343
checkSpent(w)
324344
Keep
325345
case w: WatchConfirmed[_] =>
326-
checkConfirmed(w)
346+
checkConfirmed(w, BlockHeight(blockHeight.get()))
327347
Keep
328348
case w: WatchPublished =>
329349
checkPublished(w)
@@ -333,14 +353,14 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
333353
case Keep =>
334354
log.debug("adding watch {}", w)
335355
context.watchWith(w.replyTo, StopWatching(w.replyTo))
336-
watching(watches + w, addWatchedUtxos(watchedUtxos, w))
356+
watching(watches + (w -> None), addWatchedUtxos(watchedUtxos, w))
337357
case Ignore =>
338358
Behaviors.same
339359
}
340360

341361
case StopWatching(origin) =>
342-
// we remove watches associated to dead actors
343-
val deprecatedWatches = watches.filter(_.replyTo == origin)
362+
// We remove watches associated to dead actors.
363+
val deprecatedWatches = watches.keySet.filter(_.replyTo == origin)
344364
val watchedUtxos1 = deprecatedWatches.foldLeft(watchedUtxos) { case (m, w) => removeWatchedUtxos(m, w) }
345365
watching(watches -- deprecatedWatches, watchedUtxos1)
346366

@@ -353,7 +373,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
353373
Behaviors.same
354374

355375
case r: ListWatches =>
356-
r.replyTo ! watches
376+
r.replyTo ! watches.keySet
357377
Behaviors.same
358378

359379
}
@@ -414,7 +434,7 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
414434
client.getTransaction(w.txId).map(tx => context.self ! TriggerEvent(w.replyTo, w, WatchPublishedTriggered(tx)))
415435
}
416436

417-
private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered]): Future[Unit] = {
437+
private def checkConfirmed(w: WatchConfirmed[_ <: WatchConfirmedTriggered], currentHeight: BlockHeight): Future[Unit] = {
418438
log.debug("checking confirmations of txid={}", w.txId)
419439
// NB: this is very inefficient since internally we call `getrawtransaction` three times, but it doesn't really
420440
// matter because this only happens once, when the watched transaction has reached min_depth
@@ -431,7 +451,33 @@ private class ZmqWatcher(nodeParams: NodeParams, blockHeight: AtomicLong, client
431451
}
432452
}
433453
}
434-
case _ => Future.successful((): Unit)
454+
case Some(confirmations) =>
455+
// Once the transaction is confirmed, we don't need to check again at every new block, we only need to check
456+
// again once we should have reached the minimum depth to verify that there hasn't been a reorg.
457+
context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + w.minDepth - confirmations))
458+
Future.successful(())
459+
case None =>
460+
w match {
461+
case WatchTxConfirmed(_, _, _, Some(relativeDelay)) =>
462+
log.debug("txId={} has a relative delay of {} blocks, checking parentTxId={}", w.txId, relativeDelay.delay, relativeDelay.parentTxId)
463+
// Note how we add one block to avoid an off-by-one:
464+
// - if the parent is confirmed at block P
465+
// - the CSV delay is D and the minimum depth is M
466+
// - the first block that can include the child is P + D
467+
// - the first block at which we can reach minimum depth is P + D + M
468+
// - if we are currently at block P + N, the parent has C = N + 1 confirmations
469+
// - we want to check at block P + N + D + M + 1 - C = P + N + D + M + 1 - (N + 1) = P + D + M
470+
val delay = relativeDelay.delay + w.minDepth + 1
471+
client.getTxConfirmations(relativeDelay.parentTxId).map(_.getOrElse(0)).collect {
472+
case confirmations if confirmations < delay => context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + delay - confirmations))
473+
}
474+
case _ =>
475+
// The transaction is unconfirmed: we don't need to check again at every new block: we can check only once
476+
// every minDepth blocks, which is more efficient. If the transaction is included at the current height in
477+
// a reorg, we will trigger the watch one block later than expected, but this is fine.
478+
context.self ! SetWatchHint(w, CheckAfterBlock(currentHeight + w.minDepth))
479+
Future.successful(())
480+
}
435481
}
436482
}
437483

eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ object Channel {
115115
def spawnTxPublisher(context: ActorContext, remoteNodeId: PublicKey): typed.ActorRef[TxPublisher.Command]
116116
}
117117

118-
case class SimpleTxPublisherFactory(nodeParams: NodeParams, watcher: typed.ActorRef[ZmqWatcher.Command], bitcoinClient: BitcoinCoreClient) extends TxPublisherFactory {
118+
case class SimpleTxPublisherFactory(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient) extends TxPublisherFactory {
119119
override def spawnTxPublisher(context: ActorContext, remoteNodeId: PublicKey): typed.ActorRef[TxPublisher.Command] = {
120-
context.spawn(Behaviors.supervise(TxPublisher(nodeParams, remoteNodeId, TxPublisher.SimpleChildFactory(nodeParams, bitcoinClient, watcher))).onFailure(typed.SupervisorStrategy.restart), "tx-publisher")
120+
context.spawn(Behaviors.supervise(TxPublisher(nodeParams, remoteNodeId, TxPublisher.SimpleChildFactory(nodeParams, bitcoinClient))).onFailure(typed.SupervisorStrategy.restart), "tx-publisher")
121121
}
122122
}
123123

@@ -1714,7 +1714,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
17141714
val (localCommitPublished1, claimHtlcTx_opt) = Closing.LocalClose.claimHtlcDelayedOutput(localCommitPublished, keyManager, d.commitments.latest, tx, nodeParams.currentFeerates, nodeParams.onChainFeeConf, d.finalScriptPubKey)
17151715
claimHtlcTx_opt.foreach(claimHtlcTx => {
17161716
txPublisher ! PublishFinalTx(claimHtlcTx, claimHtlcTx.fee, None)
1717-
blockchain ! WatchTxConfirmed(self, claimHtlcTx.tx.txid, nodeParams.channelConf.minDepthBlocks)
1717+
blockchain ! WatchTxConfirmed(self, claimHtlcTx.tx.txid, nodeParams.channelConf.minDepthBlocks, Some(RelativeDelay(tx.txid, d.commitments.params.remoteParams.toSelfDelay.toInt.toLong)))
17181718
})
17191719
Closing.updateLocalCommitPublished(localCommitPublished1, tx)
17201720
}),

0 commit comments

Comments
 (0)