Skip to content

Commit 71465d8

Browse files
committed
Refactor trampoline-to-legacy payments
We refactor trampoline-to-legacy payments to use a dedicated class, like what we do for trampoline-to-blinded-paths payments. This allows us to supports two encodings for those payments: - one where the trampoline onion contains a dummy payload for the recipient that must be ignored (current Phoenix wallets), which wastes space in the onion for legacy reasons - one where we don't include a dummy payload for the recipient, which is more efficient and similar to trampoline-to-blinded-paths
1 parent 02abc3a commit 71465d8

File tree

7 files changed

+168
-96
lines changed

7 files changed

+168
-96
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/payment/PaymentPacket.scala

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import fr.acinq.eclair.channel.{CMD_ADD_HTLC, CMD_FAIL_HTLC, CannotExtractShared
2222
import fr.acinq.eclair.crypto.Sphinx
2323
import fr.acinq.eclair.payment.send.Recipient
2424
import fr.acinq.eclair.router.Router.Route
25-
import fr.acinq.eclair.wire.protocol.OnionPaymentPayloadTlv.OutgoingBlindedPaths
25+
import fr.acinq.eclair.wire.protocol.OnionPaymentPayloadTlv.{InvoiceRoutingInfo, OutgoingBlindedPaths}
2626
import fr.acinq.eclair.wire.protocol.PaymentOnion.{FinalPayload, IntermediatePayload, PerHopPayload}
2727
import fr.acinq.eclair.wire.protocol._
2828
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Feature, Features, MilliSatoshi, ShortChannelId, UInt64, randomKey}
@@ -59,6 +59,7 @@ object IncomingPaymentPacket {
5959
def innerPayload: IntermediatePayload.NodeRelay
6060
}
6161
case class RelayToTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Standard, nextPacket: OnionRoutingPacket) extends NodeRelayPacket
62+
case class RelayToNonTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToNonTrampoline) extends NodeRelayPacket
6263
case class RelayToBlindedPathsPacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToBlindedPaths) extends NodeRelayPacket
6364
// @formatter:on
6465

@@ -113,16 +114,18 @@ object IncomingPaymentPacket {
113114
* @return whether the payment is to be relayed or if our node is the final recipient (or an error).
114115
*/
115116
def decrypt(add: UpdateAddHtlc, privateKey: PrivateKey, features: Features[Feature]): Either[FailureMessage, IncomingPaymentPacket] = {
116-
// We first derive the decryption key used to peel the onion.
117+
// We first derive the decryption key used to peel the outer onion.
117118
val outerOnionDecryptionKey = add.blinding_opt match {
118119
case Some(blinding) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, blinding)
119120
case None => privateKey
120121
}
121122
decryptOnion(add.paymentHash, outerOnionDecryptionKey, add.onionRoutingPacket).flatMap {
122123
case DecodedOnionPacket(payload, Some(nextPacket)) =>
124+
// We are an intermediate node: we need to relay to one of our peers.
123125
payload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
124126
case Some(_) if !features.hasFeature(Features.RouteBlinding) => Left(InvalidOnionPayload(UInt64(10), 0))
125127
case Some(encrypted) =>
128+
// We are inside a blinded path: channel relay information is encrypted.
126129
decryptEncryptedRecipientData(add, privateKey, payload, encrypted.data).flatMap {
127130
case DecodedEncryptedRecipientData(blindedPayload, nextBlinding) =>
128131
validateBlindedChannelRelayPayload(add, payload, blindedPayload, nextBlinding, nextPacket).flatMap {
@@ -132,14 +135,18 @@ object IncomingPaymentPacket {
132135
}
133136
}
134137
case None if add.blinding_opt.isDefined => Left(InvalidOnionBlinding(Sphinx.hash(add.onionRoutingPacket)))
135-
case None => IntermediatePayload.ChannelRelay.Standard.validate(payload).left.map(_.failureMessage).map {
136-
payload => ChannelRelayPacket(add, payload, nextPacket)
137-
}
138+
case None =>
139+
// We are not inside a blinded path: channel relay information is directly available.
140+
IntermediatePayload.ChannelRelay.Standard.validate(payload).left.map(_.failureMessage).map(payload => ChannelRelayPacket(add, payload, nextPacket))
138141
}
139142
case DecodedOnionPacket(payload, None) =>
143+
// We are the final node for the outer onion, so we are either:
144+
// - the final recipient of the payment.
145+
// - an intermediate trampoline node.
140146
payload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
141147
case Some(_) if !features.hasFeature(Features.RouteBlinding) => Left(InvalidOnionPayload(UInt64(10), 0))
142148
case Some(encrypted) =>
149+
// We are the final recipient of a blinded payment.
143150
decryptEncryptedRecipientData(add, privateKey, payload, encrypted.data).flatMap {
144151
case DecodedEncryptedRecipientData(blindedPayload, _) => validateBlindedFinalPayload(add, payload, blindedPayload)
145152
}
@@ -151,15 +158,33 @@ object IncomingPaymentPacket {
151158
// NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a
152159
// blinding point and use it to derive the decryption key for the blinded trampoline onion.
153160
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
154-
case DecodedOnionPacket(innerPayload, Some(next)) => validateNodeRelay(add, payload, innerPayload, next)
161+
case DecodedOnionPacket(innerPayload, Some(next)) =>
162+
// We are an intermediate trampoline node.
163+
if (innerPayload.get[InvoiceRoutingInfo].isDefined) {
164+
// The payment recipient doesn't support trampoline.
165+
// They can be reached with the invoice data provided.
166+
// The payer is a wallet using the legacy trampoline feature.
167+
validateTrampolineToNonTrampoline(add, payload, innerPayload)
168+
} else {
169+
validateNodeRelay(add, payload, innerPayload, next)
170+
}
155171
case DecodedOnionPacket(innerPayload, None) =>
156172
if (innerPayload.get[OutgoingBlindedPaths].isDefined) {
173+
// The payment recipient doesn't support trampoline.
174+
// They can be reached using the blinded paths provided.
157175
validateTrampolineToBlindedPaths(add, payload, innerPayload)
176+
} else if (innerPayload.get[InvoiceRoutingInfo].isDefined) {
177+
// The payment recipient doesn't support trampoline.
178+
// They can be reached with the invoice data provided.
179+
validateTrampolineToNonTrampoline(add, payload, innerPayload)
158180
} else {
181+
// We're the final recipient of this trampoline payment.
159182
validateTrampolineFinalPayload(add, payload, innerPayload)
160183
}
161184
}
162-
case None => validateFinalPayload(add, payload)
185+
case None =>
186+
// We are the final recipient of a standard (non-blinded, non-trampoline) payment.
187+
validateFinalPayload(add, payload)
163188
}
164189
}
165190
}
@@ -224,6 +249,16 @@ object IncomingPaymentPacket {
224249
}
225250
}
226251

252+
private def validateTrampolineToNonTrampoline(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, RelayToNonTrampolinePacket] = {
253+
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
254+
IntermediatePayload.NodeRelay.ToNonTrampoline.validate(innerPayload).left.map(_.failureMessage).flatMap {
255+
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
256+
case _ if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
257+
case innerPayload => Right(RelayToNonTrampolinePacket(add, outerPayload, innerPayload))
258+
}
259+
}
260+
}
261+
227262
private def validateTrampolineToBlindedPaths(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, RelayToBlindedPathsPacket] = {
228263
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
229264
IntermediatePayload.NodeRelay.ToBlindedPaths.validate(innerPayload).left.map(_.failureMessage).flatMap {
@@ -255,10 +290,9 @@ object OutgoingPaymentPacket {
255290
case class PaymentPayloads(amount: MilliSatoshi, expiry: CltvExpiry, payloads: Seq[NodePayload], outerBlinding_opt: Option[PublicKey])
256291

257292
sealed trait OutgoingPaymentError extends Throwable
258-
case class CannotCreateOnion(message: String) extends OutgoingPaymentError { override def getMessage: String = message }
293+
private case class CannotCreateOnion(message: String) extends OutgoingPaymentError { override def getMessage: String = message }
259294
case class InvalidRouteRecipient(expected: PublicKey, actual: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to $expected, got route to $actual" }
260295
case class IndirectRelayInBlindedRoute(nextNodeId: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"must relay directly to node $nextNodeId inside blinded route" }
261-
case class MissingTrampolineHop(trampolineNodeId: PublicKey) extends OutgoingPaymentError { override def getMessage: String = s"expected route to trampoline node $trampolineNodeId" }
262296
case class MissingBlindedHop(introductionNodeIds: Set[PublicKey]) extends OutgoingPaymentError { override def getMessage: String = s"expected blinded route using one of the following introduction nodes: ${introductionNodeIds.mkString(", ")}" }
263297
case object EmptyRoute extends OutgoingPaymentError { override def getMessage: String = "route cannot be empty" }
264298
// @formatter:on

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

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ object NodeRelay {
108108
val incomingPaymentHandler = context.actorOf(MultiPartPaymentFSM.props(nodeParams, paymentHash, totalAmountIn, mppFsmAdapters))
109109
val nextPacket_opt = nodeRelayPacket match {
110110
case IncomingPaymentPacket.RelayToTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket)
111+
case _: IncomingPaymentPacket.RelayToNonTrampolinePacket => None
111112
case _: IncomingPaymentPacket.RelayToBlindedPathsPacket => None
112113
}
113114
new NodeRelay(nodeParams, parent, register, relayId, paymentHash, nodeRelayPacket.outerPayload.paymentSecret, context, outgoingPaymentFactory, router)
@@ -126,12 +127,7 @@ object NodeRelay {
126127
} else if (payloadOut.amountToForward <= MilliSatoshi(0)) {
127128
Some(InvalidOnionPayload(UInt64(2), 0))
128129
} else {
129-
payloadOut match {
130-
// If we're relaying a standard payment to a non-trampoline recipient, we need the payment secret.
131-
case payloadOut: IntermediatePayload.NodeRelay.Standard if payloadOut.invoiceFeatures.isDefined && payloadOut.paymentSecret.isEmpty => Some(InvalidOnionPayload(UInt64(8), 0))
132-
case _: IntermediatePayload.NodeRelay.Standard => None
133-
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
134-
}
130+
None
135131
}
136132
}
137133

@@ -193,6 +189,7 @@ object NodeRelay {
193189
// Otherwise, we try to find a downstream error that we could decrypt.
194190
val outgoingNodeFailure = nextPayload match {
195191
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
192+
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
196193
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
197194
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
198195
}
@@ -257,20 +254,17 @@ class NodeRelay private(nodeParams: NodeParams,
257254
private def resolveNextNode(upstream: Upstream.Hot.Trampoline, nextPayload: IntermediatePayload.NodeRelay, nextPacket_opt: Option[OnionRoutingPacket]): Behavior[Command] = {
258255
nextPayload match {
259256
case payloadOut: IntermediatePayload.NodeRelay.Standard =>
260-
// If invoice features are provided in the onion, the sender is asking us to relay to a non-trampoline recipient.
261-
payloadOut.invoiceFeatures match {
262-
case Some(features) =>
263-
val extraEdges = payloadOut.invoiceRoutingInfo.getOrElse(Nil).flatMap(Bolt11Invoice.toExtraEdges(_, payloadOut.outgoingNodeId))
264-
val paymentSecret = payloadOut.paymentSecret.get // NB: we've verified that there was a payment secret in validateRelay
265-
val recipient = ClearRecipient(payloadOut.outgoingNodeId, Features(features).invoiceFeatures(), payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, extraEdges, payloadOut.paymentMetadata)
266-
context.log.debug("forwarding payment to non-trampoline recipient {}", recipient.nodeId)
267-
ensureRecipientReady(upstream, recipient, nextPayload, None)
268-
case None =>
269-
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
270-
val recipient = ClearRecipient(payloadOut.outgoingNodeId, Features.empty, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
271-
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
272-
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
273-
}
257+
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
258+
val recipient = ClearRecipient(payloadOut.outgoingNodeId, Features.empty, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
259+
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
260+
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
261+
case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline =>
262+
val paymentSecret = payloadOut.paymentSecret
263+
val features = Features(payloadOut.invoiceFeatures).invoiceFeatures()
264+
val extraEdges = payloadOut.invoiceRoutingInfo.flatMap(Bolt11Invoice.toExtraEdges(_, payloadOut.outgoingNodeId))
265+
val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, extraEdges, payloadOut.paymentMetadata)
266+
context.log.debug("forwarding payment to non-trampoline recipient {}", recipient.nodeId)
267+
ensureRecipientReady(upstream, recipient, nextPayload, None)
274268
case payloadOut: IntermediatePayload.NodeRelay.ToBlindedPaths =>
275269
// Blinded paths in Bolt 12 invoices may encode the introduction node with an scid and a direction: we need to
276270
// resolve that to a nodeId in order to reach that introduction node and use the blinded path.

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import fr.acinq.eclair.payment.PaymentSent.PartialPayment
2929
import fr.acinq.eclair.payment._
3030
import fr.acinq.eclair.router.Router.RouteParams
3131
import fr.acinq.eclair.wire.protocol.{PaymentOnion, PaymentOnionCodecs}
32-
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, Logs, MilliSatoshi, MilliSatoshiLong, NodeParams, ShortChannelId, randomBytes32}
32+
import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, Logs, MilliSatoshi, NodeParams, randomBytes32}
3333

3434
import java.util.UUID
3535

@@ -216,11 +216,8 @@ object TrampolinePayment {
216216
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard(totalAmount, expiry, invoice.nodeId)
217217
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, finalPayload) :: Nil, invoice.paymentHash, None).toOption.get
218218
case invoice: Bolt11Invoice =>
219-
// The recipient doesn't support trampoline: the trampoline node will convert the payment to a non-trampoline payment.
220-
// The final payload will thus never reach the recipient, so we create the smallest payload possible to avoid overflowing the trampoline onion size.
221-
val dummyPayload = PaymentOnion.IntermediatePayload.ChannelRelay.Standard(ShortChannelId(0), 0 msat, CltvExpiry(0))
222-
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.Standard.createNodeRelayToNonTrampolinePayload(totalAmount, totalAmount, expiry, invoice.nodeId, invoice)
223-
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: NodePayload(invoice.nodeId, dummyPayload) :: Nil, invoice.paymentHash, None).toOption.get
219+
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToNonTrampoline(totalAmount, totalAmount, expiry, invoice.nodeId, invoice)
220+
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get
224221
case invoice: Bolt12Invoice =>
225222
val trampolinePayload = PaymentOnion.IntermediatePayload.NodeRelay.ToBlindedPaths(totalAmount, expiry, invoice)
226223
buildOnion(NodePayload(trampolineNodeId, trampolinePayload) :: Nil, invoice.paymentHash, None).toOption.get

0 commit comments

Comments
 (0)