Skip to content

Commit bfe2d7f

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 c290dc0 commit bfe2d7f

File tree

8 files changed

+600
-55
lines changed

8 files changed

+600
-55
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
@@ -172,9 +173,14 @@ object IncomingPaymentPacket {
172173
val totalAmount = payload.get[OnionPaymentPayloadTlv.AmountToForward].map(_.amount).getOrElse(add.amountMsat)
173174
payload.copy(records = payload.records + OnionPaymentPayloadTlv.PaymentData(dummyPaymentSecret, totalAmount))
174175
}
175-
// NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a
176-
// path key and use it to derive the decryption key for the blinded trampoline onion.
177-
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
176+
// If we are an intermediate trampoline node inside a blinded path, the payer doesn't know our node_id
177+
// and has encrypted the trampoline onion to our blinded node_id: in that case, the previous trampoline
178+
// node will provide the path key in the outer onion.
179+
val trampolineOnionDecryptionKey = payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey) match {
180+
case Some(pathKey) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, pathKey)
181+
case None => privateKey
182+
}
183+
decryptOnion(add.paymentHash, trampolineOnionDecryptionKey, trampolinePacket).flatMap {
178184
case DecodedOnionPacket(innerPayload, Some(next)) =>
179185
// We are an intermediate trampoline node.
180186
if (innerPayload.get[InvoiceRoutingInfo].isDefined) {
@@ -183,7 +189,8 @@ object IncomingPaymentPacket {
183189
// The payer is a wallet using the legacy trampoline feature.
184190
validateTrampolineToNonTrampoline(add, outerPayload, innerPayload)
185191
} else {
186-
validateNodeRelay(add, outerPayload, innerPayload, next)
192+
// The recipient supports trampoline (and may support blinded payments).
193+
validateNodeRelay(add, privateKey, outerPayload, innerPayload, next)
187194
}
188195
case DecodedOnionPacket(innerPayload, None) =>
189196
if (innerPayload.get[OutgoingBlindedPaths].isDefined) {
@@ -195,8 +202,8 @@ object IncomingPaymentPacket {
195202
// They can be reached with the invoice data provided.
196203
validateTrampolineToNonTrampoline(add, outerPayload, innerPayload)
197204
} else {
198-
// We're the final recipient of this trampoline payment.
199-
validateTrampolineFinalPayload(add, outerPayload, innerPayload)
205+
// We're the final recipient of this trampoline payment (which may be blinded).
206+
validateTrampolineFinalPayload(add, privateKey, outerPayload, innerPayload)
200207
}
201208
}
202209
case None =>
@@ -238,31 +245,50 @@ object IncomingPaymentPacket {
238245
}
239246
}
240247

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

258-
private def validateNodeRelay(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, RelayToTrampolinePacket] = {
259-
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
260-
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
261-
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
262-
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
263-
case _ if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
264-
case innerPayload => Right(RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
265-
}
273+
private def validateNodeRelay(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, IncomingPaymentPacket] = {
274+
// The outer payload cannot use route blinding, but the inner payload may.
275+
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap {
276+
case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
277+
case outerPayload if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
278+
case outerPayload =>
279+
innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
280+
case Some(encrypted) =>
281+
// The path key can be found:
282+
// - in the inner payload if we are the introduction node of the blinded path (provided by the payer).
283+
// - in the outer payload if we are an intermediate node in the blinded path (provided by the previous trampoline node).
284+
val pathKey_opt = innerPayload.get[OnionPaymentPayloadTlv.PathKey].orElse(outerPayload.records.get[OnionPaymentPayloadTlv.PathKey]).map(_.publicKey)
285+
decryptEncryptedRecipientData(add, privateKey, pathKey_opt, encrypted.data).flatMap {
286+
case DecodedEncryptedRecipientData(blindedPayload, nextPathKey) =>
287+
IntermediatePayload.NodeRelay.Blinded.validate(innerPayload, blindedPayload, nextPathKey).left.map(_.failureMessage).map(innerPayload => RelayToBlindedTrampolinePacket(add, outerPayload, innerPayload, next))
288+
}
289+
case None =>
290+
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).map(innerPayload => RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
291+
}
266292
}
267293
}
268294

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
@@ -64,6 +64,7 @@ object NodeRelay {
6464
private case class WrappedPaymentSent(paymentSent: PaymentSent) extends Command
6565
private case class WrappedPaymentFailed(paymentFailed: PaymentFailed) extends Command
6666
private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command
67+
private case class WrappedOutgoingNodeId(nodeId_opt: Option[PublicKey]) extends Command
6768
private case class WrappedResolvedPaths(resolved: Seq[ResolvedPath]) extends Command
6869
private case class WrappedPeerInfo(remoteFeatures_opt: Option[Features[InitFeature]]) extends Command
6970
private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command
@@ -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
}
@@ -184,6 +186,7 @@ object NodeRelay {
184186
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
185187
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
186188
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
189+
case _: IntermediatePayload.NodeRelay.Blinded => None
187190
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
188191
}
189192
val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage }
@@ -253,6 +256,28 @@ class NodeRelay private(nodeParams: NodeParams,
253256
val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
254257
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
255258
attemptWakeUpIfRecipientIsWallet(upstream, recipient, nextPayload, nextPacket_opt)
259+
case payloadOut: IntermediatePayload.NodeRelay.Blinded =>
260+
// Blinded paths in Bolt 12 invoices may use an scid to reference the next node, if it is one of our peers.
261+
// We need to resolve that to a nodeId in order to create a payment onion.
262+
payloadOut.outgoing match {
263+
case Left(outgoingNodeId) => context.self ! WrappedOutgoingNodeId(Some(outgoingNodeId.publicKey))
264+
case Right(outgoingChannelId) => register ! Register.GetNextNodeId(context.messageAdapter[Option[PublicKey]](WrappedOutgoingNodeId), outgoingChannelId)
265+
}
266+
Behaviors.receiveMessagePartial {
267+
rejectExtraHtlcPartialFunction orElse {
268+
case WrappedOutgoingNodeId(Some(outgoingNodeId)) =>
269+
val outgoingAmount = nextPayload.outgoingAmount(upstream.amountIn)
270+
val outgoingExpiry = nextPayload.outgoingExpiry(upstream.expiryIn)
271+
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
272+
val recipient = ClearRecipient(outgoingNodeId, Features.empty, outgoingAmount, outgoingExpiry, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt, trampolinePathKey_opt = Some(payloadOut.nextPathKey))
273+
context.log.debug("forwarding payment to the next blinded trampoline node {}", recipient.nodeId)
274+
attemptWakeUpIfRecipientIsWallet(upstream, recipient, nextPayload, nextPacket_opt)
275+
case WrappedOutgoingNodeId(None) =>
276+
context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing)
277+
rejectPayment(upstream, Some(UnknownNextPeer()))
278+
stopping()
279+
}
280+
}
256281
case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline =>
257282
val paymentSecret = payloadOut.paymentSecret
258283
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)