Skip to content

Commit

Permalink
Configure dust in flight threshold (#1985)
Browse files Browse the repository at this point in the history
Add config fields for max dust htlc exposure.
These configuration fields let node operators decide on the amount of dust
htlcs that can be in-flight in each channel.

In case the channel is force-closed, up to this amount may be lost in
miner fees.

When sending and receiving htlcs, we check whether they would overflow
our configured dust exposure, and fail them instantly if they do.

A large `update_fee` may overflow our dust exposure by removing from the
commit tx htlcs that were previously untrimmed.

Node operators can choose to automatically force-close when that happens,
to avoid risking losing large dust amounts to miner fees.
  • Loading branch information
t-bast authored Oct 8, 2021
1 parent bb5e6df commit 75eafd0
Show file tree
Hide file tree
Showing 18 changed files with 1,065 additions and 103 deletions.
23 changes: 23 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ You **MUST** ensure you have some utxos available in your Bitcoin Core wallet fo

Do note that anchor outputs may still be unsafe in high-fee environments until the Bitcoin network provides support for [package relay](https://bitcoinops.org/en/topics/package-relay/).

### Configurable dust tolerance

Dust HTLCs are converted to miner fees when a channel is force-closed and these HTLCs are still pending.
This can be used as a griefing attack by malicious peers, as described in [CVE-2021-41591](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-41591).

Node operators can now configure the maximum amount of dust HTLCs that can be pending in a channel by setting `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis` in their `eclair.conf`.

Choosing the right value for your node involves trade-offs.
The lower you set it, the more protection it will offer against malicious peers.
But if it's too low, your node may reject some dust HTLCs that it would have otherwise relayed, which lowers the amount of relay fees you will be able to collect.

Another related parameter has been added: `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.close-on-update-fee-overflow`.
When this parameter is set to `true`, your node will automatically close channels when the amount of dust HTLCs overflows your configured limits.
This gives you a better protection against malicious peers, but may end up closing channels with honest peers as well.
This parameter is deactivated by default and unnecessary when using `option_anchors_zero_fee_htlc_tx`.

Note that you can override these values for specific peers, thanks to the `eclair.on-chain-fees.override-feerate-tolerance` mechanism.
You can for example set a high `eclair.on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis` with peers that you trust.

Note that if you were previously running eclair with the default configuration, your exposure to this issue was quite low because the default `max-accepted-htlc` is set to 30.
With an on-chain feerate of `10 sat/byte`, your maximum exposure would be ~70 000 satoshis per channel.
With an on-chain feerate of `5 sat/byte`, your maximum exposure would be ~40 000 satoshis per channel.

### Path-finding improvements

This release contains many improvements to path-finding and paves the way for future experimentation.
Expand Down
16 changes: 15 additions & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,16 @@ eclair {
// when using anchor outputs, we only need to use a commitment feerate that allows the tx to propagate: we will use CPFP to speed up confirmation if needed.
// the following value is the maximum feerate we'll use for our commit tx (in sat/byte)
anchor-output-max-commit-feerate = 10
// the following section lets you configure your tolerance to dust outputs
dust-tolerance {
// dust htlcs cannot be claimed on-chain and will instead go to miners if the channel is force-closed
// a malicious peer may want to abuse that, so we limit the value of pending dust htlcs in a channel
// this value cannot be lowered too much if you plan to relay a lot of htlcs
max-exposure-satoshis = 50000
// when we receive an update_fee, it could increase our dust exposure and overflow max-exposure-satoshis
// this parameter should be set to true if you want to force-close the channel when that happens
close-on-update-fee-overflow = false
}
}
override-feerate-tolerance = [ // optional per-node feerate tolerance
# {
Expand All @@ -150,6 +160,10 @@ eclair {
# ratio-low = 0.1
# ratio-high = 20.0
# anchor-output-max-commit-feerate = 10
# dust-tolerance {
# max-exposure-satoshis = 25000
# close-on-update-fee-overflow = true
# }
# }
# }
]
Expand Down Expand Up @@ -388,6 +402,6 @@ akka {
backend.min-nr-of-members = 1
frontend.min-nr-of-members = 0
}
seed-nodes = [ "akka://[email protected]:25520" ]
seed-nodes = ["akka://[email protected]:25520"]
}
}
12 changes: 10 additions & 2 deletions eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,22 @@ object NodeParams extends Logging {
defaultFeerateTolerance = FeerateTolerance(
config.getDouble("on-chain-fees.feerate-tolerance.ratio-low"),
config.getDouble("on-chain-fees.feerate-tolerance.ratio-high"),
FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate"))))
FeeratePerKw(FeeratePerByte(Satoshi(config.getLong("on-chain-fees.feerate-tolerance.anchor-output-max-commit-feerate")))),
DustTolerance(
Satoshi(config.getLong("on-chain-fees.feerate-tolerance.dust-tolerance.max-exposure-satoshis")),
config.getBoolean("on-chain-fees.feerate-tolerance.dust-tolerance.close-on-update-fee-overflow")
)
),
perNodeFeerateTolerance = config.getConfigList("on-chain-fees.override-feerate-tolerance").asScala.map { e =>
val nodeId = PublicKey(ByteVector.fromValidHex(e.getString("nodeid")))
val tolerance = FeerateTolerance(
e.getDouble("feerate-tolerance.ratio-low"),
e.getDouble("feerate-tolerance.ratio-high"),
FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate"))))
FeeratePerKw(FeeratePerByte(Satoshi(e.getLong("feerate-tolerance.anchor-output-max-commit-feerate")))),
DustTolerance(
Satoshi(e.getLong("feerate-tolerance.dust-tolerance.max-exposure-satoshis")),
e.getBoolean("feerate-tolerance.dust-tolerance.close-on-update-fee-overflow")
)
)
nodeId -> tolerance
}.toMap
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ trait FeeEstimator {

case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int)

case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw) {
/**
* @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold.
* @param closeOnUpdateFeeOverflow force-close channels when an update_fee forces us to go above our max exposure.
*/
case class DustTolerance(maxExposure: Satoshi, closeOnUpdateFeeOverflow: Boolean)

case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, dustTolerance: DustTolerance) {
/**
* @param channelType channel type
* @param networkFeerate reference fee rate (value we estimate from our view of the network)
Expand Down
46 changes: 28 additions & 18 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.channel.Commitments.PostRevocationAction
import fr.acinq.eclair.channel.Helpers.{Closing, Funding, getRelayFees}
import fr.acinq.eclair.channel.Monitoring.Metrics.ProcessMessage
import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags}
Expand All @@ -41,6 +42,7 @@ import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType
import fr.acinq.eclair.db.PendingCommandsDb
import fr.acinq.eclair.io.Peer
import fr.acinq.eclair.payment.PaymentSettlingOnChain
import fr.acinq.eclair.payment.relay.Relayer
import fr.acinq.eclair.router.Announcements
import fr.acinq.eclair.transactions.Transactions.{ClosingTx, TxOwner}
import fr.acinq.eclair.transactions._
Expand Down Expand Up @@ -769,7 +771,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
}

case Event(c: CMD_UPDATE_FEE, d: DATA_NORMAL) =>
Commitments.sendFee(d.commitments, c) match {
Commitments.sendFee(d.commitments, c, nodeParams.onChainFeeConf) match {
case Right((commitments1, fee)) =>
if (c.commit) self ! CMD_SIGN()
context.system.eventStream.publish(AvailableBalanceChanged(self, d.channelId, d.shortChannelId, commitments1))
Expand Down Expand Up @@ -839,15 +841,19 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
case Event(revocation: RevokeAndAck, d: DATA_NORMAL) =>
// we received a revocation because we sent a signature
// => all our changes have been acked
Commitments.receiveRevocation(d.commitments, revocation) match {
case Right((commitments1, forwards)) =>
Commitments.receiveRevocation(d.commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match {
case Right((commitments1, actions)) =>
cancelTimer(RevocationTimeout.toString)
log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1))
forwards.foreach {
case Right(forwardAdd) =>
log.debug("forwarding {} to relayer", forwardAdd)
relayer ! forwardAdd
case Left(result) =>
actions.foreach {
case PostRevocationAction.RelayHtlc(add) =>
log.debug("forwarding incoming htlc {} to relayer", add)
relayer ! Relayer.RelayForward(add)
case PostRevocationAction.RejectHtlc(add) =>
log.debug("rejecting incoming htlc {}", add)
// NB: we don't set commit = true, we will sign all updates at once afterwards.
self ! CMD_FAIL_HTLC(add.id, Right(TemporaryChannelFailure(d.channelUpdate)), commit = true)
case PostRevocationAction.RelayFailure(result) =>
log.debug("forwarding {} to relayer", result)
relayer ! result
}
Expand Down Expand Up @@ -1127,7 +1133,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
}

case Event(c: CMD_UPDATE_FEE, d: DATA_SHUTDOWN) =>
Commitments.sendFee(d.commitments, c) match {
Commitments.sendFee(d.commitments, c, nodeParams.onChainFeeConf) match {
case Right((commitments1, fee)) =>
if (c.commit) self ! CMD_SIGN()
handleCommandSuccess(c, d.copy(commitments = commitments1)) sending fee
Expand Down Expand Up @@ -1199,18 +1205,22 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
case Event(revocation: RevokeAndAck, d@DATA_SHUTDOWN(commitments, localShutdown, remoteShutdown, closingFeerates)) =>
// we received a revocation because we sent a signature
// => all our changes have been acked including the shutdown message
Commitments.receiveRevocation(commitments, revocation) match {
case Right((commitments1, forwards)) =>
Commitments.receiveRevocation(commitments, revocation, nodeParams.onChainFeeConf.feerateToleranceFor(remoteNodeId).dustTolerance.maxExposure) match {
case Right((commitments1, actions)) =>
cancelTimer(RevocationTimeout.toString)
log.debug("received a new rev, spec:\n{}", Commitments.specs2String(commitments1))
forwards.foreach {
case Right(forwardAdd) =>
actions.foreach {
case PostRevocationAction.RelayHtlc(add) =>
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
log.debug("closing in progress: failing {}", forwardAdd.add)
self ! CMD_FAIL_HTLC(forwardAdd.add.id, Right(PermanentChannelFailure), commit = true)
case Left(forward) =>
log.debug("forwarding {} to relayer", forward)
relayer ! forward
log.debug("closing in progress: failing {}", add)
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure), commit = true)
case PostRevocationAction.RejectHtlc(add) =>
// BOLT 2: A sending node SHOULD fail to route any HTLC added after it sent shutdown.
log.debug("closing in progress: rejecting {}", add)
self ! CMD_FAIL_HTLC(add.id, Right(PermanentChannelFailure), commit = true)
case PostRevocationAction.RelayFailure(result) =>
log.debug("forwarding {} to relayer", result)
relayer ! result
}
if (commitments1.hasNoPendingHtlcsOrFeeUpdate) {
log.debug("switching to NEGOTIATING spec:\n{}", Commitments.specs2String(commitments1))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ case class ExpiryTooBig (override val channelId: Byte
case class HtlcValueTooSmall (override val channelId: ByteVector32, minimum: MilliSatoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"htlc value too small: minimum=$minimum actual=$actual")
case class HtlcValueTooHighInFlight (override val channelId: ByteVector32, maximum: UInt64, actual: MilliSatoshi) extends ChannelException(channelId, s"in-flight htlcs hold too much value: maximum=$maximum actual=$actual")
case class TooManyAcceptedHtlcs (override val channelId: ByteVector32, maximum: Long) extends ChannelException(channelId, s"too many accepted htlcs: maximum=$maximum")
case class LocalDustHtlcExposureTooHigh (override val channelId: ByteVector32, maximum: Satoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"dust htlcs hold too much value: maximum=$maximum actual=$actual")
case class RemoteDustHtlcExposureTooHigh (override val channelId: ByteVector32, maximum: Satoshi, actual: MilliSatoshi) extends ChannelException(channelId, s"dust htlcs hold too much value: maximum=$maximum actual=$actual")
case class InsufficientFunds (override val channelId: ByteVector32, amount: MilliSatoshi, missing: Satoshi, reserve: Satoshi, fees: Satoshi) extends ChannelException(channelId, s"insufficient funds: missing=$missing reserve=$reserve fees=$fees")
case class RemoteCannotAffordFeesForNewHtlc (override val channelId: ByteVector32, amount: MilliSatoshi, missing: Satoshi, reserve: Satoshi, fees: Satoshi) extends ChannelException(channelId, s"remote can't afford increased commit tx fees once new HTLC is added: missing=$missing reserve=$reserve fees=$fees")
case class InvalidHtlcPreimage (override val channelId: ByteVector32, id: Long) extends ChannelException(channelId, s"invalid htlc preimage for htlc id=$id")
Expand Down
Loading

0 comments on commit 75eafd0

Please sign in to comment.