From 1039aedd0c94cfe1336f764fcf068c2ac0a1c4c0 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Sat, 4 May 2024 12:05:17 +0200 Subject: [PATCH] routing: find blinded paths to a destination node This commit adds a new function, `findBlindedPaths`, that does a depth first search from the target node to find a set of blinded paths to the target node given the set of restrictions. This function will select and return any candidate path. A candidate path is a path to the target node with a size determined by the given hop number constraints where all the nodes on the path signal the route blinding feature _and_ the introduction node for the path has more than one public channel. Any filtering of paths based on payment value or success probabilities is left to the caller. --- routing/pathfind.go | 194 +++++++++++++++++++++++++++++++++++++ routing/pathfind_test.go | 200 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 387 insertions(+), 7 deletions(-) diff --git a/routing/pathfind.go b/routing/pathfind.go index a80826971..086b81692 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math" + "sort" "time" "github.com/btcsuite/btcd/btcutil" @@ -1100,6 +1101,199 @@ func findPath(g *graphParams, r *RestrictParams, cfg *PathFindingConfig, return pathEdges, distance[source].probability, nil } +// blindedPathRestrictions are a set of constraints to adhere to when +// choosing a set of blinded paths to this node. +type blindedPathRestrictions struct { + // minNumHops is the minimum number of hops to include in a blinded + // path. This doesn't include our node, so if the minimum is 1, then + // the path will contain at minimum our node along with an introduction + // node hop. A minimum of 0 will include paths where this node is the + // introduction node and so should be used with caution. + minNumHops uint8 + + // maxNumHops is the maximum number of hops to include in a blinded + // path. This doesn't include our node, so if the maximum is 1, then + // the path will contain our node along with an introduction node hop. + maxNumHops uint8 +} + +// blindedHop holds the information about a hop we have selected for a blinded +// path. +type blindedHop struct { + vertex route.Vertex + edgePolicy *models.CachedEdgePolicy + edgeCapacity btcutil.Amount +} + +// findBlindedPaths does a depth first search from the target node to find a set +// of blinded paths to the target node given the set of restrictions. This +// function will select and return any candidate path. A candidate path is a +// path to the target node with a size determined by the given hop number +// constraints where all the nodes on the path signal the route blinding feature +// _and_ the introduction node for the path has more than one public channel. +// Any filtering of paths based on payment value or success probabilities is +// left to the caller. +func findBlindedPaths(g Graph, target route.Vertex, + restrictions *blindedPathRestrictions) ([][]blindedHop, error) { + + // Sanity check the restrictions. + if restrictions.minNumHops > restrictions.maxNumHops { + return nil, fmt.Errorf("maximum number of blinded path hops "+ + "(%d) must be greater than or equal to the minimum "+ + "number of hops (%d)", restrictions.maxNumHops, + restrictions.minNumHops) + } + + // 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. + supportsRouteBlinding := func(node route.Vertex) (bool, error) { + if node == target { + return true, nil + } + + features, err := g.FetchNodeFeatures(node) + if err != nil { + return false, err + } + + return features.HasFeature(lnwire.RouteBlindingOptional), nil + } + + // This function will have some recursion. We will spin out from the + // target node & append edges to the paths until we reach various exit + // 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. + paths, _, err := processNodeForBlindedPath( + g, target, supportsRouteBlinding, nil, restrictions, + ) + if err != nil { + return nil, err + } + + // 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. + orderedPaths := make([][]blindedHop, len(paths)) + for i, path := range paths { + sort.Slice(path, func(i, j int) bool { + return j < i + }) + + orderedPaths[i] = append(path, blindedHop{vertex: target}) + } + + // Handle the special case that allows a blinded path with the + // introduction node as the destination node. + if restrictions.minNumHops == 0 { + singleHopPath := [][]blindedHop{{{vertex: target}}} + + //nolint:makezero + orderedPaths = append( + orderedPaths, singleHopPath..., + ) + } + + return orderedPaths, err +} + +// processNodeForBlindedPath is a recursive function that traverses the graph +// in a depth first manner searching for a set of blinded paths to the given +// node. +func processNodeForBlindedPath(g Graph, node route.Vertex, + supportsRouteBlinding func(vertex route.Vertex) (bool, error), + alreadyVisited map[route.Vertex]bool, + restrictions *blindedPathRestrictions) ([][]blindedHop, bool, error) { + + // If we have already visited the maximum number of hops, then this path + // is complete and we can exit now. + if len(alreadyVisited) > int(restrictions.maxNumHops) { + return nil, false, nil + } + + // If we have already visited this peer on this path, then we skip + // processing it again. + if alreadyVisited[node] { + return nil, false, nil + } + + supports, err := supportsRouteBlinding(node) + if err != nil { + return nil, false, err + } + if !supports { + return nil, false, nil + } + + // At this point, copy the alreadyVisited map. + visited := make(map[route.Vertex]bool, len(alreadyVisited)) + for r := range alreadyVisited { + visited[r] = true + } + + // Add this node the visited set. + visited[node] = true + + var ( + hopSets [][]blindedHop + chanCount int + ) + + // Now, iterate over the node's channels in search for paths to this + // node that can be used for blinded paths + err = g.ForEachNodeChannel(node, + func(channel *channeldb.DirectedChannel) error { + // Keep track of how many incoming channels this node + // has. We only use a node as an introduction node if it + // has channels other than the one that lead us to it. + chanCount++ + + // Process each channel peer to gather any paths that + // lead to the peer. + nextPaths, hasMoreChans, err := processNodeForBlindedPath( //nolint:lll + g, channel.OtherNode, supportsRouteBlinding, + visited, restrictions, + ) + if err != nil { + return err + } + + hop := blindedHop{ + vertex: channel.OtherNode, + edgePolicy: channel.InPolicy, + edgeCapacity: channel.Capacity, + } + + // For each of the paths returned, unwrap them and + // append this hop to them. + for _, path := range nextPaths { + hopSets = append( + hopSets, + append([]blindedHop{hop}, path...), + ) + } + + // If this node does have channels other than the one + // that lead to it, and if the hop count up to this node + // meets the minHop requirement, then we also add a + // path that starts at this node. + if hasMoreChans && + len(visited) >= int(restrictions.minNumHops) { + + hopSets = append(hopSets, []blindedHop{hop}) + } + + return nil + }, + ) + if err != nil { + return nil, false, err + } + + return hopSets, chanCount > 1, nil +} + // getProbabilityBasedDist converts a weight into a distance that takes into // account the success probability and the (virtual) cost of a failed payment // attempt. diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 9c69cba5c..f67a8fa59 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -514,7 +514,8 @@ func (g *testGraphInstance) getLink(chanID lnwire.ShortChannelID) ( // not required and derived from the channel data. The goal is to keep // instantiating a test channel graph as light weight as possible. func createTestGraphFromChannels(t *testing.T, useCache bool, - testChannels []*testChannel, source string) (*testGraphInstance, error) { + testChannels []*testChannel, source string, + sourceFeatureBits ...lnwire.FeatureBit) (*testGraphInstance, error) { // We'll use this fake address for the IP address of all the nodes in // our tests. This value isn't needed for path finding so it doesn't @@ -578,10 +579,13 @@ func createTestGraphFromChannels(t *testing.T, useCache bool, } // Add the source node. - dbNode, err := addNodeWithAlias(source, lnwire.EmptyFeatureVector()) - if err != nil { - return nil, err - } + dbNode, err := addNodeWithAlias( + source, lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(sourceFeatureBits...), + lnwire.Features, + ), + ) + require.NoError(t, err) if err = graph.SetSourceNode(dbNode); err != nil { return nil, err @@ -3031,10 +3035,11 @@ type pathFindingTestContext struct { } func newPathFindingTestContext(t *testing.T, useCache bool, - testChannels []*testChannel, source string) *pathFindingTestContext { + testChannels []*testChannel, source string, + sourceFeatureBits ...lnwire.FeatureBit) *pathFindingTestContext { testGraphInstance, err := createTestGraphFromChannels( - t, useCache, testChannels, source, + t, useCache, testChannels, source, sourceFeatureBits..., ) require.NoError(t, err, "unable to create graph") @@ -3077,6 +3082,12 @@ func (c *pathFindingTestContext) findPath(target route.Vertex, ) } +func (c *pathFindingTestContext) findBlindedPaths( + restrictions *blindedPathRestrictions) ([][]blindedHop, error) { + + return dbFindBlindedPaths(c.graph, restrictions) +} + func (c *pathFindingTestContext) assertPath(path []*unifiedEdge, expected []uint64) { @@ -3134,6 +3145,22 @@ func dbFindPath(graph *channeldb.ChannelGraph, return route, err } +// dbFindBlindedPaths calls findBlindedPaths after getting a db transaction from +// the database graph. +func dbFindBlindedPaths(graph *channeldb.ChannelGraph, + restrictions *blindedPathRestrictions) ([][]blindedHop, error) { + + sourceNode, err := graph.SourceNode() + if err != nil { + return nil, err + } + + return findBlindedPaths( + newMockGraphSessionChanDB(graph), sourceNode.PubKeyBytes, + restrictions, + ) +} + // TestBlindedRouteConstruction tests creation of a blinded route with the // following topology: // @@ -3506,3 +3533,162 @@ func TestLastHopPayloadSize(t *testing.T) { }) } } + +// TestFindBlindedPaths tests that the findBlindedPaths function correctly +// selects a set of blinded paths to a destination node given various +// restrictions. +func TestFindBlindedPaths(t *testing.T) { + featuresWithRouteBlinding := lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(lnwire.RouteBlindingOptional), + lnwire.Features, + ) + + policyWithRouteBlinding := &testChannelPolicy{ + Expiry: 144, + FeeRate: 400, + MinHTLC: 1, + MaxHTLC: 100000000, + Features: featuresWithRouteBlinding, + } + + policyWithoutRouteBlinding := &testChannelPolicy{ + Expiry: 144, + FeeRate: 400, + MinHTLC: 1, + MaxHTLC: 100000000, + } + + // Set up the following graph where Dave will be our destination node. + // All the nodes except for A will signal the Route Blinding feature + // bit. + // + // A --- F + // | | + // G --- D --- B --- E + // | | + // C-----------/ + // + testChannels := []*testChannel{ + symmetricTestChannel( + "dave", "alice", 100000, policyWithoutRouteBlinding, 1, + ), + symmetricTestChannel( + "dave", "bob", 100000, policyWithRouteBlinding, 2, + ), + symmetricTestChannel( + "dave", "charlie", 100000, policyWithRouteBlinding, 3, + ), + symmetricTestChannel( + "alice", "frank", 100000, policyWithRouteBlinding, 4, + ), + symmetricTestChannel( + "bob", "frank", 100000, policyWithRouteBlinding, 5, + ), + symmetricTestChannel( + "eve", "charlie", 100000, policyWithRouteBlinding, 6, + ), + symmetricTestChannel( + "bob", "eve", 100000, policyWithRouteBlinding, 7, + ), + symmetricTestChannel( + "dave", "george", 100000, policyWithRouteBlinding, 8, + ), + } + + ctx := newPathFindingTestContext( + t, true, testChannels, "dave", lnwire.RouteBlindingOptional, + ) + + // assertPaths checks that the set of selected paths contains all the + // expected paths. + assertPaths := func(paths [][]blindedHop, expectedPaths []string) { + require.Len(t, paths, len(expectedPaths)) + + actualPaths := make(map[string]bool) + + for _, path := range paths { + var label string + for _, hop := range path { + label += ctx.aliasFromKey(hop.vertex) + "," + } + + actualPaths[strings.TrimRight(label, ",")] = true + } + + for _, path := range expectedPaths { + require.True(t, actualPaths[path]) + } + } + + // 1) Restrict the min & max path length such that we only include paths + // with one hop other than the destination hop. + paths, err := ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 1, + }) + require.NoError(t, err) + + // We expect the B->D and C->D paths to be chosen. + // 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{ + "bob,dave", + "charlie,dave", + }) + + // 2) Extend the search to include 2 hops other than the destination. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 2, + }) + require.NoError(t, err) + + // We expect the following paths: + // - B, D + // - F, B, D + // - E, B, D + // - C, D + // - E, C, D + assertPaths(paths, []string{ + "bob,dave", + "frank,bob,dave", + "eve,bob,dave", + "charlie,dave", + "eve,charlie,dave", + }) + + // 3) Extend the search even further and also increase the minimum path + // length. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 2, + maxNumHops: 3, + }) + require.NoError(t, err) + + // We expect the following paths: + // - F, B, D + // - E, B, D + // - E, C, D + // - B, E, C, D + // - C, E, B, D + assertPaths(paths, []string{ + "frank,bob,dave", + "eve,bob,dave", + "eve,charlie,dave", + "bob,eve,charlie,dave", + "charlie,eve,bob,dave", + }) + + // 4) Finally, we will test the special case where the destination node + // is also the recipient. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 0, + maxNumHops: 0, + }) + require.NoError(t, err) + + assertPaths(paths, []string{ + "dave", + }) +}