Skip to content

Commit 4985d1e

Browse files
committed
Add more force-close tests for HTLC settlement
This commit is importing test updates and refactoring to the closing helpers from ACINQ/eclair#3040 We don't relay HTLCs, so we don't have an upstream channel to relay preimages to, but it's important to relay preimages to the payment handler to correctly mark payments as succeeded (or failed) and store the proof of payment.
1 parent f508529 commit 4985d1e

File tree

9 files changed

+753
-585
lines changed

9 files changed

+753
-585
lines changed

modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/ChannelData.kt

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import fr.acinq.lightning.channel.Helpers.watchConfirmedIfNeeded
1010
import fr.acinq.lightning.channel.Helpers.watchSpentIfNeeded
1111
import fr.acinq.lightning.crypto.KeyManager
1212
import fr.acinq.lightning.logging.LoggingContext
13-
import fr.acinq.lightning.transactions.Scripts
1413
import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.*
1514
import fr.acinq.lightning.utils.toMilliSatoshi
1615

@@ -70,7 +69,7 @@ data class LocalCommitPublished(
7069
// is the commitment tx buried? (we need to check this because we may not have any outputs)
7170
val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid)
7271
// is our main output confirmed (if we have one)?
73-
val isMainOutputConfirmed = claimMainDelayedOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } ?: true
72+
val isMainOutputConfirmed = claimMainDelayedOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } != false
7473
// are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)?
7574
val allHtlcsSpent = (htlcTxs.keys - irrevocablySpent.keys).isEmpty()
7675
// are all outputs from htlc txs spent?
@@ -86,22 +85,6 @@ data class LocalCommitPublished(
8685
return irrevocablySpent.values.any { it.txid == commitTx.txid } || irrevocablySpent.keys.any { it.txid == commitTx.txid }
8786
}
8887

89-
fun isHtlcTimeout(tx: Transaction): Boolean {
90-
return tx.txIn
91-
.filter { htlcTxs[it.outPoint] is HtlcTx.HtlcTimeoutTx }
92-
.map { it.witness }
93-
.mapNotNull(Scripts.extractPaymentHashFromHtlcTimeout())
94-
.isNotEmpty()
95-
}
96-
97-
fun isHtlcSuccess(tx: Transaction): Boolean {
98-
return tx.txIn
99-
.filter { htlcTxs[it.outPoint] is HtlcTx.HtlcSuccessTx }
100-
.map { it.witness }
101-
.mapNotNull(Scripts.extractPreimageFromHtlcSuccess())
102-
.isNotEmpty()
103-
}
104-
10588
internal fun LoggingContext.doPublish(nodeParams: NodeParams, channelId: ByteVector32): List<ChannelAction> {
10689
val publishQueue = buildList {
10790
add(ChannelAction.Blockchain.PublishTx(commitTx, ChannelAction.Blockchain.PublishTx.Type.CommitTx))
@@ -183,7 +166,7 @@ data class RemoteCommitPublished(
183166
// is the commitment tx buried? (we need to check this because we may not have any outputs)
184167
val isCommitTxConfirmed = confirmedTxs.contains(commitTx.txid)
185168
// is our main output confirmed (if we have one)?
186-
val isMainOutputConfirmed = claimMainOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } ?: true
169+
val isMainOutputConfirmed = claimMainOutputTx?.let { irrevocablySpent.contains(it.input.outPoint) } != false
187170
// are all htlc outputs from the commitment tx spent (we need to check them all because we may receive preimages later)?
188171
val allHtlcsSpent = (claimHtlcTxs.keys - irrevocablySpent.keys).isEmpty()
189172
return isCommitTxConfirmed && isMainOutputConfirmed && allHtlcsSpent
@@ -193,22 +176,6 @@ data class RemoteCommitPublished(
193176
return irrevocablySpent.values.any { it.txid == commitTx.txid } || irrevocablySpent.keys.any { it.txid == commitTx.txid }
194177
}
195178

196-
fun isClaimHtlcTimeout(tx: Transaction): Boolean {
197-
return tx.txIn
198-
.filter { claimHtlcTxs[it.outPoint] is ClaimHtlcTx.ClaimHtlcTimeoutTx }
199-
.map { it.witness }
200-
.mapNotNull(Scripts.extractPaymentHashFromClaimHtlcTimeout())
201-
.isNotEmpty()
202-
}
203-
204-
fun isClaimHtlcSuccess(tx: Transaction): Boolean {
205-
return tx.txIn
206-
.filter { claimHtlcTxs[it.outPoint] is ClaimHtlcTx.ClaimHtlcSuccessTx }
207-
.map { it.witness }
208-
.mapNotNull(Scripts.extractPreimageFromClaimHtlcSuccess())
209-
.isNotEmpty()
210-
}
211-
212179
internal fun LoggingContext.doPublish(nodeParams: NodeParams, channelId: ByteVector32): List<ChannelAction> {
213180
val publishQueue = buildList {
214181
claimMainOutputTx?.let { add(ChannelAction.Blockchain.PublishTx(it)) }

modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/Helpers.kt

Lines changed: 162 additions & 111 deletions
Large diffs are not rendered by default.

modules/core/src/commonMain/kotlin/fr/acinq/lightning/channel/states/Closing.kt

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import fr.acinq.lightning.channel.Helpers.Closing.claimRevokedRemoteCommitTxHtlc
1515
import fr.acinq.lightning.channel.Helpers.Closing.extractPreimages
1616
import fr.acinq.lightning.channel.Helpers.Closing.onChainOutgoingHtlcs
1717
import fr.acinq.lightning.channel.Helpers.Closing.overriddenOutgoingHtlcs
18-
import fr.acinq.lightning.channel.Helpers.Closing.timedOutHtlcs
18+
import fr.acinq.lightning.channel.Helpers.Closing.trimmedOrTimedOutHtlcs
1919
import fr.acinq.lightning.transactions.Transactions.TransactionWithInputInfo.ClosingTx
20+
import fr.acinq.lightning.transactions.incomings
21+
import fr.acinq.lightning.transactions.outgoings
2022
import fr.acinq.lightning.utils.getValue
2123
import fr.acinq.lightning.wire.ChannelReestablish
2224
import fr.acinq.lightning.wire.Error
@@ -106,20 +108,38 @@ data class Closing(
106108
// This commitment may be revoked: we need to verify that its index matches our latest known index before overwriting our previous commitments.
107109
when {
108110
watch.tx.txid == commitments1.latest.localCommit.publishableTxs.commitTx.tx.txid -> {
109-
// our local commit has been published from the outside, it's unexpected but let's deal with it anyway
111+
// Our local commit has been published from the outside, it's unexpected but let's deal with it anyway.
110112
newState.run { spendLocalCurrent() }
111113
}
112114
watch.tx.txid == commitments1.latest.remoteCommit.txid && commitments1.remoteCommitIndex == commitments.remoteCommitIndex -> {
113-
// counterparty may attempt to spend its last commit tx at any time
115+
// Our counterparty may attempt to spend its last commit tx at any time.
114116
newState.run { handleRemoteSpentCurrent(watch.tx, commitments1.latest) }
115117
}
116118
watch.tx.txid == commitments1.latest.nextRemoteCommit?.commit?.txid && commitments1.remoteCommitIndex == commitments.remoteCommitIndex && commitments.remoteNextCommitInfo.isLeft -> {
117-
// counterparty may attempt to spend its next commit tx at any time
119+
// Our counterparty may attempt to spend its next commit tx at any time.
118120
newState.run { handleRemoteSpentNext(watch.tx, commitments1.latest) }
119121
}
120122
else -> {
121-
// counterparty may attempt to spend a revoked commit tx at any time
122-
newState.run { handleRemoteSpentOther(watch.tx) }
123+
// Our counterparty is trying to broadcast a revoked commit tx (cheating attempt).
124+
// We need to fail pending outgoing HTLCs, otherwise we will never properly settle them.
125+
// We must do it here because since we're overwriting the commitments data, we will lose all information
126+
// about HTLCs that are in the current commitments but were not in the revoked one.
127+
// We fail *all* outgoing HTLCs:
128+
// - those that are not in the revoked commitment will never settle on-chain
129+
// - those that are in the revoked commitment will be claimed on-chain, so it's as if they were failed
130+
// Note that if we already received the preimage for some of these HTLCs, we already relayed it to the
131+
// outgoing payment handler so the fail command will be a no-op.
132+
val outgoingHtlcs = commitments.latest.localCommit.spec.htlcs.outgoings().toSet() +
133+
commitments.latest.remoteCommit.spec.htlcs.incomings().toSet() +
134+
(commitments.latest.nextRemoteCommit?.commit?.spec?.htlcs ?: setOf()).incomings().toSet()
135+
val htlcSettledActions = outgoingHtlcs.mapNotNull { add ->
136+
commitments.payments[add.id]?.let { paymentId ->
137+
logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: overridden by revoked remote commit" }
138+
ChannelAction.ProcessCmdRes.AddSettledFail(paymentId, add, ChannelAction.HtlcResult.Fail.OnChainFail(HtlcOverriddenByLocalCommit(channelId, add)))
139+
}
140+
}
141+
val (nextState, closingActions) = newState.run { handleRemoteSpentOther(watch.tx) }
142+
Pair(nextState, closingActions + htlcSettledActions)
123143
}
124144
}
125145
}
@@ -141,15 +161,18 @@ data class Closing(
141161
// we may need to fail some htlcs in case a commitment tx was published and they have reached the timeout threshold
142162
val htlcSettledActions = mutableListOf<ChannelAction>()
143163
val timedOutHtlcs = when (val closingType = closing1.closingTypeAlreadyKnown()) {
144-
is LocalClose -> timedOutHtlcs(closingType.localCommit, closingType.localCommitPublished, commitments.params.localParams.dustLimit, watch.tx)
145-
is RemoteClose -> timedOutHtlcs(closingType.remoteCommit, closingType.remoteCommitPublished, commitments.params.remoteParams.dustLimit, watch.tx)
146-
else -> setOf() // we lose htlc outputs in option_data_loss_protect scenarios (future remote commit)
164+
is LocalClose -> trimmedOrTimedOutHtlcs(closingType.localCommit, closingType.localCommitPublished, commitments.params.localParams.dustLimit, watch.tx)
165+
is RemoteClose -> trimmedOrTimedOutHtlcs(closingType.remoteCommit, closingType.remoteCommitPublished, commitments.params.remoteParams.dustLimit, watch.tx)
166+
is RevokedClose -> setOf() // revoked commitments are handled using [overriddenOutgoingHtlcs] below
167+
is RecoveryClose -> setOf() // we lose htlc outputs in option_data_loss_protect scenarios (future remote commit)
168+
is MutualClose -> setOf()
169+
null -> setOf()
147170
}
148171
timedOutHtlcs.forEach { add ->
149172
when (val paymentId = commitments.payments[add.id]) {
150173
null -> {
151174
// same as for fulfilling the htlc (no big deal)
152-
logger.info { "cannot fail timedout htlc #${add.id} paymentHash=${add.paymentHash} (payment not found)" }
175+
logger.info { "cannot fail timed-out htlc #${add.id} paymentHash=${add.paymentHash} (payment not found)" }
153176
}
154177
else -> {
155178
logger.info { "failing htlc #${add.id} paymentHash=${add.paymentHash} paymentId=$paymentId: htlc timed out" }
@@ -243,7 +266,7 @@ data class Closing(
243266
// we can then use these preimages to fulfill payments
244267
logger.info { "processing spent closing output with txid=${watch.spendingTx.txid} tx=${watch.spendingTx}" }
245268
val htlcSettledActions = mutableListOf<ChannelAction>()
246-
extractPreimages(commitments.latest.localCommit, watch.spendingTx).forEach { (htlc, preimage) ->
269+
extractPreimages(commitments.latest, watch.spendingTx).forEach { (htlc, preimage) ->
247270
when (val paymentId = commitments.payments[htlc.id]) {
248271
null -> {
249272
// if we don't have a reference to the payment, it means that we already have forwarded the fulfill so that's not a big deal.

0 commit comments

Comments
 (0)