Skip to content

Commit 3d415bc

Browse files
authored
Use package relay for anchor force-close (#2963)
We use bitcoin core's `submitpackage` RPC to publish commit txs with their anchor transaction. We do this for both local and remote commit txs, and prefer the remote commit when it is available. Thanks to package relay, this allows the commit and anchor package to propagate even if the commit feerate is below the mempool minimum feerate, as long as enough nodes run bitcoin core v28 or higher.
1 parent 9ea9e97 commit 3d415bc

File tree

13 files changed

+200
-87
lines changed

13 files changed

+200
-87
lines changed

docs/release-notes/eclair-vnext.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
<insert changes>
88

9+
### Package relay
10+
11+
With Bitcoin Core 28.1, eclair starts relying on the `submitpackage` RPC during channel force-close.
12+
When using anchor outputs, allows propagating our local commitment transaction to peers who are also running Bitcoin Core 28.x or newer, even if the commitment feerate is low (package relay).
13+
14+
This removes the need for increasing the commitment feerate based on mempool conditions, which ensures that channels won't be force-closed anymore when nodes disagree on the current feerate.
15+
916
### API changes
1017

1118
- `listoffers` now returns more details about each offer.

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,25 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
498498
getRawTransaction(tx.txid).map(_ => tx.txid).recoverWith { case _ => Future.failed(e) }
499499
}
500500

501+
/**
502+
* Publish a 1-parent-1-child transaction package, which allows replacing a conflicting parent transaction that has
503+
* the same (or a higher) feerate by leveraging CPFP. The child transaction cannot have other unconfirmed parents.
504+
*/
505+
def publishPackage(parentTx: Transaction, childTx: Transaction)(implicit ec: ExecutionContext): Future[TxId] = {
506+
rpcClient.invoke("submitpackage", Seq(parentTx, childTx).map(_.toString())).flatMap(json => {
507+
val JString(msg) = json \ "package_msg"
508+
if (msg == "success") {
509+
// All transactions were accepted into or are already in the mempool.
510+
Future.successful(childTx.txid)
511+
} else {
512+
val childError = (json \ "tx-results" \ childTx.wtxid.toHex \ "error").extractOpt[String]
513+
val parentError = (json \ "tx-results" \ parentTx.wtxid.toHex \ "error").extractOpt[String]
514+
val error = childError.orElse(parentError).getOrElse("unknown failure")
515+
Future.failed(new IllegalArgumentException(error))
516+
}
517+
})
518+
}
519+
501520
override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = {
502521
rpcClient.invoke("abandontransaction", txId).map(_ => true).recover(_ => false)
503522
}

eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,7 @@ object Helpers {
933933

934934
// We collect all the preimages we wanted to reveal to our peer.
935935
val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap
936-
// We collect incoming HTLCs that we starting failing but didn't cross-sign.
936+
// We collect incoming HTLCs that we started failing but didn't cross-sign.
937937
val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect {
938938
case u: UpdateFailHtlc => u.id
939939
case u: UpdateFailMalformedHtlc => u.id
@@ -1107,7 +1107,7 @@ object Helpers {
11071107

11081108
// We collect all the preimages we wanted to reveal to our peer.
11091109
val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap
1110-
// We collect incoming HTLCs that we starting failing but didn't cross-sign.
1110+
// We collect incoming HTLCs that we started failing but didn't cross-sign.
11111111
val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect {
11121112
case u: UpdateFailHtlc => u.id
11131113
case u: UpdateFailMalformedHtlc => u.id

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2002,7 +2002,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20022002
} else {
20032003
// Our counterparty is trying to broadcast a revoked commit tx (cheating attempt).
20042004
// We need to fail pending outgoing HTLCs, otherwise they will timeout upstream.
2005-
// We must do it here because since we're overwriting the commitments data, we will lose all information
2005+
// We must do it here because we're overwriting the commitments data, so we will lose all information
20062006
// about HTLCs that are in the current commitments but were not in the revoked one.
20072007
// We fail *all* outgoing HTLCs:
20082008
// - those that are not in the revoked commitment will never settle on-chain

eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ private class FinalTxPublisher(nodeParams: NodeParams,
113113

114114
def publish(): Behavior[Command] = {
115115
val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), "mempool-tx-monitor")
116-
txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, cmd.fee)
116+
txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, None, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, cmd.fee)
117117
Behaviors.receiveMessagePartial {
118118
case WrappedTxResult(txResult) =>
119119
txResult match {

eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ object MempoolTxMonitor {
3838

3939
// @formatter:off
4040
sealed trait Command
41-
case class Publish(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint, minDepth: Int, desc: String, fee: Satoshi) extends Command
41+
case class Publish(replyTo: ActorRef[TxResult], tx: Transaction, parentTx_opt: Option[Transaction], input: OutPoint, minDepth: Int, desc: String, fee: Satoshi) extends Command
4242
private case object PublishOk extends Command
4343
private case class PublishFailed(reason: Throwable) extends Command
4444
private case class InputStatus(spentConfirmed: Boolean, spentUnconfirmed: Boolean) extends Command
@@ -95,7 +95,10 @@ private class MempoolTxMonitor(nodeParams: NodeParams,
9595
private val log = context.log
9696

9797
def publish(): Behavior[Command] = {
98-
context.pipeToSelf(bitcoinClient.publishTransaction(cmd.tx)) {
98+
context.pipeToSelf(cmd.parentTx_opt match {
99+
case Some(parentTx) => bitcoinClient.publishPackage(parentTx, cmd.tx)
100+
case None => bitcoinClient.publishTransaction(cmd.tx)
101+
}) {
99102
case Success(_) => PublishOk
100103
case Failure(reason) => PublishFailed(reason)
101104
}

eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import akka.actor.typed.eventstream.EventStream
2020
import akka.actor.typed.scaladsl.{ActorContext, Behaviors}
2121
import akka.actor.typed.{ActorRef, Behavior}
2222
import fr.acinq.bitcoin.psbt.Psbt
23-
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Script, Transaction, TxOut}
23+
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction, TxOut}
2424
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
2525
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
2626
import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw, OnChainFeeConf}
@@ -74,7 +74,7 @@ object ReplaceableTxFunder {
7474
Behaviors.withMdc(txPublishContext.mdc()) {
7575
Behaviors.receiveMessagePartial {
7676
case FundTransaction(replyTo, cmd, tx, requestedFeerate) =>
77-
val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf))
77+
val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment, cmd.commitTx, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf))
7878
val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context)
7979
tx match {
8080
case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate)
@@ -85,17 +85,11 @@ object ReplaceableTxFunder {
8585
}
8686
}
8787

88-
private def commitWeight(commitment: FullCommitment): Int = {
89-
val unsignedCommitTx = commitment.localCommit.commitTxAndRemoteSig.commitTx
90-
val dummySignedCommitTx = addSigs(unsignedCommitTx, PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderSig, PlaceHolderSig)
91-
dummySignedCommitTx.tx.weight()
92-
}
93-
9488
/**
9589
* The on-chain feerate can be arbitrarily high, but it wouldn't make sense to pay more fees than the amount we're
9690
* trying to claim on-chain. We compute how much funds we have at risk and the feerate that matches this amount.
9791
*/
98-
def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = {
92+
def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, commitTx: Transaction, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = {
9993
// We don't want to pay more in fees than the amount at risk in untrimmed pending HTLCs.
10094
val maxFee = txInfo match {
10195
case tx: HtlcTx => tx.input.txOut.amount
@@ -116,7 +110,7 @@ object ReplaceableTxFunder {
116110
case _: ClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight
117111
case _: LegacyClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight
118112
case _: ClaimHtlcTimeoutTx => Transactions.claimHtlcTimeoutWeight
119-
case _: ClaimLocalAnchorOutputTx => commitWeight(commitment) + Transactions.claimAnchorOutputMinWeight
113+
case _: ClaimLocalAnchorOutputTx => commitTx.weight() + Transactions.claimAnchorOutputMinWeight
120114
}
121115
// It doesn't make sense to use a feerate that is much higher than the current feerate for inclusion into the next block.
122116
Transactions.fee2rate(maxFee, weight).min(currentFeerates.fastest * 1.25)
@@ -161,12 +155,12 @@ object ReplaceableTxFunder {
161155
* Adjust the outputs of a transaction that was previously published at a lower feerate.
162156
* If the current set of inputs doesn't let us to reach the target feerate, we should request new wallet inputs from bitcoind.
163157
*/
164-
def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw, commitment: FullCommitment): AdjustPreviousTxOutputResult = {
158+
def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw, commitment: FullCommitment, commitTx: Transaction): AdjustPreviousTxOutputResult = {
165159
val dustLimit = commitment.localParams.dustLimit
166160
val targetFee = previousTx.signedTxWithWitnessData match {
167161
case _: ClaimLocalAnchorWithWitnessData =>
168162
val commitFee = commitment.localCommit.commitTxAndRemoteSig.commitTx.fee
169-
val totalWeight = previousTx.signedTx.weight() + commitWeight(commitment)
163+
val totalWeight = previousTx.signedTx.weight() + commitTx.weight()
170164
weight2fee(targetFeerate, totalWeight) - commitFee
171165
case _ =>
172166
weight2fee(targetFeerate, previousTx.signedTx.weight())
@@ -273,7 +267,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
273267

274268
private def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = {
275269
log.info("bumping {} tx (targetFeerate={})", previousTx.signedTxWithWitnessData.txInfo.desc, targetFeerate)
276-
adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitment) match {
270+
adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitment, cmd.commitTx) match {
277271
case AdjustPreviousTxOutputResult.Skip(reason) =>
278272
log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate)
279273
replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))
@@ -392,7 +386,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
392386
case Right(signedTx) =>
393387
val actualFees = kmp2scala(processPsbtResponse.psbt.computeFees())
394388
val actualWeight = locallySignedTx match {
395-
case _: ClaimLocalAnchorWithWitnessData => signedTx.weight() + commitWeight(cmd.commitment)
389+
case _: ClaimLocalAnchorWithWitnessData => signedTx.weight() + cmd.commitTx.weight()
396390
case _ => signedTx.weight()
397391
}
398392
val actualFeerate = Transactions.fee2rate(actualFees, actualWeight)
@@ -439,30 +433,34 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
439433
}
440434

441435
private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = {
442-
val dustLimit = commitment.localParams.dustLimit
443-
// NB: fundrawtransaction requires at least one output, and may add at most one additional change output.
444-
// Since the purpose of this transaction is just to do a CPFP, the resulting tx should have a single change output
445-
// (note that bitcoind doesn't let us publish a transaction with no outputs). To work around these limitations, we
446-
// start with a dummy output and later merge that dummy output with the optional change output added by bitcoind.
447-
val txNotFunded = anchorTx.txInfo.tx.copy(txOut = TxOut(dustLimit, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil)
448-
val anchorWeight = Map(anchorTx.txInfo.input.outPoint -> anchorInputWeight.toLong)
449-
// We only use confirmed inputs for anchor transactions to be able to leverage 1-parent-1-child package relay.
450-
bitcoinClient.fundTransaction(txNotFunded, targetFeerate, externalInputsWeight = anchorWeight, minInputConfirmations_opt = Some(1)).flatMap { fundTxResponse =>
451-
// Bitcoin Core may not preserve the order of inputs, we need to make sure the anchor is the first input.
452-
val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == anchorTx.txInfo.input.outPoint)
453-
// We merge our dummy change output with the one added by Bitcoin Core, if any.
454-
fundTxResponse.changePosition match {
455-
case Some(changePos) =>
456-
val changeOutput = fundTxResponse.tx.txOut(changePos).copy(amount = fundTxResponse.tx.txOut.map(_.amount).sum)
457-
val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(changeOutput))
458-
Future.successful(anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn)
459-
case None =>
460-
bitcoinClient.getChangePublicKeyScript().map(changeScript => {
461-
// We must have a change output, otherwise the transaction is invalid: we replace the PlaceHolderPubKey with a real wallet key.
462-
val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(TxOut(dustLimit, changeScript)))
463-
(anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn)
464-
})
436+
// We want to pay the commit fees using CPFP. Since the commit tx may not be in the mempool yet (its feerate may be
437+
// below the minimum acceptable mempool feerate), we cannot ask bitcoind to fund a transaction that spends that
438+
// commit tx: it would fail because it cannot find the input in the utxo set. So we instead ask bitcoind to fund an
439+
// empty transaction that pays the fees we must add to the transaction package, and we then add the input spending
440+
// the commit tx and adjust the change output.
441+
val expectedCommitFee = Transactions.weight2fee(targetFeerate, cmd.commitTx.weight())
442+
val actualCommitFee = commitment.commitInput.txOut.amount - cmd.commitTx.txOut.map(_.amount).sum
443+
val anchorInputFee = Transactions.weight2fee(targetFeerate, anchorInputWeight)
444+
val missingFee = expectedCommitFee - actualCommitFee + anchorInputFee
445+
for {
446+
changeScript <- bitcoinClient.getChangePublicKeyScript()
447+
txNotFunded = Transaction(2, Nil, TxOut(commitment.localParams.dustLimit + missingFee, changeScript) :: Nil, 0)
448+
// We only use confirmed inputs for anchor transactions to be able to leverage 1-parent-1-child package relay.
449+
fundTxResponse <- bitcoinClient.fundTransaction(txNotFunded, targetFeerate, minInputConfirmations_opt = Some(1))
450+
} yield {
451+
// We merge our dummy change output with the one added by Bitcoin Core, if any, and adjust the change amount to
452+
// pay the expected package feerate.
453+
val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn
454+
val packageWeight = cmd.commitTx.weight() + anchorInputWeight + fundTxResponse.tx.weight()
455+
val expectedFee = Transactions.weight2fee(targetFeerate, packageWeight)
456+
val currentFee = actualCommitFee + fundTxResponse.fee
457+
val changeAmount = (fundTxResponse.tx.txOut.map(_.amount).sum - expectedFee + currentFee).max(commitment.localParams.dustLimit)
458+
val changeOutput = fundTxResponse.changePosition match {
459+
case Some(changePos) => fundTxResponse.tx.txOut(changePos).copy(amount = changeAmount)
460+
case None => TxOut(changeAmount, changeScript)
465461
}
462+
val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(changeOutput))
463+
(anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn)
466464
}
467465
}
468466

eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams,
130130
// We verify that:
131131
// - our commit is not confirmed (if it is, no need to claim our anchor)
132132
// - their commit is not confirmed (if it is, no need to claim our anchor either)
133-
// - the local or remote commit tx is in the mempool (otherwise we can't claim our anchor)
134133
val fundingOutpoint = cmd.commitment.commitInput.outPoint
135134
context.pipeToSelf(bitcoinClient.getTxConfirmations(fundingOutpoint.txid).flatMap {
136135
case Some(_) =>
@@ -143,13 +142,12 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams,
143142
// have any CSV delays and don't need 2nd-stage HTLC transactions).
144143
getRemoteCommitConfirmations(cmd.commitment).flatMap {
145144
case Some(_) => Future.failed(RemoteCommitTxPublished)
146-
// Otherwise, we must ensure our local commit tx is in the mempool before publishing the anchor transaction.
147-
// If it's already published, this call will be a no-op.
148-
case None => bitcoinClient.publishTransaction(cmd.commitTx)
145+
// We're trying to bump the local commit tx: no need to do anything, we will publish it alongside the anchor transaction.
146+
case None => Future.successful(cmd.commitTx.txid)
149147
}
150148
case true =>
151-
// We're trying to bump a remote commitment: we must make sure it is in our mempool first.
152-
bitcoinClient.publishTransaction(cmd.commitTx)
149+
// We're trying to bump a remote commitment: no need to do anything, we will publish it alongside the anchor transaction.
150+
Future.successful(cmd.commitTx.txid)
153151
}
154152
case None =>
155153
// If the funding transaction cannot be found (e.g. when using 0-conf), we should retry later.

0 commit comments

Comments
 (0)