Skip to content

Commit ca06f72

Browse files
committed
Handle force-closing zero-fee channels
We add support for force-close zero-fee channels. When publishing the local commit, this works mostly the same way as other channel types. The only difference is that we don't even attempt to publish the commit tx individually: we always bundle it with the anchor transaction. When we detect the remote commit though, we're able to introduce some new behavior: - if we have a large enough main output, we use that to pay the fees of the remote commit tx (unless it is already confirmed), which avoids using a wallet input - otherwise, we spend the anchor output, which competes with the remote peer package Since we're only using the anchor transaction or our main output to spend the ephemeral anchor, we cannot publish HTLC txs until the commit tx is confirmed. We will in the future make *all* transactions go through the `ReplaceableTxPublisher`, and at the point we'll be able to simplify this, but it's too early for this refactoring, so for now we simply wait for the commit tx to be confirmed before publishing HTLC txs.
1 parent 5b128a4 commit ca06f72

File tree

13 files changed

+637
-103
lines changed

13 files changed

+637
-103
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,7 @@ object Helpers {
11771177
case class SecondStageTransactions(mainTx_opt: Option[ClaimRemoteMainOutputTx], anchorTx_opt: Option[ClaimRemoteAnchorTx], htlcTxs: Seq[ClaimHtlcTx])
11781178

11791179
/** Claim all the outputs that belong to us in the remote commitment transaction (which can be either their current or next commitment). */
1180-
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, closingFeerate: FeeratePerKw, finalScriptPubKey: ByteVector, spendAnchorWithoutHtlcs: Boolean)(implicit log: LoggingAdapter): (RemoteCommitPublished, SecondStageTransactions) = {
1180+
def claimCommitTxOutputs(channelKeys: ChannelKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, commitTx: Transaction, closingFeerate: FeeratePerKw, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector, spendAnchorWithoutHtlcs: Boolean)(implicit log: LoggingAdapter): (RemoteCommitPublished, SecondStageTransactions) = {
11811181
require(remoteCommit.txId == commitTx.txid, "txid mismatch, provided tx is not the current remote commit tx")
11821182
val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
11831183
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
@@ -1186,8 +1186,16 @@ object Helpers {
11861186
val (incomingHtlcs, htlcSuccessTxs) = claimIncomingHtlcOutputs(commitKeys, commitTx, outputs, commitment, remoteCommit, finalScriptPubKey)
11871187
val (outgoingHtlcs, htlcTimeoutTxs) = claimOutgoingHtlcOutputs(commitKeys, commitTx, outputs, commitment, remoteCommit, finalScriptPubKey)
11881188
val anchorOutput_opt = ClaimRemoteAnchorTx.findInput(commitTx, fundingKey, commitKeys, commitment.commitmentFormat).toOption
1189+
// When using v3 transactions, we can use our main output to pay commit fees if it's large enough.
1190+
// In that case, we don't need to create a dedicated anchor transaction which avoids using wallet inputs.
1191+
val useMainTxForAnchor = commitment.commitmentFormat match {
1192+
case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => false
1193+
case ZeroFeeCommitmentFormat =>
1194+
val commitFee = Transactions.weight2fee(feerates.fastest, commitTx.weight())
1195+
mainTx_opt.exists(_.tx.txOut.map(_.amount).sum > commitFee)
1196+
}
11891197
val spendAnchor = incomingHtlcs.nonEmpty || outgoingHtlcs.nonEmpty || spendAnchorWithoutHtlcs
1190-
val anchorTx_opt = if (spendAnchor) {
1198+
val anchorTx_opt = if (spendAnchor && !useMainTxForAnchor) {
11911199
claimAnchor(fundingKey, commitKeys, commitTx, commitment.commitmentFormat)
11921200
} else {
11931201
None

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
405405
val thirdStageTransactions = Closing.LocalClose.claimHtlcDelayedOutputs(c.localCommitPublished, channelKeys, commitment, closingFeerate, closing.finalScriptPubKey)
406406
doPublish(c.localCommitPublished, thirdStageTransactions)
407407
case Some(c: Closing.RemoteClose) =>
408-
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, c.remoteCommit, c.remoteCommitPublished.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
408+
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, c.remoteCommit, c.remoteCommitPublished.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
409409
doPublish(c.remoteCommitPublished, secondStageTransactions, commitment)
410410
case Some(c: Closing.RecoveryClose) =>
411411
// We cannot do anything in that case: we've already published our recovery transaction before restarting,
@@ -434,12 +434,12 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
434434
doPublish(lcp, secondStageTransactions, commitment)
435435
})
436436
closing.remoteCommitPublished.foreach(rcp => {
437-
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, commitment.remoteCommit, rcp.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
437+
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, commitment.remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
438438
doPublish(rcp, secondStageTransactions, commitment)
439439
})
440440
closing.nextRemoteCommitPublished.foreach(rcp => {
441441
val remoteCommit = commitment.nextRemoteCommit_opt.get
442-
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, rcp.commitTx, closingFeerate, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
442+
val (_, secondStageTransactions) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, rcp.commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, closing.finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
443443
doPublish(rcp, secondStageTransactions, commitment)
444444
})
445445
closing.revokedCommitPublished.foreach(rvk => {

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,10 @@ trait ErrorHandlers extends CommonHandlers {
232232

233233
/** Publish 2nd-stage transactions for our local commitment. */
234234
def doPublish(lcp: LocalCommitPublished, txs: Closing.LocalClose.SecondStageTransactions, commitment: FullCommitment): Unit = {
235-
val publishCommitTx = PublishFinalTx(lcp.commitTx, commitment.fundingInput, "commit-tx", Closing.commitTxFee(commitment.commitInput(channelKeys), lcp.commitTx, commitment.localChannelParams.paysCommitTxFees), None)
235+
val publishCommitTx_opt = commitment.commitmentFormat match {
236+
case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => Some(PublishFinalTx(lcp.commitTx, commitment.fundingInput, "commit-tx", Closing.commitTxFee(commitment.commitInput(channelKeys), lcp.commitTx, commitment.localChannelParams.paysCommitTxFees), None))
237+
case ZeroFeeCommitmentFormat => None // we will publish the commit-tx alongside the anchor tx
238+
}
236239
val publishAnchorTx_opt = txs.anchorTx_opt match {
237240
case Some(anchorTx) if !lcp.isConfirmed =>
238241
val confirmationTarget = Closing.confirmationTarget(commitment.localCommit, commitment.localCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf)
@@ -241,7 +244,7 @@ trait ErrorHandlers extends CommonHandlers {
241244
}
242245
val publishMainDelayedTx_opt = txs.mainDelayedTx_opt.map(tx => PublishFinalTx(tx, None))
243246
val publishHtlcTxs = txs.htlcTxs.map(htlcTx => PublishReplaceableTx(htlcTx, lcp.commitTx, commitment, Closing.confirmationTarget(htlcTx)))
244-
val publishQueue = Seq(publishCommitTx) ++ publishAnchorTx_opt ++ publishMainDelayedTx_opt ++ publishHtlcTxs
247+
val publishQueue = publishCommitTx_opt.toSeq ++ publishAnchorTx_opt ++ publishMainDelayedTx_opt ++ publishHtlcTxs
245248
publishIfNeeded(publishQueue, lcp.irrevocablySpent)
246249

247250
if (!lcp.isConfirmed) {
@@ -275,7 +278,7 @@ trait ErrorHandlers extends CommonHandlers {
275278
case _ => nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, maxClosingFeerateOverride_opt = None)
276279
}
277280
context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(commitments.commitInput(channelKeys), commitTx, d.commitments.localChannelParams.paysCommitTxFees), "remote-commit"))
278-
val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitments, commitments.remoteCommit, commitTx, closingFeerate, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
281+
val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitments, commitments.remoteCommit, commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
279282
val nextData = d match {
280283
case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished))
281284
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished))
@@ -297,7 +300,7 @@ trait ErrorHandlers extends CommonHandlers {
297300
case _ => nodeParams.onChainFeeConf.getClosingFeerate(nodeParams.currentBitcoinCoreFeerates, maxClosingFeerateOverride_opt = None)
298301
}
299302
context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(commitment.commitInput(channelKeys), commitTx, d.commitments.localChannelParams.paysCommitTxFees), "next-remote-commit"))
300-
val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, commitTx, closingFeerate, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
303+
val (remoteCommitPublished, closingTxs) = Closing.RemoteClose.claimCommitTxOutputs(channelKeys, commitment, remoteCommit, commitTx, closingFeerate, nodeParams.currentBitcoinCoreFeerates, finalScriptPubKey, nodeParams.onChainFeeConf.spendAnchorWithoutHtlcs)
301304
val nextData = d match {
302305
case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished))
303306
case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, waitingSince = nodeParams.currentBlockHeight, finalScriptPubKey = finalScriptPubKey, mutualCloseProposed = negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished))
@@ -314,13 +317,19 @@ trait ErrorHandlers extends CommonHandlers {
314317
case Some(commit) if rcp.commitTx.txid == commit.txId => commit
315318
case _ => commitment.remoteCommit
316319
}
320+
val confirmationTarget = Closing.confirmationTarget(remoteCommit, commitment.remoteCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf)
317321
val publishAnchorTx_opt = txs.anchorTx_opt match {
318-
case Some(anchorTx) if !rcp.isConfirmed =>
319-
val confirmationTarget = Closing.confirmationTarget(remoteCommit, commitment.remoteCommitParams.dustLimit, commitment.commitmentFormat, nodeParams.onChainFeeConf)
320-
Some(PublishReplaceableTx(anchorTx, rcp.commitTx, commitment, confirmationTarget))
322+
case Some(anchorTx) if !rcp.isConfirmed => Some(PublishReplaceableTx(anchorTx, rcp.commitTx, commitment, confirmationTarget))
321323
case _ => None
322324
}
323-
val publishMainTx_opt = txs.mainTx_opt.map(tx => PublishFinalTx(tx, None))
325+
val publishMainTx_opt = txs.mainTx_opt.map(tx => commitment.commitmentFormat match {
326+
case ZeroFeeCommitmentFormat => publishAnchorTx_opt match {
327+
// Instead of creating a dedicated anchor transaction, we use our main output to pay commit fees whenever possible.
328+
case None if !rcp.isConfirmed => PublishReplaceableTx(tx, rcp.commitTx, commitment, confirmationTarget)
329+
case _ => PublishFinalTx(tx, None)
330+
}
331+
case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => PublishFinalTx(tx, None)
332+
})
324333
val publishHtlcTxs = txs.htlcTxs.map(htlcTx => PublishReplaceableTx(htlcTx, rcp.commitTx, commitment, Closing.confirmationTarget(htlcTx)))
325334
val publishQueue = publishAnchorTx_opt ++ publishMainTx_opt ++ publishHtlcTxs
326335
publishIfNeeded(publishQueue, rcp.irrevocablySpent)
@@ -334,7 +343,7 @@ trait ErrorHandlers extends CommonHandlers {
334343
// we will watch for its confirmation. This ensures that we detect double-spends that could come from:
335344
// - our own RBF attempts
336345
// - remote transactions for outputs that both parties may spend (e.g. HTLCs)
337-
val watchSpentQueue = rcp.localOutput_opt ++ (if (!rcp.isConfirmed) rcp.anchorOutput_opt else None) ++ rcp.htlcOutputs.toSeq
346+
val watchSpentQueue = rcp.localOutput_opt ++ (if (publishAnchorTx_opt.nonEmpty) rcp.anchorOutput_opt else None) ++ rcp.htlcOutputs.toSeq
338347
watchSpentIfNeeded(rcp.commitTx, watchSpentQueue, rcp.irrevocablySpent)
339348
}
340349

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,14 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR
126126

127127
private val dustLimit = commitment.localCommitParams.dustLimit
128128
private val commitFee: Satoshi = commitment.capacity - commitTx.txOut.map(_.amount).sum
129+
private val commitFeerate: FeeratePerKw = Transactions.fee2rate(commitFee, commitTx.weight())
129130

130131
private val log = context.log
131132

132133
def fund(tx: ForceCloseTransaction, targetFeerate: FeeratePerKw): Behavior[Command] = {
133134
log.info("funding {} tx (targetFeerate={})", tx.desc, targetFeerate)
134135
tx match {
135136
case anchorTx: ClaimAnchorTx =>
136-
val commitFeerate = commitment.localCommit.spec.commitTxFeerate
137137
if (targetFeerate <= commitFeerate) {
138138
log.info("skipping {}: commit feerate is high enough (feerate={})", tx.desc, commitFeerate)
139139
// We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens

0 commit comments

Comments
 (0)