From e12a2262724e238c3ce9f85511ba9d0b3109c6cb Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Sun, 5 May 2024 13:36:26 +0200 Subject: [PATCH] routing: use mission control to select blinded paths Add a `FindBlindedPaths` method to the `ChannelRouter` which will use the new `findBlindedPaths` function to get a set of candidate blinded path routes. It then uses mission control to select the best of these paths. Note that as of this commit, the MC data we get from these queries won't mean much since we wont have data about a channel in the direction towards us. But we do this now in preparation for a future PR which will start writing mission control success pairs for successful receives from blinded route paths. --- routing/router.go | 126 ++++++++++++++++++++++++ routing/router_test.go | 219 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) diff --git a/routing/router.go b/routing/router.go index 35db5a779..9e183cde5 100644 --- a/routing/router.go +++ b/routing/router.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "math" + "sort" "sync" "sync/atomic" "time" @@ -675,6 +676,131 @@ func (r *ChannelRouter) FindRoute(req *RouteRequest) (*route.Route, float64, return route, probability, nil } +// probabilitySource defines the signature of a function that can be used to +// query the success probability of sending a given amount between the two +// given vertices. +type probabilitySource func(route.Vertex, route.Vertex, lnwire.MilliSatoshi, + btcutil.Amount) float64 + +// BlindedPathRestrictions are a set of constraints to adhere to when +// choosing a set of blinded paths to this node. +type BlindedPathRestrictions struct { + // MinDistanceFromIntroNode is the minimum number of _real_ (non-dummy) + // hops to include in a blinded path. Since we post-fix dummy hops, this + // is the minimum distance between our node and the introduction node + // of the 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. + MinDistanceFromIntroNode uint8 + + // NumHops is the number of hops that each blinded path should consist + // of. If paths are found with a number of hops less that NumHops, then + // dummy hops will be padded on to the route. This value doesn't + // include our node, so if the maximum is 1, then the path will contain + // our node along with an introduction node hop. + NumHops uint8 + + // MaxNumPaths is the maximum number of blinded paths to select. + MaxNumPaths uint8 +} + +// FindBlindedPaths finds a selection of paths to the destination node that can +// be used in blinded payment paths. +func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex, + amt lnwire.MilliSatoshi, probabilitySrc probabilitySource, + restrictions *BlindedPathRestrictions) ([]*route.Route, error) { + + // First, find a set of candidate paths given the destination node and + // path length restrictions. + paths, err := findBlindedPaths( + r.cfg.RoutingGraph, destination, &blindedPathRestrictions{ + minNumHops: restrictions.MinDistanceFromIntroNode, + maxNumHops: restrictions.NumHops, + }, + ) + if err != nil { + return nil, err + } + + // routeWithProbability groups a route with the probability of a + // payment of the given amount succeeding on that path. + type routeWithProbability struct { + route *route.Route + probability float64 + } + + // Iterate over all the candidate paths and determine the success + // probability of each path given the data we have about forwards + // between any two nodes on a path. + routes := make([]*routeWithProbability, 0, len(paths)) + for _, path := range paths { + if len(path) < 1 { + return nil, fmt.Errorf("a blinded path must have at " + + "least one hop") + } + + var ( + introNode = path[0].vertex + prevNode = introNode + hops = make( + []*route.Hop, 0, len(path)-1, + ) + totalRouteProbability = float64(1) + ) + + // For each set of hops on the path, get the success probability + // of a forward between those two vertices and use that to + // update the overall route probability. + for j := 1; j < len(path); j++ { + probability := probabilitySrc( + prevNode, path[j].vertex, amt, + path[j-1].edgeCapacity, + ) + + totalRouteProbability *= probability + + hops = append(hops, &route.Hop{ + PubKeyBytes: path[j].vertex, + ChannelID: path[j-1].edgePolicy.ChannelID, + }) + + prevNode = path[j].vertex + } + + // Don't bother adding a route if its success probability less + // minimum that can be assigned to any single pair. + if totalRouteProbability <= DefaultMinRouteProbability { + continue + } + + routes = append(routes, &routeWithProbability{ + route: &route.Route{ + SourcePubKey: introNode, + Hops: hops, + }, + probability: totalRouteProbability, + }) + } + + // Sort the routes based on probability. + sort.Slice(routes, func(i, j int) bool { + return routes[i].probability < routes[j].probability + }) + + // Now just choose the best paths up until the maximum number of allowed + // paths. + bestRoutes := make([]*route.Route, 0, restrictions.MaxNumPaths) + for _, route := range routes { + if len(bestRoutes) >= int(restrictions.MaxNumPaths) { + break + } + + bestRoutes = append(bestRoutes, route.route) + } + + return bestRoutes, nil +} + // generateNewSessionKey generates a new ephemeral private key to be used for a // payment attempt. func generateNewSessionKey() (*btcec.PrivateKey, error) { diff --git a/routing/router_test.go b/routing/router_test.go index 1749a6dab..f631669a9 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -7,6 +7,7 @@ import ( "math" "math/rand" "net" + "strings" "sync" "sync/atomic" "testing" @@ -2589,3 +2590,221 @@ func createChannelEdge(bitcoinKey1, bitcoinKey2 []byte, return fundingTx, &chanUtxo, chanID, nil } + +// TestFindBlindedPathsWithMC tests that the FindBlindedPaths method correctly +// selects a set of blinded paths by using mission control data to select the +// paths with the highest success probability. +func TestFindBlindedPathsWithMC(t *testing.T) { + t.Parallel() + + rbFeatureBits := []lnwire.FeatureBit{ + lnwire.RouteBlindingOptional, + } + + // Create the following graph and let all the nodes advertise support + // for blinded paths. + // + // C + // / \ + // / \ + // E -- A -- F -- D + // \ / + // \ / + // B + // + featuresWithRouteBlinding := lnwire.NewFeatureVector( + lnwire.NewRawFeatureVector(rbFeatureBits...), lnwire.Features, + ) + + policyWithRouteBlinding := &testChannelPolicy{ + Expiry: 144, + FeeRate: 400, + MinHTLC: 1, + MaxHTLC: 100000000, + Features: featuresWithRouteBlinding, + } + + testChannels := []*testChannel{ + symmetricTestChannel( + "eve", "alice", 100000, policyWithRouteBlinding, 1, + ), + symmetricTestChannel( + "alice", "charlie", 100000, policyWithRouteBlinding, 2, + ), + symmetricTestChannel( + "alice", "bob", 100000, policyWithRouteBlinding, 3, + ), + symmetricTestChannel( + "charlie", "dave", 100000, policyWithRouteBlinding, 4, + ), + symmetricTestChannel( + "bob", "dave", 100000, policyWithRouteBlinding, 5, + ), + symmetricTestChannel( + "alice", "frank", 100000, policyWithRouteBlinding, 6, + ), + symmetricTestChannel( + "frank", "dave", 100000, policyWithRouteBlinding, 7, + ), + } + + testGraph, err := createTestGraphFromChannels( + t, true, testChannels, "dave", rbFeatureBits..., + ) + require.NoError(t, err) + + ctx := createTestCtxFromGraphInstance(t, 101, testGraph) + + var ( + alice = ctx.aliases["alice"] + bob = ctx.aliases["bob"] + charlie = ctx.aliases["charlie"] + dave = ctx.aliases["dave"] + eve = ctx.aliases["eve"] + frank = ctx.aliases["frank"] + ) + + // Create a mission control store which initially sets the success + // probability of each node pair to 1. + missionControl := map[route.Vertex]map[route.Vertex]float64{ + eve: {alice: 1}, + alice: { + charlie: 1, + bob: 1, + frank: 1, + }, + charlie: {dave: 1}, + bob: {dave: 1}, + frank: {dave: 1}, + } + + // probabilitySrc is a helper that returns the mission control success + // probability of a forward between two vertices. + probabilitySrc := func(from route.Vertex, to route.Vertex, + amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 { + + return missionControl[from][to] + } + + // All the probabilities are set to 1. So if we restrict the path length + // to 2 and allow a max of 3 routes, then we expect three paths here. + routes, err := ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 2, + NumHops: 2, + MaxNumPaths: 3, + }, + ) + require.NoError(t, err) + require.Len(t, routes, 3) + + // assertPaths checks that the resulting set of paths is equal to the + // expected set and that the order of the paths is correct. + assertPaths := func(paths []*route.Route, expectedPaths []string) { + require.Len(t, paths, len(expectedPaths)) + + var actualPaths []string + for _, path := range paths { + label := getAliasFromPubKey( + path.SourcePubKey, ctx.aliases, + ) + "," + + for _, hop := range path.Hops { + label += getAliasFromPubKey( + hop.PubKeyBytes, ctx.aliases, + ) + "," + } + + actualPaths = append( + actualPaths, strings.TrimRight(label, ","), + ) + } + + for i, path := range expectedPaths { + require.Equal(t, expectedPaths[i], path) + } + } + + // Now, let's lower the MC probability of the B-D to 0.5 and F-D link to + // 0.25. We will leave the MaxNumPaths as 3 and so all paths should + // still be returned but the order should be: + // 1) A -> C -> D + // 2) A -> B -> D + // 3) A -> F -> D + missionControl[bob][dave] = 0.5 + missionControl[frank][dave] = 0.25 + routes, err = ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 2, + NumHops: 2, + MaxNumPaths: 3, + }, + ) + require.NoError(t, err) + assertPaths(routes, []string{ + "alice,charlie,dave", + "alice,bob,dave", + "alice,frank,dave", + }) + + // Just to show that the above result was not a fluke, let's change + // the C->D link to be the weak one. + missionControl[charlie][dave] = 0.125 + routes, err = ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 2, + NumHops: 2, + MaxNumPaths: 3, + }, + ) + require.NoError(t, err) + assertPaths(routes, []string{ + "alice,bob,dave", + "alice,frank,dave", + "alice,charlie,dave", + }) + + // Change the MaxNumPaths to 1 to assert that only the best route is + // returned. + routes, err = ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 2, + NumHops: 2, + MaxNumPaths: 1, + }, + ) + require.NoError(t, err) + assertPaths(routes, []string{ + "alice,bob,dave", + }) + + // Test the edge case where Dave, the recipient, is also the + // introduction node. + routes, err = ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 0, + NumHops: 0, + MaxNumPaths: 1, + }, + ) + require.NoError(t, err) + assertPaths(routes, []string{ + "dave", + }) + + // Finally, we make one of the routes have a probability less than the + // minimum. This means we expect that route not to be chosen. + missionControl[charlie][dave] = DefaultMinRouteProbability + routes, err = ctx.router.FindBlindedPaths( + dave, 1000, probabilitySrc, &BlindedPathRestrictions{ + MinDistanceFromIntroNode: 2, + NumHops: 2, + MaxNumPaths: 3, + }, + ) + require.NoError(t, err) + assertPaths(routes, []string{ + "alice,bob,dave", + "alice,frank,dave", + }) +}