diff --git a/docs/release-notes/release-notes-0.18.3.md b/docs/release-notes/release-notes-0.18.3.md index f8f239702..a4cbda9ec 100644 --- a/docs/release-notes/release-notes-0.18.3.md +++ b/docs/release-notes/release-notes-0.18.3.md @@ -136,6 +136,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/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/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/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 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/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/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/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, ) 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{})