Skip to content

Commit 5e1a488

Browse files
authored
Allow aborting liquidity purchases after signing (#3206)
When a liquidity purchase is signed, we eagerly add it to our DB before receiving the remote `interactive-tx` signatures. If we reach that step, our peer should always finalize the signing steps, so we didn't bother handling the case where they would instead send `tx_abort`. When that happened, we kept the upstream HTLCs pending until they got close to their expiry, at which point we failed them. We've seen cases where seemingly non-malicious mobile wallets abort that kind of liquidity purchases after a disconnection. It is harmful for an honest sender to keep the HTLCs pending, so we now immediately fail them in that case.
1 parent ff1ce1f commit 5e1a488

File tree

6 files changed

+137
-7
lines changed

6 files changed

+137
-7
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import fr.acinq.eclair.crypto.keymanager.ChannelKeys
4646
import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType
4747
import fr.acinq.eclair.db.PendingCommandsDb
4848
import fr.acinq.eclair.io.Peer
49-
import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned
49+
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseAborted, LiquidityPurchaseSigned}
5050
import fr.acinq.eclair.payment.relay.Relayer
5151
import fr.acinq.eclair.payment.{Bolt11Invoice, PaymentSettlingOnChain}
5252
import fr.acinq.eclair.reputation.Reputation
@@ -1363,6 +1363,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
13631363
case SpliceStatus.SpliceWaitingForSigs(signingSession) =>
13641364
log.info("our peer aborted the splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data)
13651365
rollbackFundingAttempt(signingSession.fundingTx.tx, previousTxs = Seq.empty) // no splice rbf yet
1366+
signingSession.liquidityPurchase_opt.collect {
1367+
case purchase if !signingSession.fundingParams.isInitiator => peer ! LiquidityPurchaseAborted(d.channelId, signingSession.fundingTx.txId, signingSession.fundingTxIndex)
1368+
}
13661369
stay() using d.copy(spliceStatus = SpliceStatus.NoSplice) sending TxAbort(d.channelId, SpliceAttemptAborted(d.channelId).getMessage) calling endQuiescence(d)
13671370
case SpliceStatus.SpliceRequested(cmd, _) =>
13681371
log.info("our peer rejected our splice attempt: ascii='{}' bin={}", msg.toAscii, msg.data)

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTrans
2525
import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession}
2626
import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId
2727
import fr.acinq.eclair.crypto.ShaChain
28-
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseSigned, OpenChannelResponse}
28+
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseAborted, LiquidityPurchaseSigned, OpenChannelResponse}
2929
import fr.acinq.eclair.transactions.Transactions
3030
import fr.acinq.eclair.wire.protocol._
3131
import fr.acinq.eclair.{ToMilliSatoshiConversion, randomBytes32}
@@ -412,6 +412,9 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
412412
case msg: TxAbort =>
413413
log.info("our peer aborted the dual funding flow: ascii='{}' bin={}", msg.toAscii, msg.data)
414414
rollbackFundingAttempt(d.signingSession.fundingTx.tx, Nil)
415+
d.signingSession.liquidityPurchase_opt.collect {
416+
case purchase if !d.signingSession.fundingParams.isInitiator => peer ! LiquidityPurchaseAborted(d.channelId, d.signingSession.fundingTx.txId, d.signingSession.fundingTxIndex)
417+
}
415418
goto(CLOSED) using IgnoreClosedData(d) sending TxAbort(d.channelId, DualFundingAborted(d.channelId).getMessage)
416419
case msg: InteractiveTxConstructionMessage =>
417420
log.info("received unexpected interactive-tx message: {}", msg.getClass.getSimpleName)

eclair-core/src/main/scala/fr/acinq/eclair/io/Peer.scala

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -740,9 +740,8 @@ class Peer(val nodeParams: NodeParams,
740740
}
741741
// We signed a liquidity purchase from our peer. At that point we're not 100% sure yet it will succeed: if
742742
// we disconnect before our peer sends their signature, the funding attempt may be cancelled when reconnecting.
743-
// If that happens, the on-the-fly proposal will stay in our state until we reach the CLTV expiry, at which
744-
// point we will forget it and fail the upstream HTLCs. This is also what would happen if we successfully
745-
// funded the channel, but it closed before we could relay the HTLCs.
743+
// If that happens, we will receive a LiquidityPurchaseAborted event, which is handled below, at which point we
744+
// will forget the purchase and fail the upstream HTLCs.
746745
val (paymentHashes, feesOwed) = e.purchase.paymentDetails match {
747746
case LiquidityAds.PaymentDetails.FromChannelBalance => (Nil, 0 msat)
748747
case p: LiquidityAds.PaymentDetails.FromChannelBalanceForFutureHtlc => (p.paymentHashes, 0 msat)
@@ -786,6 +785,19 @@ class Peer(val nodeParams: NodeParams,
786785
})
787786
stay()
788787

788+
case Event(e: LiquidityPurchaseAborted, _: ConnectedData) =>
789+
pendingOnTheFlyFunding.foreach {
790+
case (paymentHash, pending) => pending.status match {
791+
case status: OnTheFlyFunding.Status.Funded if status.channelId == e.channelId && status.txId == e.txId && status.fundingTxIndex == e.fundingTxIndex =>
792+
log.warning("funded will_add_htlc aborted by our peer after funding for payment_hash={} and fundingTxId={}", paymentHash, status.txId)
793+
pending.createFailureCommands(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
794+
nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash)
795+
pendingOnTheFlyFunding -= paymentHash
796+
case _ => ()
797+
}
798+
}
799+
stay()
800+
789801
case Event(e: OnTheFlyFunding.PaymentRelayer.RelayResult, _) =>
790802
e match {
791803
case success: OnTheFlyFunding.PaymentRelayer.RelaySuccess =>
@@ -1133,6 +1145,7 @@ object Peer {
11331145

11341146
/** We signed a funding transaction where our peer purchased some liquidity. */
11351147
case class LiquidityPurchaseSigned(channelId: ByteVector32, txId: TxId, fundingTxIndex: Long, htlcMinimum: MilliSatoshi, purchase: LiquidityAds.Purchase)
1148+
case class LiquidityPurchaseAborted(channelId: ByteVector32, txId: TxId, fundingTxIndex: Long)
11361149

11371150
case class OnTheFlyFundingTimeout(paymentHash: ByteVector32)
11381151

eclair-core/src/test/scala/fr/acinq/eclair/channel/states/b/WaitForDualFundingSignedStateSpec.scala

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.{FullySignedSharedTrans
2929
import fr.acinq.eclair.channel.publish.TxPublisher
3030
import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags}
3131
import fr.acinq.eclair.crypto.NonceGenerator
32-
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseSigned, OpenChannelResponse}
32+
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseAborted, LiquidityPurchaseSigned, OpenChannelResponse}
3333
import fr.acinq.eclair.transactions.Transactions._
3434
import fr.acinq.eclair.wire.protocol._
3535
import fr.acinq.eclair.{Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion, randomBytes32, randomKey}
@@ -233,6 +233,35 @@ class WaitForDualFundingSignedStateSpec extends TestKitBaseClass with FixtureAny
233233
assert(aliceData.latestFundingTx.sharedTx.asInstanceOf[FullySignedSharedTransaction].signedTx.txid == fundingTxId)
234234
}
235235

236+
test("complete interactive-tx protocol then aborts (with liquidity ads)", Tag(ChannelStateTestsTags.DualFunding), Tag(ChannelStateTestsTags.LiquidityAds)) { f =>
237+
import f._
238+
239+
// We don't forward signatures to simulate an abort.
240+
bob2alice.expectMsgType[CommitSig]
241+
alice2bob.expectMsgType[CommitSig]
242+
243+
// Bob signed a liquidity purchase.
244+
val purchase = bobPeer.fishForMessage() {
245+
case l: LiquidityPurchaseSigned =>
246+
assert(l.purchase.paymentDetails == LiquidityAds.PaymentDetails.FromChannelBalance)
247+
assert(l.fundingTxIndex == 0)
248+
true
249+
case _ => false
250+
}
251+
252+
// Alice aborts the channel open.
253+
alice2bob.forward(bob, TxAbort(alice.stateData.channelId, "changed my mind"))
254+
bobPeer.fishForMessage() {
255+
case l: LiquidityPurchaseAborted =>
256+
assert(l.txId == purchase.asInstanceOf[LiquidityPurchaseSigned].txId)
257+
assert(l.fundingTxIndex == 0)
258+
true
259+
case _ => false
260+
}
261+
bobListener.expectMsgType[ChannelAborted]
262+
awaitCond(bob.stateName == CLOSED)
263+
}
264+
236265
test("recv invalid CommitSig", Tag(ChannelStateTestsTags.DualFunding)) { f =>
237266
import f._
238267

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId
3535
import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFactory, PimpTestFSM}
3636
import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags}
3737
import fr.acinq.eclair.db.RevokedHtlcInfoCleaner.ForgetHtlcInfos
38-
import fr.acinq.eclair.io.Peer.LiquidityPurchaseSigned
38+
import fr.acinq.eclair.io.Peer.{LiquidityPurchaseAborted, LiquidityPurchaseSigned}
3939
import fr.acinq.eclair.payment.relay.Relayer
4040
import fr.acinq.eclair.reputation.Reputation
4141
import fr.acinq.eclair.testutils.PimpTestProbe.convert
@@ -466,6 +466,59 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
466466
}
467467
}
468468

469+
test("recv CMD_SPLICE (splice-in, liquidity ads, aborted)") { f =>
470+
import f._
471+
472+
val sender = TestProbe()
473+
val fundingRequest = LiquidityAds.RequestFunding(400_000 sat, TestConstants.defaultLiquidityRates.fundingRates.head, LiquidityAds.PaymentDetails.FromChannelBalance)
474+
val cmd = CMD_SPLICE(sender.ref, Some(SpliceIn(500_000 sat)), None, Some(fundingRequest), None)
475+
alice ! cmd
476+
477+
exchangeStfu(alice, bob, alice2bob, bob2alice)
478+
assert(alice2bob.expectMsgType[SpliceInit].requestFunding_opt.nonEmpty)
479+
alice2bob.forward(bob)
480+
assert(bob2alice.expectMsgType[SpliceAck].willFund_opt.nonEmpty)
481+
bob2alice.forward(alice)
482+
alice2bob.expectMsgType[TxAddInput]
483+
alice2bob.forward(bob)
484+
bob2alice.expectMsgType[TxAddInput]
485+
bob2alice.forward(alice)
486+
alice2bob.expectMsgType[TxAddInput]
487+
alice2bob.forward(bob)
488+
bob2alice.expectMsgType[TxAddOutput]
489+
bob2alice.forward(alice)
490+
alice2bob.expectMsgType[TxAddOutput]
491+
alice2bob.forward(bob)
492+
bob2alice.expectMsgType[TxComplete]
493+
bob2alice.forward(alice)
494+
alice2bob.expectMsgType[TxAddOutput]
495+
alice2bob.forward(bob)
496+
bob2alice.expectMsgType[TxComplete]
497+
bob2alice.forward(alice)
498+
alice2bob.expectMsgType[TxComplete]
499+
alice2bob.forward(bob)
500+
501+
// Bob signed a liquidity purchase.
502+
val purchase = bobPeer.fishForMessage() {
503+
case l: LiquidityPurchaseSigned =>
504+
assert(l.purchase.paymentDetails == LiquidityAds.PaymentDetails.FromChannelBalance)
505+
assert(l.fundingTxIndex == 1)
506+
true
507+
case _ => false
508+
}
509+
510+
// Alice aborts the splice instead of signing it: Bob will notify the peer actor to allow failing the corresponding
511+
// upstream HTLCs instead of holding them until they expire.
512+
alice2bob.forward(bob, TxAbort(alice.stateData.channelId, "thanks but no thanks"))
513+
bobPeer.fishForMessage() {
514+
case l: LiquidityPurchaseAborted =>
515+
assert(l.txId == purchase.asInstanceOf[LiquidityPurchaseSigned].txId)
516+
assert(l.fundingTxIndex == 1)
517+
true
518+
case _ => false
519+
}
520+
}
521+
469522
test("recv CMD_SPLICE (splice-in, liquidity ads, invalid will_fund signature)") { f =>
470523
import f._
471524

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import akka.testkit.{TestFSMRef, TestProbe}
2222
import com.softwaremill.quicklens.ModifyPimp
2323
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2424
import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, Satoshi, SatoshiLong, TxId}
25+
import fr.acinq.eclair.TestUtils.randomTxId
2526
import fr.acinq.eclair.blockchain.{CurrentBlockHeight, DummyOnChainWallet}
2627
import fr.acinq.eclair.channel.Upstream.Hot
2728
import fr.acinq.eclair.channel._
@@ -436,6 +437,34 @@ class OnTheFlyFundingSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike {
436437
peerConnection.expectNoMessage(100 millis)
437438
}
438439

440+
test("signed on-the-fly funding aborted") { f =>
441+
import f._
442+
443+
connect(peer)
444+
445+
// A funding proposal is signed.
446+
val upstream1 = upstreamChannel(60_000_000 msat, CltvExpiry(560))
447+
proposeFunding(50_000_000 msat, CltvExpiry(520), upstream1.add.paymentHash, upstream1)
448+
val purchase = signLiquidityPurchase(75_000 sat, LiquidityAds.PaymentDetails.FromFutureHtlc(upstream1.add.paymentHash :: Nil))
449+
450+
// An unrelated liquidity purchase is aborted.
451+
peer ! LiquidityPurchaseAborted(purchase.channelId, randomTxId(), purchase.fundingTxIndex)
452+
register.expectNoMessage(100 millis)
453+
awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).contains(upstream1.add.paymentHash))
454+
455+
// Our peer then decides to abort instead of exchanging splice signatures.
456+
peer ! LiquidityPurchaseAborted(purchase.channelId, purchase.txId, purchase.fundingTxIndex)
457+
458+
// We fail the corresponding upstream HTLC.
459+
val fwd = register.expectMsgType[Register.Forward[CMD_FAIL_HTLC]]
460+
assert(fwd.channelId == upstream1.add.channelId)
461+
assert(fwd.message.id == upstream1.add.id)
462+
register.expectNoMessage(100 millis)
463+
464+
// And forget the liquidity purchase.
465+
awaitCond(nodeParams.db.liquidity.listPendingOnTheFlyFunding(remoteNodeId).isEmpty)
466+
}
467+
439468
test("signed on-the-fly funding HTLC timeout after disconnection") { f =>
440469
import f._
441470

0 commit comments

Comments
 (0)