Skip to content

Commit f283393

Browse files
committed
Add a maximum fee threshold for anchor outputs
With anchor outputs, the actual feerate for the commit tx can be decided when broadcasting the tx by using CPFP on the anchor. That means we don't need to constantly keep the channel feerate close to what's happening on-chain. We just need a feerate that's good enough to get the tx to propagate through the bitcoin network. We set the upper threshold to 10 sat/byte, which is what lnd does as well. We let the feerate be lower than that when possible, but do note that depending on your configured `feerate-tolerance`, that means you can still experience some force-close events because of feerate mismatch.
1 parent 81f15aa commit f283393

File tree

12 files changed

+320
-62
lines changed

12 files changed

+320
-62
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
package fr.acinq.eclair.blockchain.fee
1818

1919
import fr.acinq.bitcoin.Crypto.PublicKey
20+
import fr.acinq.bitcoin.{Satoshi, SatoshiLong}
21+
import fr.acinq.eclair.blockchain.CurrentFeerates
22+
import fr.acinq.eclair.channel.ChannelVersion
2023

2124
trait FeeEstimator {
2225
// @formatter:off
@@ -25,10 +28,51 @@ trait FeeEstimator {
2528
// @formatter:on
2629
}
2730

31+
object FeeEstimator {
32+
/** When using anchor outputs, we only need to set a feerate that allows the tx to propagate: we will use CPFP to speed up confirmation if needed. */
33+
val AnchorOutputMaxCommitFeerate = FeeratePerKw(FeeratePerByte(10 sat))
34+
}
35+
2836
case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int)
2937

30-
case class FeerateTolerance(ratioLow: Double, ratioHigh: Double)
38+
case class FeerateTolerance(ratioLow: Double, ratioHigh: Double) {
39+
/**
40+
* @param channelVersion channel version
41+
* @param networkFeerate reference fee rate (value we estimate from our view of the network)
42+
* @param proposedFeerate fee rate proposed (new proposal through update_fee or previous proposal used in our current commit tx)
43+
* @return true if the difference between proposed and reference fee rates is too high.
44+
*/
45+
def isFeeDiffTooHigh(channelVersion: ChannelVersion, networkFeerate: FeeratePerKw, proposedFeerate: FeeratePerKw): Boolean = {
46+
if (channelVersion.hasAnchorOutputs) {
47+
proposedFeerate < networkFeerate * ratioLow || FeeEstimator.AnchorOutputMaxCommitFeerate * ratioHigh < proposedFeerate
48+
} else {
49+
proposedFeerate < networkFeerate * ratioLow || networkFeerate * ratioHigh < proposedFeerate
50+
}
51+
}
52+
}
3153

3254
case class OnChainFeeConf(feeTargets: FeeTargets, feeEstimator: FeeEstimator, closeOnOfflineMismatch: Boolean, updateFeeMinDiffRatio: Double, private val defaultFeerateTolerance: FeerateTolerance, private val perNodeFeerateTolerance: Map[PublicKey, FeerateTolerance]) {
55+
3356
def maxFeerateMismatchFor(nodeId: PublicKey): FeerateTolerance = perNodeFeerateTolerance.getOrElse(nodeId, defaultFeerateTolerance)
57+
58+
/** To avoid spamming our peers with fee updates every time there's a small variation, we only update the fee when the difference exceeds a given ratio. */
59+
def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw): Boolean =
60+
currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio
61+
62+
/**
63+
* Get the feerate that should apply to a channel commitment transaction:
64+
* - if we're using anchor outputs, we use a feerate that allows network propagation of the commit tx: we will use CPFP to speed up confirmation if needed
65+
* - otherwise we use a feerate that should get the commit tx confirmed in the configured number of blocks
66+
*/
67+
def getCommitmentFeerate(channelVersion: ChannelVersion, channelCapacity: Satoshi, currentFeerates_opt: Option[CurrentFeerates]): FeeratePerKw = {
68+
val networkFeerate = currentFeerates_opt match {
69+
case Some(currentFeerates) => currentFeerates.feeratesPerKw.feePerBlock(feeTargets.commitmentBlockTarget)
70+
case None => feeEstimator.getFeeratePerKw(feeTargets.commitmentBlockTarget)
71+
}
72+
if (channelVersion.hasAnchorOutputs) {
73+
networkFeerate.min(FeeEstimator.AnchorOutputMaxCommitFeerate)
74+
} else {
75+
networkFeerate
76+
}
77+
}
3478
}

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

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
161161
startWith(WAIT_FOR_INIT_INTERNAL, Nothing)
162162

163163
when(WAIT_FOR_INIT_INTERNAL)(handleExceptions {
164-
case Event(initFunder@INPUT_INIT_FUNDER(temporaryChannelId, fundingSatoshis, pushMsat, initialFeeratePerKw, fundingTxFeeratePerKw, initialRelayFees_opt, localParams, remote, _, channelFlags, channelVersion), Nothing) =>
164+
case Event(initFunder@INPUT_INIT_FUNDER(temporaryChannelId, fundingSatoshis, pushMsat, initialFeeratePerKw, fundingTxFeeratePerKw, _, localParams, remote, _, channelFlags, channelVersion), Nothing) =>
165165
context.system.eventStream.publish(ChannelCreated(self, peer, remoteNodeId, isFunder = true, temporaryChannelId, initialFeeratePerKw, Some(fundingTxFeeratePerKw)))
166166
activeConnection = remote
167167
val fundingPubKey = keyManager.fundingPublicKey(localParams.fundingKeyPath).publicKey
@@ -290,7 +290,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
290290
when(WAIT_FOR_OPEN_CHANNEL)(handleExceptions {
291291
case Event(open: OpenChannel, d@DATA_WAIT_FOR_OPEN_CHANNEL(INPUT_INIT_FUNDEE(_, localParams, _, remoteInit, channelVersion))) =>
292292
log.info("received OpenChannel={}", open)
293-
Helpers.validateParamsFundee(nodeParams, localParams.features, open, remoteNodeId) match {
293+
Helpers.validateParamsFundee(nodeParams, localParams.features, channelVersion, open, remoteNodeId) match {
294294
case Left(t) => handleLocalError(t, d, Some(open))
295295
case _ =>
296296
context.system.eventStream.publish(ChannelCreated(self, peer, remoteNodeId, isFunder = false, open.temporaryChannelId, open.feeratePerKw, None))
@@ -1574,8 +1574,8 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
15741574
// we send it (if needed) when reconnected.
15751575
if (d.commitments.localParams.isFunder) {
15761576
val currentFeeratePerKw = d.commitments.localCommit.spec.feeratePerKw
1577-
val networkFeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget)
1578-
if (Helpers.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, nodeParams.onChainFeeConf.updateFeeMinDiffRatio)) {
1577+
val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(d.commitments.channelVersion, d.commitments.capacity, None)
1578+
if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)) {
15791579
self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true)
15801580
}
15811581
}
@@ -1829,12 +1829,11 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
18291829
}
18301830

18311831
def handleCurrentFeerate(c: CurrentFeerates, d: HasCommitments) = {
1832-
val networkFeeratePerKw = c.feeratesPerKw.feePerBlock(target = nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget)
1832+
val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(d.commitments.channelVersion, d.commitments.capacity, Some(c))
18331833
val currentFeeratePerKw = d.commitments.localCommit.spec.feeratePerKw
1834-
val shouldUpdateFee = d.commitments.localParams.isFunder &&
1835-
Helpers.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, nodeParams.onChainFeeConf.updateFeeMinDiffRatio)
1834+
val shouldUpdateFee = d.commitments.localParams.isFunder && nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)
18361835
val shouldClose = !d.commitments.localParams.isFunder &&
1837-
Helpers.isFeeDiffTooHigh(networkFeeratePerKw, currentFeeratePerKw, nodeParams.onChainFeeConf.maxFeerateMismatchFor(d.commitments.remoteNodeId)) &&
1836+
nodeParams.onChainFeeConf.maxFeerateMismatchFor(d.commitments.remoteNodeId).isFeeDiffTooHigh(d.commitments.channelVersion, networkFeeratePerKw, currentFeeratePerKw) &&
18381837
d.commitments.hasPendingOrProposedHtlcs // we close only if we have HTLCs potentially at risk
18391838
if (shouldUpdateFee) {
18401839
self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true)
@@ -1854,11 +1853,11 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
18541853
* @return
18551854
*/
18561855
def handleOfflineFeerate(c: CurrentFeerates, d: HasCommitments) = {
1857-
val networkFeeratePerKw = c.feeratesPerKw.feePerBlock(target = nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget)
1856+
val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(d.commitments.channelVersion, d.commitments.capacity, Some(c))
18581857
val currentFeeratePerKw = d.commitments.localCommit.spec.feeratePerKw
18591858
// if the network fees are too high we risk to not be able to confirm our current commitment
18601859
val shouldClose = networkFeeratePerKw > currentFeeratePerKw &&
1861-
Helpers.isFeeDiffTooHigh(networkFeeratePerKw, currentFeeratePerKw, nodeParams.onChainFeeConf.maxFeerateMismatchFor(d.commitments.remoteNodeId)) &&
1860+
nodeParams.onChainFeeConf.maxFeerateMismatchFor(d.commitments.remoteNodeId).isFeeDiffTooHigh(d.commitments.channelVersion, networkFeeratePerKw, currentFeeratePerKw) &&
18621861
d.commitments.hasPendingOrProposedHtlcs // we close only if we have HTLCs potentially at risk
18631862
if (shouldClose) {
18641863
if (nodeParams.onChainFeeConf.closeOnOfflineMismatch) {

eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,8 @@ object Commitments {
276276

277277
// we allowed mismatches between our feerates and our remote's as long as commitments didn't contain any HTLC at risk
278278
// we need to verify that we're not disagreeing on feerates anymore before offering new HTLCs
279-
val localFeeratePerKw = feeConf.feeEstimator.getFeeratePerKw(target = feeConf.feeTargets.commitmentBlockTarget)
280-
if (Helpers.isFeeDiffTooHigh(localFeeratePerKw, commitments.localCommit.spec.feeratePerKw, feeConf.maxFeerateMismatchFor(commitments.remoteNodeId))) {
279+
val localFeeratePerKw = feeConf.getCommitmentFeerate(commitments.channelVersion, commitments.capacity, None)
280+
if (feeConf.maxFeerateMismatchFor(commitments.remoteNodeId).isFeeDiffTooHigh(commitments.channelVersion, localFeeratePerKw, commitments.localCommit.spec.feeratePerKw)) {
281281
return Left(FeerateTooDifferent(commitments.channelId, localFeeratePerKw = localFeeratePerKw, remoteFeeratePerKw = commitments.localCommit.spec.feeratePerKw))
282282
}
283283

@@ -337,8 +337,8 @@ object Commitments {
337337

338338
// we allowed mismatches between our feerates and our remote's as long as commitments didn't contain any HTLC at risk
339339
// we need to verify that we're not disagreeing on feerates anymore before accepting new HTLCs
340-
val localFeeratePerKw = feeConf.feeEstimator.getFeeratePerKw(target = feeConf.feeTargets.commitmentBlockTarget)
341-
if (Helpers.isFeeDiffTooHigh(localFeeratePerKw, commitments.localCommit.spec.feeratePerKw, feeConf.maxFeerateMismatchFor(commitments.remoteNodeId))) {
340+
val localFeeratePerKw = feeConf.getCommitmentFeerate(commitments.channelVersion, commitments.capacity, None)
341+
if (feeConf.maxFeerateMismatchFor(commitments.remoteNodeId).isFeeDiffTooHigh(commitments.channelVersion, localFeeratePerKw, commitments.localCommit.spec.feeratePerKw)) {
342342
return Left(FeerateTooDifferent(commitments.channelId, localFeeratePerKw = localFeeratePerKw, remoteFeeratePerKw = commitments.localCommit.spec.feeratePerKw))
343343
}
344344

@@ -484,9 +484,9 @@ object Commitments {
484484
Left(FeerateTooSmall(commitments.channelId, remoteFeeratePerKw = fee.feeratePerKw))
485485
} else {
486486
Metrics.RemoteFeeratePerKw.withoutTags().record(fee.feeratePerKw.toLong)
487-
val localFeeratePerKw = feeConf.feeEstimator.getFeeratePerKw(target = feeConf.feeTargets.commitmentBlockTarget)
487+
val localFeeratePerKw = feeConf.getCommitmentFeerate(commitments.channelVersion, commitments.capacity, None)
488488
log.info("remote feeratePerKw={}, local feeratePerKw={}, ratio={}", fee.feeratePerKw, localFeeratePerKw, fee.feeratePerKw.toLong.toDouble / localFeeratePerKw.toLong)
489-
if (Helpers.isFeeDiffTooHigh(localFeeratePerKw, fee.feeratePerKw, feeConf.maxFeerateMismatchFor(commitments.remoteNodeId)) && commitments.hasPendingOrProposedHtlcs) {
489+
if (feeConf.maxFeerateMismatchFor(commitments.remoteNodeId).isFeeDiffTooHigh(commitments.channelVersion, localFeeratePerKw, fee.feeratePerKw) && commitments.hasPendingOrProposedHtlcs) {
490490
Left(FeerateTooDifferent(commitments.channelId, localFeeratePerKw = localFeeratePerKw, remoteFeeratePerKw = fee.feeratePerKw))
491491
} else {
492492
// NB: we check that the funder can afford this new fee even if spec allows to do it at next signature

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import fr.acinq.bitcoin.Script._
2222
import fr.acinq.bitcoin._
2323
import fr.acinq.eclair._
2424
import fr.acinq.eclair.blockchain.EclairWallet
25-
import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeTargets, FeeratePerKw, FeerateTolerance}
25+
import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeTargets, FeeratePerKw}
2626
import fr.acinq.eclair.channel.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL
2727
import fr.acinq.eclair.crypto.Generators
2828
import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager
@@ -81,7 +81,7 @@ object Helpers {
8181
/**
8282
* Called by the fundee
8383
*/
84-
def validateParamsFundee(nodeParams: NodeParams, features: Features, open: OpenChannel, remoteNodeId: PublicKey): Either[ChannelException, Unit] = {
84+
def validateParamsFundee(nodeParams: NodeParams, features: Features, channelVersion: ChannelVersion, open: OpenChannel, remoteNodeId: PublicKey): Either[ChannelException, Unit] = {
8585
// BOLT #2: if the chain_hash value, within the open_channel, message is set to a hash of a chain that is unknown to the receiver:
8686
// MUST reject the channel.
8787
if (nodeParams.chainHash != open.chainHash) return Left(InvalidChainHash(open.temporaryChannelId, local = nodeParams.chainHash, remote = open.chainHash))
@@ -114,8 +114,8 @@ object Helpers {
114114
}
115115

116116
// BOLT #2: The receiving node MUST fail the channel if: it considers feerate_per_kw too small for timely processing or unreasonably large.
117-
val localFeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget)
118-
if (isFeeDiffTooHigh(localFeeratePerKw, open.feeratePerKw, nodeParams.onChainFeeConf.maxFeerateMismatchFor(remoteNodeId))) return Left(FeerateTooDifferent(open.temporaryChannelId, localFeeratePerKw, open.feeratePerKw))
117+
val localFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(channelVersion, open.fundingSatoshis, None)
118+
if (nodeParams.onChainFeeConf.maxFeerateMismatchFor(remoteNodeId).isFeeDiffTooHigh(channelVersion, localFeeratePerKw, open.feeratePerKw)) return Left(FeerateTooDifferent(open.temporaryChannelId, localFeeratePerKw, open.feeratePerKw))
119119
// only enforce dust limit check on mainnet
120120
if (nodeParams.chainHash == Block.LivenetGenesisBlock.hash) {
121121
if (open.dustLimitSatoshis < Channel.MIN_DUSTLIMIT) return Left(DustLimitTooSmall(open.temporaryChannelId, open.dustLimitSatoshis, Channel.MIN_DUSTLIMIT))
@@ -178,22 +178,6 @@ object Helpers {
178178
delay
179179
}
180180

181-
/**
182-
* To avoid spamming our peers with fee updates every time there's a small variation, we only update the fee when the
183-
* difference exceeds a given ratio (updateFeeMinDiffRatio).
184-
*/
185-
def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw, updateFeeMinDiffRatio: Double): Boolean =
186-
currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio
187-
188-
/**
189-
* @param referenceFeePerKw reference fee rate per kiloweight
190-
* @param currentFeePerKw current fee rate per kiloweight
191-
* @param maxFeerateMismatch maximum fee rate mismatch tolerated
192-
* @return true if the difference between proposed and reference fee rates is too high.
193-
*/
194-
def isFeeDiffTooHigh(referenceFeePerKw: FeeratePerKw, currentFeePerKw: FeeratePerKw, maxFeerateMismatch: FeerateTolerance): Boolean =
195-
currentFeePerKw < referenceFeePerKw * maxFeerateMismatch.ratioLow || referenceFeePerKw * maxFeerateMismatch.ratioHigh < currentFeePerKw
196-
197181
/**
198182
* @param remoteFeeratePerKw remote fee rate per kiloweight
199183
* @return true if the remote fee rate is too small

eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class Peer(val nodeParams: NodeParams, remoteNodeId: PublicKey, watcher: ActorRe
126126
val (channel, localParams) = createNewChannel(nodeParams, d.localFeatures, funder = true, c.fundingSatoshis, origin_opt = Some(sender), channelVersion)
127127
c.timeout_opt.map(openTimeout => context.system.scheduler.scheduleOnce(openTimeout.duration, channel, Channel.TickChannelOpenTimeout)(context.dispatcher))
128128
val temporaryChannelId = randomBytes32
129-
val channelFeeratePerKw = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget)
129+
val channelFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(channelVersion, c.fundingSatoshis, None)
130130
val fundingTxFeeratePerKw = c.fundingTxFeeratePerKw_opt.getOrElse(nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(target = nodeParams.onChainFeeConf.feeTargets.fundingBlockTarget))
131131
log.info(s"requesting a new channel with fundingSatoshis=${c.fundingSatoshis}, pushMsat=${c.pushMsat} and fundingFeeratePerByte=${c.fundingTxFeeratePerKw_opt} temporaryChannelId=$temporaryChannelId localParams=$localParams")
132132
channel ! INPUT_INIT_FUNDER(temporaryChannelId, c.fundingSatoshis, c.pushMsat, channelFeeratePerKw, fundingTxFeeratePerKw, c.initialRelayFees_opt, localParams, d.peerConnection, d.remoteInit, c.channelFlags.getOrElse(nodeParams.channelFlags), channelVersion)

0 commit comments

Comments
 (0)