diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 32f4447418..1ab5af2138 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -145,6 +145,10 @@ eclair { // number of blocks to target when computing fees for each transaction type target-blocks { + // When this field is set, eclair will randomly target a more aggressive confirmation window. + // This makes eclair's fee-bumping less predictable for potentially malicious peers, and makes force-close + // transactions confirm more quickly (at the cost of paying more on-chain fees). + randomize = true funding = 6 // target for the funding transaction commitment = 2 // target for the commitment transaction (used in force-close scenario) *do not change this unless you know what you are doing* commitment-without-htlcs = 12 // target for the commitment transaction when we have no htlcs to claim (used in force-close scenario) *do not change this unless you know what you are doing* diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index b5193ac230..1870a62a60 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -319,7 +319,8 @@ object NodeParams extends Logging { commitmentBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment"), commitmentWithoutHtlcsBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment-without-htlcs"), mutualCloseBlockTarget = config.getInt("on-chain-fees.target-blocks.mutual-close"), - claimMainBlockTarget = config.getInt("on-chain-fees.target-blocks.claim-main") + claimMainBlockTarget = config.getInt("on-chain-fees.target-blocks.claim-main"), + randomize = config.getBoolean("on-chain-fees.target-blocks.randomize") ) def getRelayFees(relayFeesConfig: Config): RelayFees = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index 10590d8965..1b57240de7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -30,7 +30,7 @@ trait FeeEstimator { // @formatter:on } -case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, commitmentWithoutHtlcsBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) +case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, commitmentWithoutHtlcsBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int, randomize: Boolean) /** * @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 0ca1c38e90..443edb77ad 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -73,8 +73,12 @@ object ReplaceableTxPublisher { } } - def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = { - val remainingBlocks = confirmBefore - currentBlockHeight + def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight, randomize: Boolean): FeeratePerKw = { + val remainingBlocks = if (randomize) { + Random.nextLong((confirmBefore - currentBlockHeight).max(1)) + } else { + confirmBefore - currentBlockHeight + } val blockTarget = remainingBlocks match { // If our target is still very far in the future, no need to rush case t if t >= 144 => 144 @@ -140,7 +144,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } def fund(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { - val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, nodeParams.currentBlockHeight) + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, nodeParams.currentBlockHeight, nodeParams.onChainFeeConf.feeTargets.randomize) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder") txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { @@ -173,7 +177,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case MempoolTxMonitor.TxInMempool(_, currentBlockHeight) => // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. val bumpRatio = 1.2 - val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, currentBlockHeight) + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, currentBlockHeight, nodeParams.onChainFeeConf.feeTargets.randomize) val targetFeerate_opt = if (confirmBefore <= currentBlockHeight + 6) { log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, confirmBefore - currentBlockHeight) Some(currentFeerate.max(tx.feerate * bumpRatio)) @@ -181,7 +185,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, confirmBefore - currentBlockHeight) Some(currentFeerate) } else { - log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, confirmBefore - currentBlockHeight) + log.debug("{} confirmation target is in {} blocks: no need to bump fees (previous feerate={}, current feerate={})", cmd.desc, confirmBefore - currentBlockHeight, tx.feerate, currentFeerate) None } // We avoid a herd effect whenever we fee bump transactions. 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 cde795aca5..396584e574 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -102,7 +102,7 @@ object TestConstants { dustLimit = 1100 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 36, 12, 18), + feeTargets = FeeTargets(6, 2, 36, 12, 18, randomize = false), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, @@ -235,7 +235,7 @@ object TestConstants { dustLimit = 1000 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 36, 12, 18), + feeTargets = FeeTargets(6, 2, 36, 12, 18, randomize = false), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index 13bccc9b11..6aa855248a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -27,7 +27,7 @@ class FeeEstimatorSpec extends AnyFunSuite { 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(FeeTargets(1, 1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1, 1, randomize = false), new TestFeeEstimator(), 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))) @@ -38,7 +38,7 @@ class FeeEstimatorSpec extends AnyFunSuite { test("get commitment feerate") { val feeEstimator = new TestFeeEstimator() val channelType = ChannelTypes.Standard - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1, randomize = false), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = FeeratePerKw(5000 sat))) assert(feeConf.getCommitmentFeerate(randomKey().publicKey, channelType, 100000 sat, None) === FeeratePerKw(5000 sat)) @@ -53,7 +53,7 @@ class FeeEstimatorSpec extends AnyFunSuite { val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate val overrideNodeId = randomKey().publicKey val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2 - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1, randomize = false), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = defaultMaxCommitFeerate / 2, mempoolMinFee = FeeratePerKw(250 sat))) assert(feeConf.getCommitmentFeerate(defaultNodeId, ChannelTypes.AnchorOutputs, 100000 sat, None) === defaultMaxCommitFeerate / 2) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 012357033c..b5ce4a4a39 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -41,7 +41,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging val feeConfNoMismatch = OnChainFeeConf( - FeeTargets(6, 2, 12, 2, 6), + FeeTargets(6, 2, 12, 2, 6, randomize = false), new TestFeeEstimator(), closeOnOfflineMismatch = false, 1.0,