diff --git a/routing/pathfind.go b/routing/pathfind.go index c14e32e8b..ace174fef 100644 --- a/routing/pathfind.go +++ b/routing/pathfind.go @@ -1192,6 +1192,11 @@ type blindedPathRestrictions struct { // nodeOmissionSet holds a set of node IDs of nodes that we should // ignore during blinded path selection. nodeOmissionSet fn.Set[route.Vertex] + + // incomingChainedChannels holds the chained channels list specified + // via scid (short channel id) starting from a channel which points to + // the receiver node. + incomingChainedChannels []uint64 } // blindedHop holds the information about a hop we have selected for a blinded @@ -1221,11 +1226,72 @@ func findBlindedPaths(g Graph, target route.Vertex, restrictions.minNumHops) } + var ( + // The target node is always the last hop in the path. + incomingPath = []blindedHop{{vertex: target}} + whiteListedNodes = map[route.Vertex]bool{target: true} + visited = make(map[route.Vertex]bool) + errChanFound = errors.New("found incoming channel") + nextTarget = target + ) + for _, chanID := range restrictions.incomingChainedChannels { + visited[nextTarget] = true + + err := g.ForEachNodeDirectedChannel(nextTarget, + func(channel *graphdb.DirectedChannel) error { + // Not the right channel, continue to the node's + // other channels. + if channel.ChannelID != chanID { + return nil + } + + incomingPath = append([]blindedHop{ + { + vertex: channel.OtherNode, + channelID: channel.ChannelID, + edgeCapacity: channel.Capacity, + }, + }, incomingPath...) + + // Update the target node. + nextTarget = channel.OtherNode + + return errChanFound + }, + ) + 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 + 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] { + return nil, fmt.Errorf("a circular route cannot be " + + "specified for the incoming blinded path") + } + whiteListedNodes[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 node == target { + if whiteListedNodes[node] { return true, nil } @@ -1243,27 +1309,35 @@ func findBlindedPaths(g Graph, target route.Vertex, // 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, + g, nextTarget, supportsRouteBlinding, visited, 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}) + // When there is no path to add, but incomingChainedChannels can be + // used. + lenChainedChannels := int8(len(restrictions.incomingChainedChannels)) + if len(paths) == 0 && lenChainedChannels > 0 { + 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[i] = append(path, incomingPath...) + } } // Handle the special case that allows a blinded path with the // introduction node as the destination node. - if restrictions.minNumHops == 0 { + if restrictions.minNumHops == 0 && lenChainedChannels == 0 { singleHopPath := [][]blindedHop{{{vertex: target}}} //nolint:makezero diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 8a0280686..6fc997456 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -3860,4 +3860,94 @@ func TestFindBlindedPaths(t *testing.T) { assertPaths(paths, []string{ "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. + paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 1, + maxNumHops: 1, + incomingChainedChannels: []uint64{2}, + }) + require.NoError(t, err) + + // Expect only B->D path. + assertPaths(paths, []string{ + "bob,dave", + }) + + // 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}, + }) + require.NoError(t, err) + + // We expect the following paths: + // - F, B, D + // - E, B, D + assertPaths(paths, []string{ + "frank,bob,dave", + "eve,bob,dave", + }) + + // 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}, + }) + 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", + }) + + // 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}, + }) + require.ErrorContains(t, err, "cannot simultaneously be included in "+ + "the omission set and in the partially specified path") + + // 11) Test the circular route error. + _, err = ctx.findBlindedPaths(&blindedPathRestrictions{ + minNumHops: 2, + maxNumHops: 3, + incomingChainedChannels: []uint64{2, 7, 6, 3}, + }) + require.ErrorContains(t, err, "a circular route cannot be specified") } diff --git a/routing/router.go b/routing/router.go index 440373200..8c9e145be 100644 --- a/routing/router.go +++ b/routing/router.go @@ -610,6 +610,11 @@ type BlindedPathRestrictions struct { // NodeOmissionSet is a set of nodes that should not be used within any // of the blinded paths that we generate. NodeOmissionSet fn.Set[route.Vertex] + + // IncomingChainedChannels holds the chained channels list (specified + // via channel id) starting from a channel which points to the receiver + // node. + IncomingChainedChannels []uint64 } // FindBlindedPaths finds a selection of paths to the destination node that can @@ -620,11 +625,14 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex, // First, find a set of candidate paths given the destination node and // path length restrictions. + incomingChainedChannels := restrictions.IncomingChainedChannels + minDistanceFromIntroNode := restrictions.MinDistanceFromIntroNode paths, err := findBlindedPaths( r.cfg.RoutingGraph, destination, &blindedPathRestrictions{ - minNumHops: restrictions.MinDistanceFromIntroNode, - maxNumHops: restrictions.NumHops, - nodeOmissionSet: restrictions.NodeOmissionSet, + minNumHops: minDistanceFromIntroNode, + maxNumHops: restrictions.NumHops, + nodeOmissionSet: restrictions.NodeOmissionSet, + incomingChainedChannels: incomingChainedChannels, }, ) if err != nil {