Skip to content

Commit db25da3

Browse files
committed
Add support for blinded trampoline payments
We add support for trampoline payments to blinded recipients, where each node of the blinded path is used as trampoline node. This is particularly useful to include custom TLVs from the payer to the recipient.
1 parent 8ebe621 commit db25da3

File tree

8 files changed

+588
-52
lines changed

8 files changed

+588
-52
lines changed

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

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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 RelayToBlindedTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Blinded, nextPacket: OnionRoutingPacket) extends NodeRelayPacket
6263
case class RelayToNonTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToNonTrampoline) extends NodeRelayPacket
6364
case class RelayToBlindedPathsPacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToBlindedPaths) extends NodeRelayPacket
6465
// @formatter:on
@@ -161,9 +162,14 @@ object IncomingPaymentPacket {
161162
val trampolinePacket_opt = payload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).orElse(payload.get[OnionPaymentPayloadTlv.LegacyTrampolineOnion].map(_.packet))
162163
trampolinePacket_opt match {
163164
case Some(trampolinePacket) =>
164-
// NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a
165-
// path key and use it to derive the decryption key for the blinded trampoline onion.
166-
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
165+
// If we are an intermediate trampoline node inside a blinded path, the payer doesn't know our node_id
166+
// and has encrypted the trampoline onion to our blinded node_id: in that case, the previous trampoline
167+
// node will provide the path key in the outer onion.
168+
val trampolineOnionDecryptionKey = payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey) match {
169+
case Some(pathKey) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, pathKey)
170+
case None => privateKey
171+
}
172+
decryptOnion(add.paymentHash, trampolineOnionDecryptionKey, trampolinePacket).flatMap {
167173
case DecodedOnionPacket(innerPayload, Some(next)) =>
168174
// We are an intermediate trampoline node.
169175
if (innerPayload.get[InvoiceRoutingInfo].isDefined) {
@@ -172,7 +178,8 @@ object IncomingPaymentPacket {
172178
// The payer is a wallet using the legacy trampoline feature.
173179
validateTrampolineToNonTrampoline(add, payload, innerPayload)
174180
} else {
175-
validateNodeRelay(add, payload, innerPayload, next)
181+
// The recipient supports trampoline (and may support blinded payments).
182+
validateNodeRelay(add, privateKey, payload, innerPayload, next)
176183
}
177184
case DecodedOnionPacket(innerPayload, None) =>
178185
if (innerPayload.get[OutgoingBlindedPaths].isDefined) {
@@ -184,8 +191,8 @@ object IncomingPaymentPacket {
184191
// They can be reached with the invoice data provided.
185192
validateTrampolineToNonTrampoline(add, payload, innerPayload)
186193
} else {
187-
// We're the final recipient of this trampoline payment.
188-
validateTrampolineFinalPayload(add, payload, innerPayload)
194+
// We're the final recipient of this trampoline payment (which may be blinded).
195+
validateTrampolineFinalPayload(add, privateKey, payload, innerPayload)
189196
}
190197
}
191198
case None =>
@@ -228,31 +235,50 @@ object IncomingPaymentPacket {
228235
}
229236
}
230237

231-
private def validateTrampolineFinalPayload(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
232-
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
233-
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
234-
FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
235-
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
236-
case _ if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
237-
case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry
238-
case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount
239-
case innerPayload =>
240-
// We merge contents from the outer and inner payloads.
241-
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
242-
val trampolinePacket = outerPayload.records.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet)
243-
Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata, trampolinePacket)))
244-
}
238+
private def validateTrampolineFinalPayload(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
239+
// The outer payload cannot use route blinding, but the inner payload may.
240+
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap {
241+
case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
242+
case outerPayload if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
243+
case outerPayload =>
244+
innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
245+
case Some(encrypted) =>
246+
decryptEncryptedRecipientData(add, privateKey, outerPayload.records, encrypted.data).flatMap {
247+
case DecodedEncryptedRecipientData(blindedPayload, _) => validateBlindedFinalPayload(add, innerPayload, blindedPayload)
248+
}
249+
case None =>
250+
FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
251+
case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry
252+
case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount
253+
case innerPayload =>
254+
// We merge contents from the outer and inner payloads.
255+
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
256+
val trampolinePacket = outerPayload.records.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet)
257+
Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata, trampolinePacket)))
258+
}
259+
}
245260
}
246261
}
247262

248-
private def validateNodeRelay(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, RelayToTrampolinePacket] = {
249-
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
250-
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
251-
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
252-
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
253-
case _ if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
254-
case innerPayload => Right(RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
255-
}
263+
private def validateNodeRelay(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, IncomingPaymentPacket] = {
264+
// The outer payload cannot use route blinding, but the inner payload may.
265+
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap {
266+
case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
267+
case outerPayload if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
268+
case outerPayload =>
269+
innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
270+
case Some(encrypted) =>
271+
// The path key can be found:
272+
// - in the inner payload if we are the introduction node of the blinded path (provided by the payer).
273+
// - in the outer payload if we are an intermediate node in the blinded path (provided by the previous trampoline node).
274+
val pathKey_opt = innerPayload.get[OnionPaymentPayloadTlv.PathKey].orElse(outerPayload.records.get[OnionPaymentPayloadTlv.PathKey]).map(_.publicKey)
275+
decryptEncryptedRecipientData(add, privateKey, pathKey_opt, encrypted.data).flatMap {
276+
case DecodedEncryptedRecipientData(blindedPayload, nextPathKey) =>
277+
IntermediatePayload.NodeRelay.Blinded.validate(innerPayload, blindedPayload, nextPathKey).left.map(_.failureMessage).map(innerPayload => RelayToBlindedTrampolinePacket(add, outerPayload, innerPayload, next))
278+
}
279+
case None =>
280+
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).map(innerPayload => RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
281+
}
256282
}
257283
}
258284

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ object NodeRelay {
6565
private case class WrappedPaymentSent(paymentSent: PaymentSent) extends Command
6666
private case class WrappedPaymentFailed(paymentFailed: PaymentFailed) extends Command
6767
private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command
68+
private case class WrappedOutgoingNodeId(nodeId_opt: Option[PublicKey]) extends Command
6869
private case class WrappedResolvedPaths(resolved: Seq[ResolvedPath]) extends Command
6970
private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command
7071
// @formatter:on
@@ -108,6 +109,7 @@ object NodeRelay {
108109
val incomingPaymentHandler = context.actorOf(MultiPartPaymentFSM.props(nodeParams, paymentHash, totalAmountIn, mppFsmAdapters))
109110
val nextPacket_opt = nodeRelayPacket match {
110111
case IncomingPaymentPacket.RelayToTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket)
112+
case IncomingPaymentPacket.RelayToBlindedTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket)
111113
case _: IncomingPaymentPacket.RelayToNonTrampolinePacket => None
112114
case _: IncomingPaymentPacket.RelayToBlindedPathsPacket => None
113115
}
@@ -198,6 +200,7 @@ object NodeRelay {
198200
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
199201
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
200202
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
203+
case _: IntermediatePayload.NodeRelay.Blinded => None
201204
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
202205
}
203206
val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage }
@@ -267,6 +270,28 @@ class NodeRelay private(nodeParams: NodeParams,
267270
val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
268271
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
269272
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
273+
case payloadOut: IntermediatePayload.NodeRelay.Blinded =>
274+
// Blinded paths in Bolt 12 invoices may use an scid to reference the next node, if it is one of our peers.
275+
// We need to resolve that to a nodeId in order to create a payment onion.
276+
payloadOut.outgoing match {
277+
case Left(outgoingNodeId) => context.self ! WrappedOutgoingNodeId(Some(outgoingNodeId))
278+
case Right(outgoingChannelId) => register ! Register.GetNextNodeId(context.messageAdapter[Option[PublicKey]](WrappedOutgoingNodeId), outgoingChannelId)
279+
}
280+
Behaviors.receiveMessagePartial {
281+
rejectExtraHtlcPartialFunction orElse {
282+
case WrappedOutgoingNodeId(Some(outgoingNodeId)) =>
283+
val outgoingAmount = nextPayload.outgoingAmount(upstream.amountIn)
284+
val outgoingExpiry = nextPayload.outgoingExpiry(upstream.expiryIn)
285+
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
286+
val recipient = ClearRecipient(outgoingNodeId, Features.empty, outgoingAmount, outgoingExpiry, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt, trampolinePathKey_opt = Some(payloadOut.nextPathKey))
287+
context.log.debug("forwarding payment to the next blinded trampoline node {}", recipient.nodeId)
288+
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
289+
case WrappedOutgoingNodeId(None) =>
290+
context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing)
291+
rejectPayment(upstream, Some(UnknownNextPeer()))
292+
stopping()
293+
}
294+
}
270295
case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline =>
271296
val paymentSecret = payloadOut.paymentSecret
272297
val features = Features(payloadOut.invoiceFeatures).invoiceFeatures()

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ case class ClearRecipient(nodeId: PublicKey,
7373
extraEdges: Seq[ExtraEdge] = Nil,
7474
paymentMetadata_opt: Option[ByteVector] = None,
7575
nextTrampolineOnion_opt: Option[OnionRoutingPacket] = None,
76+
// Must be provided if the payer is using a blinded trampoline path.
77+
trampolinePathKey_opt: Option[PublicKey] = None,
7678
customTlvs: Set[GenericTlv] = Set.empty) extends Recipient {
7779
// Feature bit used by the legacy trampoline feature.
7880
private val isLegacyTrampoline = features.unknown.contains(UnknownFeature(149))
@@ -81,8 +83,8 @@ case class ClearRecipient(nodeId: PublicKey,
8183
ClearRecipient.validateRoute(nodeId, route).map(_ => {
8284
val finalPayload = nextTrampolineOnion_opt match {
8385
case Some(trampolinePacket) if isLegacyTrampoline => NodePayload(nodeId, FinalPayload.Standard.createLegacyTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
84-
case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
85-
case None => NodePayload(nodeId, FinalPayload.Standard.createPayload(route.amount, totalAmount, expiry, paymentSecret, paymentMetadata_opt, trampolineOnion_opt = None, customTlvs = customTlvs))
86+
case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket, trampolinePathKey_opt))
87+
case None => NodePayload(nodeId, FinalPayload.Standard.createPayload(route.amount, totalAmount, expiry, paymentSecret, paymentMetadata_opt, trampolineOnion_opt = None, customTlvs))
8688
}
8789
Recipient.buildPayloads(PaymentPayloads(route.amount, expiry, Seq(finalPayload), None), route.hops)
8890
})
@@ -91,7 +93,7 @@ case class ClearRecipient(nodeId: PublicKey,
9193

9294
object ClearRecipient {
9395
def apply(invoice: Bolt11Invoice, totalAmount: MilliSatoshi, expiry: CltvExpiry, customTlvs: Set[GenericTlv]): ClearRecipient = {
94-
ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, customTlvs)
96+
ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, None, customTlvs)
9597
}
9698

9799
def validateRoute(nodeId: PublicKey, route: Route): Either[OutgoingPaymentError, Route] = {

0 commit comments

Comments
 (0)