Skip to content

Commit fb84a9d

Browse files
authored
Cleaner handling of HTLC settlement during force-close (#3090)
When an HTLC settles downstream while the upstream channel is closing, this allows us to either publish HTLC-success transactions for which we were missing the preimage (if the downstream HTLC was fulfilled) or stop watching the HTLC output (if the downstream HTLC was failed). We previously re-computed every closing transaction in that case, which was confusing and useless. We now explicitly handle those two cases and only republish the HTLC-success transactions that become available, if any. We also change the default feerate used for `claim-htlc-txs`: we used a high feerate in the channel actor, which meant we would skip small HTLCs that weren't economical to spend at that high feerate. But the feerate is actually set inside the tx-publisher actor based on the HTLC expiry, which may happen many blocks after the beginning of the force-close, in which case the feerate may have changed a lot. We now use the minimum feerate in the channel actor to ensure we don't skip HTLCs and let the tx-publisher actor handle RBF.
1 parent 62182f9 commit fb84a9d

8 files changed

Lines changed: 403 additions & 224 deletions

File tree

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

Lines changed: 101 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import fr.acinq.eclair.blockchain.OnChainPubkeyCache
2525
import fr.acinq.eclair.blockchain.fee._
2626
import fr.acinq.eclair.channel.fsm.Channel
2727
import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL
28+
import fr.acinq.eclair.channel.publish.{ReplaceableClaimHtlcSuccess, ReplaceableHtlcSuccess, TxPublisher}
2829
import fr.acinq.eclair.crypto.ShaChain
2930
import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys}
3031
import fr.acinq.eclair.db.ChannelsDb
@@ -910,9 +911,11 @@ object Helpers {
910911
* Claim the outputs of a local commit tx corresponding to HTLCs. If we don't have the preimage for a received
911912
* * HTLC, we still include an entry in the map because we may receive that preimage later.
912913
*/
913-
def claimHtlcOutputs(commitKeys: LocalCommitmentKeys, commitment: FullCommitment)(implicit log: LoggingAdapter): Map[OutPoint, Option[HtlcTx]] = {
914-
// We collect all the preimages we wanted to reveal to our peer.
915-
val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap
914+
private def claimHtlcOutputs(commitKeys: LocalCommitmentKeys, commitment: FullCommitment)(implicit log: LoggingAdapter): Map[OutPoint, Option[HtlcTx]] = {
915+
// We collect all the preimages available.
916+
val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect {
917+
case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage
918+
}.toMap
916919
// We collect incoming HTLCs that we started failing but didn't cross-sign.
917920
val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect {
918921
case u: UpdateFailHtlc => u.id
@@ -923,9 +926,9 @@ object Helpers {
923926
val nonRelayedIncomingHtlcs: Set[Long] = commitment.changes.remoteChanges.all.collect { case add: UpdateAddHtlc => add.id }.toSet
924927
commitment.localCommit.htlcTxsAndRemoteSigs.collect {
925928
case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, remoteSig) =>
926-
if (hash2Preimage.contains(txInfo.paymentHash)) {
929+
if (preimages.contains(txInfo.paymentHash)) {
927930
// We immediately spend incoming htlcs for which we have the preimage.
928-
val preimage = hash2Preimage(txInfo.paymentHash)
931+
val preimage = preimages(txInfo.paymentHash)
929932
Some(txInfo.input.outPoint -> withTxGenerationLog("htlc-success") {
930933
val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, Map.empty)
931934
Right(txInfo.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat))
@@ -954,6 +957,45 @@ object Helpers {
954957
}.flatten.toMap
955958
}
956959

960+
/** Claim the outputs of incoming HTLCs for the payment_hash matching the preimage provided. */
961+
def claimHtlcsWithPreimage(commitKeys: LocalCommitmentKeys, localCommitPublished: LocalCommitPublished, commitment: FullCommitment, preimage: ByteVector32)(implicit log: LoggingAdapter): (LocalCommitPublished, Seq[TxPublisher.PublishTx]) = {
962+
val (htlcTxs, toPublish) = commitment.localCommit.htlcTxsAndRemoteSigs.collect {
963+
case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, remoteSig) if txInfo.paymentHash == Crypto.sha256(preimage) =>
964+
withTxGenerationLog("htlc-success") {
965+
val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, Map.empty)
966+
Right(txInfo.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat))
967+
}.map(signedTx => {
968+
val toPublish = commitment.params.commitmentFormat match {
969+
case DefaultCommitmentFormat => TxPublisher.PublishFinalTx(signedTx, signedTx.fee, Some(localCommitPublished.commitTx.txid))
970+
case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat =>
971+
val confirmationTarget = ConfirmationTarget.Absolute(txInfo.htlcExpiry.blockHeight)
972+
TxPublisher.PublishReplaceableTx(ReplaceableHtlcSuccess(signedTx, commitKeys, preimage, remoteSig, localCommitPublished.commitTx, commitment), confirmationTarget)
973+
}
974+
(signedTx, toPublish)
975+
})
976+
}.flatten.unzip
977+
val additionalHtlcTxs = htlcTxs.map(tx => tx.input.outPoint -> Some(tx)).toMap[OutPoint, Option[HtlcTx]]
978+
val localCommitPublished1 = localCommitPublished.copy(htlcTxs = localCommitPublished.htlcTxs ++ additionalHtlcTxs)
979+
(localCommitPublished1, toPublish)
980+
}
981+
982+
/**
983+
* An incoming HTLC that we've forwarded has been failed downstream: if the channel wasn't closing we would relay
984+
* that failure. Since the channel is closing, our peer should claim the HTLC on-chain after the timeout.
985+
* We stop tracking the corresponding output because we want to move to the CLOSED state even if our peer never
986+
* claims it (which may happen if the HTLC amount is low and on-chain fees are high).
987+
*/
988+
def ignoreFailedIncomingHtlc(htlcId: Long, localCommitPublished: LocalCommitPublished, commitment: FullCommitment): LocalCommitPublished = {
989+
// If we have the preimage (e.g. for partially fulfilled multi-part payments), we keep the HTLC-success tx.
990+
val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect {
991+
case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage
992+
}.toMap
993+
val outpoints = commitment.localCommit.htlcTxsAndRemoteSigs.collect {
994+
case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, _) if txInfo.htlcId == htlcId && !preimages.contains(txInfo.paymentHash) => txInfo.input.outPoint
995+
}.toSet
996+
localCommitPublished.copy(htlcTxs = localCommitPublished.htlcTxs -- outpoints)
997+
}
998+
957999
/**
9581000
* Claim the output of a 2nd-stage HTLC transaction. If the provided transaction isn't an htlc, this will be a no-op.
9591001
*
@@ -986,7 +1028,7 @@ object Helpers {
9861028
val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex)
9871029
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
9881030
val mainTx_opt = claimMainOutput(commitment.params, commitKeys, commitTx, feerates, onChainFeeConf, finalScriptPubKey)
989-
val htlcTxs = claimHtlcOutputs(channelKeys, commitKeys, commitment, remoteCommit, feerates, finalScriptPubKey)
1031+
val htlcTxs = claimHtlcOutputs(channelKeys, commitKeys, commitment, remoteCommit, finalScriptPubKey)
9901032
val spendAnchors = htlcTxs.nonEmpty || onChainFeeConf.spendAnchorWithoutHtlcs
9911033
val anchorTx_opt = if (spendAnchors) {
9921034
claimAnchor(fundingKey, commitKeys, commitTx, commitment.params.commitmentFormat)
@@ -1031,15 +1073,17 @@ object Helpers {
10311073
* Claim the outputs of a remote commit tx corresponding to HTLCs. If we don't have the preimage for a received
10321074
* * HTLC, we still include an entry in the map because we may receive that preimage later.
10331075
*/
1034-
def claimHtlcOutputs(channelKeys: ChannelKeys, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Map[OutPoint, Option[ClaimHtlcTx]] = {
1076+
private def claimHtlcOutputs(channelKeys: ChannelKeys, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Map[OutPoint, Option[ClaimHtlcTx]] = {
10351077
val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit)
10361078
val remoteCommitTx = makeCommitTx(commitment.commitInput, remoteCommit.index, commitment.params.remoteParams.paymentBasepoint, commitKeys.ourPaymentBasePoint, !commitment.params.localParams.isChannelOpener, outputs)
10371079
require(remoteCommitTx.tx.txid == remoteCommit.txid, "txid mismatch, cannot recompute the current remote commit tx")
1038-
// We need to use a rather high fee for htlc-claim because we compete with the counterparty.
1039-
val feerateHtlc = feerates.fast
1080+
// The feerate will be set by the publisher actor based on the HTLC expiry, we don't care which feerate is used here.
1081+
val feerate = FeeratePerKw(FeeratePerByte(1 sat))
10401082

1041-
// We collect all the preimages we wanted to reveal to our peer.
1042-
val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap
1083+
// We collect all the preimages available.
1084+
val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect {
1085+
case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage
1086+
}.toMap
10431087
// We collect incoming HTLCs that we started failing but didn't cross-sign.
10441088
val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect {
10451089
case u: UpdateFailHtlc => u.id
@@ -1052,11 +1096,11 @@ object Helpers {
10521096
// Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa.
10531097
remoteCommit.spec.htlcs.collect {
10541098
case OutgoingHtlc(add: UpdateAddHtlc) =>
1055-
if (hash2Preimage.contains(add.paymentHash)) {
1099+
if (preimages.contains(add.paymentHash)) {
10561100
// We immediately spend incoming htlcs for which we have the preimage.
1057-
val preimage = hash2Preimage(add.paymentHash)
1101+
val preimage = preimages(add.paymentHash)
10581102
withTxGenerationLog("claim-htlc-success") {
1059-
ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerateHtlc, commitment.params.commitmentFormat)
1103+
ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerate, commitment.params.commitmentFormat)
10601104
}.map(claimHtlcTx => claimHtlcTx.input.outPoint -> Some(claimHtlcTx))
10611105
} else if (failedIncomingHtlcs.contains(add.id)) {
10621106
// We can ignore incoming htlcs that we started failing: our peer will claim them after the timeout.
@@ -1076,11 +1120,53 @@ object Helpers {
10761120
// claim the output, we will learn the preimage from their transaction, otherwise we will get our funds
10771121
// back after the timeout.
10781122
withTxGenerationLog("claim-htlc-timeout") {
1079-
ClaimHtlcTimeoutTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, feerateHtlc, commitment.params.commitmentFormat)
1123+
ClaimHtlcTimeoutTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, feerate, commitment.params.commitmentFormat)
10801124
}.map(claimHtlcTx => claimHtlcTx.input.outPoint -> Some(claimHtlcTx))
10811125
}.flatten.toMap
10821126
}
10831127

1128+
/** Claim the outputs of incoming HTLCs for the payment_hash matching the preimage provided. */
1129+
def claimHtlcsWithPreimage(channelKeys: ChannelKeys, remoteCommitPublished: RemoteCommitPublished, commitment: FullCommitment, remoteCommit: RemoteCommit, preimage: ByteVector32, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RemoteCommitPublished, Seq[TxPublisher.PublishReplaceableTx]) = {
1130+
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
1131+
val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit)
1132+
// The feerate will be set by the publisher actor based on the HTLC expiry, we don't care which feerate is used here.
1133+
val feerate = FeeratePerKw(FeeratePerByte(1 sat))
1134+
val toPublish = remoteCommit.spec.htlcs.collect {
1135+
// Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa.
1136+
case OutgoingHtlc(add: UpdateAddHtlc) if add.paymentHash == Crypto.sha256(preimage) =>
1137+
withTxGenerationLog("claim-htlc-success") {
1138+
ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitPublished.commitTx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerate, commitment.params.commitmentFormat)
1139+
}.map { signedTx =>
1140+
val confirmationTarget = ConfirmationTarget.Absolute(add.cltvExpiry.blockHeight)
1141+
TxPublisher.PublishReplaceableTx(ReplaceableClaimHtlcSuccess(signedTx, commitKeys, preimage, remoteCommitPublished.commitTx, commitment), confirmationTarget)
1142+
}
1143+
}.flatten.toSeq
1144+
val additionalHtlcTxs = toPublish.map(p => p.input -> Some(p.tx.txInfo.asInstanceOf[ClaimHtlcSuccessTx])).toMap[OutPoint, Option[ClaimHtlcTx]]
1145+
val remoteCommitPublished1 = remoteCommitPublished.copy(claimHtlcTxs = remoteCommitPublished.claimHtlcTxs ++ additionalHtlcTxs)
1146+
(remoteCommitPublished1, toPublish)
1147+
}
1148+
1149+
/**
1150+
* An incoming HTLC that we've forwarded has been failed downstream: if the channel wasn't closing we would relay
1151+
* that failure. Since the channel is closing, our peer should claim the HTLC on-chain after the timeout.
1152+
* We stop tracking the corresponding output because we want to move to the CLOSED state even if our peer never
1153+
* claims it (which may happen if the HTLC amount is low and on-chain fees are high).
1154+
*/
1155+
def ignoreFailedIncomingHtlc(channelKeys: ChannelKeys, htlcId: Long, remoteCommitPublished: RemoteCommitPublished, commitment: FullCommitment, remoteCommit: RemoteCommit): RemoteCommitPublished = {
1156+
// If we have the preimage (e.g. for partially fulfilled multi-part payments), we keep the HTLC-success tx.
1157+
val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect {
1158+
case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage
1159+
}.toMap
1160+
val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint)
1161+
val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit)
1162+
val outpoints = remoteCommit.spec.htlcs.collect {
1163+
// Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa.
1164+
case OutgoingHtlc(add: UpdateAddHtlc) if add.id == htlcId && !preimages.contains(add.paymentHash) =>
1165+
ClaimHtlcSuccessTx.findInput(remoteCommitPublished.commitTx, outputs, add).map(_.outPoint)
1166+
}.flatten
1167+
remoteCommitPublished.copy(claimHtlcTxs = remoteCommitPublished.claimHtlcTxs -- outpoints)
1168+
}
1169+
10841170
}
10851171

10861172
object RevokedClose {

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

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,19 +1857,36 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
18571857
case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c)
18581858
}) match {
18591859
case Right((commitments1, _)) =>
1860-
log.info("got valid settlement for htlc={}, recalculating htlc transactions", c.id)
18611860
val commitment = commitments1.latest
1862-
val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => localCommitPublished.copy(htlcTxs = Closing.LocalClose.claimHtlcOutputs(commitment.localKeys(channelKeys), commitment)))
1863-
val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => remoteCommitPublished.copy(claimHtlcTxs = Closing.RemoteClose.claimHtlcOutputs(channelKeys, commitment.remoteKeys(channelKeys, commitment.remoteCommit.remotePerCommitmentPoint), commitment, commitment.remoteCommit, nodeParams.currentBitcoinCoreFeerates, d.finalScriptPubKey)))
1864-
val nextRemoteCommitPublished1 = d.nextRemoteCommitPublished.map(remoteCommitPublished => remoteCommitPublished.copy(claimHtlcTxs = Closing.RemoteClose.claimHtlcOutputs(channelKeys, commitment.remoteKeys(channelKeys, commitment.nextRemoteCommit_opt.get.commit.remotePerCommitmentPoint), commitment, commitment.nextRemoteCommit_opt.get.commit, nodeParams.currentBitcoinCoreFeerates, d.finalScriptPubKey)))
1865-
1866-
def republish(): Unit = {
1867-
localCommitPublished1.foreach(lcp => doPublish(lcp, commitment))
1868-
remoteCommitPublished1.foreach(rcp => doPublish(rcp, commitment))
1869-
nextRemoteCommitPublished1.foreach(rcp => doPublish(rcp, commitment))
1861+
val d1 = c match {
1862+
case c: CMD_FULFILL_HTLC =>
1863+
log.info("htlc #{} with payment_hash={} was fulfilled downstream, recalculating htlc-success transactions", c.id, c.r)
1864+
// We may be able to publish HTLC-success transactions for which we didn't have the preimage.
1865+
// We are already watching the corresponding outputs: no need to set additional watches.
1866+
val lcp1 = d.localCommitPublished.map(lcp => {
1867+
val (lcp1, toPublish) = Closing.LocalClose.claimHtlcsWithPreimage(commitment.localKeys(channelKeys), lcp, commitment, c.r)
1868+
toPublish.foreach(publishTx => txPublisher ! publishTx)
1869+
lcp1
1870+
})
1871+
val rcp1 = d.remoteCommitPublished.map(rcp => {
1872+
val (rcp1, toPublish) = Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, rcp, commitment, commitment.remoteCommit, c.r, d.finalScriptPubKey)
1873+
toPublish.foreach(publishTx => txPublisher ! publishTx)
1874+
rcp1
1875+
})
1876+
val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => {
1877+
val (nrcp1, toPublish) = Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, nrcp, commitment, commitment.nextRemoteCommit_opt.get.commit, c.r, d.finalScriptPubKey)
1878+
toPublish.foreach(publishTx => txPublisher ! publishTx)
1879+
nrcp1
1880+
})
1881+
d.copy(commitments = commitments1, localCommitPublished = lcp1, remoteCommitPublished = rcp1, nextRemoteCommitPublished = nrcp1)
1882+
case _: CMD_FAIL_HTLC | _: CMD_FAIL_MALFORMED_HTLC =>
1883+
log.info("htlc #{} was failed downstream, recalculating watched htlc outputs", c.id)
1884+
val lcp1 = d.localCommitPublished.map(lcp => Closing.LocalClose.ignoreFailedIncomingHtlc(c.id, lcp, commitment))
1885+
val rcp1 = d.remoteCommitPublished.map(rcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(channelKeys, c.id, rcp, commitment, commitment.remoteCommit))
1886+
val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(channelKeys, c.id, nrcp, commitment, commitment.nextRemoteCommit_opt.get.commit))
1887+
d.copy(commitments = commitments1, localCommitPublished = lcp1, remoteCommitPublished = rcp1, nextRemoteCommitPublished = nrcp1)
18701888
}
1871-
1872-
handleCommandSuccess(c, d.copy(commitments = commitments1, localCommitPublished = localCommitPublished1, remoteCommitPublished = remoteCommitPublished1, nextRemoteCommitPublished = nextRemoteCommitPublished1)) storing() calling republish()
1889+
handleCommandSuccess(c, d1) storing()
18731890
case Left(cause) => handleCommandError(cause, c)
18741891
}
18751892

0 commit comments

Comments
 (0)