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.
This commit is contained in:
Elle Mouton 2024-05-04 12:05:17 +02:00
parent 9787ae9c89
commit 1039aedd0c
No known key found for this signature in database
GPG Key ID: D7D916376026F177
2 changed files with 387 additions and 7 deletions

View File

@ -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.

View File

@ -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",
})
}