Skip to content

Commit 8381fc4

Browse files
authored
Decrypt on-the-fly funding trampoline failures (#2960)
While we ignore the actual failure and always return a temporary failure upstream, it's useful to decrypt the recipient failure to log it. When we add support for trampoline errors, we will need the decrypted failure to be able to re-wrap it with trampoline onion secrets.
1 parent feef44b commit 8381fc4

File tree

8 files changed

+59
-35
lines changed

8 files changed

+59
-35
lines changed

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

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import fr.acinq.eclair.blockchain.fee.FeeratePerKw
3232
import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates, OnChainChannelFunder, OnchainPubkeyCache}
3333
import fr.acinq.eclair.channel._
3434
import fr.acinq.eclair.channel.fsm.Channel
35+
import fr.acinq.eclair.crypto.Sphinx
3536
import fr.acinq.eclair.db.PendingCommandsDb
3637
import fr.acinq.eclair.io.MessageRelay.Status
3738
import fr.acinq.eclair.io.Monitoring.{Metrics, Tags}
@@ -44,7 +45,7 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
4445
import fr.acinq.eclair.router.Router
4546
import fr.acinq.eclair.wire.protocol
4647
import fr.acinq.eclair.wire.protocol.FailureMessageCodecs.createBadOnionFailure
47-
import fr.acinq.eclair.wire.protocol.{AddFeeCredit, ChannelTlv, CurrentFeeCredit, Error, FailureReason, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RecommendedFeerates, RoutingMessage, SpliceInit, TemporaryChannelFailure, TlvStream, TxAbort, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc}
48+
import fr.acinq.eclair.wire.protocol.{AddFeeCredit, ChannelTlv, CurrentFeeCredit, Error, FailureReason, HasChannelId, HasTemporaryChannelId, LightningMessage, LiquidityAds, NodeAddress, OnTheFlyFundingFailureMessage, OnionMessage, OnionRoutingPacket, RecommendedFeerates, RoutingMessage, SpliceInit, TlvStream, TxAbort, UnknownMessage, Warning, WillAddHtlc, WillFailHtlc, WillFailMalformedHtlc}
4849

4950
/**
5051
* This actor represents a logical peer. There is one [[Peer]] per unique remote node id at all time.
@@ -267,27 +268,27 @@ class Peer(val nodeParams: NodeParams,
267268
status.timer.cancel()
268269
val timer = context.system.scheduler.scheduleOnce(nodeParams.onTheFlyFundingConfig.proposalTimeout, self, OnTheFlyFundingTimeout(cmd.paymentHash))(context.dispatcher)
269270
pending.copy(
270-
proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream),
271+
proposed = pending.proposed :+ OnTheFlyFunding.Proposal(htlc, cmd.upstream, cmd.onionSharedSecrets),
271272
status = OnTheFlyFunding.Status.Proposed(timer)
272273
)
273274
case status: OnTheFlyFunding.Status.AddedToFeeCredit =>
274275
log.info("received extra payment for on-the-fly funding that was added to fee credit (payment_hash={}, amount={})", cmd.paymentHash, cmd.amount)
275-
val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream)
276+
val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream, cmd.onionSharedSecrets)
276277
proposal.createFulfillCommands(status.preimage).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
277278
pending.copy(proposed = pending.proposed :+ proposal)
278279
case status: OnTheFlyFunding.Status.Funded =>
279280
log.info("rejecting extra payment for on-the-fly funding that has already been funded with txId={} (payment_hash={}, amount={})", status.txId, cmd.paymentHash, cmd.amount)
280281
// The payer is buggy and is paying the same payment_hash multiple times. We could simply claim that
281282
// extra payment for ourselves, but we're nice and instead immediately fail it.
282-
val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream)
283-
proposal.createFailureCommands(None).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
283+
val proposal = OnTheFlyFunding.Proposal(htlc, cmd.upstream, cmd.onionSharedSecrets)
284+
proposal.createFailureCommands(None)(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
284285
pending
285286
}
286287
case None =>
287288
self ! Peer.OutgoingMessage(htlc, d.peerConnection)
288289
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Proposed).increment()
289290
val timer = context.system.scheduler.scheduleOnce(nodeParams.onTheFlyFundingConfig.proposalTimeout, self, OnTheFlyFundingTimeout(cmd.paymentHash))(context.dispatcher)
290-
OnTheFlyFunding.Pending(Seq(OnTheFlyFunding.Proposal(htlc, cmd.upstream)), OnTheFlyFunding.Status.Proposed(timer))
291+
OnTheFlyFunding.Pending(Seq(OnTheFlyFunding.Proposal(htlc, cmd.upstream, cmd.onionSharedSecrets)), OnTheFlyFunding.Status.Proposed(timer))
291292
}
292293
pendingOnTheFlyFunding += (htlc.paymentHash -> pending)
293294
stay()
@@ -303,7 +304,7 @@ class Peer(val nodeParams: NodeParams,
303304
case msg: WillFailHtlc => FailureReason.EncryptedDownstreamFailure(msg.reason)
304305
case msg: WillFailMalformedHtlc => FailureReason.LocalFailure(createBadOnionFailure(msg.onionHash, msg.failureCode))
305306
}
306-
htlc.createFailureCommands(Some(failure)).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
307+
htlc.createFailureCommands(Some(failure))(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
307308
val proposed1 = pending.proposed.filterNot(_.htlc.id == msg.id)
308309
if (proposed1.isEmpty) {
309310
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Rejected).increment()
@@ -335,7 +336,7 @@ class Peer(val nodeParams: NodeParams,
335336
pending.status match {
336337
case _: OnTheFlyFunding.Status.Proposed =>
337338
log.warning("on-the-fly funding proposal timed out for payment_hash={}", timeout.paymentHash)
338-
pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
339+
pending.createFailureCommands(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
339340
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Expired).increment()
340341
pendingOnTheFlyFunding -= timeout.paymentHash
341342
self ! Peer.OutgoingMessage(Warning(s"on-the-fly funding proposal timed out for payment_hash=${timeout.paymentHash}"), d.peerConnection)
@@ -584,14 +585,14 @@ class Peer(val nodeParams: NodeParams,
584585
case _: OnTheFlyFunding.Status.Proposed =>
585586
log.warning("proposed will_add_htlc expired for payment_hash={}", paymentHash)
586587
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment()
587-
pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
588+
pending.createFailureCommands(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
588589
case _: OnTheFlyFunding.Status.AddedToFeeCredit =>
589590
// Nothing to do, we already fulfilled the upstream HTLCs.
590591
log.debug("forgetting will_add_htlc added to fee credit for payment_hash={}", paymentHash)
591592
case _: OnTheFlyFunding.Status.Funded =>
592593
log.warning("funded will_add_htlc expired for payment_hash={}, our peer may be malicious", paymentHash)
593594
Metrics.OnTheFlyFunding.withTag(Tags.OnTheFlyFundingState, Tags.OnTheFlyFundingStates.Timeout).increment()
594-
pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
595+
pending.createFailureCommands(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
595596
nodeParams.db.liquidity.removePendingOnTheFlyFunding(remoteNodeId, paymentHash)
596597
}
597598
}
@@ -675,7 +676,7 @@ class Peer(val nodeParams: NodeParams,
675676
// We emit a relay event: since we waited for on-chain funding before relaying the payment, the timestamps
676677
// won't be accurate, but everything else is.
677678
pending.proposed.foreach {
678-
case OnTheFlyFunding.Proposal(htlc, upstream) => upstream match {
679+
case OnTheFlyFunding.Proposal(htlc, upstream, _) => upstream match {
679680
case _: Upstream.Local => ()
680681
case u: Upstream.Hot.Channel =>
681682
val incoming = PaymentRelayed.IncomingPart(u.add.amountMsat, u.add.channelId, u.receivedAt)
@@ -810,7 +811,7 @@ class Peer(val nodeParams: NodeParams,
810811
case status: OnTheFlyFunding.Status.Proposed =>
811812
log.info("cancelling on-the-fly funding for payment_hash={}", paymentHash)
812813
status.timer.cancel()
813-
pending.createFailureCommands().foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
814+
pending.createFailureCommands(log).foreach { case (channelId, cmd) => PendingCommandsDb.safeSend(register, nodeParams.db.pendingCommands, channelId, cmd) }
814815
true
815816
// We keep proposals that have been added to fee credit until we reach the HTLC expiry or we restart. This
816817
// guarantees that our peer cannot concurrently add to their fee credit a payment for which we've signed a
@@ -983,7 +984,7 @@ object Peer {
983984
case class SpawnChannelNonInitiator(open: Either[protocol.OpenChannel, protocol.OpenDualFundedChannel], channelConfig: ChannelConfig, channelType: SupportedChannelType, addFunding_opt: Option[LiquidityAds.AddFunding], localParams: LocalParams, peerConnection: ActorRef)
984985

985986
/** If [[Features.OnTheFlyFunding]] is supported and we're connected, relay a funding proposal to our peer. */
986-
case class ProposeOnTheFlyFunding(replyTo: typed.ActorRef[ProposeOnTheFlyFundingResponse], amount: MilliSatoshi, paymentHash: ByteVector32, expiry: CltvExpiry, onion: OnionRoutingPacket, nextPathKey_opt: Option[PublicKey], upstream: Upstream.Hot)
987+
case class ProposeOnTheFlyFunding(replyTo: typed.ActorRef[ProposeOnTheFlyFundingResponse], amount: MilliSatoshi, paymentHash: ByteVector32, expiry: CltvExpiry, onion: OnionRoutingPacket, onionSharedSecrets: Seq[Sphinx.SharedSecret], nextPathKey_opt: Option[PublicKey], upstream: Upstream.Hot)
987988

988989
sealed trait ProposeOnTheFlyFundingResponse
989990
object ProposeOnTheFlyFundingResponse {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,8 @@ class ChannelRelay private(nodeParams: NodeParams,
186186
context.log.info("rejecting htlc reason={}", cmdFail.reason)
187187
safeSendAndStop(r.add.channelId, cmdFail)
188188
case RelayNeedsFunding(nextNodeId, cmdFail) =>
189-
val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, nextPathKey_opt, upstream)
189+
// Note that in the channel relay case, we don't have any outgoing onion shared secrets.
190+
val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, r.amountToForward, r.add.paymentHash, r.outgoingCltv, r.nextPacket, Nil, nextPathKey_opt, upstream)
190191
register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, nextNodeId, cmd)
191192
waitForOnTheFlyFundingResponse(cmdFail)
192193
case RelaySuccess(selectedChannelId, cmdAdd) =>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ class NodeRelay private(nodeParams: NodeParams,
422422
case Right(nextPacket) =>
423423
val forwardNodeIdFailureAdapter = context.messageAdapter[Register.ForwardNodeIdFailure[Peer.ProposeOnTheFlyFunding]](_ => WrappedOnTheFlyFundingResponse(Peer.ProposeOnTheFlyFundingResponse.NotAvailable("peer not found")))
424424
val onTheFlyFundingResponseAdapter = context.messageAdapter[Peer.ProposeOnTheFlyFundingResponse](WrappedOnTheFlyFundingResponse)
425-
val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, amountOut, paymentHash, expiryOut, nextPacket.cmd.onion, nextPacket.cmd.nextPathKey_opt, upstream)
425+
val cmd = Peer.ProposeOnTheFlyFunding(onTheFlyFundingResponseAdapter, amountOut, paymentHash, expiryOut, nextPacket.cmd.onion, nextPacket.sharedSecrets, nextPacket.cmd.nextPathKey_opt, upstream)
426426
register ! Register.ForwardNodeId(forwardNodeIdFailureAdapter, walletNodeId, cmd)
427427
Behaviors.receiveMessagePartial {
428428
rejectExtraHtlcPartialFunction orElse {

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import akka.actor.Cancellable
2020
import akka.actor.typed.scaladsl.adapter.TypedActorRefOps
2121
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
2222
import akka.actor.typed.{ActorRef, Behavior}
23+
import akka.event.LoggingAdapter
2324
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
2425
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, TxId}
2526
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
@@ -87,12 +88,12 @@ object OnTheFlyFunding {
8788
// @formatter:on
8889

8990
/** An on-the-fly funding proposal sent to our peer. */
90-
case class Proposal(htlc: WillAddHtlc, upstream: Upstream.Hot) {
91+
case class Proposal(htlc: WillAddHtlc, upstream: Upstream.Hot, onionSharedSecrets: Seq[Sphinx.SharedSecret]) {
9192
/** Maximum fees that can be collected from this HTLC. */
9293
def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = htlc.amount - htlcMinimum
9394

9495
/** Create commands to fail all upstream HTLCs. */
95-
def createFailureCommands(failure_opt: Option[FailureReason]): Seq[(ByteVector32, CMD_FAIL_HTLC)] = upstream match {
96+
def createFailureCommands(failure_opt: Option[FailureReason])(implicit log: LoggingAdapter): Seq[(ByteVector32, CMD_FAIL_HTLC)] = upstream match {
9697
case _: Upstream.Local => Nil
9798
case u: Upstream.Hot.Channel =>
9899
val failure = htlc.pathKey_opt match {
@@ -101,11 +102,18 @@ object OnTheFlyFunding {
101102
}
102103
Seq(u.add.channelId -> CMD_FAIL_HTLC(u.add.id, failure, commit = true))
103104
case u: Upstream.Hot.Trampoline =>
104-
// In the trampoline case, we currently ignore downstream failures: we should add dedicated failures to the
105-
// BOLTs to better handle those cases.
106105
val failure = failure_opt match {
107106
case Some(f) => f match {
108-
case _: FailureReason.EncryptedDownstreamFailure => FailureReason.LocalFailure(TemporaryNodeFailure())
107+
case f: FailureReason.EncryptedDownstreamFailure =>
108+
// In the trampoline case, we currently ignore downstream failures: we should add dedicated failures to
109+
// the BOLTs to better handle those cases.
110+
Sphinx.FailurePacket.decrypt(f.packet, onionSharedSecrets) match {
111+
case Left(Sphinx.CannotDecryptFailurePacket(_)) =>
112+
log.warning("couldn't decrypt downstream on-the-fly funding failure")
113+
case Right(f) =>
114+
log.warning("downstream on-the-fly funding failure: {}", f.failureMessage.message)
115+
}
116+
FailureReason.LocalFailure(TemporaryNodeFailure())
109117
case _: FailureReason.LocalFailure => f
110118
}
111119
case None => FailureReason.LocalFailure(UnknownNextPeer())
@@ -131,7 +139,7 @@ object OnTheFlyFunding {
131139
def maxFees(htlcMinimum: MilliSatoshi): MilliSatoshi = proposed.map(_.maxFees(htlcMinimum)).sum
132140

133141
/** Create commands to fail all upstream HTLCs. */
134-
def createFailureCommands(): Seq[(ByteVector32, CMD_FAIL_HTLC)] = proposed.flatMap(_.createFailureCommands(None))
142+
def createFailureCommands(implicit log: LoggingAdapter): Seq[(ByteVector32, CMD_FAIL_HTLC)] = proposed.flatMap(_.createFailureCommands(None))
135143

136144
/** Create commands to fulfill all upstream HTLCs. */
137145
def createFulfillCommands(preimage: ByteVector32): Seq[(ByteVector32, CMD_FULFILL_HTLC)] = proposed.flatMap(_.createFulfillCommands(preimage))
@@ -355,7 +363,13 @@ object OnTheFlyFunding {
355363
.typecase(0x01, upstreamChannel)
356364
.typecase(0x02, upstreamTrampoline)
357365

358-
val proposal: Codec[Proposal] = (("willAddHtlc" | lengthDelimited(willAddHtlcCodec)) :: ("upstream" | upstream)).as[Proposal]
366+
val proposal: Codec[Proposal] = (
367+
("willAddHtlc" | lengthDelimited(willAddHtlcCodec)) ::
368+
("upstream" | upstream) ::
369+
// We don't need to persist the onion shared secrets: we only persist on-the-fly funding proposals once they
370+
// have been funded, at which point we will ignore downstream failures.
371+
("onionSharedSecrets" | provide(Seq.empty[Sphinx.SharedSecret]))
372+
).as[Proposal]
359373

360374
val proposals: Codec[Seq[Proposal]] = listOfN(uint16, proposal).xmap(_.toSeq, _.toList)
361375

eclair-core/src/test/scala/fr/acinq/eclair/db/LiquidityDbSpec.scala

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,28 +78,28 @@ class LiquidityDbSpec extends AnyFunSuite {
7878
val pendingAlice = Seq(
7979
OnTheFlyFunding.Pending(
8080
proposed = Seq(
81-
OnTheFlyFunding.Proposal(createWillAdd(20_000 msat, paymentHash1, CltvExpiry(500)), upstream(0)),
82-
OnTheFlyFunding.Proposal(createWillAdd(1 msat, paymentHash1, CltvExpiry(750), Some(randomKey().publicKey)), upstream(1)),
81+
OnTheFlyFunding.Proposal(createWillAdd(20_000 msat, paymentHash1, CltvExpiry(500)), upstream(0), Nil),
82+
OnTheFlyFunding.Proposal(createWillAdd(1 msat, paymentHash1, CltvExpiry(750), Some(randomKey().publicKey)), upstream(1), Nil),
8383
),
8484
status = OnTheFlyFunding.Status.Funded(randomBytes32(), TxId(randomBytes32()), 7, 500 msat)
8585
),
8686
OnTheFlyFunding.Pending(
8787
proposed = Seq(
88-
OnTheFlyFunding.Proposal(createWillAdd(195_000_000 msat, paymentHash2, CltvExpiry(1000)), Upstream.Hot.Trampoline(upstream(2) :: upstream(3) :: Nil)),
88+
OnTheFlyFunding.Proposal(createWillAdd(195_000_000 msat, paymentHash2, CltvExpiry(1000)), Upstream.Hot.Trampoline(upstream(2) :: upstream(3) :: Nil), Nil),
8989
),
9090
status = OnTheFlyFunding.Status.Funded(randomBytes32(), TxId(randomBytes32()), 3, 0 msat)
9191
)
9292
)
9393
val pendingBob = Seq(
9494
OnTheFlyFunding.Pending(
9595
proposed = Seq(
96-
OnTheFlyFunding.Proposal(createWillAdd(20_000 msat, paymentHash1, CltvExpiry(42)), upstream(0)),
96+
OnTheFlyFunding.Proposal(createWillAdd(20_000 msat, paymentHash1, CltvExpiry(42)), upstream(0), Nil),
9797
),
9898
status = OnTheFlyFunding.Status.Funded(randomBytes32(), TxId(randomBytes32()), 11, 3_500 msat)
9999
),
100100
OnTheFlyFunding.Pending(
101101
proposed = Seq(
102-
OnTheFlyFunding.Proposal(createWillAdd(24_000_000 msat, paymentHash2, CltvExpiry(800_000), Some(randomKey().publicKey)), Upstream.Local(UUID.randomUUID())),
102+
OnTheFlyFunding.Proposal(createWillAdd(24_000_000 msat, paymentHash2, CltvExpiry(800_000), Some(randomKey().publicKey)), Upstream.Local(UUID.randomUUID()), Nil),
103103
),
104104
status = OnTheFlyFunding.Status.Funded(randomBytes32(), TxId(randomBytes32()), 0, 10_000 msat)
105105
)

0 commit comments

Comments
 (0)