Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion eclair-core/src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ eclair {
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*
mutual-close = 12 // target for the mutual close transaction
claim-main = 12 // target for the claim main transaction (tx that spends main channel output back to wallet)
safe-utxos-threshold = 10 // when our utxos count is below this threshold, we will use more aggressive confirmation targets in force-close scenarios
}

feerate-tolerance {
Expand Down Expand Up @@ -247,7 +248,7 @@ eclair {
channel-capacity = 0.55 // when computing the weight for a channel, consider its CAPACITY in this proportion
}
locked-funds-risk = 1e-8 // msat per msat locked per block. It should be your expected interest rate per block multiplied by the probability that something goes wrong and your funds stay locked.
// 1e-8 corresponds to an interest rate of ~5% per year (1e-6 per block) and a probability of 1% that the channel will fail and our funds will be locked.
// 1e-8 corresponds to an interest rate of ~5% per year (1e-6 per block) and a probability of 1% that the channel will fail and our funds will be locked.
// virtual fee for failed payments: how much you are willing to pay to get one less failed payment attempt
failure-cost {
fee-base-msat = 2000
Expand Down
3 changes: 2 additions & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
safeUtxosThreshold = config.getInt("on-chain-fees.target-blocks.safe-utxos-threshold"),
)

def getRelayFees(relayFeesConfig: Config): RelayFees = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, safeUtxosThreshold: Int)

/**
* @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ package fr.acinq.eclair.channel.publish

import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
import akka.actor.typed.{ActorRef, Behavior}
import fr.acinq.bitcoin.{OutPoint, Transaction}
import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Transaction}
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw}
Expand All @@ -29,7 +29,7 @@ import fr.acinq.eclair.{BlockHeight, NodeParams}

import scala.concurrent.duration.{DurationInt, DurationLong}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Random
import scala.util.{Failure, Random, Success}

/**
* Created by t-bast on 10/06/2021.
Expand All @@ -50,6 +50,7 @@ object ReplaceableTxPublisher {

private case class WrappedPreconditionsResult(result: ReplaceableTxPrePublisher.PreconditionsResult) extends Command
private case object TimeLocksOk extends Command
private case class CheckUtxosResult(isSafe: Boolean, currentBlockHeight: BlockHeight) extends Command
private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command
private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command
private case class BumpFee(targetFeerate: FeeratePerKw) extends Command
Expand All @@ -73,20 +74,29 @@ object ReplaceableTxPublisher {
}
}

def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = {
def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight, hasEnoughSafeUtxos: Boolean): FeeratePerKw = {
val remainingBlocks = 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
case t if t >= 72 => 72
case t if t >= 36 => 36
// However, if we get closer to the target, we start being more aggressive
case t if t >= 18 => 12
case t if t >= 12 => 6
case t if t >= 2 => 2
case _ => 1
if (hasEnoughSafeUtxos) {
val blockTarget = remainingBlocks match {
// If our target is still very far in the future, no need to rush
case t if t >= 144 => 144
case t if t >= 72 => 72
case t if t >= 36 => 36
// However, if we get closer to the target, we start being more aggressive
case t if t >= 18 => 12
case t if t >= 12 => 6
case t if t >= 2 => 2
case _ => 1
}
feeEstimator.getFeeratePerKw(blockTarget)
} else {
// We don't have many safe utxos so we want the transaction to confirm quickly.
if (remainingBlocks <= 1) {
feeEstimator.getFeeratePerKw(1)
} else {
feeEstimator.getFeeratePerKw(2)
}
}
feeEstimator.getFeeratePerKw(blockTarget)
}

}
Expand Down Expand Up @@ -125,12 +135,12 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams,
def checkTimeLocks(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = {
txWithWitnessData match {
// There are no time locks on anchor transactions, we can claim them right away.
case _: ClaimLocalAnchorWithWitnessData => fund(txWithWitnessData)
case _: ClaimLocalAnchorWithWitnessData => chooseFeerate(txWithWitnessData)
case _ =>
val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, txPublishContext), "time-locks-monitor")
timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc)
Behaviors.receiveMessagePartial {
case TimeLocksOk => fund(txWithWitnessData)
case TimeLocksOk => chooseFeerate(txWithWitnessData)
case UpdateConfirmationTarget(target) =>
confirmBefore = target
Behaviors.same
Expand All @@ -139,8 +149,23 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams,
}
}

def fund(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = {
val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, nodeParams.currentBlockHeight)
def chooseFeerate(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = {
context.pipeToSelf(hasEnoughSafeUtxos(nodeParams.onChainFeeConf.feeTargets.safeUtxosThreshold)) {
case Success(isSafe) => CheckUtxosResult(isSafe, nodeParams.currentBlockHeight)
case Failure(_) => CheckUtxosResult(isSafe = false, nodeParams.currentBlockHeight) // if we can't check our utxos, we assume the worst
}
Behaviors.receiveMessagePartial {
case CheckUtxosResult(isSafe, currentBlockHeight) =>
val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, currentBlockHeight, isSafe)
fund(txWithWitnessData, targetFeerate)
case UpdateConfirmationTarget(target) =>
confirmBefore = target
Behaviors.same
case Stop => Behaviors.stopped
}
}

def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = {
val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder")
txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate)
Behaviors.receiveMessagePartial {
Expand Down Expand Up @@ -171,26 +196,32 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams,
case WrappedTxResult(txResult) =>
txResult match {
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 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))
} else if (tx.feerate * bumpRatio <= currentFeerate) {
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)
None
context.pipeToSelf(hasEnoughSafeUtxos(nodeParams.onChainFeeConf.feeTargets.safeUtxosThreshold)) {
case Success(isSafe) => CheckUtxosResult(isSafe, currentBlockHeight)
case Failure(_) => CheckUtxosResult(isSafe = false, currentBlockHeight) // if we can't check our utxos, we assume the worst
}
// We avoid a herd effect whenever we fee bump transactions.
targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis))
Behaviors.same
case MempoolTxMonitor.TxRecentlyConfirmed(_, _) => Behaviors.same // just wait for the tx to be deeply buried
case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx), None)
case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(txPublishContext.id, cmd, reason), Some(Seq(tx.signedTx)))
}
case CheckUtxosResult(isSafe, 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, isSafe)
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))
} else if (tx.feerate * bumpRatio <= currentFeerate) {
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)
None
}
// We avoid a herd effect whenever we fee bump transactions.
targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis))
Behaviors.same
case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx)
case UpdateConfirmationTarget(target) =>
confirmBefore = target
Expand Down Expand Up @@ -347,5 +378,10 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams,
}
}

/** If we don't have a lot of safe utxos left, we will use an aggressive feerate to ensure our utxos aren't locked for too long. */
private def hasEnoughSafeUtxos(threshold: Int): Future[Boolean] = {
bitcoinClient.listUnspent().map(_.count(utxo => utxo.safe && utxo.amount >= 10_000.sat) >= threshold)
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -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, 0),
feeEstimator = new TestFeeEstimator,
closeOnOfflineMismatch = true,
updateFeeMinDiffRatio = 0.1,
Expand Down Expand Up @@ -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, 0),
feeEstimator = new TestFeeEstimator,
closeOnOfflineMismatch = true,
updateFeeMinDiffRatio = 0.1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, 1), 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)))
Expand All @@ -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, 1), 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))
Expand All @@ -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, 1), 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, 1),
new TestFeeEstimator(),
closeOnOfflineMismatch = false,
1.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import akka.actor.typed.ActorRef
import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter}
import akka.pattern.pipe
import akka.testkit.{TestFSMRef, TestProbe}
import com.softwaremill.quicklens.ModifyPimp
import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Transaction, TxOut}
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
import fr.acinq.eclair.blockchain.CurrentBlockHeight
Expand All @@ -35,7 +36,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher._
import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags}
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32, randomKey}
import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, NodeParams, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuiteLike

Expand Down Expand Up @@ -66,8 +67,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
publisher: ActorRef[ReplaceableTxPublisher.Command],
probe: TestProbe) {

def createPublisher(): ActorRef[ReplaceableTxPublisher.Command] = {
system.spawnAnonymous(ReplaceableTxPublisher(alice.underlyingActor.nodeParams, wallet, alice2blockchain.ref, TxPublishContext(UUID.randomUUID(), randomKey().publicKey, None)))
def createPublisher(): ActorRef[ReplaceableTxPublisher.Command] = createPublisher(alice.underlyingActor.nodeParams)

def createPublisher(nodeParams: NodeParams): ActorRef[ReplaceableTxPublisher.Command] = {
system.spawnAnonymous(ReplaceableTxPublisher(nodeParams, wallet, alice2blockchain.ref, TxPublishContext(UUID.randomUUID(), randomKey().publicKey, None)))
}

def aliceBlockHeight(): BlockHeight = alice.underlyingActor.nodeParams.currentBlockHeight
Expand Down Expand Up @@ -1010,6 +1013,28 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
}
}

test("utxos count too low, setting short confirmation target") {
withFixture(Seq(15 millibtc, 10 millibtc, 5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f =>
import f._

val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 144)
// The HTLC confirmation target is far away, but we have less safe utxos than the configured threshold.
// We will target a 1-block confirmation to get a safe utxo back as soon as possible.
val highSafeThresholdParams = alice.underlyingActor.nodeParams.modify(_.onChainFeeConf.feeTargets.safeUtxosThreshold).setTo(10)
setFeerate(FeeratePerKw(2500 sat))
val targetFeerate = FeeratePerKw(5000 sat)
setFeerate(targetFeerate, blockTarget = 2)

val htlcSuccessPublisher = createPublisher(highSafeThresholdParams)
htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess)
val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed]
w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe), 0, commitTx)
val htlcSuccessTx = getMempoolTxs(1).head
val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx.weight.toInt)
assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee")
}
}

test("unlock utxos when htlc tx cannot be published") {
withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f =>
import f._
Expand Down