diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala index 7deea85fbd..2a009c4ebc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConf.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.blockchain.fee import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.Satoshi +import fr.acinq.bitcoin.scalacompat.{Satoshi, SatoshiLong} import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ @@ -65,8 +65,8 @@ case class DustTolerance(maxExposure: Satoshi, closeOnUpdateFeeOverflow: Boolean case class FeerateTolerance(ratioLow: Double, ratioHigh: Double, anchorOutputMaxCommitFeerate: FeeratePerKw, dustTolerance: DustTolerance) { /** - * @param networkFeerate reference fee rate (value we estimate from our view of the network) - * @param proposedFeerate fee rate proposed (new proposal through update_fee or previous proposal used in our current commit tx) + * @param networkFeerate reference fee rate (value we estimate from our view of the network) + * @param proposedFeerate fee rate proposed (new proposal through update_fee or previous proposal used in our current commit tx) * @return true if the difference between proposed and reference fee rates is too high. */ def isProposedCommitFeerateTooHigh(networkFeerate: FeeratePerKw, proposedFeerate: FeeratePerKw): Boolean = networkFeerate * ratioHigh < proposedFeerate @@ -85,15 +85,24 @@ case class OnChainFeeConf(feeTargets: FeeTargets, def feerateToleranceFor(nodeId: PublicKey): FeerateTolerance = perNodeFeerateTolerance.getOrElse(nodeId, defaultFeerateTolerance) /** 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. */ - def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw): Boolean = - currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio + def shouldUpdateFee(currentFeeratePerKw: FeeratePerKw, nextFeeratePerKw: FeeratePerKw, commitmentFormat: CommitmentFormat): Boolean = { + commitmentFormat match { + case Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat | Transactions.PhoenixSimpleTaprootChannelCommitmentFormat => + // If we're not already using 1 sat/byte, we update the fee. + FeeratePerKw(FeeratePerByte(1 sat)) < currentFeeratePerKw + case Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => + // If the fee has a large enough change, we update the fee. + currentFeeratePerKw.toLong == 0 || Math.abs((currentFeeratePerKw.toLong - nextFeeratePerKw.toLong).toDouble / currentFeeratePerKw.toLong) > updateFeeMinDiffRatio + } + } def getFundingFeerate(feerates: FeeratesPerKw): FeeratePerKw = feeTargets.funding.getFeerate(feerates) /** * Get the feerate that should apply to a channel commitment transaction: - * - 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 - * - otherwise we use a feerate that should get the commit tx confirmed within the configured block target + * - if the remote peer is a mobile wallet that supports anchor outputs, we use 1 sat/byte + * - otherwise, we use a feerate that should allow network propagation of the commit tx on its own: we will use CPFP + * on the anchor output to speed up confirmation if needed or to help propagation * * @param remoteNodeId nodeId of our channel peer * @param commitmentFormat commitment format @@ -102,7 +111,11 @@ case class OnChainFeeConf(feeTargets: FeeTargets, val networkFeerate = feerates.fast val networkMinFee = feerates.minimum commitmentFormat match { - case _: Transactions.AnchorOutputsCommitmentFormat | _: Transactions.SimpleTaprootChannelCommitmentFormat => + case Transactions.UnsafeLegacyAnchorOutputsCommitmentFormat | Transactions.PhoenixSimpleTaprootChannelCommitmentFormat => + // Since Bitcoin Core v28, 1-parent-1-child package relay has been deployed: it should be ok if the commit tx + // doesn't propagate on its own. + FeeratePerKw(FeeratePerByte(1 sat)) + case Transactions.ZeroFeeHtlcTxAnchorOutputsCommitmentFormat | Transactions.ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat => val targetFeerate = networkFeerate.min(feerateToleranceFor(remoteNodeId).anchorOutputMaxCommitFeerate) // We make sure the feerate is always greater than the propagation threshold. targetFeerate.max(networkMinFee * 1.25) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 6d92187dd2..84ac61dce2 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -2736,7 +2736,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall if (d.commitments.localChannelParams.paysCommitTxFees && !shutdownInProgress) { val currentFeeratePerKw = d.commitments.latest.localCommit.spec.commitTxFeerate val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat) - if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw)) { + if (nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, d.commitments.latest.commitmentFormat)) { self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true) } } @@ -3219,7 +3219,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall val commitments = d.commitments.latest val networkFeeratePerKw = nodeParams.onChainFeeConf.getCommitmentFeerate(nodeParams.currentBitcoinCoreFeerates, remoteNodeId, d.commitments.latest.commitmentFormat) val currentFeeratePerKw = commitments.localCommit.spec.commitTxFeerate - val shouldUpdateFee = d.commitments.localChannelParams.paysCommitTxFees && nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw) + val shouldUpdateFee = d.commitments.localChannelParams.paysCommitTxFees && nodeParams.onChainFeeConf.shouldUpdateFee(currentFeeratePerKw, networkFeeratePerKw, d.commitments.latest.commitmentFormat) if (shouldUpdateFee) { self ! CMD_UPDATE_FEE(networkFeeratePerKw, commit = true) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index b939873da8..97b8a51464 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -53,6 +53,7 @@ object TestConstants { val nonInitiatorPushAmount: MilliSatoshi = 100_000_000L msat val feeratePerKw: FeeratePerKw = FeeratePerKw(10_000 sat) val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2_500 sat) + val phoenixCommitFeeratePerKw: FeeratePerKw = FeeratePerByte(1 sat).perKw val defaultLiquidityRates: LiquidityAds.WillFundRates = LiquidityAds.WillFundRates( fundingRates = LiquidityAds.FundingRate(100_000 sat, 10_000_000 sat, 500, 100, 100 sat, 1000 sat) :: Nil, paymentTypes = Set(LiquidityAds.PaymentType.FromChannelBalance) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala index 67b3c43e0d..446ae06d46 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/OnChainFeeConfSpec.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.blockchain.fee import fr.acinq.bitcoin.scalacompat.SatoshiLong import fr.acinq.eclair.randomKey -import fr.acinq.eclair.transactions.Transactions.{UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} +import fr.acinq.eclair.transactions.Transactions.{PhoenixSimpleTaprootChannelCommitmentFormat, UnsafeLegacyAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat} import org.scalatest.funsuite.AnyFunSuite class OnChainFeeConfSpec extends AnyFunSuite { @@ -29,22 +29,25 @@ class OnChainFeeConfSpec extends AnyFunSuite { test("should update fee when diff ratio exceeded") { val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, 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))) - assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(899 sat))) - assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1101 sat))) + assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)) + assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat), ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat)) + assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)) + assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(899 sat), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat)) + assert(feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1101 sat), ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat)) } - test("get commitment feerate") { + test("should update fee to set to 1 sat/byte") { val feeConf = OnChainFeeConf(defaultFeeTargets, defaultMaxClosingFeerate, 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, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(2500 sat)) - val feerates2 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = FeeratePerKw(2000 sat)) - assert(feeConf.getCommitmentFeerate(feerates2, randomKey().publicKey, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(2000 sat)) + // We always use 1 sat/byte for mobile wallet commitment formats, regardless of the current feerate. + val feerates = FeeratesPerKw.single(FeeratePerKw(FeeratePerByte(20 sat))) + assert(feeConf.getCommitmentFeerate(feerates, randomKey().publicKey, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(FeeratePerByte(1 sat))) + assert(feeConf.getCommitmentFeerate(feerates, randomKey().publicKey, PhoenixSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(FeeratePerByte(1 sat))) + // If we're not already using 1 sat/byte, we update the feerate. + assert(feeConf.shouldUpdateFee(FeeratePerKw(300 sat), FeeratePerKw(FeeratePerByte(1 sat)), UnsafeLegacyAnchorOutputsCommitmentFormat)) + assert(feeConf.shouldUpdateFee(FeeratePerKw(300 sat), FeeratePerKw(FeeratePerByte(1 sat)), PhoenixSimpleTaprootChannelCommitmentFormat)) } - test("get commitment feerate (anchor outputs)") { + test("get commitment feerate") { val defaultNodeId = randomKey().publicKey val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate val overrideNodeId = randomKey().publicKey @@ -52,33 +55,33 @@ class OnChainFeeConfSpec extends AnyFunSuite { 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))) val feerates1 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat)) - assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2) + assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate / 2) assert(feeConf.getCommitmentFeerate(feerates1, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2) val feerates2 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate * 2, minimum = FeeratePerKw(250 sat)) - assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate) + assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate) assert(feeConf.getCommitmentFeerate(feerates2, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate) - assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == overrideMaxCommitFeerate) + assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == overrideMaxCommitFeerate) assert(feeConf.getCommitmentFeerate(feerates2, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == overrideMaxCommitFeerate) val feerates3 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate / 2, minimum = FeeratePerKw(250 sat)) - assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2) + assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate / 2) assert(feeConf.getCommitmentFeerate(feerates3, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate / 2) val feerates4 = FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(fast = defaultMaxCommitFeerate * 1.5, minimum = FeeratePerKw(250 sat)) - assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate) + assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == defaultMaxCommitFeerate) assert(feeConf.getCommitmentFeerate(feerates4, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == defaultMaxCommitFeerate) val feerates5 = FeeratesPerKw.single(FeeratePerKw(25000 sat)).copy(minimum = FeeratePerKw(10000 sat)) - assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) + assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) assert(feeConf.getCommitmentFeerate(feerates5, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) - assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) + assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) assert(feeConf.getCommitmentFeerate(feerates5, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) val feerates6 = FeeratesPerKw.single(FeeratePerKw(25000 sat)).copy(minimum = FeeratePerKw(10000 sat)) - assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) + assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) assert(feeConf.getCommitmentFeerate(feerates6, defaultNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) - assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, UnsafeLegacyAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) + assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, ZeroFeeHtlcTxSimpleTaprootChannelCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) assert(feeConf.getCommitmentFeerate(feerates6, overrideNodeId, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) == FeeratePerKw(10000 sat) * 1.25) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index acf6b994c4..a2aa6ae263 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -140,7 +140,10 @@ trait ChannelStateTestsBase extends Assertions with Eventually { temporaryChannelId = ByteVector32.Zeroes, fundingAmount = fundingAmount, dualFunded = dualFunded, - commitTxFeerate = TestConstants.anchorOutputsFeeratePerKw, + commitTxFeerate = channelType match { + case _: ChannelTypes.AnchorOutputs | ChannelTypes.SimpleTaprootChannelsPhoenix => TestConstants.phoenixCommitFeeratePerKw + case _ => TestConstants.anchorOutputsFeeratePerKw + }, fundingTxFeerate = TestConstants.feeratePerKw, fundingTxFeeBudget_opt = None, pushAmount_opt = pushAmount_opt, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala index af697c84db..b1b794ba60 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/io/PeerSpec.scala @@ -547,7 +547,7 @@ class PeerSpec extends FixtureSpec { assert(init.channelType == ChannelTypes.AnchorOutputs()) assert(!init.dualFunded) assert(init.fundingAmount == 15000.sat) - assert(init.commitTxFeerate == TestConstants.anchorOutputsFeeratePerKw) + assert(init.commitTxFeerate == TestConstants.phoenixCommitFeeratePerKw) assert(init.fundingTxFeerate == nodeParams.onChainFeeConf.getFundingFeerate(nodeParams.currentFeeratesForFundingClosing)) }