From 3d5f20b70ff824363e188698d1add667d7e3be3d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 May 2024 14:38:53 +0200 Subject: [PATCH 1/9] multi: introduce BlindedPaymentPathSet This commit introduces a new type, `BlindedPaymentPathSet`. For now, it holds only a single `BlindedPayment` but eventually it will hold and manage a set of blinded payments provided for a specific payment. To make the PR easier to follow though, we start off just letting it hold a single one and do some basic replacements. --- lnrpc/routerrpc/router_backend.go | 102 ++++++++++++---------- routing/blinding.go | 136 ++++++++++++++++++++++++++++++ rpcserver.go | 28 +++--- 3 files changed, 207 insertions(+), 59 deletions(-) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index 970fb04cf..487da5356 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -280,7 +280,7 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( var ( targetPubKey *route.Vertex routeHintEdges map[route.Vertex][]routing.AdditionalEdge - blindedPmt *routing.BlindedPayment + blindedPathSet *routing.BlindedPaymentPathSet // finalCLTVDelta varies depending on whether we're sending to // a blinded route or an unblinded node. For blinded paths, @@ -297,13 +297,14 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( // Validate that the fields provided in the request are sane depending // on whether it is using a blinded path or not. if len(in.BlindedPaymentPaths) > 0 { - blindedPmt, err = parseBlindedPayment(in) + blindedPathSet, err = parseBlindedPaymentPaths(in) if err != nil { return nil, err } - if blindedPmt.Features != nil { - destinationFeatures = blindedPmt.Features.Clone() + pathFeatures := blindedPathSet.Features() + if pathFeatures != nil { + destinationFeatures = pathFeatures.Clone() } } else { // If we do not have a blinded path, a target pubkey must be @@ -390,7 +391,7 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( DestCustomRecords: record.CustomSet(in.DestCustomRecords), CltvLimit: cltvLimit, DestFeatures: destinationFeatures, - BlindedPayment: blindedPmt, + BlindedPayment: blindedPathSet.GetPath(), } // Pass along an outgoing channel restriction if specified. @@ -419,39 +420,24 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( return routing.NewRouteRequest( sourcePubKey, targetPubKey, amt, in.TimePref, restrictions, - customRecords, routeHintEdges, blindedPmt, finalCLTVDelta, + customRecords, routeHintEdges, blindedPathSet.GetPath(), + finalCLTVDelta, ) } -func parseBlindedPayment(in *lnrpc.QueryRoutesRequest) ( - *routing.BlindedPayment, error) { +func parseBlindedPaymentPaths(in *lnrpc.QueryRoutesRequest) ( + *routing.BlindedPaymentPathSet, error) { if len(in.PubKey) != 0 { return nil, fmt.Errorf("target pubkey: %x should not be set "+ "when blinded path is provided", in.PubKey) } - if len(in.BlindedPaymentPaths) != 1 { - return nil, errors.New("query routes only supports a single " + - "blinded path") - } - - blindedPath := in.BlindedPaymentPaths[0] - if len(in.RouteHints) > 0 { return nil, errors.New("route hints and blinded path can't " + "both be set") } - blindedPmt, err := unmarshalBlindedPayment(blindedPath) - if err != nil { - return nil, fmt.Errorf("parse blinded payment: %w", err) - } - - if err := blindedPmt.Validate(); err != nil { - return nil, fmt.Errorf("invalid blinded path: %w", err) - } - if in.FinalCltvDelta != 0 { return nil, errors.New("final cltv delta should be " + "zero for blinded paths") @@ -466,7 +452,21 @@ func parseBlindedPayment(in *lnrpc.QueryRoutesRequest) ( "be populated in blinded path") } - return blindedPmt, nil + paths := make([]*routing.BlindedPayment, len(in.BlindedPaymentPaths)) + for i, paymentPath := range in.BlindedPaymentPaths { + blindedPmt, err := unmarshalBlindedPayment(paymentPath) + if err != nil { + return nil, fmt.Errorf("parse blinded payment: %w", err) + } + + if err := blindedPmt.Validate(); err != nil { + return nil, fmt.Errorf("invalid blinded path: %w", err) + } + + paths[i] = blindedPmt + } + + return routing.NewBlindedPaymentPathSet(paths) } func unmarshalBlindedPayment(rpcPayment *lnrpc.BlindedPaymentPath) ( @@ -1001,28 +1001,24 @@ func (r *RouterBackend) extractIntentFromSendRequest( payIntent.Metadata = payReq.Metadata if len(payReq.BlindedPaymentPaths) > 0 { - // NOTE: Currently we only choose a single payment path. - // This will be updated in a future PR to handle - // multiple blinded payment paths. - path := payReq.BlindedPaymentPaths[0] - if len(path.Hops) == 0 { - return nil, fmt.Errorf("a blinded payment " + - "must have at least 1 hop") + pathSet, err := BuildBlindedPathSet( + payReq.BlindedPaymentPaths, + ) + if err != nil { + return nil, err } + payIntent.BlindedPayment = pathSet.GetPath() - finalHop := path.Hops[len(path.Hops)-1] - - payIntent.BlindedPayment = MarshalBlindedPayment(path) - - // Replace the target node with the blinded public key - // of the blinded path's final node. + // Replace the target node with the target public key + // of the blinded path set. copy( payIntent.Target[:], - finalHop.BlindedNodePub.SerializeCompressed(), + pathSet.TargetPubKey().SerializeCompressed(), ) - if !path.Features.IsEmpty() { - payIntent.DestFeatures = path.Features.Clone() + pathFeatures := pathSet.Features() + if !pathFeatures.IsEmpty() { + payIntent.DestFeatures = pathFeatures.Clone() } } } else { @@ -1163,9 +1159,29 @@ func (r *RouterBackend) extractIntentFromSendRequest( return payIntent, nil } -// MarshalBlindedPayment marshals a zpay32.BLindedPaymentPath into a +// BuildBlindedPathSet marshals a set of zpay32.BlindedPaymentPath and uses +// the result to build a new routing.BlindedPaymentPathSet. +func BuildBlindedPathSet(paths []*zpay32.BlindedPaymentPath) ( + *routing.BlindedPaymentPathSet, error) { + + marshalledPaths := make([]*routing.BlindedPayment, len(paths)) + for i, path := range paths { + paymentPath := marshalBlindedPayment(path) + + err := paymentPath.Validate() + if err != nil { + return nil, err + } + + marshalledPaths[i] = paymentPath + } + + return routing.NewBlindedPaymentPathSet(marshalledPaths) +} + +// marshalBlindedPayment marshals a zpay32.BLindedPaymentPath into a // routing.BlindedPayment. -func MarshalBlindedPayment( +func marshalBlindedPayment( path *zpay32.BlindedPaymentPath) *routing.BlindedPayment { return &routing.BlindedPayment{ diff --git a/routing/blinding.go b/routing/blinding.go index 788fb7b77..c491f4d85 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/channeldb/models" "github.com/lightningnetwork/lnd/lnwire" @@ -25,6 +26,141 @@ var ( ErrHTLCRestrictions = errors.New("invalid htlc minimum and maximum") ) +// BlindedPaymentPathSet groups the data we need to handle sending to a set of +// blinded paths provided by the recipient of a payment. +// +// NOTE: for now this only holds a single BlindedPayment. By the end of the PR +// series, it will handle multiple paths. +type BlindedPaymentPathSet struct { + // paths is the set of blinded payment paths for a single payment. + // NOTE: For now this will always only have a single entry. By the end + // of this PR, it can hold multiple. + paths []*BlindedPayment + + // targetPubKey is the ephemeral node pub key that we will inject into + // each path as the last hop. This is only for the sake of path finding. + // Once the path has been found, the original destination pub key is + // used again. In the edge case where there is only a single hop in the + // path (the introduction node is the destination node), then this will + // just be the introduction node's real public key. + targetPubKey *btcec.PublicKey + + // features is the set of relay features available for the payment. + // This is extracted from the set of blinded payment paths. At the + // moment we require that all paths for the same payment have the + // same feature set. + features *lnwire.FeatureVector +} + +// NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of +// BlindedPayments. +func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet, + error) { + + if len(paths) == 0 { + return nil, ErrNoBlindedPath + } + + // For now, we assert that all the paths have the same set of features. + features := paths[0].Features + noFeatures := features == nil || features.IsEmpty() + for i := 1; i < len(paths); i++ { + noFeats := paths[i].Features == nil || + paths[i].Features.IsEmpty() + + if noFeatures && !noFeats { + return nil, fmt.Errorf("all blinded paths must have " + + "the same set of features") + } + + if noFeatures { + continue + } + + if !features.RawFeatureVector.Equals( + paths[i].Features.RawFeatureVector, + ) { + + return nil, fmt.Errorf("all blinded paths must have " + + "the same set of features") + } + } + + // NOTE: for now, we just take a single path. By the end of this PR + // series, all paths will be kept. + path := paths[0] + + finalHop := path.BlindedPath. + BlindedHops[len(path.BlindedPath.BlindedHops)-1] + + return &BlindedPaymentPathSet{ + paths: paths, + targetPubKey: finalHop.BlindedNodePub, + features: features, + }, nil +} + +// TargetPubKey returns the public key to be used as the destination node's +// public key during pathfinding. +func (s *BlindedPaymentPathSet) TargetPubKey() *btcec.PublicKey { + return s.targetPubKey +} + +// Features returns the set of relay features available for the payment. +func (s *BlindedPaymentPathSet) Features() *lnwire.FeatureVector { + return s.features +} + +// GetPath is a temporary getter for the single path that the set holds. +// This will be removed later on in this PR. +func (s *BlindedPaymentPathSet) GetPath() *BlindedPayment { + return s.paths[0] +} + +// LargestLastHopPayloadPath returns the BlindedPayment in the set that has the +// largest last-hop payload. This is to be used for onion size estimation in +// path finding. +func (s *BlindedPaymentPathSet) LargestLastHopPayloadPath() *BlindedPayment { + var ( + largestPath *BlindedPayment + currentMax int + ) + for _, path := range s.paths { + numHops := len(path.BlindedPath.BlindedHops) + lastHop := path.BlindedPath.BlindedHops[numHops-1] + + if len(lastHop.CipherText) > currentMax { + largestPath = path + } + } + + return largestPath +} + +// ToRouteHints converts the blinded path payment set into a RouteHints map so +// that the blinded payment paths can be treated like route hints throughout the +// code base. +func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) { + hints := make(RouteHints) + + for _, path := range s.paths { + pathHints, err := path.toRouteHints() + if err != nil { + return nil, err + } + + for from, edges := range pathHints { + hints[from] = append(hints[from], edges...) + } + } + + if len(hints) == 0 { + return nil, nil + } + + return hints, nil +} + // BlindedPayment provides the path and payment parameters required to send a // payment along a blinded path. type BlindedPayment struct { diff --git a/rpcserver.go b/rpcserver.go index 9ab014b3d..ff7aa613a 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -5242,28 +5242,24 @@ func (r *rpcServer) extractPaymentIntent(rpcPayReq *rpcPaymentRequest) (rpcPayme payIntent.metadata = payReq.Metadata if len(payReq.BlindedPaymentPaths) > 0 { - // NOTE: Currently we only choose a single payment path. - // This will be updated in a future PR to handle - // multiple blinded payment paths. - path := payReq.BlindedPaymentPaths[0] - if len(path.Hops) == 0 { - return payIntent, fmt.Errorf("a blinded " + - "payment must have at least 1 hop") + pathSet, err := routerrpc.BuildBlindedPathSet( + payReq.BlindedPaymentPaths, + ) + if err != nil { + return payIntent, err } + payIntent.blindedPayment = pathSet.GetPath() - finalHop := path.Hops[len(path.Hops)-1] - payIntent.blindedPayment = - routerrpc.MarshalBlindedPayment(path) - - // Replace the target node with the blinded public key - // of the blinded path's final node. + // Replace the destination node with the target public + // key of the blinded path set. copy( payIntent.dest[:], - finalHop.BlindedNodePub.SerializeCompressed(), + pathSet.TargetPubKey().SerializeCompressed(), ) - if !payReq.BlindedPaymentPaths[0].Features.IsEmpty() { - payIntent.destFeatures = path.Features.Clone() + pathFeatures := pathSet.Features() + if !pathFeatures.IsEmpty() { + payIntent.destFeatures = pathFeatures.Clone() } } From 4a22ec841368acf2187a9e3e47caac817c4ca44c Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 May 2024 15:08:19 +0200 Subject: [PATCH 2/9] routing: pass BlindedPaymentPathSet around everywhere Building on from the previous commit, here we pass the PathSet around everywhere where we previously passed around the single BlindedPayment. --- lnrpc/routerrpc/router_backend.go | 12 +++--- routing/pathfind.go | 23 +++++++---- routing/pathfind_test.go | 63 ++++++++++++++++++------------- routing/payment_session.go | 14 ++----- routing/router.go | 51 +++++++++---------------- routing/router_test.go | 17 ++++++--- rpcserver.go | 6 +-- 7 files changed, 94 insertions(+), 92 deletions(-) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index 487da5356..faf53c4b6 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -388,10 +388,10 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( fromNode, toNode, amt, capacity, ) }, - DestCustomRecords: record.CustomSet(in.DestCustomRecords), - CltvLimit: cltvLimit, - DestFeatures: destinationFeatures, - BlindedPayment: blindedPathSet.GetPath(), + DestCustomRecords: record.CustomSet(in.DestCustomRecords), + CltvLimit: cltvLimit, + DestFeatures: destinationFeatures, + BlindedPaymentPathSet: blindedPathSet, } // Pass along an outgoing channel restriction if specified. @@ -420,7 +420,7 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) ( return routing.NewRouteRequest( sourcePubKey, targetPubKey, amt, in.TimePref, restrictions, - customRecords, routeHintEdges, blindedPathSet.GetPath(), + customRecords, routeHintEdges, blindedPathSet, finalCLTVDelta, ) } @@ -1007,7 +1007,7 @@ func (r *RouterBackend) extractIntentFromSendRequest( if err != nil { return nil, err } - payIntent.BlindedPayment = pathSet.GetPath() + payIntent.BlindedPathSet = pathSet // Replace the target node with the target public key // of the blinded path set. diff --git a/routing/pathfind.go b/routing/pathfind.go index d76c5ea22..ee920c564 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -136,9 +136,8 @@ type finalHopParams struct { // NOTE: If a non-nil blinded path is provided it is assumed to have been // validated by the caller. func newRoute(sourceVertex route.Vertex, - pathEdges []*unifiedEdge, currentHeight uint32, - finalHop finalHopParams, blindedPath *sphinx.BlindedPath) ( - *route.Route, error) { + pathEdges []*unifiedEdge, currentHeight uint32, finalHop finalHopParams, + blindedPathSet *BlindedPaymentPathSet) (*route.Route, error) { var ( hops []*route.Hop @@ -153,8 +152,14 @@ func newRoute(sourceVertex route.Vertex, // backwards below, this next hop gets closer and closer to the // sender of the payment. nextIncomingAmount lnwire.MilliSatoshi + + blindedPath *sphinx.BlindedPath ) + if blindedPathSet != nil { + blindedPath = blindedPathSet.GetPath().BlindedPath + } + pathLength := len(pathEdges) for i := pathLength - 1; i >= 0; i-- { // Now we'll start to calculate the items within the per-hop @@ -437,9 +442,9 @@ type RestrictParams struct { // the payee. Metadata []byte - // BlindedPayment is necessary to determine the hop size of the + // BlindedPaymentPathSet is necessary to determine the hop size of the // last/exit hop. - BlindedPayment *BlindedPayment + BlindedPaymentPathSet *BlindedPaymentPathSet } // PathFindingConfig defines global parameters that control the trade-off in @@ -1365,9 +1370,11 @@ func getProbabilityBasedDist(weight int64, probability float64, func lastHopPayloadSize(r *RestrictParams, finalHtlcExpiry int32, amount lnwire.MilliSatoshi) uint64 { - if r.BlindedPayment != nil { - blindedPath := r.BlindedPayment.BlindedPath.BlindedHops - blindedPoint := r.BlindedPayment.BlindedPath.BlindingPoint + if r.BlindedPaymentPathSet != nil { + paymentPath := r.BlindedPaymentPathSet. + LargestLastHopPayloadPath() + blindedPath := paymentPath.BlindedPath.BlindedHops + blindedPoint := paymentPath.BlindedPath.BlindingPoint encryptedData := blindedPath[len(blindedPath)-1].CipherText finalHop := route.Hop{ diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index f67a8fa59..1200035bb 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3277,6 +3277,11 @@ func TestBlindedRouteConstruction(t *testing.T) { require.NoError(t, blindedPayment.Validate()) + blindedPathSet, err := NewBlindedPaymentPathSet( + []*BlindedPayment{blindedPayment}, + ) + require.NoError(t, err) + // Generate route hints from our blinded payment and a set of edges // that make up the graph we'll give to route construction. The hints // map is keyed by source node, so we can retrieve our blinded edges @@ -3382,7 +3387,7 @@ func TestBlindedRouteConstruction(t *testing.T) { route, err := newRoute( sourceVertex, edges, currentHeight, finalHopParams, - blindedPath, + blindedPathSet, ) require.NoError(t, err) require.Equal(t, expectedRoute, route) @@ -3409,31 +3414,38 @@ func TestLastHopPayloadSize(t *testing.T) { amtToForward = lnwire.MilliSatoshi(10000) finalHopExpiry int32 = 144 - oneHopBlindedPayment = &BlindedPayment{ - BlindedPath: &sphinx.BlindedPath{ - BlindedHops: []*sphinx.BlindedHopInfo{ - { - CipherText: encrypedData, - }, + oneHopPath = &sphinx.BlindedPath{ + BlindedHops: []*sphinx.BlindedHopInfo{ + { + CipherText: encrypedData, }, - BlindingPoint: blindedPoint, }, + BlindingPoint: blindedPoint, } - twoHopBlindedPayment = &BlindedPayment{ - BlindedPath: &sphinx.BlindedPath{ - BlindedHops: []*sphinx.BlindedHopInfo{ - { - CipherText: encrypedData, - }, - { - CipherText: encrypedData, - }, + + twoHopPath = &sphinx.BlindedPath{ + BlindedHops: []*sphinx.BlindedHopInfo{ + { + CipherText: encrypedData, + }, + { + CipherText: encrypedData, }, - BlindingPoint: blindedPoint, }, + BlindingPoint: blindedPoint, } ) + oneHopBlindedPayment, err := NewBlindedPaymentPathSet( + []*BlindedPayment{{BlindedPath: oneHopPath}}, + ) + require.NoError(t, err) + + twoHopBlindedPayment, err := NewBlindedPaymentPathSet( + []*BlindedPayment{{BlindedPath: twoHopPath}}, + ) + require.NoError(t, err) + testCases := []struct { name string restrictions *RestrictParams @@ -3454,7 +3466,7 @@ func TestLastHopPayloadSize(t *testing.T) { { name: "Blinded final hop introduction point", restrictions: &RestrictParams{ - BlindedPayment: oneHopBlindedPayment, + BlindedPaymentPathSet: oneHopBlindedPayment, }, amount: amtToForward, finalHopExpiry: finalHopExpiry, @@ -3462,7 +3474,7 @@ func TestLastHopPayloadSize(t *testing.T) { { name: "Blinded final hop of a two hop payment", restrictions: &RestrictParams{ - BlindedPayment: twoHopBlindedPayment, + BlindedPaymentPathSet: twoHopBlindedPayment, }, amount: amtToForward, finalHopExpiry: finalHopExpiry, @@ -3490,12 +3502,11 @@ func TestLastHopPayloadSize(t *testing.T) { } var finalHop route.Hop - if tc.restrictions.BlindedPayment != nil { - blindedPath := tc.restrictions.BlindedPayment. - BlindedPath.BlindedHops - - blindedPoint := tc.restrictions.BlindedPayment. - BlindedPath.BlindingPoint + if tc.restrictions.BlindedPaymentPathSet != nil { + path := tc.restrictions.BlindedPaymentPathSet. + LargestLastHopPayloadPath() + blindedPath := path.BlindedPath.BlindedHops + blindedPoint := path.BlindedPath.BlindingPoint //nolint:lll finalHop = route.Hop{ diff --git a/routing/payment_session.go b/routing/payment_session.go index f320ce0dc..00b4ab70e 100644 --- a/routing/payment_session.go +++ b/routing/payment_session.go @@ -5,7 +5,6 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btclog" - sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/build" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb/models" @@ -206,13 +205,13 @@ func newPaymentSession(p *LightningPayment, selfNode route.Vertex, return nil, err } - if p.BlindedPayment != nil { + if p.BlindedPathSet != nil { if len(edges) != 0 { return nil, fmt.Errorf("cannot have both route hints " + "and blinded path") } - edges, err = p.BlindedPayment.toRouteHints() + edges, err = p.BlindedPathSet.ToRouteHints() if err != nil { return nil, err } @@ -342,7 +341,7 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi, // can split. Split payments to blinded paths won't have // MPP records. if p.payment.PaymentAddr == nil && - p.payment.BlindedPayment == nil { + p.payment.BlindedPathSet == nil { p.log.Debugf("not splitting because payment " + "address is unspecified") @@ -407,11 +406,6 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi, return nil, err } - var blindedPath *sphinx.BlindedPath - if p.payment.BlindedPayment != nil { - blindedPath = p.payment.BlindedPayment.BlindedPath - } - // With the next candidate path found, we'll attempt to turn // this into a route by applying the time-lock and fee // requirements. @@ -424,7 +418,7 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi, records: p.payment.DestCustomRecords, paymentAddr: p.payment.PaymentAddr, metadata: p.payment.Metadata, - }, blindedPath, + }, p.payment.BlindedPathSet, ) if err != nil { return nil, err diff --git a/routing/router.go b/routing/router.go index 5551c5345..1bf6a56c7 100644 --- a/routing/router.go +++ b/routing/router.go @@ -477,10 +477,10 @@ type RouteRequest struct { // in blinded payment. FinalExpiry uint16 - // BlindedPayment contains an optional blinded path and parameters - // used to reach a target node via a blinded path. This field is + // BlindedPathSet contains a set of optional blinded paths and + // parameters used to reach a target node blinded paths. This field is // mutually exclusive with the Target field. - BlindedPayment *BlindedPayment + BlindedPathSet *BlindedPaymentPathSet } // RouteHints is an alias type for a set of route hints, with the source node @@ -494,7 +494,7 @@ type RouteHints map[route.Vertex][]AdditionalEdge func NewRouteRequest(source route.Vertex, target *route.Vertex, amount lnwire.MilliSatoshi, timePref float64, restrictions *RestrictParams, customRecords record.CustomSet, - routeHints RouteHints, blindedPayment *BlindedPayment, + routeHints RouteHints, blindedPathSet *BlindedPaymentPathSet, finalExpiry uint16) (*RouteRequest, error) { var ( @@ -504,11 +504,8 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, err error ) - if blindedPayment != nil { - if err := blindedPayment.Validate(); err != nil { - return nil, fmt.Errorf("invalid blinded payment: %w", - err) - } + if blindedPathSet != nil { + blindedPayment := blindedPathSet.GetPath() introVertex := route.NewVertex( blindedPayment.BlindedPath.IntroductionPoint, @@ -539,13 +536,13 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, requestExpiry = blindedPayment.CltvExpiryDelta } - requestHints, err = blindedPayment.toRouteHints() + requestHints, err = blindedPathSet.ToRouteHints() if err != nil { return nil, err } } - requestTarget, err := getTargetNode(target, blindedPayment) + requestTarget, err := getTargetNode(target, blindedPathSet) if err != nil { return nil, err } @@ -559,15 +556,15 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, CustomRecords: customRecords, RouteHints: requestHints, FinalExpiry: requestExpiry, - BlindedPayment: blindedPayment, + BlindedPathSet: blindedPathSet, }, nil } -func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) ( - route.Vertex, error) { +func getTargetNode(target *route.Vertex, + blindedPathSet *BlindedPaymentPathSet) (route.Vertex, error) { var ( - blinded = blindedPayment != nil + blinded = blindedPathSet != nil targetSet = target != nil ) @@ -576,6 +573,8 @@ func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) ( return route.Vertex{}, ErrTargetAndBlinded case blinded: + blindedPayment := blindedPathSet.GetPath() + // If we're dealing with an edge-case blinded path that just // has an introduction node (first hop expected to be the intro // hop), then we return the unblinded introduction node as our @@ -597,16 +596,6 @@ func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) ( } } -// blindedPath returns the request's blinded path, which is set if the payment -// is to a blinded route. -func (r *RouteRequest) blindedPath() *sphinx.BlindedPath { - if r.BlindedPayment == nil { - return nil - } - - return r.BlindedPayment.BlindedPath -} - // FindRoute attempts to query the ChannelRouter for the optimum path to a // particular target destination to which it is able to send `amt` after // factoring in channel capacities and cumulative fees along the route. @@ -664,7 +653,7 @@ func (r *ChannelRouter) FindRoute(req *RouteRequest) (*route.Route, float64, totalAmt: req.Amount, cltvDelta: req.FinalExpiry, records: req.CustomRecords, - }, req.blindedPath(), + }, req.BlindedPathSet, ) if err != nil { return nil, 0, err @@ -926,14 +915,10 @@ type LightningPayment struct { // BlindedPayment field. RouteHints [][]zpay32.HopHint - // BlindedPayment holds the information about a blinded path to the - // payment recipient. This is mutually exclusive to the RouteHints + // BlindedPathSet holds the information about a set of blinded paths to + // the payment recipient. This is mutually exclusive to the RouteHints // field. - // - // NOTE: a recipient may provide multiple blinded payment paths in the - // same invoice. Currently, LND will only attempt to use the first one. - // A future PR will handle multiple blinded payment paths. - BlindedPayment *BlindedPayment + BlindedPathSet *BlindedPaymentPathSet // OutgoingChannelIDs is the list of channels that are allowed for the // first hop. If nil, any channel may be used. diff --git a/routing/router_test.go b/routing/router_test.go index f631669a9..0ffd75db2 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -2223,11 +2223,6 @@ func TestNewRouteRequest(t *testing.T) { finalExpiry: unblindedCltv, err: ErrExpiryAndBlinded, }, - { - name: "invalid blinded payment", - blindedPayment: &BlindedPayment{}, - err: ErrNoBlindedPath, - }, } for _, testCase := range testCases { @@ -2236,9 +2231,19 @@ func TestNewRouteRequest(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() + var blindedPathInfo *BlindedPaymentPathSet + if testCase.blindedPayment != nil { + blindedPathInfo, err = NewBlindedPaymentPathSet( + []*BlindedPayment{ + testCase.blindedPayment, + }, + ) + require.NoError(t, err) + } + req, err := NewRouteRequest( source, testCase.target, 1000, 0, nil, nil, - testCase.routeHints, testCase.blindedPayment, + testCase.routeHints, blindedPathInfo, testCase.finalExpiry, ) require.ErrorIs(t, err, testCase.err) diff --git a/rpcserver.go b/rpcserver.go index ff7aa613a..86bc6415c 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -5105,7 +5105,7 @@ type rpcPaymentIntent struct { paymentAddr *[32]byte payReq []byte metadata []byte - blindedPayment *routing.BlindedPayment + blindedPathSet *routing.BlindedPaymentPathSet destCustomRecords record.CustomSet @@ -5248,7 +5248,7 @@ func (r *rpcServer) extractPaymentIntent(rpcPayReq *rpcPaymentRequest) (rpcPayme if err != nil { return payIntent, err } - payIntent.blindedPayment = pathSet.GetPath() + payIntent.blindedPathSet = pathSet // Replace the destination node with the target public // key of the blinded path set. @@ -5417,7 +5417,7 @@ func (r *rpcServer) dispatchPaymentIntent( DestFeatures: payIntent.destFeatures, PaymentAddr: payIntent.paymentAddr, Metadata: payIntent.metadata, - BlindedPayment: payIntent.blindedPayment, + BlindedPathSet: payIntent.blindedPathSet, // Don't enable multi-part payments on the main rpc. // Users need to use routerrpc for that. From 8df03de3e9c87afd7ffcd8ea6c9b9aba2d0c5db2 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 May 2024 15:52:17 +0200 Subject: [PATCH 3/9] routing: swap out final hop blinded route pub keys If multiple blinded paths are provided, they will each have a different pub key for the destination node. This makes using our existing pathfinding logic tricky since it depends on having a single destination node (characterised by a single pub key). We want to re-use this logic. So what we do is swap out the pub keys of the destinaion hop with a pseudo target pub key. This will then be used during pathfinding. Later on once a path is found, we will swap the real destination keys back in so that onion creation can be done. --- routing/blinding.go | 62 +++++++++++++++++++++++++++++++--------- routing/blinding_test.go | 5 ++-- routing/pathfind.go | 43 ++++++++++++++++++++++------ routing/pathfind_test.go | 5 +++- routing/router.go | 15 +--------- routing/router_test.go | 11 +++++-- 6 files changed, 99 insertions(+), 42 deletions(-) diff --git a/routing/blinding.go b/routing/blinding.go index c491f4d85..32bcfa3ff 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/channeldb/models" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" ) @@ -86,16 +87,35 @@ func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet, } } - // NOTE: for now, we just take a single path. By the end of this PR - // series, all paths will be kept. - path := paths[0] + // Derive an ephemeral target priv key that will be injected into each + // blinded path final hop. + targetPriv, err := btcec.NewPrivateKey() + if err != nil { + return nil, err + } + targetPub := targetPriv.PubKey() - finalHop := path.BlindedPath. - BlindedHops[len(path.BlindedPath.BlindedHops)-1] + // If any provided blinded path only has a single hop (ie, the + // destination node is also the introduction node), then we discard all + // other paths since we know the real pub key of the destination node. + // For a single hop path, there is also no need for the pseudo target + // pub key replacement, so our target pub key in this case just remains + // the real introduction node ID. + var pathSet = paths + for _, path := range paths { + if len(path.BlindedPath.BlindedHops) != 1 { + continue + } + + pathSet = []*BlindedPayment{path} + targetPub = path.BlindedPath.IntroductionPoint + + break + } return &BlindedPaymentPathSet{ - paths: paths, - targetPubKey: finalHop.BlindedNodePub, + paths: pathSet, + targetPubKey: targetPub, features: features, }, nil } @@ -144,7 +164,7 @@ func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) { hints := make(RouteHints) for _, path := range s.paths { - pathHints, err := path.toRouteHints() + pathHints, err := path.toRouteHints(fn.Some(s.targetPubKey)) if err != nil { return nil, err } @@ -223,8 +243,11 @@ func (b *BlindedPayment) Validate() error { // effectively the final_cltv_delta for the receiving introduction node). In // the case of multiple blinded hops, CLTV delta is fully accounted for in the // hints (both for intermediate hops and the final_cltv_delta for the receiving -// node). -func (b *BlindedPayment) toRouteHints() (RouteHints, error) { +// node). The pseudoTarget, if provided, will be used to override the pub key +// of the destination node in the path. +func (b *BlindedPayment) toRouteHints( + pseudoTarget fn.Option[*btcec.PublicKey]) (RouteHints, error) { + // If we just have a single hop in our blinded route, it just contains // an introduction node (this is a valid path according to the spec). // Since we have the un-blinded node ID for the introduction node, we @@ -272,12 +295,12 @@ func (b *BlindedPayment) toRouteHints() (RouteHints, error) { ToNodeFeatures: features, } - edge, err := NewBlindedEdge(edgePolicy, b, 0) + lastEdge, err := NewBlindedEdge(edgePolicy, b, 0) if err != nil { return nil, err } - hints[fromNode] = []AdditionalEdge{edge} + hints[fromNode] = []AdditionalEdge{lastEdge} // Start at an offset of 1 because the first node in our blinded hops // is the introduction node and terminate at the second-last node @@ -304,13 +327,24 @@ func (b *BlindedPayment) toRouteHints() (RouteHints, error) { ToNodeFeatures: features, } - edge, err := NewBlindedEdge(edgePolicy, b, i) + lastEdge, err = NewBlindedEdge(edgePolicy, b, i) if err != nil { return nil, err } - hints[fromNode] = []AdditionalEdge{edge} + hints[fromNode] = []AdditionalEdge{lastEdge} } + pseudoTarget.WhenSome(func(key *btcec.PublicKey) { + // For the very last hop on the path, switch out the ToNodePub + // for the pseudo target pub key. + lastEdge.policy.ToNodePubKey = func() route.Vertex { + return route.NewVertex(key) + } + + // Then override the final hint with this updated edge. + hints[fromNode] = []AdditionalEdge{lastEdge} + }) + return hints, nil } diff --git a/routing/blinding_test.go b/routing/blinding_test.go index 58ad56594..950cb0210 100644 --- a/routing/blinding_test.go +++ b/routing/blinding_test.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/channeldb/models" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" @@ -128,7 +129,7 @@ func TestBlindedPaymentToHints(t *testing.T) { HtlcMaximum: htlcMax, Features: features, } - hints, err := blindedPayment.toRouteHints() + hints, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]()) require.NoError(t, err) require.Nil(t, hints) @@ -183,7 +184,7 @@ func TestBlindedPaymentToHints(t *testing.T) { }, } - actual, err := blindedPayment.toRouteHints() + actual, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]()) require.NoError(t, err) require.Equal(t, len(expected), len(actual)) diff --git a/routing/pathfind.go b/routing/pathfind.go index ee920c564..bf1d3bf4f 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -153,19 +153,24 @@ func newRoute(sourceVertex route.Vertex, // sender of the payment. nextIncomingAmount lnwire.MilliSatoshi - blindedPath *sphinx.BlindedPath + blindedPayment *BlindedPayment ) - if blindedPathSet != nil { - blindedPath = blindedPathSet.GetPath().BlindedPath - } - pathLength := len(pathEdges) for i := pathLength - 1; i >= 0; i-- { // Now we'll start to calculate the items within the per-hop // payload for the hop this edge is leading to. edge := pathEdges[i].policy + // If this is an edge from a blinded path and the + // blindedPayment variable has not been set yet, then set it now + // by extracting the corresponding blinded payment from the + // edge. + isBlindedEdge := pathEdges[i].blindedPayment != nil + if isBlindedEdge && blindedPayment == nil { + blindedPayment = pathEdges[i].blindedPayment + } + // We'll calculate the amounts, timelocks, and fees for each hop // in the route. The base case is the final hop which includes // their amount and timelocks. These values will accumulate @@ -212,8 +217,9 @@ func newRoute(sourceVertex route.Vertex, // node's CLTV delta. The exception is for the case // where the final hop is the blinded path introduction // node. - if blindedPath == nil || - len(blindedPath.BlindedHops) == 1 { + if blindedPathSet == nil || + len(blindedPathSet.GetPath().BlindedPath. + BlindedHops) == 1 { // As this is the last hop, we'll use the // specified final CLTV delta value instead of @@ -245,7 +251,7 @@ func newRoute(sourceVertex route.Vertex, metadata = finalHop.metadata - if blindedPath != nil { + if blindedPathSet != nil { totalAmtMsatBlinded = finalHop.totalAmt } } else { @@ -305,11 +311,25 @@ func newRoute(sourceVertex route.Vertex, // If we are creating a route to a blinded path, we need to add some // additional data to the route that is required for blinded forwarding. // We do another pass on our edges to append this data. - if blindedPath != nil { + if blindedPathSet != nil { + // If the passed in BlindedPaymentPathSet is non-nil but no + // edge had a BlindedPayment attached, it means that the path + // chosen was an introduction-node-only path. So in this case, + // we can assume the relevant payment is the only one in the + // payment set. + if blindedPayment == nil { + blindedPayment = blindedPathSet.GetPath() + } + var ( inBlindedRoute bool dataIndex = 0 + blindedPath = blindedPayment.BlindedPath + numHops = len(blindedPath.BlindedHops) + realFinal = blindedPath.BlindedHops[numHops-1]. + BlindedNodePub + introVertex = route.NewVertex( blindedPath.IntroductionPoint, ) @@ -337,6 +357,11 @@ func newRoute(sourceVertex route.Vertex, if i != len(hops)-1 { hop.AmtToForward = 0 hop.OutgoingTimeLock = 0 + } else { + // For the final hop, we swap out the pub key + // bytes to the original destination node pub + // key for that payment path. + hop.PubKeyBytes = route.NewVertex(realFinal) } dataIndex++ diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 1200035bb..802385351 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -23,6 +23,7 @@ import ( sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb/models" + "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/htlcswitch" switchhop "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/kvdb" @@ -3286,7 +3287,9 @@ func TestBlindedRouteConstruction(t *testing.T) { // that make up the graph we'll give to route construction. The hints // map is keyed by source node, so we can retrieve our blinded edges // accordingly. - blindedEdges, err := blindedPayment.toRouteHints() + blindedEdges, err := blindedPayment.toRouteHints( + fn.None[*btcec.PublicKey](), + ) require.NoError(t, err) carolDaveEdge := blindedEdges[carolVertex][0] diff --git a/routing/router.go b/routing/router.go index 1bf6a56c7..13e36f304 100644 --- a/routing/router.go +++ b/routing/router.go @@ -573,20 +573,7 @@ func getTargetNode(target *route.Vertex, return route.Vertex{}, ErrTargetAndBlinded case blinded: - blindedPayment := blindedPathSet.GetPath() - - // If we're dealing with an edge-case blinded path that just - // has an introduction node (first hop expected to be the intro - // hop), then we return the unblinded introduction node as our - // target. - hops := blindedPayment.BlindedPath.BlindedHops - if len(hops) == 1 { - return route.NewVertex( - blindedPayment.BlindedPath.IntroductionPoint, - ), nil - } - - return route.NewVertex(hops[len(hops)-1].BlindedNodePub), nil + return route.NewVertex(blindedPathSet.TargetPubKey()), nil case targetSet: return *target, nil diff --git a/routing/router_test.go b/routing/router_test.go index 0ffd75db2..28cced4e6 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -2231,7 +2231,10 @@ func TestNewRouteRequest(t *testing.T) { t.Run(testCase.name, func(t *testing.T) { t.Parallel() - var blindedPathInfo *BlindedPaymentPathSet + var ( + blindedPathInfo *BlindedPaymentPathSet + expectedTarget = testCase.expectedTarget + ) if testCase.blindedPayment != nil { blindedPathInfo, err = NewBlindedPaymentPathSet( []*BlindedPayment{ @@ -2239,6 +2242,10 @@ func TestNewRouteRequest(t *testing.T) { }, ) require.NoError(t, err) + + expectedTarget = route.NewVertex( + blindedPathInfo.TargetPubKey(), + ) } req, err := NewRouteRequest( @@ -2253,7 +2260,7 @@ func TestNewRouteRequest(t *testing.T) { return } - require.Equal(t, req.Target, testCase.expectedTarget) + require.Equal(t, req.Target, expectedTarget) require.Equal( t, req.FinalExpiry, testCase.expectedCltv, ) From daaa24b69ce601a235c5432ed0d5abed741aef30 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 May 2024 15:59:43 +0200 Subject: [PATCH 4/9] routing: let BlindedPaymentPathSet handle FinalCLTV logic Instead of needing to remember how to handle the FinalCLTV value of a blinded payment path at various points in the code base, we hide the logic behind a unified FinalCLTVDelta method on the blinded path. --- routing/blinding.go | 40 ++++++++++++++++++++++++++++++++++++---- routing/pathfind.go | 19 +++++-------------- routing/router.go | 12 +----------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/routing/blinding.go b/routing/blinding.go index 32bcfa3ff..401d7f3ee 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -51,6 +51,22 @@ type BlindedPaymentPathSet struct { // moment we require that all paths for the same payment have the // same feature set. features *lnwire.FeatureVector + + // finalCLTV is the final hop's expiry delta of _any_ path in the set. + // For any multi-hop path, the final CLTV delta should be seen as zero + // since the final hop's final CLTV delta is accounted for in the + // accumulated path policy values. The only edge case is for when the + // final hop in the path is also the introduction node in which case + // that path's FinalCLTV must be the non-zero min CLTV of the final hop + // so that it is accounted for in path finding. For this reason, if + // we have any single path in the set with only one hop, then we throw + // away all the other paths. This should be fine to do since if there is + // a path where the intro node is also the destination node, then there + // isn't any need to try any other longer blinded path. In other words, + // if this value is non-zero, then there is only one path in this + // blinded path set and that path only has a single hop: the + // introduction node. + finalCLTV uint16 } // NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of @@ -95,19 +111,25 @@ func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet, } targetPub := targetPriv.PubKey() + var ( + pathSet = paths + finalCLTVDelta uint16 + ) // If any provided blinded path only has a single hop (ie, the // destination node is also the introduction node), then we discard all // other paths since we know the real pub key of the destination node. - // For a single hop path, there is also no need for the pseudo target - // pub key replacement, so our target pub key in this case just remains - // the real introduction node ID. - var pathSet = paths + // We also then set the final CLTV delta to the path's delta since + // there are no other edge hints that will account for it. For a single + // hop path, there is also no need for the pseudo target pub key + // replacement, so our target pub key in this case just remains the + // real introduction node ID. for _, path := range paths { if len(path.BlindedPath.BlindedHops) != 1 { continue } pathSet = []*BlindedPayment{path} + finalCLTVDelta = path.CltvExpiryDelta targetPub = path.BlindedPath.IntroductionPoint break @@ -117,6 +139,7 @@ func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet, paths: pathSet, targetPubKey: targetPub, features: features, + finalCLTV: finalCLTVDelta, }, nil } @@ -137,6 +160,15 @@ func (s *BlindedPaymentPathSet) GetPath() *BlindedPayment { return s.paths[0] } +// FinalCLTVDelta is the minimum CLTV delta to use for the final hop on the +// route. In most cases this will return zero since the value is accounted for +// in the path's accumulated CLTVExpiryDelta. Only in the edge case of the path +// set only including a single path which only includes an introduction node +// will this return a non-zero value. +func (s *BlindedPaymentPathSet) FinalCLTVDelta() uint16 { + return s.finalCLTV +} + // LargestLastHopPayloadPath returns the BlindedPayment in the set that has the // largest last-hop payload. This is to be used for onion size estimation in // path finding. diff --git a/routing/pathfind.go b/routing/pathfind.go index bf1d3bf4f..5169bdab9 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -210,21 +210,12 @@ func newRoute(sourceVertex route.Vertex, // reporting through RPC. Set to zero for the final hop. fee = 0 - // Only include the final hop CLTV delta in the total - // time lock value if this is not a route to a blinded - // path. For blinded paths, the total time-lock from the - // whole path will be deduced from the introduction - // node's CLTV delta. The exception is for the case - // where the final hop is the blinded path introduction - // node. - if blindedPathSet == nil || - len(blindedPathSet.GetPath().BlindedPath. - BlindedHops) == 1 { - - // As this is the last hop, we'll use the - // specified final CLTV delta value instead of - // the value from the last link in the route. + if blindedPathSet == nil { totalTimeLock += uint32(finalHop.cltvDelta) + } else { + totalTimeLock += uint32( + blindedPathSet.FinalCLTVDelta(), + ) } outgoingTimeLock = totalTimeLock diff --git a/routing/router.go b/routing/router.go index 13e36f304..bca78b5ad 100644 --- a/routing/router.go +++ b/routing/router.go @@ -524,17 +524,7 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, return nil, ErrExpiryAndBlinded } - // If we have a blinded path with 1 hop, the cltv expiry - // will not be included in any hop hints (since we're just - // sending to the introduction node and need no blinded hints). - // In this case, we include it to make sure that the final - // cltv delta is accounted for (since it's part of the blinded - // delta). In the case of a multi-hop route, we set our final - // cltv to zero, since it's going to be accounted for in the - // delta for our hints. - if len(blindedPayment.BlindedPath.BlindedHops) == 1 { - requestExpiry = blindedPayment.CltvExpiryDelta - } + requestExpiry = blindedPathSet.FinalCLTVDelta() requestHints, err = blindedPathSet.ToRouteHints() if err != nil { From e87110317b195b3c7f19a767151d30ea50b93a98 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 16 May 2024 10:33:00 +0200 Subject: [PATCH 5/9] routing: final changes to BlindedPaymentPathSet Continue adding some complexity behind the BlindedPaymentPathSet. What we do here is add a new IntroNodeOnlyPath method. The assumption we make here is: If multiple blinded paths are provided to us in an invoice but one of those paths only includes an intro node, then there is no point in looking at any other path since we know that the intro node is the destination node. So in such a case, we would have discarded any other path in the `NewBlindedPaymentPathSet` constructor. So then we would only have a single blinded path made up of an introduction node only. In this specific case, in the `newRoute` function, no edge passed to the function would have a blindedPayment associated with it (since there are no blinded hops in this case). So we will have a case where `blindedPathSet` passed to `newRoute` is not nil but `blindedPayment` is nil since nonce was extacted from any edge. If this happens then we can assume that this is the Intro-Node-Only situation described above. And so we grabe the associated payment from the path set. --- routing/blinding.go | 34 ++++++++++++++++++++++++++++++---- routing/pathfind.go | 6 +++++- routing/pathfind_test.go | 19 +++++++++++++++---- routing/router.go | 7 +------ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/routing/blinding.go b/routing/blinding.go index 401d7f3ee..270f998d9 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -154,10 +154,36 @@ func (s *BlindedPaymentPathSet) Features() *lnwire.FeatureVector { return s.features } -// GetPath is a temporary getter for the single path that the set holds. -// This will be removed later on in this PR. -func (s *BlindedPaymentPathSet) GetPath() *BlindedPayment { - return s.paths[0] +// IntroNodeOnlyPath can be called if it is expected that the path set only +// contains a single payment path which itself only has one hop. It errors if +// this is not the case. +func (s *BlindedPaymentPathSet) IntroNodeOnlyPath() (*BlindedPayment, error) { + if len(s.paths) != 1 { + return nil, fmt.Errorf("expected only a single path in the "+ + "blinded payment set, got %d", len(s.paths)) + } + + if len(s.paths[0].BlindedPath.BlindedHops) > 1 { + return nil, fmt.Errorf("an intro node only path cannot have " + + "more than one hop") + } + + return s.paths[0], nil +} + +// IsIntroNode returns true if the given vertex is an introduction node for one +// of the paths in the blinded payment path set. +func (s *BlindedPaymentPathSet) IsIntroNode(source route.Vertex) bool { + for _, path := range s.paths { + introVertex := route.NewVertex( + path.BlindedPath.IntroductionPoint, + ) + if source == introVertex { + return true + } + } + + return false } // FinalCLTVDelta is the minimum CLTV delta to use for the final hop on the diff --git a/routing/pathfind.go b/routing/pathfind.go index 5169bdab9..35b44cf6b 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -309,7 +309,11 @@ func newRoute(sourceVertex route.Vertex, // we can assume the relevant payment is the only one in the // payment set. if blindedPayment == nil { - blindedPayment = blindedPathSet.GetPath() + var err error + blindedPayment, err = blindedPathSet.IntroNodeOnlyPath() + if err != nil { + return nil, err + } } var ( diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 802385351..8fc50bb4f 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3296,10 +3296,21 @@ func TestBlindedRouteConstruction(t *testing.T) { daveEveEdge := blindedEdges[daveBlindedVertex][0] edges := []*unifiedEdge{ - {policy: aliceBobEdge}, - {policy: bobCarolEdge}, - {policy: carolDaveEdge.EdgePolicy()}, - {policy: daveEveEdge.EdgePolicy()}, + { + policy: aliceBobEdge, + }, + { + policy: bobCarolEdge, + blindedPayment: blindedPayment, + }, + { + policy: carolDaveEdge.EdgePolicy(), + blindedPayment: blindedPayment, + }, + { + policy: daveEveEdge.EdgePolicy(), + blindedPayment: blindedPayment, + }, } // Total timelock for the route should include: diff --git a/routing/router.go b/routing/router.go index bca78b5ad..0a0af0d86 100644 --- a/routing/router.go +++ b/routing/router.go @@ -505,12 +505,7 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex, ) if blindedPathSet != nil { - blindedPayment := blindedPathSet.GetPath() - - introVertex := route.NewVertex( - blindedPayment.BlindedPath.IntroductionPoint, - ) - if source == introVertex { + if blindedPathSet.IsIntroNode(source) { return nil, ErrSelfIntro } From e4165018026d1deba2cdb1997814bf176f34dbe2 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 10 Jul 2024 12:13:50 +0200 Subject: [PATCH 6/9] itest: test sending MP payment over multiple blinded paths --- itest/list_on_test.go | 4 + itest/lnd_route_blinding_test.go | 163 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/itest/list_on_test.go b/itest/list_on_test.go index c5d63bd2d..adedfd541 100644 --- a/itest/list_on_test.go +++ b/itest/list_on_test.go @@ -594,6 +594,10 @@ var allTestCases = []*lntest.TestCase{ Name: "mpp to single blinded path", TestFunc: testMPPToSingleBlindedPath, }, + { + Name: "mpp to multiple blinded paths", + TestFunc: testMPPToMultipleBlindedPaths, + }, { Name: "route blinding dummy hops", TestFunc: testBlindedRouteDummyHops, diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index 3b10f3b59..48f31900f 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1229,3 +1229,166 @@ func testBlindedRouteDummyHops(ht *lntest.HarnessTest) { ht.AssertNumWaitingClose(hn, 0) } } + +// testMPPToMultipleBlindedPaths tests that a two-shard MPP payment can be sent +// over a multiple blinded paths. The following network is created where Dave +// is the recipient and Alice the sender. Dave will create an invoice containing +// two blinded paths: one with Bob at the intro node and one with Carol as the +// intro node. Channel liquidity will be set up in such a way that Alice will be +// forced to send one shared via the Bob->Dave route and one over the +// Carol->Dave route. +// +// --- Bob --- +// / \ +// Alice Dave +// \ / +// --- Carol --- +func testMPPToMultipleBlindedPaths(ht *lntest.HarnessTest) { + alice, bob := ht.Alice, ht.Bob + + // Create a four-node context consisting of Alice, Bob and three new + // nodes. + dave := ht.NewNode("dave", []string{ + "--routing.blinding.min-num-real-hops=1", + "--routing.blinding.num-hops=1", + }) + carol := ht.NewNode("carol", nil) + + // Connect nodes to ensure propagation of channels. + ht.EnsureConnected(alice, carol) + ht.EnsureConnected(alice, bob) + ht.EnsureConnected(carol, dave) + ht.EnsureConnected(bob, dave) + + // Fund the new nodes. + ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, carol) + ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, dave) + ht.MineBlocksAndAssertNumTxes(1, 2) + + const paymentAmt = btcutil.Amount(300000) + + nodes := []*node.HarnessNode{alice, bob, carol, dave} + + reqs := []*lntest.OpenChannelRequest{ + { + Local: alice, + Remote: bob, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2 / 3, + }, + }, + { + Local: alice, + Remote: carol, + Param: lntest.OpenChannelParams{ + Amt: paymentAmt * 2 / 3, + }, + }, + { + Local: bob, + Remote: dave, + Param: lntest.OpenChannelParams{Amt: paymentAmt * 2}, + }, + { + Local: carol, + Remote: dave, + Param: lntest.OpenChannelParams{Amt: paymentAmt * 2}, + }, + } + + channelPoints := ht.OpenMultiChannelsAsync(reqs) + + // Make sure every node has heard every channel. + for _, hn := range nodes { + for _, cp := range channelPoints { + ht.AssertTopologyChannelOpen(hn, cp) + } + + // Each node should have exactly 5 edges. + ht.AssertNumEdges(hn, len(channelPoints), false) + } + + // Ok now make a payment that must be split to succeed. + + // Make Dave create an invoice for Alice to pay + invoice := &lnrpc.Invoice{ + Memo: "test", + Value: int64(paymentAmt), + Blind: true, + } + invoiceResp := dave.RPC.AddInvoice(invoice) + + // Assert that two blinded paths are included in the invoice. + payReq := dave.RPC.DecodePayReq(invoiceResp.PaymentRequest) + require.Len(ht, payReq.BlindedPaths, 2) + + sendReq := &routerrpc.SendPaymentRequest{ + PaymentRequest: invoiceResp.PaymentRequest, + MaxParts: 10, + TimeoutSeconds: 60, + FeeLimitMsat: noFeeLimitMsat, + } + payment := ht.SendPaymentAssertSettled(alice, sendReq) + + preimageBytes, err := hex.DecodeString(payment.PaymentPreimage) + require.NoError(ht, err) + + preimage, err := lntypes.MakePreimage(preimageBytes) + require.NoError(ht, err) + + hash, err := lntypes.MakeHash(invoiceResp.RHash) + require.NoError(ht, err) + + // Make sure we got the preimage. + require.True(ht, preimage.Matches(hash), "preimage doesn't match") + + // Check that Alice split the payment in at least two shards. Because + // the hand-off of the htlc to the link is asynchronous (via a mailbox), + // there is some non-determinism in the process. Depending on whether + // the new pathfinding round is started before or after the htlc is + // locked into the channel, different sharding may occur. Therefore we + // can only check if the number of shards isn't below the theoretical + // minimum. + succeeded := 0 + for _, htlc := range payment.Htlcs { + if htlc.Status == lnrpc.HTLCAttempt_SUCCEEDED { + succeeded++ + } + } + + const minExpectedShards = 2 + require.GreaterOrEqual(ht, succeeded, minExpectedShards, + "expected shards not reached") + + // Make sure Dave show the invoice as settled for the full amount. + inv := dave.RPC.LookupInvoice(invoiceResp.RHash) + + require.EqualValues(ht, paymentAmt, inv.AmtPaidSat, + "incorrect payment amt") + + require.Equal(ht, lnrpc.Invoice_SETTLED, inv.State, + "Invoice not settled") + + settled := 0 + for _, htlc := range inv.Htlcs { + if htlc.State == lnrpc.InvoiceHTLCState_SETTLED { + settled++ + } + } + require.Equal(ht, succeeded, settled, "num of HTLCs wrong") + + // Close all channels without mining the closing transactions. + ht.CloseChannelAssertPending(alice, channelPoints[0], false) + ht.CloseChannelAssertPending(alice, channelPoints[1], false) + ht.CloseChannelAssertPending(bob, channelPoints[2], false) + ht.CloseChannelAssertPending(carol, channelPoints[3], false) + + // Now mine a block to include all the closing transactions. (first + // iteration: no blinded paths) + ht.MineBlocksAndAssertNumTxes(1, 4) + + // Assert that the channels are closed. + for _, hn := range nodes { + ht.AssertNumWaitingClose(hn, 0) + } +} From 8a14955a0a246a12b91286adad2eda9a13ab48de Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 10 Jul 2024 12:14:31 +0200 Subject: [PATCH 7/9] docs: add release note --- docs/release-notes/release-notes-0.18.3.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/release-notes-0.18.3.md b/docs/release-notes/release-notes-0.18.3.md index 5184e9671..f2e4e736e 100644 --- a/docs/release-notes/release-notes-0.18.3.md +++ b/docs/release-notes/release-notes-0.18.3.md @@ -130,6 +130,9 @@ commitment when the channel was force closed. the `lncli addinvoice` command to instruct LND to include blinded paths in the invoice. +* Add the ability to [send to use multiple blinded payment + paths](https://github.com/lightningnetwork/lnd/pull/8764) in an MP payment. + ## Testing ## Database From b271922501807dd2950beff61a2731aa503b1188 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 31 Jul 2024 09:32:46 +0200 Subject: [PATCH 8/9] routing: dont use InPolicy for blinded paths We only need the ChannelID, so no need to use the InPolicy which might be nil. --- routing/pathfind.go | 4 ++-- routing/router.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/routing/pathfind.go b/routing/pathfind.go index 35b44cf6b..ba9d111c4 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -1156,7 +1156,7 @@ type blindedPathRestrictions struct { // path. type blindedHop struct { vertex route.Vertex - edgePolicy *models.CachedEdgePolicy + channelID uint64 edgeCapacity btcutil.Amount } @@ -1296,7 +1296,7 @@ func processNodeForBlindedPath(g Graph, node route.Vertex, hop := blindedHop{ vertex: channel.OtherNode, - edgePolicy: channel.InPolicy, + channelID: channel.ChannelID, edgeCapacity: channel.Capacity, } diff --git a/routing/router.go b/routing/router.go index 0a0af0d86..3c9be1030 100644 --- a/routing/router.go +++ b/routing/router.go @@ -722,7 +722,7 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex, hops = append(hops, &route.Hop{ PubKeyBytes: path[j].vertex, - ChannelID: path[j-1].edgePolicy.ChannelID, + ChannelID: path[j-1].channelID, }) prevNode = path[j].vertex From be4c3dd9e4c1ac79de5c55755ce1171df963dd7d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 31 Jul 2024 14:11:00 +0200 Subject: [PATCH 9/9] zpay32: enforce a cipher text upper limit To prevent an attacker from causing us to assign a huge in-memory buffer, we place a cap on the maximum cipher text size of a blinded path hop. --- zpay32/blinded_path.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/zpay32/blinded_path.go b/zpay32/blinded_path.go index 1f3adb8ce..0c4da57ee 100644 --- a/zpay32/blinded_path.go +++ b/zpay32/blinded_path.go @@ -4,7 +4,6 @@ import ( "encoding/binary" "fmt" "io" - "math" "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" @@ -21,6 +20,12 @@ const ( // proposal](https://github.com/lightning/blips/pull/39) for a detailed // calculation. maxNumHopsPerPath = 7 + + // maxCipherTextLength defines the largest cipher text size allowed. + // This is derived by using the `data_length` upper bound of 639 bytes + // and then assuming the case of a path with only a single hop (meaning + // the cipher text may be as large as possible). + maxCipherTextLength = 535 ) var ( @@ -215,6 +220,12 @@ func DecodeBlindedHop(r io.Reader) (*sphinx.BlindedHopInfo, error) { return nil, err } + if dataLen > maxCipherTextLength { + return nil, fmt.Errorf("a blinded hop cipher text blob may "+ + "not exceed the maximum of %d bytes", + maxCipherTextLength) + } + encryptedData := make([]byte, dataLen) _, err = r.Read(encryptedData) if err != nil { @@ -238,9 +249,9 @@ func EncodeBlindedHop(w io.Writer, hop *sphinx.BlindedHopInfo) error { return err } - if len(hop.CipherText) > math.MaxUint16 { + if len(hop.CipherText) > maxCipherTextLength { return fmt.Errorf("encrypted recipient data can not exceed a "+ - "length of %d bytes", math.MaxUint16) + "length of %d bytes", maxCipherTextLength) } err = tlv.WriteVarInt(w, uint64(len(hop.CipherText)), &[8]byte{})