Skip to content

Commit

Permalink
Add configurable threshold on maximum anchor fee (#2816)
Browse files Browse the repository at this point in the history
* Add configurable threshold on maximum anchor fee

We previously bumped up to 5% of our channel balance when no HTLCs were
at risk. For large channels, 5% is an unreasonably high value. In most
cases it doesn't matter, because the transaction confirms before we try
to bump it to unreasonable levels. But if the commitment transaction
was pruned and couldn't be relayed to miners, then eclair would keep
trying to bump until it reached that threshold. We now restrict this to
a value configurable by the node operator. Note that when HTLCs are at
risk, we still bump up to the HTLC amount, which may be higher than the
new configuration parameter: we want that behavior as a scorched earth
strategy against pinning attacks.

* Add feerate upper bound from fee estimator

We add a new limit to the feerate used for fee-bumping, based on the
fastest feerate returned by our fee estimator. It doesn't make sense
to use much higher values, since this feerate should guarantee that
the transaction is included in the next block.
  • Loading branch information
t-bast committed Feb 5, 2024
1 parent aae16cf commit 5b1c69c
Show file tree
Hide file tree
Showing 9 changed files with 85 additions and 27 deletions.
15 changes: 15 additions & 0 deletions docs/release-notes/eclair-vnext.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,21 @@ eclair.on-chain-fees.confirmation-priority {

This configuration section replaces the previous `eclair.on-chain-fees.target-blocks` section.

### Add configurable maximum anchor fee

Whenever an anchor outputs channel force-closes, we regularly bump the fees of the commitment transaction to get it to confirm.
We previously ensured that the fees paid couldn't exceed 5% of our channel balance, but that may already be extremely high for large channels.
Without package relay, our anchor transactions may not propagate to miners, and eclair may end up bumping the fees more than the actual feerate, because it cannot know why the transaction isn't confirming.

We introduced a new parameter to control the maximum fee that will be paid during fee-bumping, that node operators may configure:

```eclair.conf
// maximum amount of fees we will pay to bump an anchor output when we have no HTLC at risk
eclair.on-chain-fees.anchor-without-htlcs-max-fee-satoshis = 10000
```

We also limit the feerate used to be at most the same order of magnitude of `fatest` feerate provided by our fee estimator.

### Managing Bitcoin Core wallet keys

You can now use Eclair to manage the private keys for on-chain funds monitored by a Bitcoin Core watch-only wallet.
Expand Down
2 changes: 2 additions & 0 deletions eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ eclair {
// it can still be manually fee-bumped using the bumpforceclose RPC
// *do not change this unless you know what you are doing*
spend-anchor-without-htlcs = true
// maximum amount of fees we will pay to bump an anchor output when we have no HTLC at risk
anchor-without-htlcs-max-fee-satoshis = 10000

close-on-offline-feerate-mismatch = true // do not change this unless you know what you are doing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ object NodeParams extends Logging {
feeTargets = feeTargets,
safeUtxosThreshold = config.getInt("on-chain-fees.safe-utxos-threshold"),
spendAnchorWithoutHtlcs = config.getBoolean("on-chain-fees.spend-anchor-without-htlcs"),
anchorWithoutHtlcsMaxFee = Satoshi(config.getLong("on-chain-fees.anchor-without-htlcs-max-fee-satoshis")),
closeOnOfflineMismatch = config.getBoolean("on-chain-fees.close-on-offline-feerate-mismatch"),
updateFeeMinDiffRatio = config.getDouble("on-chain-fees.update-fee-min-diff-ratio"),
defaultFeerateTolerance = FeerateTolerance(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMax
}
}

case class OnChainFeeConf(feeTargets: FeeTargets, safeUtxosThreshold: Int, spendAnchorWithoutHtlcs: Boolean, closeOnOfflineMismatch: Boolean, updateFeeMinDiffRatio: Double, private val defaultFeerateTolerance: FeerateTolerance, private val perNodeFeerateTolerance: Map[PublicKey, FeerateTolerance]) {
case class OnChainFeeConf(feeTargets: FeeTargets,
safeUtxosThreshold: Int,
spendAnchorWithoutHtlcs: Boolean,
anchorWithoutHtlcsMaxFee: Satoshi,
closeOnOfflineMismatch: Boolean,
updateFeeMinDiffRatio: Double,
private val defaultFeerateTolerance: FeerateTolerance,
private val perNodeFeerateTolerance: Map[PublicKey, FeerateTolerance]) {

def feerateToleranceFor(nodeId: PublicKey): FeerateTolerance = perNodeFeerateTolerance.getOrElse(nodeId, defaultFeerateTolerance)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, Script, Tr
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundTransactionOptions, InputWeight}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw, OnChainFeeConf}
import fr.acinq.eclair.channel.FullCommitment
import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._
import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext
Expand Down Expand Up @@ -75,7 +75,7 @@ object ReplaceableTxFunder {
Behaviors.withMdc(txPublishContext.mdc()) {
Behaviors.receiveMessagePartial {
case FundTransaction(replyTo, cmd, tx, requestedFeerate) =>
val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment))
val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment, nodeParams.currentFeerates, nodeParams.onChainFeeConf))
val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context)
tx match {
case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate)
Expand All @@ -96,17 +96,18 @@ object ReplaceableTxFunder {
* The on-chain feerate can be arbitrarily high, but it wouldn't make sense to pay more fees than the amount we're
* trying to claim on-chain. We compute how much funds we have at risk and the feerate that matches this amount.
*/
def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment): FeeratePerKw = {
def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = {
// We don't want to pay more in fees than the amount at risk in untrimmed pending HTLCs.
val maxFee = txInfo match {
case tx: HtlcTx => tx.input.txOut.amount
case tx: ClaimHtlcTx => tx.input.txOut.amount
case _: ClaimLocalAnchorOutputTx =>
val htlcBalance = commitment.localCommit.htlcTxsAndRemoteSigs.map(_.htlcTx.input.txOut.amount).sum
val mainBalance = commitment.localCommit.spec.toLocal.truncateToSatoshi
// If there are no HTLCs or a low HTLC amount, we may still want to get back our main balance.
// In that case, we spend at most 5% of our balance in fees.
htlcBalance.max(mainBalance * 5 / 100)
// If there are no HTLCs or a low HTLC amount, we still want to get back our main balance.
// In that case, we spend at most 5% of our balance in fees, with a hard cap configured by the node operator.
val mainBalanceFee = (mainBalance * 5 / 100).min(feeConf.anchorWithoutHtlcsMaxFee)
htlcBalance.max(mainBalanceFee)
}
// We cannot know beforehand how many wallet inputs will be added, but an estimation should be good enough.
val weight = txInfo match {
Expand All @@ -118,7 +119,8 @@ object ReplaceableTxFunder {
case _: ClaimHtlcTimeoutTx => Transactions.claimHtlcTimeoutWeight
case _: ClaimLocalAnchorOutputTx => commitWeight(commitment) + Transactions.claimAnchorOutputMinWeight
}
Transactions.fee2rate(maxFee, weight)
// It doesn't make sense to use a feerate that is much higher than the current feerate for inclusion into the next block.
Transactions.fee2rate(maxFee, weight).min(currentFeerates.fastest * 1.25)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import fr.acinq.eclair.message.OnionMessages.OnionMessageConfig
import fr.acinq.eclair.payment.relay.Relayer.{AsyncPaymentsParams, RelayFees, RelayParams}
import fr.acinq.eclair.router.Graph.{MessagePath, WeightRatios}
import fr.acinq.eclair.router.PathFindingExperimentConf
import fr.acinq.eclair.router.Router.{MessageRouteParams, MultiPartParams, PathFindingConf, RouterConf, SearchBoundaries}
import fr.acinq.eclair.router.Router._
import fr.acinq.eclair.wire.protocol.{Color, EncodingType, NodeAddress, OnionRoutingPacket}
import org.scalatest.Tag
import scodec.bits.{ByteVector, HexStringSyntax}
Expand Down Expand Up @@ -143,6 +143,7 @@ object TestConstants {
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 100_000.sat,
closeOnOfflineMismatch = true,
updateFeeMinDiffRatio = 0.1,
defaultFeerateTolerance = FeerateTolerance(0.5, 8.0, anchorOutputsFeeratePerKw, DustTolerance(25_000 sat, closeOnUpdateFeeOverflow = true)),
Expand Down Expand Up @@ -308,6 +309,7 @@ object TestConstants {
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 100_000.sat,
closeOnOfflineMismatch = true,
updateFeeMinDiffRatio = 0.1,
defaultFeerateTolerance = FeerateTolerance(0.75, 1.5, anchorOutputsFeeratePerKw, DustTolerance(30_000 sat, closeOnUpdateFeeOverflow = true)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@

package fr.acinq.eclair.blockchain.fee

import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{Satoshi, SatoshiLong}
import fr.acinq.bitcoin.scalacompat.SatoshiLong
import fr.acinq.eclair.channel.ChannelTypes
import fr.acinq.eclair.randomKey
import fr.acinq.eclair.transactions.Transactions.{CommitmentFormat, DefaultCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat}
import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat}
import org.scalatest.funsuite.AnyFunSuite

class OnChainFeeConfSpec extends AnyFunSuite {
Expand All @@ -29,7 +28,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {
private val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), DustTolerance(15000 sat, closeOnUpdateFeeOverflow = false))

test("should update fee when diff ratio exceeded") {
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat)))
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat)))
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat)))
Expand All @@ -39,7 +38,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {

test("get commitment feerate") {
val commitmentFormat = DefaultCommitmentFormat
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)

val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = FeeratePerKw(5000 sat))
assert(feeConf.getCommitmentFeerate(feerates1, randomKey().publicKey, commitmentFormat, 100000 sat) == FeeratePerKw(5000 sat))
Expand All @@ -53,7 +52,7 @@ class OnChainFeeConfSpec extends AnyFunSuite {
val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate
val overrideNodeId = randomKey().publicKey
val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate)))
val feeConf = OnChainFeeConf(defaultFeeTargets, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate)))

val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat))
assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat, 100000 sat) == defaultMaxCommitFeerate / 2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
feeTargets = FeeTargets(funding = ConfirmationPriority.Medium, closing = ConfirmationPriority.Medium),
safeUtxosThreshold = 0,
spendAnchorWithoutHtlcs = true,
anchorWithoutHtlcsMaxFee = 10_000.sat,
closeOnOfflineMismatch = false,
1.0,
FeerateTolerance(0.00001, 100000.0, TestConstants.anchorOutputsFeeratePerKw, DustTolerance(100000 sat, closeOnUpdateFeeOverflow = false)),
Expand Down
Loading

0 comments on commit 5b1c69c

Please sign in to comment.