Skip to content

Commit

Permalink
Notify node operator on low fee bumping reserve (#2104)
Browse files Browse the repository at this point in the history
With anchor outputs, we need to keep utxos available for fee bumping.
Having enough on-chain funds to claim HTLCs in case channels force-close
is necessary to guarantee funds safety.

There is no perfect formula to estimate how much we should reserve: it
depends on the feerate when the channels force-close, the number of
impacted HTLCs and their amount.

We use a very rough estimation, to avoid needlessly spamming node operators
while still notifying them when the situation becomes risky.
  • Loading branch information
t-bast authored Dec 14, 2021
1 parent 535daec commit 4ebc8b5
Show file tree
Hide file tree
Showing 4 changed files with 35 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package fr.acinq.eclair.balance
import akka.actor.typed.eventstream.EventStream
import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
import akka.actor.typed.{ActorRef, Behavior}
import fr.acinq.bitcoin.{ByteVector32, MilliBtcDouble}
import fr.acinq.bitcoin.{ByteVector32, SatoshiLong}
import fr.acinq.eclair.NotificationsLogger
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
import fr.acinq.eclair.balance.BalanceActor._
Expand All @@ -27,7 +27,7 @@ object BalanceActor {
private final case object TickBalance extends Command
final case class GetGlobalBalance(replyTo: ActorRef[Try[GlobalBalance]], channels: Map[ByteVector32, HasCommitments]) extends Command
private final case class WrappedChannels(wrapped: ChannelsListener.GetChannelsResponse) extends Command
private final case class WrappedGlobalBalance(wrapped: Try[GlobalBalance]) extends Command
private final case class WrappedGlobalBalanceWithChannels(wrapped: Try[GlobalBalance], channelsCount: Int) extends Command
private final case class WrappedUtxoInfo(wrapped: Try[UtxoInfo]) extends Command
// @formatter:on

Expand Down Expand Up @@ -79,15 +79,27 @@ private class BalanceActor(context: ActorContext[Command],
context.pipeToSelf(checkUtxos(bitcoinClient))(WrappedUtxoInfo)
Behaviors.same
case WrappedChannels(res) =>
context.pipeToSelf(CheckBalance.computeGlobalBalance(res.channels, db, bitcoinClient))(WrappedGlobalBalance)
val channelsCount = res.channels.size
context.pipeToSelf(CheckBalance.computeGlobalBalance(res.channels, db, bitcoinClient))(b => WrappedGlobalBalanceWithChannels(b, channelsCount))
Behaviors.same
case WrappedGlobalBalance(res) =>
case WrappedGlobalBalanceWithChannels(res, channelsCount) =>
res match {
case Success(result) =>
log.info("current balance: total={} onchain.confirmed={} onchain.unconfirmed={} offchain={}", result.total.toDouble, result.onChain.confirmed.toDouble, result.onChain.unconfirmed.toDouble, result.offChain.total.toDouble)
log.debug("current balance details : {}", result)
if (result.onChain.confirmed < 1.millibtc) {
context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, s"on-chain confirmed balance is low (${result.onChain.confirmed.toMilliBtc}), eclair may not be able to guarantee funds safety in case channels force-close: you should add some utxos to your bitcoin wallet"))
log.debug("current balance details: {}", result)
// 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.
val perChannelFeeBumpingReserve = 50_000.sat
// Instead of scaling this linearly with the number of channels we have, we use sqrt(channelsCount) to reflect
// the fact that if you have channels with many peers, only a subset of these peers will likely be malicious.
val estimatedFeeBumpingReserve = perChannelFeeBumpingReserve * Math.sqrt(channelsCount)
if (result.onChain.confirmed < estimatedFeeBumpingReserve) {
val message =
s"""On-chain confirmed balance is low (${result.onChain.confirmed.toMilliBtc}): eclair may not be able to guarantee funds safety in case channels force-close.
|You have $channelsCount channels, which could cost $estimatedFeeBumpingReserve in fees if some of these channels are malicious.
|Please note that the value above is a very arbitrary estimation: the real cost depends on the feerate and the number of malicious channels.
|You should add more utxos to your bitcoin wallet to guarantee funds safety.
|""".stripMargin
context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, message))
}
Metrics.GlobalBalance.withoutTags().update(result.total.toMilliBtc.toDouble)
Metrics.GlobalBalanceDetailed.withTag(Tags.BalanceType, Tags.BalanceTypes.OnchainConfirmed).update(result.onChain.confirmed.toMilliBtc.toDouble)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ object CheckBalance {
}
} yield CorrectedOnChainBalance(detailed.confirmed, detailed.unconfirmed)

case class GlobalBalance (onChain: CorrectedOnChainBalance, offChain: OffChainBalance) {
case class GlobalBalance(onChain: CorrectedOnChainBalance, offChain: OffChainBalance) {
val total: Btc = onChain.total + offChain.total
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package fr.acinq.eclair.json
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.{Btc, ByteVector32, ByteVector64, OutPoint, Satoshi, Transaction}
import fr.acinq.eclair.balance.CheckBalance.GlobalBalance
import fr.acinq.eclair.balance.CheckBalance.{CorrectedOnChainBalance, GlobalBalance, OffChainBalance}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel._
import fr.acinq.eclair.crypto.Sphinx.RouteBlinding.BlindedRoute
Expand Down Expand Up @@ -399,22 +399,15 @@ object OriginSerializer extends MinimalSerializer({
})
})

object GlobalBalanceSerializer extends MinimalSerializer({
case o: GlobalBalance =>
val formats = DefaultFormats + ByteVector32KeySerializer + BtcSerializer + SatoshiSerializer
JObject(JField("total", JDecimal(o.total.toDouble))) merge Extraction.decompose(o)(formats)
})

// @formatter:off
private case class GlobalBalanceJson(total: Btc, onChain: CorrectedOnChainBalance, offChain: OffChainBalance)
object GlobalBalanceSerializer extends ConvertClassSerializer[GlobalBalance](b => GlobalBalanceJson(b.total, b.onChain, b.offChain))

private case class PeerInfoJson(nodeId: PublicKey, state: String, address: Option[InetSocketAddress], channels: Int)
object PeerInfoSerializer extends ConvertClassSerializer[Peer.PeerInfo](peerInfo => PeerInfoJson(peerInfo.nodeId, peerInfo.state.toString, peerInfo.address, peerInfo.channels))
// @formatter:on

// @formatter:off
private[json] case class MessageReceivedJson(pathId: Option[ByteVector], replyPath: Option[BlindedRoute], unknownTlvs: Map[String, ByteVector])
object OnionMessageReceivedSerializer extends ConvertClassSerializer[OnionMessages.ReceiveMessage]({ m: OnionMessages.ReceiveMessage =>
MessageReceivedJson(m.pathId, m.finalPayload.replyPath.map(_.blindedRoute), m.finalPayload.records.unknown.map(tlv => (tlv.tag.toString -> tlv.value)).toMap)
})
object OnionMessageReceivedSerializer extends ConvertClassSerializer[OnionMessages.ReceiveMessage](m => MessageReceivedJson(m.pathId, m.finalPayload.replyPath.map(_.blindedRoute), m.finalPayload.records.unknown.map(tlv => tlv.tag.toString -> tlv.value).toMap))
// @formatter:on

case class CustomTypeHints(custom: Map[Class[_], String]) extends TypeHints {
Expand Down Expand Up @@ -524,6 +517,7 @@ object JsonSerializers {
PaymentRequestSerializer +
JavaUUIDSerializer +
OriginSerializer +
ByteVector32KeySerializer +
GlobalBalanceSerializer +
PeerInfoSerializer +
PaymentFailedSummarySerializer +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import fr.acinq.bitcoin.{Btc, ByteVector32, OutPoint, Satoshi, SatoshiLong, Tran
import fr.acinq.eclair._
import fr.acinq.eclair.balance.CheckBalance
import fr.acinq.eclair.balance.CheckBalance.{ClosingBalance, GlobalBalance, MainAndHtlcBalance, PossiblyPublishedMainAndHtlcBalance, PossiblyPublishedMainBalance}
import fr.acinq.eclair.channel.{CMD_UPDATE_RELAY_FEE, CommandResponse, CommandUnavailableInThisState, Origin, RES_FAILURE, RES_SUCCESS}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.io.Peer.PeerInfo
import fr.acinq.eclair.payment.{PaymentRequest, PaymentSettlingOnChain}
Expand Down Expand Up @@ -185,16 +185,14 @@ class JsonSerializersSpec extends AnyFunSuite with Matchers {
offChain = CheckBalance.OffChainBalance(normal = MainAndHtlcBalance(
toLocal = Btc(0.2),
htlcs = Btc(0.05)
),
closing = ClosingBalance(
localCloseBalance = PossiblyPublishedMainAndHtlcBalance(
toLocal = Map(ByteVector32(hex"4d176ad844c363bed59edf81962b008faa6194c3b3757ffcd26ba60f95716db2") -> Btc(0.1)),
htlcs = Map(ByteVector32(hex"94b70cec5a98d67d17c6e3de5c7697f8a6cab4f698df91e633ce35efa3574d71") -> Btc(0.03), ByteVector32(hex"a844edd41ce8503864f3c95d89d850b177a09d7d35e950a7d27c14abb63adb13") -> Btc(0.06)),
htlcsUnpublished = Btc(0.01)),
mutualCloseBalance = PossiblyPublishedMainBalance(
toLocal = Map(ByteVector32(hex"7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247") -> Btc(0.1)))
)
)
), closing = ClosingBalance(
localCloseBalance = PossiblyPublishedMainAndHtlcBalance(
toLocal = Map(ByteVector32(hex"4d176ad844c363bed59edf81962b008faa6194c3b3757ffcd26ba60f95716db2") -> Btc(0.1)),
htlcs = Map(ByteVector32(hex"94b70cec5a98d67d17c6e3de5c7697f8a6cab4f698df91e633ce35efa3574d71") -> Btc(0.03), ByteVector32(hex"a844edd41ce8503864f3c95d89d850b177a09d7d35e950a7d27c14abb63adb13") -> Btc(0.06)),
htlcsUnpublished = Btc(0.01)),
mutualCloseBalance = PossiblyPublishedMainBalance(
toLocal = Map(ByteVector32(hex"7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247") -> Btc(0.1)))
))
)
JsonSerializers.serialization.write(gb)(JsonSerializers.formats) shouldBe """{"total":1.0,"onChain":{"confirmed":0.4,"unconfirmed":0.05},"offChain":{"waitForFundingConfirmed":0.0,"waitForFundingLocked":0.0,"normal":{"toLocal":0.2,"htlcs":0.05},"shutdown":{"toLocal":0.0,"htlcs":0.0},"negotiating":0.0,"closing":{"localCloseBalance":{"toLocal":{"4d176ad844c363bed59edf81962b008faa6194c3b3757ffcd26ba60f95716db2":0.1},"htlcs":{"94b70cec5a98d67d17c6e3de5c7697f8a6cab4f698df91e633ce35efa3574d71":0.03,"a844edd41ce8503864f3c95d89d850b177a09d7d35e950a7d27c14abb63adb13":0.06},"htlcsUnpublished":0.01},"remoteCloseBalance":{"toLocal":{},"htlcs":{},"htlcsUnpublished":0.0},"mutualCloseBalance":{"toLocal":{"7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247":0.1}},"unknownCloseBalance":{"toLocal":0.0,"htlcs":0.0}},"waitForPublishFutureCommitment":0.0}}"""
}
Expand Down

0 comments on commit 4ebc8b5

Please sign in to comment.