Skip to content

Commit 1833eae

Browse files
committed
Stop sending update_fee for mobile wallets
We stop sending `update_fee` and set the feerate to `1 sat/byte` for channels with mobile wallet users. This removes edge cases around `update_fee` handling in tricky cases (splicing, shutdown, etc) while still allowing channels to force-close thanks to package relay. Note that mobile wallets that don't have an on-chain wallet to use CPFP on the commit transaction may not be able to get their commit tx confirmed, but that was already the case before that change since the LSP decides the commit feerate. This will get better with v3 txs and https://delvingbitcoin.org/t/zero-fee-commitments-for-mobile-wallets/1453
1 parent a62fd6e commit 1833eae

File tree

6 files changed

+53
-33
lines changed

6 files changed

+53
-33
lines changed

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

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package fr.acinq.eclair.blockchain.fee
1818

1919
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
20-
import fr.acinq.bitcoin.scalacompat.Satoshi
20+
import fr.acinq.bitcoin.scalacompat.{Satoshi, SatoshiLong}
2121
import fr.acinq.eclair.BlockHeight
2222
import fr.acinq.eclair.transactions.Transactions
2323
import fr.acinq.eclair.transactions.Transactions._
@@ -65,8 +65,8 @@ case class DustTolerance(maxExposure: Satoshi, closeOnUpdateFeeOverflow: Boolean
6565

6666
case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, dustTolerance: DustTolerance) {
6767
/**
68-
* @param networkFeerate reference fee rate (value we estimate from our view of the network)
69-
* @param proposedFeerate fee rate proposed (new proposal through update_fee or previous proposal used in our current commit tx)
68+
* @param networkFeerate reference fee rate (value we estimate from our view of the network)
69+
* @param proposedFeerate fee rate proposed (new proposal through update_fee or previous proposal used in our current commit tx)
7070
* @return true if the difference between proposed and reference fee rates is too high.
7171
*/
7272
def isProposedCommitFeerateTooHigh(networkFeerate: FeeratePerKw, proposedFeerate: FeeratePerKw): Boolean = networkFeerate * ratioHigh < proposedFeerate
@@ -85,15 +85,24 @@ case class OnChainFeeConf(feeTargets: FeeTargets,
8585
def feerateToleranceFor(nodeId: PublicKey): FeerateTolerance = perNodeFeerateTolerance.getOrElse(nodeId, defaultFeerateTolerance)
8686

8787
/** 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. */
88-
def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw): Boolean =
89-
currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio
88+
def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw, commitmentFormat: CommitmentFormat): Boolean = {
89+
commitmentFormat match {
90+
case Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat | Transactions.PhoenixSimpleTaprootChannelCommitmentFormat =>
91+
// If we're not already using 1 sat/byte, we update the fee.
92+
FeeratePerKw(FeeratePerByte(1 sat)) < currentFeeratePerKw
93+
case Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat =>
94+
// If the fee has a large enough change, we update the fee.
95+
currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio
96+
}
97+
}
9098

9199
def getFundingFeerate(feerates: FeeratesPerKw): FeeratePerKw = feeTargets.funding.getFeerate(feerates)
92100

93101
/**
94102
* Get the feerate that should apply to a channel commitment transaction:
95-
* - 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
96-
* - otherwise we use a feerate that should get the commit tx confirmed within the configured block target
103+
* - if the remote peer is a mobile wallet that supports anchor outputs, we use 1 sat/byte
104+
* - otherwise, we use a feerate that should allow network propagation of the commit tx on its own: we will use CPFP
105+
* on the anchor output to speed up confirmation if needed or to help propagation
97106
*
98107
* @param remoteNodeId nodeId of our channel peer
99108
* @param commitmentFormat commitment format
@@ -102,7 +111,11 @@ case class OnChainFeeConf(feeTargets: FeeTargets,
102111
val networkFeerate = feerates.fast
103112
val networkMinFee = feerates.minimum
104113
commitmentFormat match {
105-
case _: Transactions.AnchorOutputsCommitmentFormat | _: Transactions.SimpleTaprootChannelCommitmentFormat =>
114+
case Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat | Transactions.PhoenixSimpleTaprootChannelCommitmentFormat =>
115+
// Since Bitcoin Core v28, 1-parent-1-child package relay has been deployed: it should be ok if the commit tx
116+
// doesn't propagate on its own.
117+
FeeratePerKw(FeeratePerByte(1 sat))
118+
case Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat =>
106119
val targetFeerate = networkFeerate.min(feerateToleranceFor(remoteNodeId).anchorOutputMaxCommitFeerate)
107120
// We make sure the feerate is always greater than the propagation threshold.
108121
targetFeerate.max(networkMinFee * 1.25)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2736,7 +2736,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
27362736
if (d.commitments.localChannelParams.paysCommitTxFees && !shutdownInProgress) {
27372737
val currentFeeratePerKw = d.commitments.latest.localCommit.spec.commitTxFeerate
27382738
val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat)
2739-
if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)) {
2739+
if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, d.commitments.latest.commitmentFormat)) {
27402740
self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true)
27412741
}
27422742
}
@@ -3219,7 +3219,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
32193219
val commitments = d.commitments.latest
32203220
val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat)
32213221
val currentFeeratePerKw = commitments.localCommit.spec.commitTxFeerate
3222-
val shouldUpdateFee = d.commitments.localChannelParams.paysCommitTxFees && nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)
3222+
val shouldUpdateFee = d.commitments.localChannelParams.paysCommitTxFees && nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, d.commitments.latest.commitmentFormat)
32233223
if (shouldUpdateFee) {
32243224
self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true)
32253225
}

eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ object TestConstants {
5353
val nonInitiatorPushAmount: MilliSatoshi = 100_000_000L msat
5454
val feeratePerKw: FeeratePerKw = FeeratePerKw(10_000 sat)
5555
val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2_500 sat)
56+
val phoenixCommitFeeratePerKw: FeeratePerKw = FeeratePerByte(1 sat).perKw
5657
val defaultLiquidityRates: LiquidityAds.WillFundRates = LiquidityAds.WillFundRates(
5758
fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat, 1000 sat) :: Nil,
5859
paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance)

eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ package fr.acinq.eclair.blockchain.fee
1818

1919
import fr.acinq.bitcoin.scalacompat.SatoshiLong
2020
import fr.acinq.eclair.randomKey
21-
import fr.acinq.eclair.transactions.Transactions.{UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat}
21+
import fr.acinq.eclair.transactions.Transactions.{PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat}
2222
import org.scalatest.funsuite.AnyFunSuite
2323

2424
class OnChainFeeConfSpec extends AnyFunSuite {
@@ -29,56 +29,59 @@ class OnChainFeeConfSpec extends AnyFunSuite {
2929

3030
test("should update fee when diff ratio exceeded") {
3131
val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
32-
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat)))
33-
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat)))
34-
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat)))
35-
assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(899 sat)))
36-
assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1101 sat)))
32+
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat))
33+
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat), ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat))
34+
assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat))
35+
assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(899 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat))
36+
assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1101 sat), ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat))
3737
}
3838

39-
test("get commitment feerate") {
39+
test("should update fee to set to 1 sat/byte") {
4040
val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty)
41-
val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = FeeratePerKw(5000 sat))
42-
assert(feeConf.getCommitmentFeerate(feerates1, randomKey().publicKey, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(2500 sat))
43-
val feerates2 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = FeeratePerKw(2000 sat))
44-
assert(feeConf.getCommitmentFeerate(feerates2, randomKey().publicKey, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(2000 sat))
41+
// We always use 1 sat/byte for mobile wallet commitment formats, regardless of the current feerate.
42+
val feerates = FeeratesPerKw.single(FeeratePerKw(FeeratePerByte(20 sat)))
43+
assert(feeConf.getCommitmentFeerate(feerates, randomKey().publicKey, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(FeeratePerByte(1 sat)))
44+
assert(feeConf.getCommitmentFeerate(feerates, randomKey().publicKey, PhoenixSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(FeeratePerByte(1 sat)))
45+
// If we're not already using 1 sat/byte, we update the feerate.
46+
assert(feeConf.shouldUpdateFee(FeeratePerKw(300 sat), FeeratePerKw(FeeratePerByte(1 sat)), UnsafeLegacyAnchorOutputsCommitmentFormat))
47+
assert(feeConf.shouldUpdateFee(FeeratePerKw(300 sat), FeeratePerKw(FeeratePerByte(1 sat)), PhoenixSimpleTaprootChannelCommitmentFormat))
4548
}
4649

47-
test("get commitment feerate (anchor outputs)") {
50+
test("get commitment feerate") {
4851
val defaultNodeId = randomKey().publicKey
4952
val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate
5053
val overrideNodeId = randomKey().publicKey
5154
val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2
5255
val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, safeUtxosThreshold = 0, spendAnchorWithoutHtlcs = true, anchorWithoutHtlcsMaxFee = 10_000.sat, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate)))
5356

5457
val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat))
55-
assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2)
58+
assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate / 2)
5659
assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2)
5760

5861
val feerates2 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate * 2, minimum = FeeratePerKw(250 sat))
59-
assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate)
62+
assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate)
6063
assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate)
61-
assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == overrideMaxCommitFeerate)
64+
assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == overrideMaxCommitFeerate)
6265
assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == overrideMaxCommitFeerate)
6366

6467
val feerates3 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat))
65-
assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2)
68+
assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate / 2)
6669
assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2)
6770

6871
val feerates4 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate * 1.5, minimum = FeeratePerKw(250 sat))
69-
assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate)
72+
assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate)
7073
assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate)
7174

7275
val feerates5 = FeeratesPerKw.single(FeeratePerKw(25000 sat)).copy(minimum = FeeratePerKw(10000 sat))
73-
assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
76+
assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
7477
assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
75-
assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
78+
assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
7679
assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
7780

7881
val feerates6 = FeeratesPerKw.single(FeeratePerKw(25000 sat)).copy(minimum = FeeratePerKw(10000 sat))
79-
assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
82+
assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
8083
assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
81-
assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
84+
assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
8285
assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25)
8386
}
8487

eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ trait ChannelStateTestsBase extends Assertions with Eventually {
140140
temporaryChannelId = ByteVector32.Zeroes,
141141
fundingAmount = fundingAmount,
142142
dualFunded = dualFunded,
143-
commitTxFeerate = TestConstants.anchorOutputsFeeratePerKw,
143+
commitTxFeerate = channelType match {
144+
case _: ChannelTypes.AnchorOutputs | ChannelTypes.SimpleTaprootChannelsPhoenix => TestConstants.phoenixCommitFeeratePerKw
145+
case _ => TestConstants.anchorOutputsFeeratePerKw
146+
},
144147
fundingTxFeerate = TestConstants.feeratePerKw,
145148
fundingTxFeeBudget_opt = None,
146149
pushAmount_opt = pushAmount_opt,

eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ class PeerSpec extends FixtureSpec {
547547
assert(init.channelType == ChannelTypes.AnchorOutputs())
548548
assert(!init.dualFunded)
549549
assert(init.fundingAmount == 15000.sat)
550-
assert(init.commitTxFeerate == TestConstants.anchorOutputsFeeratePerKw)
550+
assert(init.commitTxFeerate == TestConstants.phoenixCommitFeeratePerKw)
551551
assert(init.fundingTxFeerate == nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing))
552552
}
553553

0 commit comments

Comments
 (0)