Repository: lightningnetwork/lnd
Pull request #8764 (2024-05-16 08:43:35)

[3/4] Route Blinding: send MPP over multiple blinded paths  
In this PR, we remove the restriction of only being able to send to a single blinded payment path.
Nodes will now be able to take advantage of the multiple blinded paths provided for a given 
payment to perform MPP if needed. 

Depends on: https://github.com/lightningnetwork/lnd/pull/8735
Tracking Issue: https://github.com/lightningnetwork/lnd/issues/6690

<!-- Reviewable:start -->
- - -
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/lightningnetwork/lnd/8764)
<!-- Reviewable:end -->


lightninglabs-deploy: @ellemouton, remember to re-request review from reviewers when ready
lightninglabs-deploy: @ziggie1984: review reminder
@ellemouton, remember to re-request review from reviewers when ready
lightninglabs-deploy: @ellemouton, remember to re-request review from reviewers when ready
ziggie1984: > Finally, there is one more assumption we make here: if a recipient provides us with a set of paths of various lengths but one of those paths is an intro-node-only path, then we just go ahead and discard the rest of the paths since it doesnt make sense to try those since we know that the intro node is the dest node.

I wonder if this would be considered a wrong behaviour of the person providing us the invoice, so maybe we should consider this as an invalid invoice to spot wrong implementations ?
Roasbeef: I went to _create_ an invoice with a blinded path on testnet, and ran into this panic: 
```
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x143a82b]

goroutine 4084 [running]:
github.com/lightningnetwork/lnd/routing.(*ChannelRouter).FindBlindedPaths(0xc0022b6f00, {0x2, 0x70, 0x68, 0x5c, 0xa8, 0x1a, 0x8e, 0x4d, 0x4d, ...}, ...)
	github.com/lightningnetwork/lnd@v0.18.2-beta/routing/router.go:727 +0x64b
github.com/lightningnetwork/lnd.(*rpcServer).AddInvoice.func3(0x20?)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcserver.go:5816 +0x97
github.com/lightningnetwork/lnd/lnrpc/invoicesrpc.buildBlindedPaymentPaths(0xc0070713e0)
	github.com/lightningnetwork/lnd@v0.18.2-beta/lnrpc/invoicesrpc/addinvoice.go:1054 +0x42
github.com/lightningnetwork/lnd/lnrpc/invoicesrpc.AddInvoice({0x36f9090, 0xc003fc4450}, 0xc004d95080, 0xc0070715c8)
	github.com/lightningnetwork/lnd@v0.18.2-beta/lnrpc/invoicesrpc/addinvoice.go:517 +0x108e
github.com/lightningnetwork/lnd.(*rpcServer).AddInvoice(0xc00061a600, {0x36f9090, 0xc003fc4450}, 0xc001487980)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcserver.go:5856 +0x6a5
github.com/lightningnetwork/lnd/lnrpc._Lightning_AddInvoice_Handler.func1({0x36f9090?, 0xc003fc4450?}, {0x22965c0?, 0xc001487980?})
	github.com/lightningnetwork/lnd@v0.18.2-beta/lnrpc/lightning_grpc.pb.go:2610 +0xcb
github.com/lightningnetwork/lnd/rpcperms.(*InterceptorChain).CreateServerOpts.(*InterceptorChain).middlewareUnaryServerInterceptor.func7({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980}, 0xc00391a660, 0xc002205ec0)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcperms/interceptor.go:832 +0x111
google.golang.org/grpc.getChainUnaryHandler.func1({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980})
	google.golang.org/grpc@v1.59.0/server.go:1163 +0xb2
github.com/lightningnetwork/lnd/rpcperms.(*InterceptorChain).CreateServerOpts.(*InterceptorChain).MacaroonUnaryServerInterceptor.func5({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980}, 0xc00391a660?, 0xc003f8fec0)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcperms/interceptor.go:689 +0x7d
google.golang.org/grpc.getChainUnaryHandler.func1({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980})
	google.golang.org/grpc@v1.59.0/server.go:1163 +0xb2
github.com/lightningnetwork/lnd/rpcperms.(*InterceptorChain).CreateServerOpts.(*InterceptorChain).rpcStateUnaryServerInterceptor.func3({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980}, 0xc00391a660, 0xc002b80900)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcperms/interceptor.go:781 +0xfc
google.golang.org/grpc.getChainUnaryHandler.func1({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980})
	google.golang.org/grpc@v1.59.0/server.go:1163 +0xb2
github.com/lightningnetwork/lnd/rpcperms.(*InterceptorChain).CreateServerOpts.errorLogUnaryServerInterceptor.func1({0x36f9090?, 0xc003fc4450?}, {0x22965c0?, 0xc001487980?}, 0xc00391a660, 0xc002205ec0?)
	github.com/lightningnetwork/lnd@v0.18.2-beta/rpcperms/interceptor.go:605 +0x52
google.golang.org/grpc.NewServer.chainUnaryServerInterceptors.chainUnaryInterceptors.func1({0x36f9090, 0xc003fc4450}, {0x22965c0, 0xc001487980}, 0xc00391a660, 0x78?)
	google.golang.org/grpc@v1.59.0/server.go:1154 +0x85
github.com/lightningnetwork/lnd/lnrpc._Lightning_AddInvoice_Handler({0x22d29c0, 0xc00061a600}, {0x36f9090, 0xc003fc4450}, 0xc004b3b280, 0xc0007747a0)
	github.com/lightningnetwork/lnd@v0.18.2-beta/lnrpc/lightning_grpc.pb.go:2612 +0x143
google.golang.org/grpc.(*Server).processUnaryRPC(0xc0002661e0, {0x36f9090, 0xc003ed0f30}, {0x3708e60, 0xc004871a00}, 0xc002440240, 0xc000769770, 0x4a551d8, 0x0)
	google.golang.org/grpc@v1.59.0/server.go:1343 +0xdd1
google.golang.org/grpc.(*Server).handleStream(0xc0002661e0, {0x3708e60, 0xc004871a00}, 0xc002440240)
	google.golang.org/grpc@v1.59.0/server.go:1737 +0xc47
google.golang.org/grpc.(*Server).serveStreams.func1.1()
	google.golang.org/grpc@v1.59.0/server.go:986 +0x86
created by google.golang.org/grpc.(*Server).serveStreams.func1 in goroutine 4083
	google.golang.org/grpc@v1.59.0/server.go:997 +0x136
``` 
Roasbeef: Can be rebased to point to master now!
ziggie1984: Hmm seems like we need to check that the inpolicy is not nil as we already do in other parts in the routing codebase ?

```
// If there is no edge policy for this candidate node, skip.
		// Note that we are searching backwards so this node would have
		// come prior to the pivot node in the route.
		if channel.InPolicy == nil {
			return nil
		}
```
Roasbeef:  @ziggie1984 yep was thinking the same thing, I'll add that then try again. FWIW, this is a very very very old node, which makes it great for testing! 

Patch:
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
 
diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go
index 970fb04cf..faf53c4b6 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
@@ -387,10 +388,10 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
 				fromNode, toNode, amt, capacity,
 			)
 		},
-		DestCustomRecords: record.CustomSet(in.DestCustomRecords),
-		CltvLimit:         cltvLimit,
-		DestFeatures:      destinationFeatures,
-		BlindedPayment:    blindedPmt,
+		DestCustomRecords:     record.CustomSet(in.DestCustomRecords),
+		CltvLimit:             cltvLimit,
+		DestFeatures:          destinationFeatures,
+		BlindedPaymentPathSet: blindedPathSet,
 	}
 
 	// 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,
+		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.BlindedPathSet = pathSet
 
-			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..270f998d9 100644
--- a/routing/blinding.go
+++ b/routing/blinding.go
@@ -4,8 +4,10 @@ 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/fn"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
 )
@@ -25,6 +27,218 @@ 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
+
+	// 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
+// 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")
+		}
+	}
+
+	// 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()
+
+	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.
+	// 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
+	}
+
+	return &BlindedPaymentPathSet{
+		paths:        pathSet,
+		targetPubKey: targetPub,
+		features:     features,
+		finalCLTV:    finalCLTVDelta,
+	}, 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
+}
+
+// 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
+// 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.
+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(fn.Some(s.targetPubKey))
+		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 {
@@ -87,8 +301,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
@@ -136,12 +353,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
@@ -168,13 +385,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/pathfind.go b/routing/pathfind.go
index d76c5ea22..ba9d111c4 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,6 +152,8 @@ func newRoute(sourceVertex route.Vertex,
 		// backwards below, this next hop gets closer and closer to the
 		// sender of the payment.
 		nextIncomingAmount lnwire.MilliSatoshi
+
+		blindedPayment *BlindedPayment
 	)
 
 	pathLength := len(pathEdges)
@@ -161,6 +162,15 @@ func newRoute(sourceVertex route.Vertex,
 		// 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
@@ -200,20 +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 blindedPath == nil ||
-				len(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
 
@@ -240,7 +242,7 @@ func newRoute(sourceVertex route.Vertex,
 
 			metadata = finalHop.metadata
 
-			if blindedPath != nil {
+			if blindedPathSet != nil {
 				totalAmtMsatBlinded = finalHop.totalAmt
 			}
 		} else {
@@ -300,11 +302,29 @@ 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 {
+			var err error
+			blindedPayment, err = blindedPathSet.IntroNodeOnlyPath()
+			if err != nil {
+				return nil, err
+			}
+		}
+
 		var (
 			inBlindedRoute bool
 			dataIndex      = 0
 
+			blindedPath = blindedPayment.BlindedPath
+			numHops     = len(blindedPath.BlindedHops)
+			realFinal   = blindedPath.BlindedHops[numHops-1].
+					BlindedNodePub
+
 			introVertex = route.NewVertex(
 				blindedPath.IntroductionPoint,
 			)
@@ -332,6 +352,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++
@@ -437,9 +462,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
@@ -1131,7 +1156,7 @@ type blindedPathRestrictions struct {
 // path.
 type blindedHop struct {
 	vertex       route.Vertex
-	edgePolicy   *models.CachedEdgePolicy
+	channelID    uint64
 	edgeCapacity btcutil.Amount
 }
 
@@ -1271,7 +1296,7 @@ func processNodeForBlindedPath(g Graph, node route.Vertex,
 
 			hop := blindedHop{
 				vertex:       channel.OtherNode,
-				edgePolicy:   channel.InPolicy,
+				channelID:    channel.ChannelID,
 				edgeCapacity: channel.Capacity,
 			}
 
@@ -1365,9 +1390,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/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..3c9be1030 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,16 +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)
-		}
-
-		introVertex := route.NewVertex(
-			blindedPayment.BlindedPath.IntroductionPoint,
-		)
-		if source == introVertex {
+	if blindedPathSet != nil {
+		if blindedPathSet.IsIntroNode(source) {
 			return nil, ErrSelfIntro
 		}
 
@@ -527,25 +519,15 @@ 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 = 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 +541,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,18 +558,7 @@ func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) (
 		return route.Vertex{}, ErrTargetAndBlinded
 
 	case blinded:
-		// 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
@@ -597,16 +568,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 +625,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
@@ -761,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
@@ -926,14 +887,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/rpcserver.go b/rpcserver.go
index 9ab014b3d..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
 
@@ -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.blindedPathSet = pathSet
 
-			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()
 			}
 		}
 
@@ -5421,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.
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{})


Test patch:
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)
+	}
+}
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_test.go b/routing/pathfind_test.go
index f67a8fa59..8fc50bb4f 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"
@@ -3277,21 +3278,39 @@ 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
 	// accordingly.
-	blindedEdges, err := blindedPayment.toRouteHints()
+	blindedEdges, err := blindedPayment.toRouteHints(
+		fn.None[*btcec.PublicKey](),
+	)
 	require.NoError(t, err)
 
 	carolDaveEdge := blindedEdges[carolVertex][0]
 	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:
@@ -3382,7 +3401,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 +3428,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 +3480,7 @@ func TestLastHopPayloadSize(t *testing.T) {
 		{
 			name: "Blinded final hop introduction point",
 			restrictions: &RestrictParams{
-				BlindedPayment: oneHopBlindedPayment,
+				BlindedPaymentPathSet: oneHopBlindedPayment,
 			},
 			amount:         amtToForward,
 			finalHopExpiry: finalHopExpiry,
@@ -3462,7 +3488,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 +3516,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/router_test.go b/routing/router_test.go
index f631669a9..28cced4e6 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,26 @@ func TestNewRouteRequest(t *testing.T) {
 		t.Run(testCase.name, func(t *testing.T) {
 			t.Parallel()
 
+			var (
+				blindedPathInfo *BlindedPaymentPathSet
+				expectedTarget  = testCase.expectedTarget
+			)
+			if testCase.blindedPayment != nil {
+				blindedPathInfo, err = NewBlindedPaymentPathSet(
+					[]*BlindedPayment{
+						testCase.blindedPayment,
+					},
+				)
+				require.NoError(t, err)
+
+				expectedTarget = route.NewVertex(
+					blindedPathInfo.TargetPubKey(),
+				)
+			}
+
 			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)
@@ -2248,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,
 			)