From da37fe20fc480552484feaddd2171368c0d992c8 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:05:49 +0200 Subject: [PATCH 1/8] routing: add comments and clean-up to `findBlindedPaths` Here we add some more comments and a bit of code clean up to the `findBlindedPaths` function. No logic is changed here. --- routing/pathfind.go | 77 +++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/routing/pathfind.go b/routing/pathfind.go index ace174fef..d2082f4e5 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -1227,24 +1227,46 @@ func findBlindedPaths(g Graph, target route.Vertex, } var ( - // The target node is always the last hop in the path. - incomingPath = []blindedHop{{vertex: target}} - whiteListedNodes = map[route.Vertex]bool{target: true} + // The target node is always the last hop in the path, and so + // we add it to the incoming path from the get-go. Any additions + // to the slice should be prepended. + incomingPath = []blindedHop{{ + vertex: target, + }} + + // supportsRouteBlinding is a list of nodes that we can assume + // support route blinding without needing to rely on the feature + // bits announced in their node announcement. Since we are + // finding a path to the target node, we can assume it supports + // route blinding. + supportsRouteBlinding = map[route.Vertex]bool{ + target: true, + } + visited = make(map[route.Vertex]bool) - errChanFound = errors.New("found incoming channel") nextTarget = target + haveIncomingPath = len(restrictions.incomingChainedChannels) > 0 + + // errChanFound is an error variable we return from the DB + // iteration call below when we have found the channel we are + // looking for. This lets us exit the iteration early. + errChanFound = errors.New("found incoming channel") ) for _, chanID := range restrictions.incomingChainedChannels { + // Mark that we have visited this node so that we don't revisit + // it later on when we call "processNodeForBlindedPath". visited[nextTarget] = true err := g.ForEachNodeDirectedChannel(nextTarget, func(channel *graphdb.DirectedChannel) error { - // Not the right channel, continue to the node's - // other channels. + // This is not the right channel, continue to + // the node's other channels. if channel.ChannelID != chanID { return nil } + // We found the channel in question. Prepend it + // to the incoming path. incomingPath = append([]blindedHop{ { vertex: channel.OtherNode, @@ -1259,39 +1281,37 @@ func findBlindedPaths(g Graph, target route.Vertex, return errChanFound }, ) - if err == nil { + // We expect errChanFound to be returned if the channel in + // question was found. + if !errors.Is(err, errChanFound) && err != nil { + return nil, err + } else if err == nil { return nil, fmt.Errorf("incoming channel %d is not "+ "seen as owned by node %v", chanID, nextTarget) } - if !errors.Is(err, errChanFound) { - return nil, err - } // Check that the user didn't accidentally add a channel that - // is owned by a node in the node omission set + // is owned by a node in the node omission set. if restrictions.nodeOmissionSet.Contains(nextTarget) { return nil, fmt.Errorf("node %v cannot simultaneously "+ "be included in the omission set and in the "+ "partially specified path", nextTarget) } - if whiteListedNodes[nextTarget] { + // Check that we have not already visited the next target node + // since this would mean a circular incoming path. + if visited[nextTarget] { return nil, fmt.Errorf("a circular route cannot be " + "specified for the incoming blinded path") } - whiteListedNodes[nextTarget] = true + + supportsRouteBlinding[nextTarget] = true } - // If the node is not the destination node, then it is required that the - // node advertise the route blinding feature-bit in order for it to be - // chosen as a node on the blinded path. - // We skip checking the target node, as accepting blinded payments - // (via invoice) doesn't imply support for routing them (via node - // announcement). - // We skip checking incomingChainedChannels nodes, as we might not yet - // have an updated node announcement for them. - supportsRouteBlinding := func(node route.Vertex) (bool, error) { - if whiteListedNodes[node] { + // A helper closure which checks if the node in question has advertised + // that it supports route blinding. + nodeSupportsRouteBlinding := func(node route.Vertex) (bool, error) { + if supportsRouteBlinding[node] { return true, nil } @@ -1308,8 +1328,12 @@ func findBlindedPaths(g Graph, target route.Vertex, // conditions such as: The maxHops number being reached or reaching // a node that doesn't have any other edges - in that final case, the // whole path should be ignored. + // + // NOTE: any paths returned will end at the "nextTarget" node meaning + // that if we have a fixed list of incoming chained channels, then this + // fixed list must be appended to any of the returned paths. paths, _, err := processNodeForBlindedPath( - g, nextTarget, supportsRouteBlinding, visited, restrictions, + g, nextTarget, nodeSupportsRouteBlinding, visited, restrictions, ) if err != nil { return nil, err @@ -1319,8 +1343,7 @@ func findBlindedPaths(g Graph, target route.Vertex, // When there is no path to add, but incomingChainedChannels can be // used. - lenChainedChannels := int8(len(restrictions.incomingChainedChannels)) - if len(paths) == 0 && lenChainedChannels > 0 { + if len(paths) == 0 && haveIncomingPath { orderedPaths = [][]blindedHop{incomingPath} } else { // Reverse each path so that the order is correct (from @@ -1337,7 +1360,7 @@ func findBlindedPaths(g Graph, target route.Vertex, // Handle the special case that allows a blinded path with the // introduction node as the destination node. - if restrictions.minNumHops == 0 && lenChainedChannels == 0 { + if restrictions.minNumHops == 0 && !haveIncomingPath { singleHopPath := [][]blindedHop{{{vertex: target}}} //nolint:makezero From 4bc0aee35ba26e1c84cd4b3dcc129170041ef53f Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:10:16 +0200 Subject: [PATCH 2/8] routing: improve TestFindBlindedPaths readability Add a helper so that we can refer to channels by using the aliases of the nodes that own the channel instead of needing to use the raw channel ID. --- routing/pathfind_test.go | 72 ++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index e1962f5d9..d51c6a1b2 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -538,6 +538,7 @@ func createTestGraphFromChannels(t *testing.T, useCache bool, aliasMap := make(map[string]route.Vertex) privKeyMap := make(map[string]*btcec.PrivateKey) + channelIDs := make(map[route.Vertex]map[route.Vertex]uint64) nodeIndex := byte(0) addNodeWithAlias := func(alias string, features *lnwire.FeatureVector) ( @@ -652,6 +653,16 @@ func createTestGraphFromChannels(t *testing.T, useCache bool, node1Vertex, node2Vertex = node2Vertex, node1Vertex } + if _, ok := channelIDs[node1Vertex]; !ok { + channelIDs[node1Vertex] = map[route.Vertex]uint64{} + } + channelIDs[node1Vertex][node2Vertex] = channelID + + if _, ok := channelIDs[node2Vertex]; !ok { + channelIDs[node2Vertex] = map[route.Vertex]uint64{} + } + channelIDs[node2Vertex][node1Vertex] = channelID + // We first insert the existence of the edge between the two // nodes. edgeInfo := models.ChannelEdgeInfo{ @@ -765,6 +776,7 @@ func createTestGraphFromChannels(t *testing.T, useCache bool, mcBackend: graphBackend, aliasMap: aliasMap, privKeyMap: privKeyMap, + channelIDs: channelIDs, links: links, }, nil } @@ -3192,6 +3204,16 @@ func newPathFindingTestContext(t *testing.T, useCache bool, return ctx } +func (c *pathFindingTestContext) nodePairChannel(alias1, alias2 string) uint64 { + node1 := c.keyFromAlias(alias1) + node2 := c.keyFromAlias(alias2) + + channel, ok := c.testGraphInstance.channelIDs[node1][node2] + require.True(c.t, ok) + + return channel +} + func (c *pathFindingTestContext) keyFromAlias(alias string) route.Vertex { return c.testGraphInstance.aliasMap[alias] } @@ -3914,9 +3936,11 @@ func TestFindBlindedPaths(t *testing.T) { // with one hop other than the destination hop. Now with bob-dave as the // incoming channel. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 1, - maxNumHops: 1, - incomingChainedChannels: []uint64{2}, + minNumHops: 1, + maxNumHops: 1, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("bob", "dave"), + }, }) require.NoError(t, err) @@ -3928,9 +3952,11 @@ func TestFindBlindedPaths(t *testing.T) { // 8) Extend the search to include 2 hops other than the destination, // with bob-dave as the incoming channel. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 1, - maxNumHops: 2, - incomingChainedChannels: []uint64{2}, + minNumHops: 1, + maxNumHops: 2, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("bob", "dave"), + }, }) require.NoError(t, err) @@ -3945,9 +3971,11 @@ func TestFindBlindedPaths(t *testing.T) { // 9) Extend the search even further and also increase the minimum path // length, but this time with charlie-dave as the incoming channel. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 2, - maxNumHops: 3, - incomingChainedChannels: []uint64{3}, + minNumHops: 2, + maxNumHops: 3, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("charlie", "dave"), + }, }) require.NoError(t, err) @@ -3962,19 +3990,27 @@ func TestFindBlindedPaths(t *testing.T) { // 10) Repeat the above test but instruct the function to never use // charlie. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 2, - maxNumHops: 3, - nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("charlie")), - incomingChainedChannels: []uint64{3}, + minNumHops: 2, + maxNumHops: 3, + nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("charlie")), + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("charlie", "dave"), + }, }) require.ErrorContains(t, err, "cannot simultaneously be included in "+ "the omission set and in the partially specified path") - // 11) Test the circular route error. + // 11) Assert that an error is returned if a user accidentally tries + // to force a circular path. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 2, - maxNumHops: 3, - incomingChainedChannels: []uint64{2, 7, 6, 3}, + minNumHops: 2, + maxNumHops: 3, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("dave", "alice"), + ctx.nodePairChannel("alice", "frank"), + ctx.nodePairChannel("frank", "bob"), + ctx.nodePairChannel("bob", "dave"), + }, }) - require.ErrorContains(t, err, "a circular route cannot be specified") + require.ErrorContains(t, err, "circular route") } From 067e316df8dde9dbd745da39fd8143fde8148871 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:18:02 +0200 Subject: [PATCH 3/8] routing: clean-up the tests a bit This is just in preparation for the next commit. The test will be cleaned up more after a bug is fixed. --- routing/pathfind_test.go | 44 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index d51c6a1b2..5afb7bd14 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3898,7 +3898,7 @@ func TestFindBlindedPaths(t *testing.T) { "eve,bob,dave", }) - // 5) Finally, we will test the special case where the destination node + // 5) We will also test the special case where the destination node // is also the recipient. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 0, @@ -3910,31 +3910,11 @@ func TestFindBlindedPaths(t *testing.T) { "dave", }) - // 6) Restrict the min & max path length such that we only include paths - // with one being only the intro-node and the others with one hop other - // than the destination hop. - paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 0, - maxNumHops: 1, - }) - require.NoError(t, err) - - // We expect the following paths: - // - D - // - B, D - // - C, D - // The (A, D) path is not chosen since A does not advertise the route - // blinding feature bit. The (G, D) path is not chosen since G does not - // have any other known channels. - assertPaths(paths, []string{ - "dave", - "bob,dave", - "charlie,dave", - }) - - // 7) Restrict the min & max path length such that we only include paths - // with one hop other than the destination hop. Now with bob-dave as the - // incoming channel. + // 6) Now, we will test some cases where the user manually specifies + // the first few incoming channels of a route. + // + // 6.1) Let the user specify the B-D channel as the last hop with a + // max of 1 hop. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 1, maxNumHops: 1, @@ -3944,13 +3924,13 @@ func TestFindBlindedPaths(t *testing.T) { }) require.NoError(t, err) - // Expect only B->D path. + // If the max number of hops is 1, then only the B->D path is chosen assertPaths(paths, []string{ "bob,dave", }) - // 8) Extend the search to include 2 hops other than the destination, - // with bob-dave as the incoming channel. + // 6.2) Extend the search to include 2 hops along with the B-D channel + // restriction. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 1, maxNumHops: 2, @@ -3968,7 +3948,7 @@ func TestFindBlindedPaths(t *testing.T) { "eve,bob,dave", }) - // 9) Extend the search even further and also increase the minimum path + // 6.3) Extend the search even further and also increase the minimum path // length, but this time with charlie-dave as the incoming channel. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, @@ -3987,7 +3967,7 @@ func TestFindBlindedPaths(t *testing.T) { "bob,eve,charlie,dave", }) - // 10) Repeat the above test but instruct the function to never use + // 6.4) Repeat the above test but instruct the function to never use // charlie. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, @@ -4000,7 +3980,7 @@ func TestFindBlindedPaths(t *testing.T) { require.ErrorContains(t, err, "cannot simultaneously be included in "+ "the omission set and in the partially specified path") - // 11) Assert that an error is returned if a user accidentally tries + // 6.5) Assert that an error is returned if a user accidentally tries // to force a circular path. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, From 51f1932d48af7d9c1eec3943cc44c523eddcd5ad Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 14:47:38 +0200 Subject: [PATCH 4/8] itest: clean-up partially specified blinded path test Improve the readability of the existing test. --- itest/lnd_route_blinding_test.go | 176 +++++++++++++++---------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index 2aadb5755..5e043f8a1 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1428,25 +1428,55 @@ func testBlindedPaymentHTLCReForward(ht *lntest.HarnessTest) { // pre-specified. func testPartiallySpecifiedBlindedPath(ht *lntest.HarnessTest) { // Create a six hop network: - // Alice -> Bob -> Carol -> Dave -> Eve -> Frank. - chanAmt := btcutil.Amount(100000) - cfgs := [][]string{nil, nil, nil, nil, nil, nil} + // Alice -> Bob -> Carol -> Dave -> Eve -> Frank. + // Carol will be the node generating invoices containing blinded paths. chanPoints, nodes := ht.CreateSimpleNetwork( - cfgs, lntest.OpenChannelParams{Amt: chanAmt}, + [][]string{nil, nil, nil, nil, nil, nil}, + lntest.OpenChannelParams{ + Amt: chanAmt, + PushAmt: chanAmt / 2, + }, ) - alice, bob, carol, dave, eve := nodes[0], nodes[1], nodes[2], nodes[3], - nodes[4] - - chanPointAliceBob, chanPointBobCarol, chanPointCarolDave := - chanPoints[0], chanPoints[1], chanPoints[2] + var ( + alice = nodes[0] + bob = nodes[1] + carol = nodes[2] + dave = nodes[3] + eve = nodes[4] + frank = nodes[5] + ) // Lookup full channel info so that we have channel ids for our route. - aliceBobChan := ht.GetChannelByChanPoint(alice, chanPointAliceBob) - bobCarolChan := ht.GetChannelByChanPoint(bob, chanPointBobCarol) - carolDaveChan := ht.GetChannelByChanPoint(carol, chanPointCarolDave) + aliceBobChan := ht.GetChannelByChanPoint(alice, chanPoints[0]) + bobCarolChan := ht.GetChannelByChanPoint(bob, chanPoints[1]) + carolDaveChan := ht.GetChannelByChanPoint(carol, chanPoints[2]) - // Let carol set no incoming channel restrictions. + // assertPathDetails is a helper function that asserts the details of + // the blinded paths returned in the invoice. + assertPathDetails := func(invoice *lnrpc.AddInvoiceResponse, + expPathLengths int, expIntroNodes ...[]byte) { + + payReq := carol.RPC.DecodePayReq(invoice.PaymentRequest) + paths := payReq.BlindedPaths + + introNodes := make([][]byte, 0, len(paths)) + for _, p := range paths { + require.Len( + ht, p.BlindedPath.BlindedHops, expPathLengths, + ) + + introNodes = append( + introNodes, p.BlindedPath.IntroductionNode, + ) + } + require.ElementsMatch(ht, introNodes, expIntroNodes) + } + + // Let carol set no incoming channel restrictions. With a min of 1 hop, + // we expect the following blinded paths to be included: + // 1) Bob->Carol + // 2) Dave->Carol var ( minNumRealHops uint32 = 1 numHops uint32 = 1 @@ -1460,122 +1490,94 @@ func testPartiallySpecifiedBlindedPath(ht *lntest.HarnessTest) { NumHops: &numHops, }, }) + assertPathDetails(invoice, 2, bob.PubKey[:], dave.PubKey[:]) - introNodesFound := make([][]byte, 0) - introNodesExpected := [][]byte{bob.PubKey[:], dave.PubKey[:]} - - // Assert that it contains two blinded path with only 2 hops each one. - payReq := carol.RPC.DecodePayReq(invoice.PaymentRequest) - require.Len(ht, payReq.BlindedPaths, 2) - path := payReq.BlindedPaths[0].BlindedPath - require.Len(ht, path.BlindedHops, 2) - introNodesFound = append(introNodesFound, path.IntroductionNode) - path = payReq.BlindedPaths[1].BlindedPath - require.Len(ht, path.BlindedHops, 2) - introNodesFound = append(introNodesFound, path.IntroductionNode) - - // Assert the introduction nodes without caring about the routes order. - require.ElementsMatch(ht, introNodesExpected, introNodesFound) - - // Let carol choose the wrong incoming channel. - var ( - incomingChannelList = []uint64{aliceBobChan.ChanId} - ) - - err := fmt.Sprintf("incoming channel %v is not seen as owned by node", - aliceBobChan.ChanId) - + // Let carol choose an invalid incoming channel list for the blinded + // path. It is invalid since it does not connect to her node. + // Assert that the expected error is returned. carol.RPC.AddInvoiceAssertErr( &lnrpc.Invoice{ Memo: "test", ValueMsat: 10_000_000, IsBlinded: true, BlindedPathConfig: &lnrpc.BlindedPathConfig{ - MinNumRealHops: &minNumRealHops, - NumHops: &numHops, - IncomingChannelList: incomingChannelList, + MinNumRealHops: &minNumRealHops, + NumHops: &numHops, + IncomingChannelList: []uint64{ + aliceBobChan.ChanId, + }, }, }, - err, + fmt.Sprintf("incoming channel %v is not seen as owned by node", + aliceBobChan.ChanId), ) // Let Carol set the incoming channel list greater than minimum number // of real hops. - incomingChannelList = []uint64{aliceBobChan.ChanId, bobCarolChan.ChanId} - err = fmt.Sprintf("minimum number of blinded path hops (%d) must be "+ - "greater than or equal to the number of hops specified on "+ - "the chained channels (%d)", minNumRealHops, - len(incomingChannelList), - ) - + // Assert that the expected error is returned. carol.RPC.AddInvoiceAssertErr( &lnrpc.Invoice{ Memo: "test", ValueMsat: 10_000_000, IsBlinded: true, BlindedPathConfig: &lnrpc.BlindedPathConfig{ - MinNumRealHops: &minNumRealHops, - NumHops: &numHops, - IncomingChannelList: incomingChannelList, + MinNumRealHops: &minNumRealHops, + NumHops: &numHops, + IncomingChannelList: []uint64{ + aliceBobChan.ChanId, + bobCarolChan.ChanId, + }, }, }, - err, + "must be greater than or equal to the number of hops "+ + "specified on the chained channels", ) // Let Carol choose an incoming channel that points to a node in the // omission set. - incomingChannelList = []uint64{bobCarolChan.ChanId} - var nodeOmissionList [][]byte - nodeOmissionList = append(nodeOmissionList, bob.PubKey[:]) - - err = fmt.Sprintf("cannot simultaneously be included in the omission " + - "set and in the partially specified path", - ) - carol.RPC.AddInvoiceAssertErr( &lnrpc.Invoice{ Memo: "test", ValueMsat: 10_000_000, IsBlinded: true, BlindedPathConfig: &lnrpc.BlindedPathConfig{ - MinNumRealHops: &minNumRealHops, - NumHops: &numHops, - IncomingChannelList: incomingChannelList, - NodeOmissionList: nodeOmissionList, + MinNumRealHops: &minNumRealHops, + NumHops: &numHops, + IncomingChannelList: []uint64{ + bobCarolChan.ChanId, + }, + NodeOmissionList: [][]byte{ + bob.PubKey[:], + }, }, }, - err, + "cannot simultaneously be included in the omission set and "+ + "in the partially specified path", ) // Let carol restrict bob as incoming channel. - incomingChannelList = []uint64{bobCarolChan.ChanId} - invoice = carol.RPC.AddInvoice(&lnrpc.Invoice{ Memo: "test", ValueMsat: 10_000_000, IsBlinded: true, BlindedPathConfig: &lnrpc.BlindedPathConfig{ - MinNumRealHops: &minNumRealHops, - NumHops: &numHops, - IncomingChannelList: incomingChannelList, + MinNumRealHops: &minNumRealHops, + NumHops: &numHops, + IncomingChannelList: []uint64{ + bobCarolChan.ChanId, + }, }, }) // Assert that it contains a single blinded path with only // 2 hops, with bob as the introduction node. - payReq = carol.RPC.DecodePayReq(invoice.PaymentRequest) - require.Len(ht, payReq.BlindedPaths, 1) - path = payReq.BlindedPaths[0].BlindedPath - require.Len(ht, path.BlindedHops, 2) - require.EqualValues(ht, path.IntroductionNode, bob.PubKey[:]) + assertPathDetails(invoice, 2, bob.PubKey[:]) - // Now let alice pay the invoice. + // Check that Alice can pay the invoice. ht.CompletePaymentRequests(alice, []string{invoice.PaymentRequest}) - // Let carol restrict dave as incoming channel and max Hops as 2 + // Let carol restrict dave as incoming channel and max hops as 2. numHops = 2 - incomingChannelList = []uint64{carolDaveChan.ChanId} - invoice = carol.RPC.AddInvoice(&lnrpc.Invoice{ Memo: "test", ValueMsat: 10_000_000, @@ -1583,16 +1585,14 @@ func testPartiallySpecifiedBlindedPath(ht *lntest.HarnessTest) { BlindedPathConfig: &lnrpc.BlindedPathConfig{ MinNumRealHops: &minNumRealHops, NumHops: &numHops, - IncomingChannelList: incomingChannelList, + IncomingChannelList: []uint64{carolDaveChan.ChanId}, }, }) - // Assert that it contains one path with 3 hops, with dave as the - // introduction node. The path alice -> bob -> carol is discarded - // because alice is a dead-end. - payReq = carol.RPC.DecodePayReq(invoice.PaymentRequest) - require.Len(ht, payReq.BlindedPaths, 1) - path = payReq.BlindedPaths[0].BlindedPath - require.Len(ht, path.BlindedHops, 3) - require.EqualValues(ht, path.IntroductionNode, eve.PubKey[:]) + // Assert that it contains one path with 3 hops, with eve as the + // introduction node. + assertPathDetails(invoice, 3, eve.PubKey[:]) + + // Check that Frank can pay the invoice. + ht.CompletePaymentRequests(frank, []string{invoice.PaymentRequest}) } From 34154802f3d1b590f1af92ca5db8d0a4f60902e3 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:20:00 +0200 Subject: [PATCH 5/8] routing+itest: fix route blinding path selection bug Here we fix a bug which would not include the selected incoming blinded path chain from being included in the selected path set if it meets the minimum length requirement. The appropriate unit test and itest is updated to demonstrate the fix. --- itest/lnd_route_blinding_test.go | 7 +++--- routing/pathfind.go | 38 ++++++++++++++++++-------------- routing/pathfind_test.go | 2 ++ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/itest/lnd_route_blinding_test.go b/itest/lnd_route_blinding_test.go index 5e043f8a1..0e3c61f61 100644 --- a/itest/lnd_route_blinding_test.go +++ b/itest/lnd_route_blinding_test.go @@ -1589,9 +1589,10 @@ func testPartiallySpecifiedBlindedPath(ht *lntest.HarnessTest) { }, }) - // Assert that it contains one path with 3 hops, with eve as the - // introduction node. - assertPathDetails(invoice, 3, eve.PubKey[:]) + // Assert that it contains two paths: one 3 hop one starting at Eve and + // a 3 hop one starting at Dave (this one will be padded with a dummy + // hop) in order to keep all the paths equidistant. + assertPathDetails(invoice, 3, eve.PubKey[:], dave.PubKey[:]) // Check that Frank can pay the invoice. ht.CompletePaymentRequests(frank, []string{invoice.PaymentRequest}) diff --git a/routing/pathfind.go b/routing/pathfind.go index d2082f4e5..f489173f9 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -1339,31 +1339,35 @@ func findBlindedPaths(g Graph, target route.Vertex, return nil, err } - orderedPaths := make([][]blindedHop, len(paths)) + // Reverse each path so that the order is correct (from introduction + // node to last hop node) and then append the incoming path (if any was + // specified) to the end of the path. + orderedPaths := make([][]blindedHop, 0, len(paths)) + for _, path := range paths { + sort.Slice(path, func(i, j int) bool { + return j < i + }) - // When there is no path to add, but incomingChainedChannels can be - // used. - if len(paths) == 0 && haveIncomingPath { - orderedPaths = [][]blindedHop{incomingPath} - } else { - // Reverse each path so that the order is correct (from - // introduction node to last hop node) and then append this node - // on as the destination of each path. - for i, path := range paths { - sort.Slice(path, func(i, j int) bool { - return j < i - }) + orderedPaths = append( + orderedPaths, append(path, incomingPath...), + ) + } - orderedPaths[i] = append(path, incomingPath...) - } + // There is a chance we have an incoming path that by itself satisfies + // the minimum hop restriction. In that case, we add it as its own path. + if haveIncomingPath && + len(incomingPath) > int(restrictions.minNumHops) { + + orderedPaths = append(orderedPaths, incomingPath) } // Handle the special case that allows a blinded path with the - // introduction node as the destination node. + // introduction node as the destination node. This only applies if no + // incoming path was specified since in that case, by definition, the + // caller wants a non-zero length blinded path. if restrictions.minNumHops == 0 && !haveIncomingPath { singleHopPath := [][]blindedHop{{{vertex: target}}} - //nolint:makezero orderedPaths = append( orderedPaths, singleHopPath..., ) diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 5afb7bd14..36511f218 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3941,9 +3941,11 @@ func TestFindBlindedPaths(t *testing.T) { require.NoError(t, err) // We expect the following paths: + // - B, D // - F, B, D // - E, B, D assertPaths(paths, []string{ + "bob,dave", "frank,bob,dave", "eve,bob,dave", }) From 4945bd42fa2f2d0cc468e2c3a79ecf2437112692 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:28:24 +0200 Subject: [PATCH 6/8] routing: expand routeblinding path finding tests Cover more cases for the incoming chained channel feature. --- routing/pathfind_test.go | 101 +++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 36511f218..6a7821003 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3950,39 +3950,66 @@ func TestFindBlindedPaths(t *testing.T) { "eve,bob,dave", }) - // 6.3) Extend the search even further and also increase the minimum path - // length, but this time with charlie-dave as the incoming channel. - paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 2, - maxNumHops: 3, - incomingChainedChannels: []uint64{ - ctx.nodePairChannel("charlie", "dave"), - }, - }) - require.NoError(t, err) - - // We expect the following paths: - // - E, C, D - // - B, E, C, D - assertPaths(paths, []string{ - "eve,charlie,dave", - "bob,eve,charlie,dave", - }) - - // 6.4) Repeat the above test but instruct the function to never use - // charlie. + // 6.3) Repeat the above test but instruct the function to never use + // bob. This should fail since bob owns one of the channels in the + // partially specified path. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ - minNumHops: 2, - maxNumHops: 3, - nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("charlie")), + minNumHops: 1, + maxNumHops: 2, + nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("bob")), incomingChainedChannels: []uint64{ - ctx.nodePairChannel("charlie", "dave"), + ctx.nodePairChannel("bob", "dave"), }, }) require.ErrorContains(t, err, "cannot simultaneously be included in "+ "the omission set and in the partially specified path") - // 6.5) Assert that an error is returned if a user accidentally tries + // 6.4) Repeat it again but this time omit frank and demonstrate that + // the resulting set contains all the results from 6.2 except for the + // frank path. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 2, + nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("frank")), + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("bob", "dave"), + }, + }) + require.NoError(t, err) + + // We expect the following paths: + // - B, D + // - E, B, D + assertPaths(paths, []string{ + "bob,dave", + "eve,bob,dave", + }) + + // 6.5) Users may specify channels to nodes that do not signal route + // blinding (like A). So if we specify the A-D channel, we should get + // valid paths. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 4, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("dave", "alice"), + }, + }) + require.NoError(t, err) + + // We expect the following paths: + // - A, D + // - F, A, D + // - B, F, A, D + // - E, B, F, A, D + assertPaths(paths, []string{ + "alice,dave", + "frank,alice,dave", + "bob,frank,alice,dave", + "eve,bob,frank,alice,dave", + }) + + // 6.6) Assert that an error is returned if a user accidentally tries // to force a circular path. _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, @@ -3995,4 +4022,26 @@ func TestFindBlindedPaths(t *testing.T) { }, }) require.ErrorContains(t, err, "circular route") + + // 6.7) Test specifying a chain of incoming channels. We specify + // the following incoming list: [A->D, F->A]. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 4, + incomingChainedChannels: []uint64{ + ctx.nodePairChannel("dave", "alice"), + ctx.nodePairChannel("alice", "frank"), + }, + }) + require.NoError(t, err) + + // We expect the following paths: + // - F, A, D + // - B, F, A, D + // - E, B, F, A, D + assertPaths(paths, []string{ + "frank,alice,dave", + "bob,frank,alice,dave", + "eve,bob,frank,alice,dave", + }) } From 64757877509fe8aba32ff5a6efbd688a711a6a8f Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 13:00:58 +0200 Subject: [PATCH 7/8] routing: fix log line formatting --- routing/router.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routing/router.go b/routing/router.go index 0ce9a1ca8..b0c6bd323 100644 --- a/routing/router.go +++ b/routing/router.go @@ -699,8 +699,8 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex, "path since it resulted in an low "+ "probability path(%.3f)", route.ChanIDString(routeWithProbability.route), - routeWithProbability.probability, - ) + routeWithProbability.probability) + continue } From a4459cc5a571a6ceeae25cd5a09c1abb1f12c3c4 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Tue, 10 Jun 2025 14:51:29 +0200 Subject: [PATCH 8/8] docs: update release notes --- docs/release-notes/release-notes-0.20.0.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/release-notes-0.20.0.md b/docs/release-notes/release-notes-0.20.0.md index 040290f33..ad6bd35ab 100644 --- a/docs/release-notes/release-notes-0.20.0.md +++ b/docs/release-notes/release-notes-0.20.0.md @@ -37,7 +37,8 @@ circuit. The indices are only available for forwarding events saved after v0.20. * The `lncli addinvoice --blind` command now has the option to include a - [chained channels](https://github.com/lightningnetwork/lnd/pull/9127) + chained channels [1](https://github.com/lightningnetwork/lnd/pull/9127) + [2](https://github.com/lightningnetwork/lnd/pull/9925) incoming list `--blinded_path_incoming_channel_list` which gives users the control of specifying the channels they prefer to receive the payment on. With the option to specify multiple channels this control can be extended to