Skip to content

Commit d02760d

Browse files
pm47t-bast
andauthored
Fail unsigned outgoing htlcs on force-close (#1832)
Fail outgoing _unsigned_ htlcs on force close, just like we do when disconnected. Fixes #1829. Co-authored-by: Bastien Teinturier <[email protected]>
1 parent 3ae9a4a commit d02760d

File tree

7 files changed

+131
-14
lines changed

7 files changed

+131
-14
lines changed

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

+18-4
Original file line numberDiff line numberDiff line change
@@ -1038,12 +1038,13 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
10381038
case Event(INPUT_DISCONNECTED, d: DATA_NORMAL) =>
10391039
// we cancel the timer that would have made us send the enabled update after reconnection (flappy channel protection)
10401040
cancelTimer(Reconnected.toString)
1041-
// if we have pending unsigned htlcs, then we cancel them and advertise the fact that the channel is now disabled
1041+
// if we have pending unsigned htlcs, then we cancel them and generate an update with the disabled flag set, that will be returned to the sender in a temporary channel failure
10421042
val d1 = if (d.commitments.localChanges.proposed.collectFirst { case add: UpdateAddHtlc => add }.isDefined) {
10431043
log.debug("updating channel_update announcement (reason=disabled)")
10441044
val channelUpdate = Announcements.makeChannelUpdate(nodeParams.chainHash, nodeParams.privateKey, remoteNodeId, d.shortChannelId, d.channelUpdate.cltvExpiryDelta, d.channelUpdate.htlcMinimumMsat, d.channelUpdate.feeBaseMsat, d.channelUpdate.feeProportionalMillionths, d.commitments.capacity.toMilliSatoshi, enable = false)
1045+
// NB: the htlcs stay in the commitments.localChange, they will be cleaned up after reconnection
10451046
d.commitments.localChanges.proposed.collect {
1046-
case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.Disconnected(channelUpdate))
1047+
case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.DisconnectedBeforeSigned(channelUpdate))
10471048
}
10481049
d.copy(channelUpdate = channelUpdate)
10491050
} else {
@@ -1790,7 +1791,8 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
17901791
case data: HasCommitments =>
17911792
val replyTo = if (c.replyTo == ActorRef.noSender) sender else c.replyTo
17921793
replyTo ! RES_SUCCESS(c, data.channelId)
1793-
handleLocalError(ForcedLocalCommit(data.channelId), data, Some(c))
1794+
val failure = ForcedLocalCommit(data.channelId)
1795+
handleLocalError(failure, data, Some(c))
17941796
case _ => handleCommandError(CommandUnavailableInThisState(d.channelId, "forceclose", stateName), c)
17951797
}
17961798

@@ -1888,12 +1890,14 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
18881890
}
18891891
}
18901892

1893+
/** Metrics */
18911894
onTransition {
18921895
case state -> nextState if state != nextState =>
18931896
if (state != WAIT_FOR_INIT_INTERNAL) Metrics.ChannelsCount.withTag(Tags.State, state.toString).decrement()
18941897
if (nextState != WAIT_FOR_INIT_INTERNAL) Metrics.ChannelsCount.withTag(Tags.State, nextState.toString).increment()
18951898
}
18961899

1900+
/** Check pending settlement commands */
18971901
onTransition {
18981902
case _ -> CLOSING =>
18991903
PendingCommandsDb.getSettlementCommands(nodeParams.db.pendingCommands, nextStateData.asInstanceOf[HasCommitments].channelId) match {
@@ -1914,6 +1918,17 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
19141918
}
19151919
}
19161920

1921+
/** Fail outgoing unsigned htlcs right away when transitioning from NORMAL to CLOSING */
1922+
onTransition {
1923+
case NORMAL -> CLOSING =>
1924+
nextStateData match {
1925+
case d: DATA_CLOSING =>
1926+
d.commitments.localChanges.proposed.collect {
1927+
case add: UpdateAddHtlc => relayer ! RES_ADD_SETTLED(d.commitments.originChannels(add.id), add, HtlcResult.ChannelFailureBeforeSigned)
1928+
}
1929+
}
1930+
}
1931+
19171932
/*
19181933
888 888 d8888 888b 888 8888888b. 888 8888888888 8888888b. .d8888b.
19191934
888 888 d88888 8888b 888 888 "Y88b 888 888 888 Y88b d88P Y88b
@@ -2578,4 +2593,3 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
25782593
initialize()
25792594

25802595
}
2581-

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

+3-2
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,9 @@ object HtlcResult {
214214
sealed trait Fail extends HtlcResult
215215
case class RemoteFail(fail: UpdateFailHtlc) extends Fail
216216
case class RemoteFailMalformed(fail: UpdateFailMalformedHtlc) extends Fail
217-
case class OnChainFail(cause: ChannelException) extends Fail
218-
case class Disconnected(channelUpdate: ChannelUpdate) extends Fail { assert(!Announcements.isEnabled(channelUpdate.channelFlags), "channel update must have disabled flag set") }
217+
case class OnChainFail(cause: Throwable) extends Fail
218+
case object ChannelFailureBeforeSigned extends Fail
219+
case class DisconnectedBeforeSigned(channelUpdate: ChannelUpdate) extends Fail { assert(!Announcements.isEnabled(channelUpdate.channelFlags), "channel update must have disabled flag set") }
219220
}
220221
final case class RES_ADD_SETTLED[+O <: Origin, +R <: HtlcResult](origin: O, htlc: UpdateAddHtlc, result: R) extends CommandSuccess[CMD_ADD_HTLC]
221222

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ object ChannelRelay {
8181
case f: HtlcResult.RemoteFail => CMD_FAIL_HTLC(originHtlcId, Left(f.fail.reason), commit = true)
8282
case f: HtlcResult.RemoteFailMalformed => CMD_FAIL_MALFORMED_HTLC(originHtlcId, f.fail.onionHash, f.fail.failureCode, commit = true)
8383
case _: HtlcResult.OnChainFail => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure), commit = true)
84-
case f: HtlcResult.Disconnected => CMD_FAIL_HTLC(originHtlcId, Right(TemporaryChannelFailure(f.channelUpdate)), commit = true)
84+
case HtlcResult.ChannelFailureBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(PermanentChannelFailure), commit = true)
85+
case f: HtlcResult.DisconnectedBeforeSigned => CMD_FAIL_HTLC(originHtlcId, Right(TemporaryChannelFailure(f.channelUpdate)), commit = true)
8586
}
8687
}
8788

eclair-core/src/main/scala/fr/acinq/eclair/payment/send/PaymentLifecycle.scala

+6-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ class PaymentLifecycle(nodeParams: NodeParams, cfg: SendPaymentConfig, router: A
114114
case HtlcResult.OnChainFail(cause) =>
115115
// if the outgoing htlc is being resolved on chain, we treat it like a local error but we cannot retry
116116
handleLocalFail(d, cause, isFatal = true)
117-
case HtlcResult.Disconnected(_) =>
117+
case HtlcResult.ChannelFailureBeforeSigned =>
118+
// the channel that we wanted to use for the outgoing htlc has failed before that htlc was signed, we may
119+
// retry with another channel
120+
handleLocalFail(d, ChannelFailureException, isFatal = false)
121+
case HtlcResult.DisconnectedBeforeSigned(_) =>
118122
// a disconnection occured before the outgoing htlc got signed
119123
// again, we consider it a local error and treat is as such
120124
handleLocalFail(d, DisconnectedException, isFatal = false)
@@ -347,6 +351,7 @@ object PaymentLifecycle {
347351

348352
/** custom exceptions to handle corner cases */
349353
case object UpdateMalformedException extends RuntimeException("first hop returned an UpdateFailMalformedHtlc message")
354+
case object ChannelFailureException extends RuntimeException("a channel failure occured with the first hop")
350355
case object DisconnectedException extends RuntimeException("a disconnection occurred with the first hop")
351356
// @formatter:on
352357

eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala

+99-4
Original file line numberDiff line numberDiff line change
@@ -1915,6 +1915,21 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
19151915
awaitCond(alice.stateName == NORMAL)
19161916
}
19171917

1918+
test("recv CMD_FORCECLOSE (with pending unsigned htlcs)") { f =>
1919+
import f._
1920+
val sender = TestProbe()
1921+
val (_, htlc1) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
1922+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
1923+
val aliceData = alice.stateData.asInstanceOf[DATA_NORMAL]
1924+
assert(aliceData.commitments.localChanges.proposed.size == 1)
1925+
1926+
// actual test starts here
1927+
alice ! CMD_FORCECLOSE(sender.ref)
1928+
sender.expectMsgType[RES_SUCCESS[CMD_FORCECLOSE]]
1929+
val addSettled = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.ChannelFailureBeforeSigned.type]]
1930+
assert(addSettled.htlc == htlc1)
1931+
}
1932+
19181933
def testShutdown(f: FixtureParam, script_opt: Option[ByteVector]): Unit = {
19191934
import f._
19201935
val bobParams = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localParams
@@ -2395,6 +2410,22 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
23952410
assert(claimFee == expectedFee)
23962411
}
23972412

2413+
test("recv WatchFundingSpentTriggered (their commit w/ pending unsigned htlcs)") { f =>
2414+
import f._
2415+
val sender = TestProbe()
2416+
val (_, htlc1) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2417+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2418+
val aliceData = alice.stateData.asInstanceOf[DATA_NORMAL]
2419+
assert(aliceData.commitments.localChanges.proposed.size == 1)
2420+
2421+
// actual test starts here
2422+
// bob publishes his current commit tx
2423+
val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx
2424+
alice ! WatchFundingSpentTriggered(bobCommitTx)
2425+
val addSettled = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.ChannelFailureBeforeSigned.type]]
2426+
assert(addSettled.htlc == htlc1)
2427+
}
2428+
23982429
test("recv WatchFundingSpentTriggered (their *next* commit w/ htlc)") { f =>
23992430
import f._
24002431

@@ -2456,6 +2487,29 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
24562487
assert(getClaimHtlcTimeoutTxs(rcp).length === 2)
24572488
}
24582489

2490+
test("recv WatchFundingSpentTriggered (their *next* commit w/ pending unsigned htlcs)") { f =>
2491+
import f._
2492+
val sender = TestProbe()
2493+
addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2494+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2495+
// alice sign but we intercept bob's revocation
2496+
alice ! CMD_SIGN()
2497+
alice2bob.expectMsgType[CommitSig]
2498+
alice2bob.forward(bob)
2499+
bob2alice.expectMsgType[RevokeAndAck]
2500+
val (_, htlc2) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2501+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2502+
val aliceData = alice.stateData.asInstanceOf[DATA_NORMAL]
2503+
assert(aliceData.commitments.localChanges.proposed.size == 1)
2504+
2505+
// actual test starts here
2506+
// bob publishes his current commit tx
2507+
val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx
2508+
alice ! WatchFundingSpentTriggered(bobCommitTx)
2509+
val addSettled = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.ChannelFailureBeforeSigned.type]]
2510+
assert(addSettled.htlc == htlc2)
2511+
}
2512+
24592513
test("recv WatchFundingSpentTriggered (revoked commit)") { f =>
24602514
import f._
24612515
// initially we have :
@@ -2566,6 +2620,29 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
25662620
htlcPenaltyTxs.foreach(htlcPenaltyTx => Transaction.correctlySpends(htlcPenaltyTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS))
25672621
}
25682622

2623+
test("recv WatchFundingSpentTriggered (revoked commit w/ pending unsigned htlcs)") { f =>
2624+
import f._
2625+
val sender = TestProbe()
2626+
addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2627+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2628+
crossSign(alice, bob, alice2bob, bob2alice)
2629+
val bobRevokedCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx
2630+
addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2631+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2632+
crossSign(alice, bob, alice2bob, bob2alice)
2633+
val (_, htlc3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2634+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2635+
val aliceData = alice.stateData.asInstanceOf[DATA_NORMAL]
2636+
assert(aliceData.commitments.localChanges.proposed.size == 1)
2637+
2638+
// actual test starts here
2639+
// bob publishes his current commit tx
2640+
2641+
alice ! WatchFundingSpentTriggered(bobRevokedCommitTx)
2642+
val addSettled = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.ChannelFailureBeforeSigned.type]]
2643+
assert(addSettled.htlc == htlc3)
2644+
}
2645+
25692646
test("recv Error") { f =>
25702647
import f._
25712648
val (ra1, htlca1) = addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice)
@@ -2654,6 +2731,20 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
26542731
assert(localCommitPublished.commitTx.txid == bobCommitTx.txid)
26552732
}
26562733

2734+
test("recv Error (with pending unsigned htlcs)") { f =>
2735+
import f._
2736+
val sender = TestProbe()
2737+
val (_, htlc1) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice, sender.ref)
2738+
sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]]
2739+
val aliceData = alice.stateData.asInstanceOf[DATA_NORMAL]
2740+
assert(aliceData.commitments.localChanges.proposed.size == 1)
2741+
2742+
// actual test starts here
2743+
alice ! Error(ByteVector32.Zeroes, "oops")
2744+
val addSettled = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.ChannelFailureBeforeSigned.type]]
2745+
assert(addSettled.htlc == htlc1)
2746+
}
2747+
26572748
test("recv WatchFundingDeeplyBuriedTriggered", Tag(StateTestsTags.ChannelsPublic)) { f =>
26582749
import f._
26592750
alice ! WatchFundingDeeplyBuriedTriggered(400000, 42, null)
@@ -2808,8 +2899,12 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
28082899
// actual test starts here
28092900
Thread.sleep(1100)
28102901
alice ! INPUT_DISCONNECTED
2811-
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.Disconnected]].htlc.paymentHash === htlc1.paymentHash)
2812-
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.Disconnected]].htlc.paymentHash === htlc2.paymentHash)
2902+
val addSettled1 = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult]]
2903+
assert(addSettled1.htlc == htlc1)
2904+
assert(addSettled1.result.isInstanceOf[HtlcResult.DisconnectedBeforeSigned])
2905+
val addSettled2 = relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult]]
2906+
assert(addSettled2.htlc == htlc2)
2907+
assert(addSettled2.result.isInstanceOf[HtlcResult.DisconnectedBeforeSigned])
28132908
assert(!Announcements.isEnabled(channelUpdateListener.expectMsgType[LocalChannelUpdate].channelUpdate.channelFlags))
28142909
awaitCond(alice.stateName == OFFLINE)
28152910
}
@@ -2851,8 +2946,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with
28512946
// actual test starts here
28522947
Thread.sleep(1100)
28532948
alice ! INPUT_DISCONNECTED
2854-
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.Disconnected]].htlc.paymentHash === htlc1.paymentHash)
2855-
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.Disconnected]].htlc.paymentHash === htlc2.paymentHash)
2949+
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.DisconnectedBeforeSigned]].htlc.paymentHash === htlc1.paymentHash)
2950+
assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.DisconnectedBeforeSigned]].htlc.paymentHash === htlc2.paymentHash)
28562951
val update2a = channelUpdateListener.expectMsgType[LocalChannelUpdate]
28572952
assert(update1a.channelUpdate.timestamp < update2a.channelUpdate.timestamp)
28582953
assert(!Announcements.isEnabled(update2a.channelUpdate.channelFlags))

eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentLifecycleSpec.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ class PaymentLifecycleSpec extends BaseRouterSpec {
361361

362362
register.expectMsg(ForwardShortId(paymentFSM, channelId_ab, cmd1))
363363
val update_bc_disabled = update_bc.copy(channelFlags = Announcements.makeChannelFlags(isNode1 = true, enable = false))
364-
sender.send(paymentFSM, addCompleted(HtlcResult.Disconnected(update_bc_disabled)))
364+
sender.send(paymentFSM, addCompleted(HtlcResult.DisconnectedBeforeSigned(update_bc_disabled)))
365365

366366
// then the payment lifecycle will ask for a new route excluding the channel
367367
routerForwarder.expectMsg(defaultRouteRequest(a, d, cfg).copy(ignore = Ignore(Set.empty, Set(ChannelDesc(channelId_ab, a, b)))))

eclair-core/src/test/scala/fr/acinq/eclair/payment/relay/ChannelRelayerSpec.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,8 @@ class ChannelRelayerSpec extends ScalaTestWithActorTestKit(ConfigFactory.load("a
361361
TestCase(HtlcResult.RemoteFail(UpdateFailHtlc(channelId1, downstream_htlc.id, hex"deadbeef")), CMD_FAIL_HTLC(r.add.id, Left(hex"deadbeef"), commit = true)),
362362
TestCase(HtlcResult.RemoteFailMalformed(UpdateFailMalformedHtlc(channelId1, downstream_htlc.id, ByteVector32.One, FailureMessageCodecs.BADONION)), CMD_FAIL_MALFORMED_HTLC(r.add.id, ByteVector32.One, FailureMessageCodecs.BADONION, commit = true)),
363363
TestCase(HtlcResult.OnChainFail(HtlcOverriddenByLocalCommit(channelId1, downstream_htlc)), CMD_FAIL_HTLC(r.add.id, Right(PermanentChannelFailure), commit = true)),
364-
TestCase(HtlcResult.Disconnected(u_disabled.channelUpdate), CMD_FAIL_HTLC(r.add.id, Right(TemporaryChannelFailure(u_disabled.channelUpdate)), commit = true))
364+
TestCase(HtlcResult.DisconnectedBeforeSigned(u_disabled.channelUpdate), CMD_FAIL_HTLC(r.add.id, Right(TemporaryChannelFailure(u_disabled.channelUpdate)), commit = true)),
365+
TestCase(HtlcResult.ChannelFailureBeforeSigned, CMD_FAIL_HTLC(r.add.id, Right(PermanentChannelFailure), commit = true))
365366
)
366367

367368
testCases.foreach { testCase =>

0 commit comments

Comments
 (0)