Skip to content

Commit

Permalink
Add support for blinded trampoline payments
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
t-bast committed Nov 29, 2024
1 parent fc3c459 commit 3d5a8fe
Show file tree
Hide file tree
Showing 8 changed files with 574 additions and 48 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ object IncomingPaymentPacket {
def innerPayload: IntermediatePayload.NodeRelay
}
case class RelayToTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Standard, nextPacket: OnionRoutingPacket) extends NodeRelayPacket
case class RelayToBlindedTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.Blinded, nextPacket: OnionRoutingPacket) extends NodeRelayPacket
case class RelayToNonTrampolinePacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToNonTrampoline) extends NodeRelayPacket
case class RelayToBlindedPathsPacket(add: UpdateAddHtlc, outerPayload: FinalPayload.Standard, innerPayload: IntermediatePayload.NodeRelay.ToBlindedPaths) extends NodeRelayPacket
// @formatter:on
Expand Down Expand Up @@ -161,9 +162,14 @@ object IncomingPaymentPacket {
val trampolinePacket_opt = payload.get[OnionPaymentPayloadTlv.TrampolineOnion].map(_.packet).orElse(payload.get[OnionPaymentPayloadTlv.LegacyTrampolineOnion].map(_.packet))
trampolinePacket_opt match {
case Some(trampolinePacket) =>
// NB: when we enable blinded trampoline routes, we will need to check if the outer onion contains a
// path key and use it to derive the decryption key for the blinded trampoline onion.
decryptOnion(add.paymentHash, privateKey, trampolinePacket).flatMap {
// If we are an intermediate trampoline node inside a blinded path, the payer doesn't know our node_id
// and has encrypted the trampoline onion to our blinded node_id: in that case, the previous trampoline
// node will provide the path key in the outer onion.
val trampolineOnionDecryptionKey = payload.get[OnionPaymentPayloadTlv.PathKey].map(_.publicKey) match {
case Some(pathKey) => Sphinx.RouteBlinding.derivePrivateKey(privateKey, pathKey)
case None => privateKey
}
decryptOnion(add.paymentHash, trampolineOnionDecryptionKey, trampolinePacket).flatMap {
case DecodedOnionPacket(innerPayload, Some(next)) =>
// We are an intermediate trampoline node.
if (innerPayload.get[InvoiceRoutingInfo].isDefined) {
Expand All @@ -172,7 +178,8 @@ object IncomingPaymentPacket {
// The payer is a wallet using the legacy trampoline feature.
validateTrampolineToNonTrampoline(add, payload, innerPayload)
} else {
validateNodeRelay(add, payload, innerPayload, next)
// The recipient supports trampoline (and may support blinded payments).
validateNodeRelay(add, privateKey, payload, innerPayload, next)
}
case DecodedOnionPacket(innerPayload, None) =>
if (innerPayload.get[OutgoingBlindedPaths].isDefined) {
Expand All @@ -184,8 +191,8 @@ object IncomingPaymentPacket {
// They can be reached with the invoice data provided.
validateTrampolineToNonTrampoline(add, payload, innerPayload)
} else {
// We're the final recipient of this trampoline payment.
validateTrampolineFinalPayload(add, payload, innerPayload)
// We're the final recipient of this trampoline payment (which may be blinded).
validateTrampolineFinalPayload(add, privateKey, payload, innerPayload)
}
}
case None =>
Expand Down Expand Up @@ -228,30 +235,49 @@ object IncomingPaymentPacket {
}
}

private def validateTrampolineFinalPayload(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
case _ if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry
case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount
case innerPayload =>
// We merge contents from the outer and inner payloads.
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata)))
}
private def validateTrampolineFinalPayload(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv]): Either[FailureMessage, FinalPacket] = {
// The outer payload cannot use route blinding, but the inner payload may.
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap {
case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
case outerPayload if add.cltvExpiry < outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
case outerPayload =>
innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
case Some(encrypted) =>
decryptEncryptedRecipientData(add, privateKey, outerPayload.records, encrypted.data).flatMap {
case DecodedEncryptedRecipientData(blindedPayload, _) => validateBlindedFinalPayload(add, innerPayload, blindedPayload)
}
case None =>
FinalPayload.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
case innerPayload if outerPayload.expiry < innerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry)) // previous trampoline didn't forward the right expiry
case innerPayload if outerPayload.totalAmount < innerPayload.amount => Left(FinalIncorrectHtlcAmount(outerPayload.totalAmount)) // previous trampoline didn't forward the right amount
case innerPayload =>
// We merge contents from the outer and inner payloads.
// We must use the inner payload's total amount and payment secret because the payment may be split between multiple trampoline payments (#reckless).
Right(FinalPacket(add, FinalPayload.Standard.createPayload(outerPayload.amount, innerPayload.totalAmount, innerPayload.expiry, innerPayload.paymentSecret, innerPayload.paymentMetadata)))
}
}
}
}

private def validateNodeRelay(add: UpdateAddHtlc, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, RelayToTrampolinePacket] = {
// The outer payload cannot use route blinding, but the inner payload may (but it's not supported yet).
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap { outerPayload =>
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).flatMap {
case _ if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
case _ if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
case innerPayload => Right(RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
}
private def validateNodeRelay(add: UpdateAddHtlc, privateKey: PrivateKey, outerPayload: TlvStream[OnionPaymentPayloadTlv], innerPayload: TlvStream[OnionPaymentPayloadTlv], next: OnionRoutingPacket): Either[FailureMessage, IncomingPaymentPacket] = {
// The outer payload cannot use route blinding, but the inner payload may.
FinalPayload.Standard.validate(outerPayload).left.map(_.failureMessage).flatMap {
case outerPayload if add.amountMsat < outerPayload.amount => Left(FinalIncorrectHtlcAmount(add.amountMsat))
case outerPayload if add.cltvExpiry != outerPayload.expiry => Left(FinalIncorrectCltvExpiry(add.cltvExpiry))
case outerPayload =>
innerPayload.get[OnionPaymentPayloadTlv.EncryptedRecipientData] match {
case Some(encrypted) =>
// The path key can be found:
// - in the inner payload if we are the introduction node of the blinded path (provided by the payer).
// - in the outer payload if we are an intermediate node in the blinded path (provided by the previous trampoline node).
val pathKey_opt = innerPayload.get[OnionPaymentPayloadTlv.PathKey].orElse(outerPayload.records.get[OnionPaymentPayloadTlv.PathKey]).map(_.publicKey)
decryptEncryptedRecipientData(add, privateKey, pathKey_opt, encrypted.data).flatMap {
case DecodedEncryptedRecipientData(blindedPayload, nextPathKey) =>
IntermediatePayload.NodeRelay.Blinded.validate(innerPayload, blindedPayload, nextPathKey).left.map(_.failureMessage).map(innerPayload => RelayToBlindedTrampolinePacket(add, outerPayload, innerPayload, next))
}
case None =>
IntermediatePayload.NodeRelay.Standard.validate(innerPayload).left.map(_.failureMessage).map(innerPayload => RelayToTrampolinePacket(add, outerPayload, innerPayload, next))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ object NodeRelay {
private case class WrappedPaymentSent(paymentSent: PaymentSent) extends Command
private case class WrappedPaymentFailed(paymentFailed: PaymentFailed) extends Command
private case class WrappedPeerReadyResult(result: PeerReadyNotifier.Result) extends Command
private case class WrappedOutgoingNodeId(nodeId_opt: Option[PublicKey]) extends Command
private case class WrappedResolvedPaths(resolved: Seq[ResolvedPath]) extends Command
private case class WrappedOnTheFlyFundingResponse(result: Peer.ProposeOnTheFlyFundingResponse) extends Command
// @formatter:on
Expand Down Expand Up @@ -108,6 +109,7 @@ object NodeRelay {
val incomingPaymentHandler = context.actorOf(MultiPartPaymentFSM.props(nodeParams, paymentHash, totalAmountIn, mppFsmAdapters))
val nextPacket_opt = nodeRelayPacket match {
case IncomingPaymentPacket.RelayToTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket)
case IncomingPaymentPacket.RelayToBlindedTrampolinePacket(_, _, _, nextPacket) => Some(nextPacket)
case _: IncomingPaymentPacket.RelayToNonTrampolinePacket => None
case _: IncomingPaymentPacket.RelayToBlindedPathsPacket => None
}
Expand Down Expand Up @@ -198,6 +200,7 @@ object NodeRelay {
case nextPayload: IntermediatePayload.NodeRelay.Standard => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
case nextPayload: IntermediatePayload.NodeRelay.ToNonTrampoline => failures.collectFirst { case RemoteFailure(_, _, e) if e.originNode == nextPayload.outgoingNodeId => e.failureMessage }
// When using blinded paths, we will never get a failure from the final node (for privacy reasons).
case _: IntermediatePayload.NodeRelay.Blinded => None
case _: IntermediatePayload.NodeRelay.ToBlindedPaths => None
}
val otherNodeFailure = failures.collectFirst { case RemoteFailure(_, _, e) => e.failureMessage }
Expand Down Expand Up @@ -267,6 +270,28 @@ class NodeRelay private(nodeParams: NodeParams,
val recipient = ClearRecipient(payloadOut.outgoingNodeId, features, payloadOut.amountToForward, payloadOut.outgoingCltv, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt)
context.log.debug("forwarding payment to the next trampoline node {}", recipient.nodeId)
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
case payloadOut: IntermediatePayload.NodeRelay.Blinded =>
// Blinded paths in Bolt 12 invoices may use an scid to reference the next node, if it is one of our peers.
// We need to resolve that to a nodeId in order to create a payment onion.
payloadOut.outgoing match {
case Left(outgoingNodeId) => context.self ! WrappedOutgoingNodeId(Some(outgoingNodeId))
case Right(outgoingChannelId) => register ! Register.GetNextNodeId(context.messageAdapter[Option[PublicKey]](WrappedOutgoingNodeId), outgoingChannelId)
}
Behaviors.receiveMessagePartial {
rejectExtraHtlcPartialFunction orElse {
case WrappedOutgoingNodeId(Some(outgoingNodeId)) =>
val outgoingAmount = nextPayload.outgoingAmount(upstream.amountIn)
val outgoingExpiry = nextPayload.outgoingExpiry(upstream.expiryIn)
val paymentSecret = randomBytes32() // we generate a new secret to protect against probing attacks
val recipient = ClearRecipient(outgoingNodeId, Features.empty, outgoingAmount, outgoingExpiry, paymentSecret, nextTrampolineOnion_opt = nextPacket_opt, trampolinePathKey_opt = Some(payloadOut.nextPathKey))
context.log.debug("forwarding payment to the next blinded trampoline node {}", recipient.nodeId)
ensureRecipientReady(upstream, recipient, nextPayload, nextPacket_opt)
case WrappedOutgoingNodeId(None) =>
context.log.warn("rejecting trampoline payment to blinded trampoline: cannot identify next node for scid={}", payloadOut.outgoing)
rejectPayment(upstream, Some(UnknownNextPeer()))
stopping()
}
}
case payloadOut: IntermediatePayload.NodeRelay.ToNonTrampoline =>
val paymentSecret = payloadOut.paymentSecret
val features = Features(payloadOut.invoiceFeatures).invoiceFeatures()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ case class ClearRecipient(nodeId: PublicKey,
extraEdges: Seq[ExtraEdge] = Nil,
paymentMetadata_opt: Option[ByteVector] = None,
nextTrampolineOnion_opt: Option[OnionRoutingPacket] = None,
// Must be provided if the payer is using a blinded trampoline path.
trampolinePathKey_opt: Option[PublicKey] = None,
customTlvs: Set[GenericTlv] = Set.empty) extends Recipient {
// Feature bit used by the legacy trampoline feature.
private val isLegacyTrampoline = features.unknown.contains(UnknownFeature(149))
Expand All @@ -81,7 +83,7 @@ case class ClearRecipient(nodeId: PublicKey,
ClearRecipient.validateRoute(nodeId, route).map(_ => {
val finalPayload = nextTrampolineOnion_opt match {
case Some(trampolinePacket) if isLegacyTrampoline => NodePayload(nodeId, FinalPayload.Standard.createLegacyTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket))
case Some(trampolinePacket) => NodePayload(nodeId, FinalPayload.Standard.createTrampolinePayload(route.amount, totalAmount, expiry, paymentSecret, trampolinePacket, trampolinePathKey_opt))
case None => NodePayload(nodeId, FinalPayload.Standard.createPayload(route.amount, totalAmount, expiry, paymentSecret, paymentMetadata_opt, customTlvs))
}
Recipient.buildPayloads(PaymentPayloads(route.amount, expiry, Seq(finalPayload), None), route.hops)
Expand All @@ -91,7 +93,7 @@ case class ClearRecipient(nodeId: PublicKey,

object ClearRecipient {
def apply(invoice: Bolt11Invoice, totalAmount: MilliSatoshi, expiry: CltvExpiry, customTlvs: Set[GenericTlv]): ClearRecipient = {
ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, customTlvs)
ClearRecipient(invoice.nodeId, invoice.features, totalAmount, expiry, invoice.paymentSecret, invoice.extraEdges, invoice.paymentMetadata, None, None, customTlvs)
}

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

0 comments on commit 3d5a8fe

Please sign in to comment.