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