Skip to content

Commit fbc7004

Browse files
authored
Add more force-close tests for HTLC settlement (#3040)
While auditing our force-close code, I discovered that we were missing tests for scenarios where we must fail HTLCs back upstream. There are also important properties of various components that are crucial to funds safety and weren't explicitly spelled out or tested (such as the fact that redundant HTLC settlement commands are simply ignored in favor of the first settlement command created). I also took this opportunity to slightly refactor some of the closing code, improve comments and simplify code that tried being too clever, which could bite us in the future when introducing additional channel types.
1 parent 2dfdc26 commit fbc7004

File tree

12 files changed

+734
-400
lines changed

12 files changed

+734
-400
lines changed

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

Lines changed: 89 additions & 115 deletions
Large diffs are not rendered by default.

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

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,23 +2001,29 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20012001
handleRemoteSpentNext(tx, d1)
20022002
} else {
20032003
// Our counterparty is trying to broadcast a revoked commit tx (cheating attempt).
2004-
// We need to fail pending outgoing HTLCs: we must do it here because we're overwriting the commitments data, so we won't be able to do it afterwards.
2005-
val remoteCommit = d.commitments.latest.remoteCommit
2006-
val nextRemoteCommit_opt = d.commitments.latest.nextRemoteCommit_opt.map(_.commit)
2007-
val pendingOutgoingHtlcs = nextRemoteCommit_opt.getOrElse(remoteCommit).spec.htlcs.collect(DirectedHtlc.incoming)
2008-
val failedHtlcs = Closing.recentlyFailedHtlcs(remoteCommit, nextRemoteCommit_opt, d.commitments.changes)
2009-
(pendingOutgoingHtlcs ++ failedHtlcs).foreach { add =>
2004+
// 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
2006+
// about HTLCs that are in the current commitments but were not in the revoked one.
2007+
// We fail *all* outgoing HTLCs:
2008+
// - those that are not in the revoked commitment will never settle on-chain
2009+
// - those that are in the revoked commitment will be claimed on-chain, so it's as if they were failed
2010+
// Note that if we already received the preimage for some of these HTLCs, we already relayed it upstream
2011+
// so the fail command will be a no-op.
2012+
val outgoingHtlcs = d.commitments.latest.localCommit.spec.htlcs.collect(DirectedHtlc.outgoing) ++
2013+
d.commitments.latest.remoteCommit.spec.htlcs.collect(DirectedHtlc.incoming) ++
2014+
d.commitments.latest.nextRemoteCommit_opt.map(_.commit.spec.htlcs.collect(DirectedHtlc.incoming)).getOrElse(Set.empty)
2015+
outgoingHtlcs.foreach { add =>
20102016
d.commitments.originChannels.get(add.id) match {
20112017
case Some(origin) =>
2012-
log.info(s"failing htlc #${add.id} paymentHash=${add.paymentHash} origin=$origin: overridden by revoked remote commit")
2018+
log.info("failing htlc #{} paymentHash={} origin={}: overridden by revoked remote commit", add.id, add.paymentHash, origin)
20132019
relayer ! RES_ADD_SETTLED(origin, add, HtlcResult.OnChainFail(HtlcOverriddenByLocalCommit(d.channelId, add)))
20142020
case None => ()
20152021
}
20162022
}
20172023
handleRemoteSpentOther(tx, d1)
20182024
}
20192025
case None =>
2020-
log.warning(s"ignoring unrecognized alternative commit tx=${tx.txid}")
2026+
log.warning("ignoring unrecognized alternative commit tx={}", tx.txid)
20212027
stay()
20222028
}
20232029

@@ -2028,20 +2034,22 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20282034
// when a remote or local commitment tx containing outgoing htlcs is published on the network,
20292035
// we watch it in order to extract payment preimage if funds are pulled by the counterparty
20302036
// we can then use these preimages to fulfill origin htlcs
2031-
log.debug(s"processing bitcoin output spent by txid=${tx.txid} tx=$tx")
2037+
log.debug(s"processing bitcoin output spent by txid={} tx={}", tx.txid, tx)
20322038
val extracted = Closing.extractPreimages(d.commitments.latest, tx)
20332039
extracted.foreach { case (htlc, preimage) =>
20342040
d.commitments.originChannels.get(htlc.id) match {
20352041
case Some(origin) =>
2036-
log.info(s"fulfilling htlc #${htlc.id} paymentHash=${htlc.paymentHash} origin=$origin")
2042+
log.info("fulfilling htlc #{} paymentHash={} origin={}", htlc.id, htlc.paymentHash, origin)
20372043
relayer ! RES_ADD_SETTLED(origin, htlc, HtlcResult.OnChainFulfill(preimage))
20382044
case None =>
20392045
// if we don't have the origin, it means that we already have forwarded the fulfill so that's not a big deal.
20402046
// this can happen if they send a signature containing the fulfill, then fail the channel before we have time to sign it
2041-
log.info(s"cannot fulfill htlc #${htlc.id} paymentHash=${htlc.paymentHash} (origin not found)")
2047+
log.warning("cannot fulfill htlc #{} paymentHash={} (origin not found)", htlc.id, htlc.paymentHash)
20422048
}
20432049
}
20442050
val revokedCommitPublished1 = d.revokedCommitPublished.map { rev =>
2051+
// this transaction may be an HTLC transaction spending a revoked commitment
2052+
// in that case, we immediately publish an HTLC-penalty transaction spending its output(s)
20452053
val (rev1, penaltyTxs) = Closing.RevokedClose.claimHtlcTxOutputs(keyManager, d.commitments.params, d.commitments.remotePerCommitmentSecrets, rev, tx, nodeParams.currentBitcoinCoreFeerates, d.finalScriptPubKey)
20462054
penaltyTxs.foreach(claimTx => txPublisher ! PublishFinalTx(claimTx, claimTx.fee, None))
20472055
penaltyTxs.foreach(claimTx => blockchain ! WatchOutputSpent(self, tx.txid, claimTx.input.outPoint.index.toInt, claimTx.amountIn, hints = Set(claimTx.tx.txid)))
@@ -2050,7 +2058,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
20502058
stay() using d.copy(revokedCommitPublished = revokedCommitPublished1) storing()
20512059

20522060
case Event(WatchTxConfirmedTriggered(blockHeight, _, tx), d: DATA_CLOSING) =>
2053-
log.info(s"txid=${tx.txid} has reached mindepth, updating closing state")
2061+
log.info("txid={} has reached mindepth, updating closing state", tx.txid)
20542062
context.system.eventStream.publish(TransactionConfirmed(d.channelId, remoteNodeId, tx))
20552063
// first we check if this tx belongs to one of the current local/remote commits, update it and update the channel data
20562064
val d1 = d.copy(
@@ -2101,27 +2109,31 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
21012109
val timedOutHtlcs = Closing.isClosingTypeAlreadyKnown(d1) match {
21022110
case Some(c: Closing.LocalClose) => Closing.trimmedOrTimedOutHtlcs(d.commitments.params.commitmentFormat, c.localCommit, c.localCommitPublished, d.commitments.params.localParams.dustLimit, tx)
21032111
case Some(c: Closing.RemoteClose) => Closing.trimmedOrTimedOutHtlcs(d.commitments.params.commitmentFormat, c.remoteCommit, c.remoteCommitPublished, d.commitments.params.remoteParams.dustLimit, tx)
2104-
case _ => Set.empty[UpdateAddHtlc] // we lose htlc outputs in dataloss protection scenarios (future remote commit)
2112+
case Some(_: Closing.RevokedClose) => Set.empty[UpdateAddHtlc] // revoked commitments are handled using [[overriddenOutgoingHtlcs]] below
2113+
case Some(_: Closing.RecoveryClose) => Set.empty[UpdateAddHtlc] // we lose htlc outputs in dataloss protection scenarios (future remote commit)
2114+
case Some(_: Closing.MutualClose) => Set.empty[UpdateAddHtlc]
2115+
case None => Set.empty[UpdateAddHtlc]
21052116
}
21062117
timedOutHtlcs.foreach { add =>
21072118
d.commitments.originChannels.get(add.id) match {
21082119
case Some(origin) =>
2109-
log.info(s"failing htlc #${add.id} paymentHash=${add.paymentHash} origin=$origin: htlc timed out")
2120+
log.info("failing htlc #{} paymentHash={} origin={}: htlc timed out", add.id, add.paymentHash, origin)
21102121
relayer ! RES_ADD_SETTLED(origin, add, HtlcResult.OnChainFail(HtlcsTimedoutDownstream(d.channelId, Set(add))))
21112122
case None =>
21122123
// same as for fulfilling the htlc (no big deal)
2113-
log.info(s"cannot fail timed out htlc #${add.id} paymentHash=${add.paymentHash} (origin not found)")
2124+
log.info("cannot fail timed out htlc #{} paymentHash={} (origin not found)", add.id, add.paymentHash)
21142125
}
21152126
}
21162127
// we also need to fail outgoing htlcs that we know will never reach the blockchain
2128+
// if we previously received the preimage, we have already relayed it upstream and the command below will be ignored
21172129
Closing.overriddenOutgoingHtlcs(d, tx).foreach { add =>
21182130
d.commitments.originChannels.get(add.id) match {
21192131
case Some(origin) =>
2120-
log.info(s"failing htlc #${add.id} paymentHash=${add.paymentHash} origin=$origin: overridden by local commit")
2132+
log.info("failing htlc #{} paymentHash={} origin={}: overridden by local commit", add.id, add.paymentHash, origin)
21212133
relayer ! RES_ADD_SETTLED(origin, add, HtlcResult.OnChainFail(HtlcOverriddenByLocalCommit(d.channelId, add)))
21222134
case None =>
21232135
// same as for fulfilling the htlc (no big deal)
2124-
log.info(s"cannot fail overridden htlc #${add.id} paymentHash=${add.paymentHash} (origin not found)")
2136+
log.info("cannot fail overridden htlc #{} paymentHash={} (origin not found)", add.id, add.paymentHash)
21252137
}
21262138
}
21272139
// for our outgoing payments, let's send events if we know that they will settle on chain
@@ -2295,8 +2307,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
22952307
case _ => Set.empty
22962308
}
22972309
val lastFundingLockedTlvs: Set[ChannelReestablishTlv] = if (d.commitments.params.remoteParams.initFeatures.hasFeature(Features.SplicePrototype)) {
2298-
d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++
2299-
d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet
2310+
d.commitments.lastLocalLocked_opt.map(c => ChannelReestablishTlv.MyCurrentFundingLockedTlv(c.fundingTxId)).toSet ++
2311+
d.commitments.lastRemoteLocked_opt.map(c => ChannelReestablishTlv.YourLastFundingLockedTlv(c.fundingTxId)).toSet
23002312
} else Set.empty
23012313

23022314
val channelReestablish = ChannelReestablish(
@@ -2996,11 +3008,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder with
29963008
/** Fail outgoing unsigned htlcs right away when transitioning from NORMAL to CLOSING */
29973009
onTransition {
29983010
case NORMAL -> CLOSING =>
2999-
(nextStateData: @unchecked) match {
3011+
nextStateData match {
30003012
case d: DATA_CLOSING =>
30013013
d.commitments.changes.localChanges.proposed.collect {
30023014
case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.ChannelFailureBeforeSigned)
30033015
}
3016+
case _ => ()
30043017
}
30053018
}
30063019

eclair-core/src/main/scala/fr/acinq/eclair/db/PendingCommandsDb.scala

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,35 +23,30 @@ import fr.acinq.eclair.channel._
2323
import fr.acinq.eclair.wire.protocol.{UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFulfillHtlc, UpdateMessage}
2424

2525
/**
26-
* This database stores CMD_FULFILL_HTLC and CMD_FAIL_HTLC that we have received from downstream
27-
* (either directly via UpdateFulfillHtlc or by extracting the value from the
28-
* blockchain).
26+
* This database stores [[CMD_FULFILL_HTLC]], [[CMD_FAIL_HTLC]] and [[CMD_FAIL_MALFORMED_HTLC]] commands received from
27+
* downstream (either directly via channel settlement like [[UpdateFulfillHtlc]] or [[UpdateFailHtlc]] or by extracting
28+
* the preimage from the blockchain during a force-close).
2929
*
30-
* This means that this database is only used in the context of *relaying* payments.
30+
* We must ensure that if a downstream channel is able to pull funds from us, we can always do the same from upstream,
31+
* otherwise we lose money. Hence the need for persistence to handle all corner cases where we disconnect or restart
32+
* before settling on the upstream channel.
3133
*
32-
* We need to be sure that if downstream is able to pull funds from us, we can always
33-
* do the same from upstream, otherwise we lose money. Hence the need for persistence
34-
* to handle all corner cases.
34+
* Importantly, we must only store the *first* command received for a given upstream HTLC: if we first receive
35+
* [[CMD_FULFILL_HTLC]] and then [[CMD_FAIL_HTLC]], the second command must be ignored. This should be implemented by
36+
* using a primary key based on the (channel_id, htlc_id) pair and ignoring conflicting inserts.
3537
*
38+
* Note: this database is only used in the context of *relaying* payments.
3639
*/
3740
trait PendingCommandsDb {
38-
41+
// @formatter:off
3942
def addSettlementCommand(channelId: ByteVector32, cmd: HtlcSettlementCommand): Unit
40-
4143
def removeSettlementCommand(channelId: ByteVector32, htlcId: Long): Unit
42-
4344
def listSettlementCommands(channelId: ByteVector32): Seq[HtlcSettlementCommand]
44-
4545
def listSettlementCommands(): Seq[(ByteVector32, HtlcSettlementCommand)]
46-
46+
// @formatter:on
4747
}
4848

4949
object PendingCommandsDb {
50-
/**
51-
* We store [[CMD_FULFILL_HTLC]]/[[CMD_FAIL_HTLC]]/[[CMD_FAIL_MALFORMED_HTLC]]
52-
* in a database because we don't want to lose preimages, or to forget to fail
53-
* incoming htlcs, which would lead to unwanted channel closings.
54-
*/
5550
def safeSend(register: ActorRef, db: PendingCommandsDb, channelId: ByteVector32, cmd: HtlcSettlementCommand): Unit = {
5651
// htlc settlement commands don't have replyTo
5752
register ! Register.Forward(null, channelId, cmd)
@@ -65,17 +60,17 @@ object PendingCommandsDb {
6560

6661
def ackSettlementCommands(db: PendingCommandsDb, updates: List[UpdateMessage])(implicit log: LoggingAdapter): Unit = updates.collect {
6762
case u: UpdateFulfillHtlc =>
68-
log.debug(s"fulfill acked for htlcId=${u.id}")
63+
log.debug("fulfill acked for htlcId={}", u.id)
6964
db.removeSettlementCommand(u.channelId, u.id)
7065
case u: UpdateFailHtlc =>
71-
log.debug(s"fail acked for htlcId=${u.id}")
66+
log.debug("fail acked for htlcId={}", u.id)
7267
db.removeSettlementCommand(u.channelId, u.id)
7368
case u: UpdateFailMalformedHtlc =>
74-
log.debug(s"fail-malformed acked for htlcId=${u.id}")
69+
log.debug("fail-malformed acked for htlcId={}", u.id)
7570
db.removeSettlementCommand(u.channelId, u.id)
7671
}
7772

78-
def getSettlementCommands(db: PendingCommandsDb, channelId: ByteVector32)(implicit log: LoggingAdapter): Seq[HtlcSettlementCommand] = {
73+
def getSettlementCommands(db: PendingCommandsDb, channelId: ByteVector32): Seq[HtlcSettlementCommand] = {
7974
db.listSettlementCommands(channelId)
8075
}
8176
}

eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/PostRestartHtlcCleaner.scala

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import fr.acinq.eclair.db._
2828
import fr.acinq.eclair.payment.Monitoring.Tags
2929
import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPaymentPacket, PaymentFailed, PaymentSent}
3030
import fr.acinq.eclair.transactions.DirectedHtlc.outgoing
31-
import fr.acinq.eclair.wire.protocol.{FailureMessage, FailureReason, InvalidOnionBlinding, TemporaryNodeFailure, UpdateAddHtlc}
31+
import fr.acinq.eclair.wire.protocol._
3232
import fr.acinq.eclair.{CustomCommitmentsPlugin, Feature, Features, Logs, MilliSatoshiLong, NodeParams, TimestampMilli}
3333

3434
import scala.concurrent.Promise
@@ -407,31 +407,35 @@ object PostRestartHtlcCleaner {
407407
val htlcsOut = channels
408408
.collect { case c: ChannelDataWithCommitments => c }
409409
.flatMap { c =>
410-
// Filter out HTLCs that will never reach the blockchain or have already been timed-out on-chain.
410+
// Filter out HTLCs that will never reach the blockchain or have already been settled on-chain.
411411
val htlcsToIgnore: Set[Long] = c match {
412412
case d: DATA_CLOSING =>
413413
val closingType_opt = Closing.isClosingTypeAlreadyKnown(d)
414414
val overriddenHtlcs: Set[Long] = (closingType_opt match {
415415
case Some(c: Closing.LocalClose) => Closing.overriddenOutgoingHtlcs(d, c.localCommitPublished.commitTx)
416416
case Some(c: Closing.RemoteClose) => Closing.overriddenOutgoingHtlcs(d, c.remoteCommitPublished.commitTx)
417417
case Some(c: Closing.RevokedClose) => Closing.overriddenOutgoingHtlcs(d, c.revokedCommitPublished.commitTx)
418-
case _ => Set.empty[UpdateAddHtlc]
418+
case Some(c: Closing.RecoveryClose) => Closing.overriddenOutgoingHtlcs(d, c.remoteCommitPublished.commitTx)
419+
case Some(_: Closing.MutualClose) => Set.empty[UpdateAddHtlc]
420+
case None => Set.empty[UpdateAddHtlc]
419421
}).map(_.id)
420-
val irrevocablySpent = closingType_opt match {
421-
case Some(c: Closing.LocalClose) => c.localCommitPublished.irrevocablySpent.values.toSeq
422-
case Some(c: Closing.RemoteClose) => c.remoteCommitPublished.irrevocablySpent.values.toSeq
423-
case Some(c: Closing.RevokedClose) => c.revokedCommitPublished.irrevocablySpent.values.toSeq
424-
case _ => Nil
422+
val confirmedTxs = closingType_opt match {
423+
case Some(c: Closing.LocalClose) => c.localCommitPublished.irrevocablySpent.values.toSet
424+
case Some(c: Closing.RemoteClose) => c.remoteCommitPublished.irrevocablySpent.values.toSet
425+
case Some(c: Closing.RevokedClose) => c.revokedCommitPublished.irrevocablySpent.values.toSet
426+
case Some(c: Closing.RecoveryClose) => c.remoteCommitPublished.irrevocablySpent.values.toSet
427+
case Some(_: Closing.MutualClose) => Set.empty
428+
case None => Set.empty
425429
}
430+
val params = d.commitments.params
426431
val timedOutHtlcs: Set[Long] = (closingType_opt match {
427-
case Some(c: Closing.LocalClose) =>
428-
val confirmedTxs = c.localCommitPublished.commitTx +: irrevocablySpent.filter(tx => Closing.isHtlcTimeout(tx, c.localCommitPublished))
429-
confirmedTxs.flatMap(tx => Closing.trimmedOrTimedOutHtlcs(d.commitments.params.commitmentFormat, c.localCommit, c.localCommitPublished, d.commitments.params.localParams.dustLimit, tx))
430-
case Some(c: Closing.RemoteClose) =>
431-
val confirmedTxs = c.remoteCommitPublished.commitTx +: irrevocablySpent.filter(tx => Closing.isClaimHtlcTimeout(tx, c.remoteCommitPublished))
432-
confirmedTxs.flatMap(tx => Closing.trimmedOrTimedOutHtlcs(d.commitments.params.commitmentFormat, c.remoteCommit, c.remoteCommitPublished, d.commitments.params.remoteParams.dustLimit, tx))
433-
case _ => Seq.empty[UpdateAddHtlc]
434-
}).map(_.id).toSet
432+
case Some(c: Closing.LocalClose) => confirmedTxs.flatMap(tx => Closing.trimmedOrTimedOutHtlcs(params.commitmentFormat, c.localCommit, c.localCommitPublished, params.localParams.dustLimit, tx))
433+
case Some(c: Closing.RemoteClose) => confirmedTxs.flatMap(tx => Closing.trimmedOrTimedOutHtlcs(params.commitmentFormat, c.remoteCommit, c.remoteCommitPublished, params.remoteParams.dustLimit, tx))
434+
case Some(_: Closing.RevokedClose) => Set.empty // revoked commitments are handled using [[overriddenOutgoingHtlcs]] above
435+
case Some(_: Closing.RecoveryClose) => Set.empty // we lose htlc outputs in dataloss protection scenarios (future remote commit)
436+
case Some(_: Closing.MutualClose) => Set.empty
437+
case None => Set.empty
438+
}).map(_.id)
435439
overriddenHtlcs ++ timedOutHtlcs
436440
case _ => Set.empty
437441
}

0 commit comments

Comments
 (0)