Skip to content

Commit 2c10538

Browse files
authored
Rework closing channel balance computation (#3096)
We re-work the channel balance computation (`CheckBalance.scala`) used to continuously monitor the overall balance of the node. We previously assumed that all pending HTLCs would timeout on-chain, and monitored our mempool to deduplicate unconfirmed transactions and RBF attempts. But it is actually simpler to assume that all pending HTLCs will be fulfilled, and keep counting them in our off-chain balance until the spending transaction reaches `min-depth`. We introduce a distinction between recently confirmed transactions and deeply confirmed transactions in our on-chain balance: there will be cases where we don't know yet whether we'll be able to gets funds back and the information of pending on-chain funds lets us verify that our overall balance is looking good. It always eventually converges to be in our on-chain balance. Fixes #3085
1 parent f8b1272 commit 2c10538

7 files changed

Lines changed: 421 additions & 553 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ class Setup(val datadir: File,
398398

399399
_ = for (i <- 0 until config.getInt("autoprobe-count")) yield system.actorOf(SimpleSupervisor.props(Autoprobe.props(nodeParams, router, paymentInitiator), s"payment-autoprobe-$i", SupervisorStrategy.Restart))
400400

401-
balanceActor = system.spawn(BalanceActor(nodeParams.db, bitcoinClient, channelsListener, nodeParams.balanceCheckInterval), name = "balance-actor")
401+
balanceActor = system.spawn(BalanceActor(bitcoinClient, nodeParams.channelConf.minDepth, channelsListener, nodeParams.balanceCheckInterval), name = "balance-actor")
402402

403403
postman = system.spawn(Behaviors.supervise(Postman(nodeParams, switchboard, router.toTyped, register, offerManager)).onFailure(typed.SupervisorStrategy.restart), name = "postman")
404404

eclair-core/src/main/scala/fr/acinq/eclair/balance/BalanceActor.scala

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
/*
2+
* Copyright 2024 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
117
package fr.acinq.eclair.balance
218

319
import akka.actor.typed.eventstream.EventStream
@@ -7,12 +23,11 @@ import fr.acinq.bitcoin.scalacompat.{Btc, ByteVector32, SatoshiLong}
723
import fr.acinq.eclair.NotificationsLogger
824
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
925
import fr.acinq.eclair.balance.BalanceActor._
10-
import fr.acinq.eclair.balance.CheckBalance.{GlobalBalance, computeOffChainBalance}
26+
import fr.acinq.eclair.balance.CheckBalance.{GlobalBalance, OffChainBalance}
1127
import fr.acinq.eclair.balance.Monitoring.{Metrics, Tags}
1228
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
1329
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.Utxo
1430
import fr.acinq.eclair.channel.PersistentChannelData
15-
import fr.acinq.eclair.db.Databases
1631

1732
import scala.concurrent.ExecutionContext
1833
import scala.concurrent.duration._
@@ -29,26 +44,26 @@ object BalanceActor {
2944
private final case class WrappedGlobalBalanceWithChannels(wrapped: Try[GlobalBalance], channelsCount: Int) extends Command
3045
// @formatter:on
3146

32-
def apply(db: Databases, bitcoinClient: BitcoinCoreClient, channelsListener: ActorRef[ChannelsListener.GetChannels], interval: FiniteDuration)(implicit ec: ExecutionContext): Behavior[Command] = {
47+
def apply(bitcoinClient: BitcoinCoreClient, minDepth: Int, channelsListener: ActorRef[ChannelsListener.GetChannels], interval: FiniteDuration)(implicit ec: ExecutionContext): Behavior[Command] = {
3348
Behaviors.setup { context =>
3449
Behaviors.withTimers { timers =>
3550
timers.startTimerWithFixedDelay(TickBalance, interval)
36-
new BalanceActor(context, db, bitcoinClient, channelsListener).apply(refBalance_opt = None, previousBalance_opt = None)
51+
new BalanceActor(context, bitcoinClient, minDepth, channelsListener).apply(refBalance_opt = None, previousBalance_opt = None)
3752
}
3853
}
3954
}
4055

4156
}
4257

4358
private class BalanceActor(context: ActorContext[Command],
44-
db: Databases,
4559
bitcoinClient: BitcoinCoreClient,
60+
minDepth: Int,
4661
channelsListener: ActorRef[ChannelsListener.GetChannels])(implicit ec: ExecutionContext) {
4762

4863
private val log = context.log
4964

5065
/**
51-
* @param refBalance_opt the reference balance computed once at startup, useful for telling if we are making or losing money overall
66+
* @param refBalance_opt the reference balance computed once at startup, useful for telling if we are making or losing money overall
5267
* @param previousBalance_opt the last computed balance, it is useful to make a detailed diff between two successive balance checks
5368
* @return
5469
*/
@@ -65,7 +80,7 @@ private class BalanceActor(context: ActorContext[Command],
6580
Behaviors.same
6681
case WrappedChannels(res) =>
6782
val channelsCount = res.channels.size
68-
context.pipeToSelf(CheckBalance.computeGlobalBalance(res.channels, db, bitcoinClient))(b => WrappedGlobalBalanceWithChannels(b, channelsCount))
83+
context.pipeToSelf(CheckBalance.computeGlobalBalance(res.channels, bitcoinClient, minDepth))(b => WrappedGlobalBalanceWithChannels(b, channelsCount))
6984
Behaviors.same
7085
case WrappedGlobalBalanceWithChannels(res, channelsCount) =>
7186
res match {
@@ -89,61 +104,61 @@ private class BalanceActor(context: ActorContext[Command],
89104
}
90105
previousBalance_opt match {
91106
case Some(previousBalance) =>
107+
// On-chain metrics:
92108
log.info("on-chain diff={}", balance.onChain.total - previousBalance.onChain.total)
93-
val utxosBefore = previousBalance.onChain.confirmed ++ previousBalance.onChain.unconfirmed
94-
val utxosAfter = balance.onChain.confirmed ++ balance.onChain.unconfirmed
95-
val utxosAdded = utxosAfter -- utxosBefore.keys
96-
val utxosRemoved = utxosBefore -- utxosAfter.keys
109+
val utxosBefore = previousBalance.onChain.utxos.toSet
110+
val utxosAfter = balance.onChain.utxos.toSet
111+
val utxosAdded = utxosAfter -- utxosBefore
112+
val utxosRemoved = utxosBefore -- utxosAfter
97113
utxosAdded
98-
.toList.sortBy(-_._2)
99-
.foreach { case (outPoint, amount) => log.info("+ utxo={} amount={}", outPoint, amount) }
114+
.toList.sortBy(_.amount)
115+
.foreach(utxo => log.info("+ utxo={} amount={}", utxo.outPoint, utxo.amount))
100116
utxosRemoved
101-
.toList.sortBy(-_._2)
102-
.foreach { case (outPoint, amount) => log.info("- utxo={} amount={}", outPoint, amount) }
103-
117+
.toList.sortBy(_.amount)
118+
.foreach(utxo => log.info("- utxo={} amount={}", utxo.outPoint, utxo.amount))
119+
// Off-chain metrics:
104120
log.info("off-chain diff={}", balance.offChain.total - previousBalance.offChain.total)
105-
val offchainBalancesBefore = previousBalance.channels.view.mapValues(computeOffChainBalance(previousBalance.knownPreimages, _).total)
106-
val offchainBalancesAfter = balance.channels.view.mapValues(computeOffChainBalance(balance.knownPreimages, _).total)
107-
offchainBalancesAfter
108-
.map { case (channelId, balanceAfter) => (channelId, balanceAfter - offchainBalancesBefore.getOrElse(channelId, Btc(0))) }
121+
val offChainBalancesBefore = previousBalance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel).total)
122+
val offChainBalancesAfter = balance.channels.view.mapValues(channel => OffChainBalance().addChannelBalance(channel).total)
123+
offChainBalancesAfter
124+
.map { case (channelId, balanceAfter) => (channelId, balanceAfter - offChainBalancesBefore.getOrElse(channelId, Btc(0))) }
109125
.filter { case (_, balanceDiff) => balanceDiff > 0.sat }
110126
.toList.sortBy(-_._2)
111127
.foreach { case (channelId, balanceDiff) => log.info("+ channelId={} amount={}", channelId, balanceDiff) }
112-
offchainBalancesBefore
113-
.map { case (channelId, balanceBefore) => (channelId, balanceBefore - offchainBalancesAfter.getOrElse(channelId, Btc(0))) }
128+
offChainBalancesBefore
129+
.map { case (channelId, balanceBefore) => (channelId, balanceBefore - offChainBalancesAfter.getOrElse(channelId, Btc(0))) }
114130
.filter { case (_, balanceDiff) => balanceDiff > 0.sat }
115131
.toList.sortBy(-_._2)
116132
.foreach { case (channelId, balanceDiff) => log.info("- channelId={} amount={}", channelId, balanceDiff) }
117133
case None => ()
118134
}
119-
120-
log.info("current balance: total={} onchain.confirmed={} onchain.unconfirmed={} offchain={}", balance.total.toDouble, balance.onChain.totalConfirmed.toDouble, balance.onChain.totalUnconfirmed.toDouble, balance.offChain.total.toDouble)
135+
log.info("current balance: total={} onchain.deeply-confirmed={} onchain.recently-confirmed={} onchain.unconfirmed={} offchain={}", balance.total.toDouble, balance.onChain.totalDeeplyConfirmed.toDouble, balance.onChain.totalRecentlyConfirmed.toDouble, balance.onChain.totalUnconfirmed.toDouble, balance.offChain.total.toDouble)
121136
log.debug("current balance details: {}", balance)
122137
// This is a very rough estimation of the fee we would need to pay for a force-close with 5 pending HTLCs at 100 sat/byte.
123138
val perChannelFeeBumpingReserve = 50_000.sat
124139
// Instead of scaling this linearly with the number of channels we have, we use sqrt(channelsCount) to reflect
125140
// the fact that if you have channels with many peers, only a subset of these peers will likely be malicious.
126141
val estimatedFeeBumpingReserve = perChannelFeeBumpingReserve * Math.sqrt(channelsCount)
127-
if (balance.onChain.totalConfirmed < estimatedFeeBumpingReserve) {
142+
val totalConfirmedBalance = balance.onChain.totalDeeplyConfirmed + balance.onChain.totalRecentlyConfirmed
143+
if (totalConfirmedBalance < estimatedFeeBumpingReserve) {
128144
val message =
129-
s"""On-chain confirmed balance is low (${balance.onChain.totalConfirmed.toMilliBtc}): eclair may not be able to guarantee funds safety in case channels force-close.
145+
s"""On-chain confirmed balance is low (${totalConfirmedBalance.toMilliBtc}): eclair may not be able to guarantee funds safety in case channels force-close.
130146
|You have $channelsCount channels, which could cost $estimatedFeeBumpingReserve in fees if some of these channels are malicious.
131147
|Please note that the value above is a very arbitrary estimation: the real cost depends on the feerate and the number of malicious channels.
132148
|You should add more utxos to your bitcoin wallet to guarantee funds safety.
133149
|""".stripMargin
134150
context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, message))
135151
}
136152
Metrics.GlobalBalance.withoutTags().update(balance.total.toMilliBtc.toDouble)
137-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnchainConfirmed).update(balance.onChain.totalConfirmed.toMilliBtc.toDouble)
138-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnchainUnconfirmed).update(balance.onChain.totalUnconfirmed.toMilliBtc.toDouble)
139-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.waitForFundingConfirmed).update(balance.offChain.waitForFundingConfirmed.toMilliBtc.toDouble)
140-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.waitForChannelReady).update(balance.offChain.waitForChannelReady.toMilliBtc.toDouble)
141-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.normal).update(balance.offChain.normal.total.toMilliBtc.toDouble)
142-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.shutdown).update(balance.offChain.shutdown.total.toMilliBtc.toDouble)
143-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.closingLocal).update(balance.offChain.closing.localCloseBalance.total.toMilliBtc.toDouble)
144-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.closingRemote).update(balance.offChain.closing.remoteCloseBalance.total.toMilliBtc.toDouble)
145-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.closingUnknown).update(balance.offChain.closing.unknownCloseBalance.total.toMilliBtc.toDouble)
146-
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.Offchain).withTag(Tags.OffchainState, Tags.OffchainStates.waitForPublishFutureCommitment).update(balance.offChain.waitForPublishFutureCommitment.toMilliBtc.toDouble)
153+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnChainDeeplyConfirmed).update(balance.onChain.totalDeeplyConfirmed.toMilliBtc.toDouble)
154+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnChainRecentlyConfirmed).update(balance.onChain.totalRecentlyConfirmed.toMilliBtc.toDouble)
155+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnChainUnconfirmed).update(balance.onChain.totalUnconfirmed.toMilliBtc.toDouble)
156+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.waitForFundingConfirmed).update(balance.offChain.waitForFundingConfirmed.toMilliBtc.toDouble)
157+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.waitForChannelReady).update(balance.offChain.waitForChannelReady.toMilliBtc.toDouble)
158+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.normal).update(balance.offChain.normal.total.toMilliBtc.toDouble)
159+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.shutdown).update(balance.offChain.shutdown.total.toMilliBtc.toDouble)
160+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.closing).update(balance.offChain.closing.total.toMilliBtc.toDouble)
161+
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OffChain).withTag(Tags.OffChainState, Tags.OffChainStates.waitForPublishFutureCommitment).update(balance.offChain.waitForPublishFutureCommitment.toMilliBtc.toDouble)
147162
refBalance_opt match {
148163
case Some(refBalance) =>
149164
val normalizedValue = 100 + (if (refBalance.total.toSatoshi.toLong > 0) (balance.total.toSatoshi.toLong - refBalance.total.toSatoshi.toLong) * 1000D / refBalance.total.toSatoshi.toLong else 0)
@@ -162,7 +177,7 @@ private class BalanceActor(context: ActorContext[Command],
162177
Behaviors.same
163178
}
164179
case GetGlobalBalance(replyTo, channels) =>
165-
CheckBalance.computeGlobalBalance(channels, db, bitcoinClient) onComplete (replyTo ! _)
180+
CheckBalance.computeGlobalBalance(channels, bitcoinClient, minDepth) onComplete (replyTo ! _)
166181
Behaviors.same
167182
}
168183
}

0 commit comments

Comments
 (0)