Skip to content

Commit

Permalink
Add upper bound on fees paid during force-close (#2668)
Browse files Browse the repository at this point in the history
The on-chain feerate can be arbitrarily high, but it wouldn't make sense
to pay more fees than the amount we have at risk and need to claim
on-chain.

We compute an upper bound on the fees we'll pay and make sure we don't
exceed it, even when trying to RBF htlc transactions that get close to
their deadline.
  • Loading branch information
t-bast committed May 25, 2023
1 parent aaad2e1 commit 835b33b
Show file tree
Hide file tree
Showing 3 changed files with 101 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.FullCommitment
import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._
import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.{NodeParams, NotificationsLogger}

Expand Down Expand Up @@ -72,7 +73,8 @@ object ReplaceableTxFunder {
Behaviors.setup { context =>
Behaviors.withMdc(txPublishContext.mdc()) {
Behaviors.receiveMessagePartial {
case FundTransaction(replyTo, cmd, tx, targetFeerate) =>
case FundTransaction(replyTo, cmd, tx, requestedFeerate) =>
val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment))
val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context)
tx match {
case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate)
Expand All @@ -88,6 +90,35 @@ object ReplaceableTxFunder {
addSigs(unsignedCommitTx, PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderSig, PlaceHolderSig)
}

/**
* The on-chain feerate can be arbitrarily high, but it wouldn't make sense to pay more fees than the amount we're
* trying to claim on-chain. We compute how much funds we have at risk and the feerate that matches this amount.
*/
def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment): FeeratePerKw = {
// We don't want to pay more in fees than the amount at risk in untrimmed pending HTLCs.
val maxFee = txInfo match {
case tx: HtlcTx => tx.input.txOut.amount
case tx: ClaimHtlcTx => tx.input.txOut.amount
case _: ClaimLocalAnchorOutputTx =>
val htlcBalance = commitment.localCommit.htlcTxsAndRemoteSigs.map(_.htlcTx.input.txOut.amount).sum
val mainBalance = commitment.localCommit.spec.toLocal.truncateToSatoshi
// If there are no HTLCs or a low HTLC amount, we may still want to get back our main balance.
// In that case, we spend at most 5% of our balance in fees.
htlcBalance.max(mainBalance * 5 / 100)
}
// We cannot know beforehand how many wallet inputs will be added, but an estimation should be good enough.
val weight = txInfo match {
// For HTLC transactions, we add a p2wpkh input and a p2wpkh change output.
case _: HtlcSuccessTx => commitment.params.commitmentFormat.htlcSuccessWeight + Transactions.claimP2WPKHOutputWeight
case _: HtlcTimeoutTx => commitment.params.commitmentFormat.htlcTimeoutWeight + Transactions.claimP2WPKHOutputWeight
case _: ClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight
case _: LegacyClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight
case _: ClaimHtlcTimeoutTx => Transactions.claimHtlcTimeoutWeight
case _: ClaimLocalAnchorOutputTx => dummySignedCommitTx(commitment).tx.weight() + Transactions.claimAnchorOutputMinWeight
}
Transactions.fee2rate(maxFee, weight)
}

/**
* Adjust the main output of a claim-htlc tx to match our target feerate.
* If the resulting output is too small, we skip the transaction.
Expand Down Expand Up @@ -203,6 +234,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
private val log = context.log

def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = {
log.info("funding {} tx (targetFeerate={})", txWithWitnessData.txInfo.desc, targetFeerate)
txWithWitnessData match {
case claimLocalAnchor: ClaimLocalAnchorWithWitnessData =>
val commitFeerate = cmd.commitment.localCommit.spec.commitTxFeerate
Expand Down Expand Up @@ -237,6 +269,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
}

private def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = {
log.info("bumping {} tx (targetFeerate={})", previousTx.signedTxWithWitnessData.txInfo.desc, targetFeerate)
adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitment) match {
case AdjustPreviousTxOutputResult.Skip(reason) =>
log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate)
Expand All @@ -260,7 +293,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
}
Behaviors.receiveMessagePartial {
case AddInputsOk(fundedTx, totalAmountIn) =>
log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc)
log.debug("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc)
sign(fundedTx, targetFeerate, totalAmountIn)
case AddInputsFailed(reason) =>
if (reason.getMessage.contains("Insufficient funds")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsT
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions._
import fr.acinq.eclair.wire.protocol.{CommitSig, RevokeAndAck}
import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, NodeParams, NotificationsLogger, TestConstants, TestFeeEstimator, TestKitBaseClass, randomKey}
import fr.acinq.eclair.{BlockHeight, MilliSatoshi, MilliSatoshiLong, NodeParams, NotificationsLogger, TestConstants, TestFeeEstimator, TestKitBaseClass, randomKey}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuiteLike

Expand Down Expand Up @@ -380,6 +380,37 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
}
}

test("commit tx feerate too low, spending anchor output (feerate upper bound reached)") {
withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f =>
import f._

val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30)
wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref)
probe.expectMsg(commitTx.tx.txid)
assert(getMempool().length == 1)

val maxFeerate = ReplaceableTxFunder.maxFeerate(anchorTx.txInfo, anchorTx.commitment)
val targetFeerate = FeeratePerKw(50_000 sat)
assert(maxFeerate <= targetFeerate / 2)
setFeerate(targetFeerate, blockTarget = 12)
publisher ! Publish(probe.ref, anchorTx)
// wait for the commit tx and anchor tx to be published
val mempoolTxs = getMempoolTxs(2)
assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid))

val targetFee = Transactions.weight2fee(maxFeerate, mempoolTxs.map(_.weight).sum.toInt)
val actualFee = mempoolTxs.map(_.fees).sum
assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee")

generateBlocks(5)
system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe)))
val result = probe.expectMsgType[TxConfirmed]
assert(result.cmd == anchorTx)
assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid))
assert(mempoolTxs.map(_.txid).contains(result.tx.txid))
}
}

test("commit tx not published, publishing it and spending anchor output") {
withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f =>
import f._
Expand Down Expand Up @@ -517,7 +548,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
system.eventStream.subscribe(listener.ref, classOf[TransactionPublished])

// The feerate is (much) higher for higher block targets
val targetFeerate = FeeratePerKw(75_000 sat)
val targetFeerate = FeeratePerKw(20_000 sat)
setFeerate(FeeratePerKw(3000 sat))
setFeerate(targetFeerate, blockTarget = 6)
publisher ! Publish(probe.ref, anchorTx)
Expand Down Expand Up @@ -801,13 +832,13 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
}
}

def closeChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = {
def closeChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight, outgoingHtlcAmount: MilliSatoshi = 120_000_000 msat, incomingHtlcAmount: MilliSatoshi = 100_000_000 msat): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = {
import f._

// Add htlcs in both directions and ensure that preimages are available.
addHtlc(5_000_000 msat, alice, bob, alice2bob, bob2alice)
addHtlc(outgoingHtlcAmount, alice, bob, alice2bob, bob2alice)
crossSign(alice, bob, alice2bob, bob2alice)
val (r, htlc) = addHtlc(4_000_000 msat, bob, alice, bob2alice, alice2bob)
val (r, htlc) = addHtlc(incomingHtlcAmount, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
probe.send(alice, CMD_FULFILL_HTLC(htlc.id, r, replyTo_opt = Some(probe.ref)))
probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]]
Expand Down Expand Up @@ -871,6 +902,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
val htlcSuccessTx = getMempoolTxs(1).head
val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx.weight.toInt)
assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.2, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee")
assert(htlcSuccessTx.fees <= htlcSuccess.txInfo.input.txOut.amount)

generateBlocks(4)
system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe)))
Expand Down Expand Up @@ -900,6 +932,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
val htlcTimeoutTx = getMempoolTxs(1).head
val htlcTimeoutTargetFee = Transactions.weight2fee(targetFeerate, htlcTimeoutTx.weight.toInt)
assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.2, s"actualFee=${htlcTimeoutTx.fees} targetFee=$htlcTimeoutTargetFee")
assert(htlcTimeoutTx.fees <= htlcTimeout.txInfo.input.txOut.amount)

generateBlocks(4)
system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe)))
Expand Down Expand Up @@ -975,6 +1008,27 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w
}
}

test("htlc tx feerate zero, adding wallet inputs (feerate upper bound reached)") {
withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f =>
import f._

val targetFeerate = FeeratePerKw(15_000 sat)
// HTLC amount is small, so we should cap the feerate to avoid paying more in fees than what we're claiming.
val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30, outgoingHtlcAmount = 5_000_000 msat, incomingHtlcAmount = 4_000_000 msat)
setFeerate(targetFeerate, blockTarget = 12)
assert(htlcSuccess.txInfo.fee == 0.sat)
val htlcSuccessMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcSuccess.txInfo, htlcSuccess.commitment)
assert(htlcSuccessMaxFeerate < targetFeerate / 2)
val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, htlcSuccessMaxFeerate)
assert(htlcSuccessTx.txIn.length > 1)
assert(htlcTimeout.txInfo.fee == 0.sat)
val htlcTimeoutMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcTimeout.txInfo, htlcTimeout.commitment)
assert(htlcTimeoutMaxFeerate < targetFeerate / 2)
val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, htlcTimeoutMaxFeerate)
assert(htlcTimeoutTx.txIn.length > 1)
}
}

test("htlc tx feerate too low, adding multiple wallet inputs") {
val utxos = Seq(
// channel funding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
// ClaimP2WPKHOutputTx
// first we create a fake commitTx tx, containing only the output that will be spent by the ClaimP2WPKHOutputTx
val pubKeyScript = write(pay2wpkh(localPaymentPriv.publicKey))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val Right(claimP2WPKHOutputTx) = makeClaimP2WPKHOutputTx(commitTx, localDustLimit, localPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(claimP2WPKHOutputTx, localPaymentPriv.publicKey, PlaceHolderSig).tx)
Expand All @@ -131,7 +131,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
// HtlcDelayedTx
// first we create a fake htlcSuccessOrTimeoutTx tx, containing only the output that will be spent by the 3rd-stage tx
val pubKeyScript = write(pay2wsh(toLocalDelayed(localRevocationPriv.publicKey, toLocalDelay, localPaymentPriv.publicKey)))
val htlcSuccessOrTimeoutTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val htlcSuccessOrTimeoutTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val Right(htlcDelayedTx) = makeHtlcDelayedTx(htlcSuccessOrTimeoutTx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localPaymentPriv.publicKey, finalPubKeyScript, feeratePerKw)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(htlcDelayedTx, PlaceHolderSig).tx)
Expand All @@ -142,7 +142,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
// MainPenaltyTx
// first we create a fake commitTx tx, containing only the output that will be spent by the MainPenaltyTx
val pubKeyScript = write(pay2wsh(toLocalDelayed(localRevocationPriv.publicKey, toLocalDelay, localPaymentPriv.publicKey)))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(20000 sat, pubKeyScript) :: Nil, lockTime = 0)
val Right(mainPenaltyTx) = makeMainPenaltyTx(commitTx, localDustLimit, localRevocationPriv.publicKey, finalPubKeyScript, toLocalDelay, localPaymentPriv.publicKey, feeratePerKw)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(mainPenaltyTx, PlaceHolderSig).tx)
Expand All @@ -156,7 +156,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
val htlc = UpdateAddHtlc(ByteVector32.Zeroes, 0, (20000 * 1000) msat, sha256(paymentPreimage), CltvExpiryDelta(144).toCltvExpiry(blockHeight), TestConstants.emptyOnionPacket, None)
val redeemScript = htlcReceived(localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localRevocationPriv.publicKey, ripemd160(htlc.paymentHash), htlc.cltvExpiry, DefaultCommitmentFormat)
val pubKeyScript = write(pay2wsh(redeemScript))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val Right(htlcPenaltyTx) = makeHtlcPenaltyTx(commitTx, 0, Script.write(redeemScript), localDustLimit, finalPubKeyScript, feeratePerKw)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(htlcPenaltyTx, PlaceHolderSig, localRevocationPriv.publicKey).tx)
Expand All @@ -171,7 +171,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
val spec = CommitmentSpec(Set(OutgoingHtlc(htlc)), feeratePerKw, toLocal = 0 msat, toRemote = 0 msat)
val outputs = makeCommitTxOutputs(localIsInitiator = true, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, remotePaymentPriv.publicKey, localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localFundingPriv.publicKey, remoteFundingPriv.publicKey, spec, DefaultCommitmentFormat)
val pubKeyScript = write(pay2wsh(htlcOffered(localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localRevocationPriv.publicKey, ripemd160(htlc.paymentHash), DefaultCommitmentFormat)))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val Right(claimHtlcSuccessTx) = makeClaimHtlcSuccessTx(commitTx, outputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc, feeratePerKw, DefaultCommitmentFormat)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(claimHtlcSuccessTx, PlaceHolderSig, paymentPreimage).tx)
Expand All @@ -186,7 +186,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
val spec = CommitmentSpec(Set(IncomingHtlc(htlc)), feeratePerKw, toLocal = 0 msat, toRemote = 0 msat)
val outputs = makeCommitTxOutputs(localIsInitiator = true, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, remotePaymentPriv.publicKey, localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localFundingPriv.publicKey, remoteFundingPriv.publicKey, spec, DefaultCommitmentFormat)
val pubKeyScript = write(pay2wsh(htlcReceived(localHtlcPriv.publicKey, remoteHtlcPriv.publicKey, localRevocationPriv.publicKey, ripemd160(htlc.paymentHash), htlc.cltvExpiry, DefaultCommitmentFormat)))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(htlc.amountMsat.truncateToSatoshi, pubKeyScript) :: Nil, lockTime = 0)
val Right(claimClaimHtlcTimeoutTx) = makeClaimHtlcTimeoutTx(commitTx, outputs, localDustLimit, remoteHtlcPriv.publicKey, localHtlcPriv.publicKey, localRevocationPriv.publicKey, finalPubKeyScript, htlc, feeratePerKw, DefaultCommitmentFormat)
// we use dummy signatures to compute the weight
val weight = Transaction.weight(addSigs(claimClaimHtlcTimeoutTx, PlaceHolderSig).tx)
Expand All @@ -197,7 +197,7 @@ class TransactionsSpec extends AnyFunSuite with Logging {
// ClaimAnchorOutputTx
// first we create a fake commitTx tx, containing only the output that will be spent by the ClaimAnchorOutputTx
val pubKeyScript = write(pay2wsh(anchor(localFundingPriv.publicKey)))
val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(anchorAmount, pubKeyScript) :: Nil, lockTime = 0)
val commitTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(anchorAmount, pubKeyScript) :: Nil, lockTime = 0)
val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx, localFundingPriv.publicKey, BlockHeight(1105))
assert(claimAnchorOutputTx.tx.txOut.isEmpty)
assert(claimAnchorOutputTx.confirmBefore == BlockHeight(1105))
Expand Down

0 comments on commit 835b33b

Please sign in to comment.