From c9609b8214b5513f6878b510eadd5f8b5f5e6e4b Mon Sep 17 00:00:00 2001 From: Carla Kirk-Cohen Date: Thu, 15 Dec 2022 16:48:56 -0500 Subject: [PATCH] multi: add blinded route to route requests expressed as hints Add the option to include a blinded route in a route request (exclusive to including hop hints, because it's incongruous to include both), and express the route as a chain of hop hints. Using a chain of hints over a single hint to represent the whole path allows us to re-use our route construction to fill in a lot of the path on our behalf. --- lnrpc/routerrpc/router_backend.go | 2 +- lnrpc/routerrpc/router_server.go | 2 +- routing/blinding.go | 93 +++++++++++++++++ routing/blinding_test.go | 113 +++++++++++++++++++++ routing/pathfind_test.go | 4 +- routing/router.go | 146 ++++++++++++++++++++++++--- routing/router_test.go | 159 +++++++++++++++++++++++++++++- 7 files changed, 501 insertions(+), 18 deletions(-) diff --git a/lnrpc/routerrpc/router_backend.go b/lnrpc/routerrpc/router_backend.go index 8594e1384..e1826c85a 100644 --- a/lnrpc/routerrpc/router_backend.go +++ b/lnrpc/routerrpc/router_backend.go @@ -327,7 +327,7 @@ func (r *RouterBackend) QueryRoutes(ctx context.Context, // the route. routeReq, err := routing.NewRouteRequest( sourcePubKey, &targetPubKey, amt, in.TimePref, restrictions, - customRecords, routeHintEdges, finalCLTVDelta, + customRecords, routeHintEdges, nil, finalCLTVDelta, ) if err != nil { return nil, err diff --git a/lnrpc/routerrpc/router_server.go b/lnrpc/routerrpc/router_server.go index fa0913dab..2d48e675b 100644 --- a/lnrpc/routerrpc/router_server.go +++ b/lnrpc/routerrpc/router_server.go @@ -404,7 +404,7 @@ func (s *Server) EstimateRouteFee(ctx context.Context, FeeLimit: feeLimit, CltvLimit: s.cfg.RouterBackend.MaxTotalTimelock, ProbabilitySource: mc.GetProbability, - }, nil, nil, s.cfg.RouterBackend.DefaultFinalCltvDelta, + }, nil, nil, nil, s.cfg.RouterBackend.DefaultFinalCltvDelta, ) if err != nil { return nil, err diff --git a/routing/blinding.go b/routing/blinding.go index 0a226276d..143808646 100644 --- a/routing/blinding.go +++ b/routing/blinding.go @@ -5,7 +5,9 @@ import ( "fmt" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" ) var ( @@ -77,3 +79,94 @@ func (b *BlindedPayment) Validate() error { return nil } + +// toRouteHints produces a set of chained route hints that represent a blinded +// path. In the case of a single hop blinded route (which is paying directly +// to the introduction point), no hints will be returned. In this case callers +// *must* account for the blinded route's CLTV delta elsewhere (as this is +// effectively the final_cltv_delta for the receiving introduction node). In +// the case of multiple blinded hops, CLTV delta is fully accounted for in the +// hints (both for intermediate hops and the final_cltv_delta for the receiving +// node). +func (b *BlindedPayment) toRouteHints() RouteHints { + // If we just have a single hop in our blinded route, it just contains + // an introduction node (this is a valid path according to the spec). + // Since we have the un-blinded node ID for the introduction node, we + // don't need to add any route hints. + if len(b.BlindedPath.BlindedHops) == 1 { + return nil + } + + hintCount := len(b.BlindedPath.BlindedHops) - 1 + hints := make( + map[route.Vertex][]*channeldb.CachedEdgePolicy, hintCount, + ) + + // Start at the unblinded introduction node, because our pathfinding + // will be able to locate this point in the graph. + fromNode := route.NewVertex(b.BlindedPath.IntroductionPoint) + + features := lnwire.EmptyFeatureVector() + if b.Features != nil { + features = b.Features.Clone() + } + + // Use the total aggregate relay parameters for the entire blinded + // route as the policy for the hint from our introduction node. This + // will ensure that pathfinding provides sufficient fees/delay for the + // blinded portion to the introduction node. + firstBlindedHop := b.BlindedPath.BlindedHops[1].BlindedNodePub + hints[fromNode] = []*channeldb.CachedEdgePolicy{ + { + TimeLockDelta: b.CltvExpiryDelta, + MinHTLC: lnwire.MilliSatoshi(b.HtlcMinimum), + MaxHTLC: lnwire.MilliSatoshi(b.HtlcMaximum), + FeeBaseMSat: lnwire.MilliSatoshi(b.BaseFee), + FeeProportionalMillionths: lnwire.MilliSatoshi( + b.ProportionalFee, + ), + ToNodePubKey: func() route.Vertex { + return route.NewVertex( + // The first node in this slice is + // the introduction node, so we start + // at index 1 to get the first blinded + // relaying node. + firstBlindedHop, + ) + }, + ToNodeFeatures: features, + }, + } + + // Start at an offset of 1 because the first node in our blinded hops + // is the introduction node and terminate at the second-last node + // because we're dealing with hops as pairs. + for i := 1; i < hintCount; i++ { + // Set our origin node to the current + fromNode = route.NewVertex( + b.BlindedPath.BlindedHops[i].BlindedNodePub, + ) + + // Create a hint which has no fee or cltv delta. We + // specifically want zero values here because our relay + // parameters are expressed in encrypted blobs rather than the + // route itself for blinded routes. + nextHopIdx := i + 1 + nextNode := route.NewVertex( + b.BlindedPath.BlindedHops[nextHopIdx].BlindedNodePub, + ) + + hint := &channeldb.CachedEdgePolicy{ + ToNodePubKey: func() route.Vertex { + return nextNode + }, + ToNodeFeatures: features, + } + + hints[fromNode] = []*channeldb.CachedEdgePolicy{ + hint, + } + } + + return hints +} diff --git a/routing/blinding_test.go b/routing/blinding_test.go index 75ea36d9e..5dc71354f 100644 --- a/routing/blinding_test.go +++ b/routing/blinding_test.go @@ -3,7 +3,10 @@ package routing import ( "testing" + "github.com/btcsuite/btcd/btcec/v2" sphinx "github.com/lightningnetwork/lightning-onion" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" ) @@ -68,3 +71,113 @@ func TestBlindedPathValidation(t *testing.T) { }) } } + +// TestBlindedPaymentToHints tests conversion of a blinded path to a chain of +// route hints. As our function assumes that the blinded payment has already +// been validated. +func TestBlindedPaymentToHints(t *testing.T) { + t.Parallel() + + var ( + _, pk1 = btcec.PrivKeyFromBytes([]byte{1}) + _, pkb1 = btcec.PrivKeyFromBytes([]byte{2}) + _, pkb2 = btcec.PrivKeyFromBytes([]byte{3}) + _, pkb3 = btcec.PrivKeyFromBytes([]byte{4}) + + v1 = route.NewVertex(pk1) + vb2 = route.NewVertex(pkb2) + vb3 = route.NewVertex(pkb3) + + baseFee uint32 = 1000 + ppmFee uint32 = 500 + cltvDelta uint16 = 140 + htlcMin uint64 = 100 + htlcMax uint64 = 100_000_000 + + rawFeatures = lnwire.NewRawFeatureVector( + lnwire.AMPOptional, + ) + + features = lnwire.NewFeatureVector( + rawFeatures, lnwire.Features, + ) + ) + + // Create a blinded payment that's just to the introduction node and + // assert that we get nil hints. + blindedPayment := &BlindedPayment{ + BlindedPath: &sphinx.BlindedPath{ + IntroductionPoint: pk1, + BlindedHops: []*sphinx.BlindedHopInfo{ + {}, + }, + }, + BaseFee: baseFee, + ProportionalFee: ppmFee, + CltvExpiryDelta: cltvDelta, + HtlcMinimum: htlcMin, + HtlcMaximum: htlcMax, + Features: features, + } + require.Nil(t, blindedPayment.toRouteHints()) + + // Populate the blinded payment with hops. + blindedPayment.BlindedPath.BlindedHops = []*sphinx.BlindedHopInfo{ + { + BlindedNodePub: pkb1, + }, + { + BlindedNodePub: pkb2, + }, + { + BlindedNodePub: pkb3, + }, + } + + expected := RouteHints{ + v1: { + { + TimeLockDelta: cltvDelta, + MinHTLC: lnwire.MilliSatoshi(htlcMin), + MaxHTLC: lnwire.MilliSatoshi(htlcMax), + FeeBaseMSat: lnwire.MilliSatoshi(baseFee), + FeeProportionalMillionths: lnwire.MilliSatoshi( + ppmFee, + ), + ToNodePubKey: func() route.Vertex { + return vb2 + }, + ToNodeFeatures: features, + }, + }, + vb2: { + { + ToNodePubKey: func() route.Vertex { + return vb3 + }, + ToNodeFeatures: features, + }, + }, + } + actual := blindedPayment.toRouteHints() + + require.Equal(t, len(expected), len(actual)) + for vertex, expectedHint := range expected { + actualHint, ok := actual[vertex] + require.True(t, ok, "node not found: %v", vertex) + + require.Len(t, expectedHint, 1) + require.Len(t, actualHint, 1) + + // We can't assert that our functions are equal, so we check + // their output and then mark as nil so that we can use + // require.Equal for all our other fields. + require.Equal(t, expectedHint[0].ToNodePubKey(), + actualHint[0].ToNodePubKey()) + + actualHint[0].ToNodePubKey = nil + expectedHint[0].ToNodePubKey = nil + + require.Equal(t, expectedHint[0], actualHint[0]) + } +} diff --git a/routing/pathfind_test.go b/routing/pathfind_test.go index 5b66e5c73..e95548c0d 100644 --- a/routing/pathfind_test.go +++ b/routing/pathfind_test.go @@ -2293,7 +2293,7 @@ func TestPathFindSpecExample(t *testing.T) { const amt lnwire.MilliSatoshi = 4999999 req, err := NewRouteRequest( bobNode.PubKeyBytes, &carol, amt, 0, noRestrictions, nil, nil, - MinCLTVDelta, + nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") @@ -2346,7 +2346,7 @@ func TestPathFindSpecExample(t *testing.T) { // We'll now request a route from A -> B -> C. req, err = NewRouteRequest( source.PubKeyBytes, &carol, amt, 0, noRestrictions, nil, nil, - MinCLTVDelta, + nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") diff --git a/routing/router.go b/routing/router.go index f0858bf5a..4db775da3 100644 --- a/routing/router.go +++ b/routing/router.go @@ -91,6 +91,34 @@ var ( // ErrRouterShuttingDown is returned if the router is in the process of // shutting down. ErrRouterShuttingDown = fmt.Errorf("router shutting down") + + // ErrSelfIntro is a failure returned when the source node of a + // route request is also the introduction node. This is not yet + // supported because LND does not support blinded forwardingg. + ErrSelfIntro = errors.New("introduction point as own node not " + + "supported") + + // ErrHintsAndBlinded is returned if a route request has both + // bolt 11 route hints and a blinded path set. + ErrHintsAndBlinded = errors.New("bolt 11 route hints and blinded " + + "paths are mutually exclusive") + + // ErrExpiryAndBlinded is returned if a final cltv and a blinded path + // are provided, as the cltv should be provided within the blinded + // path. + ErrExpiryAndBlinded = errors.New("final cltv delta and blinded " + + "paths are mutually exclusive") + + // ErrTargetAndBlinded is returned is a target destination and a + // blinded path are both set (as the target is inferred from the + // blinded path). + ErrTargetAndBlinded = errors.New("target node and blinded paths " + + "are mutually exclusive") + + // ErrNoTarget is returned when the target node for a route is not + // provided by either a blinded route or a cleartext pubkey. + ErrNoTarget = errors.New("destination not set in target or blinded " + + "path") ) // ChannelGraphSource represents the source of information about the topology @@ -1809,12 +1837,16 @@ type routingMsg struct { err chan error } -// RouteRequest contains the parameters for a pathfinding request. +// RouteRequest contains the parameters for a pathfinding request. It may +// describe a request to make a regular payment or one to a blinded path +// (incdicated by a non-nil BlindedPayment field). type RouteRequest struct { // Source is the node that the path originates from. Source route.Vertex - // Target is the node that the path terminates at. + // Target is the node that the path terminates at. If the route + // includes a blinded path, target will be the blinded node id of the + // final hop in the blinded route. Target route.Vertex // Amount is the Amount in millisatoshis to be delivered to the target @@ -1834,39 +1866,131 @@ type RouteRequest struct { CustomRecords record.CustomSet // RouteHints contains an additional set of edges to include in our - // view of the graph. + // view of the graph. This may either be a set of hints for private + // channels or a "virtual" hop hint that represents a blinded route. RouteHints RouteHints - // FinalExpiry is the cltv delta for the final hop. + // FinalExpiry is the cltv delta for the final hop. If paying to a + // blinded path, this value is a duplicate of the delta provided + // in blinded payment. FinalExpiry uint16 + + // BlindedPayment contains an optional blinded path and parameters + // used to reach a target node via a blinded path. This field is + // mutually exclusive with the Target field. + BlindedPayment *BlindedPayment } // RouteHints is an alias type for a set of route hints, with the source node // as the map's key and the details of the hint(s) in the edge policy. type RouteHints map[route.Vertex][]*channeldb.CachedEdgePolicy -// NewRouteRequest produces a new route request. +// NewRouteRequest produces a new route request for a regular payment or one +// to a blinded route, validating that the target, routeHints and finalExpiry +// parameters are mutually exclusive with the blindedPayment parameter (which +// contains these values for blinded payments). func NewRouteRequest(source route.Vertex, target *route.Vertex, amount lnwire.MilliSatoshi, timePref float64, restrictions *RestrictParams, customRecords record.CustomSet, - routeHints RouteHints, finalExpiry uint16) (*RouteRequest, error) { + routeHints RouteHints, blindedPayment *BlindedPayment, + finalExpiry uint16) (*RouteRequest, error) { - if target == nil { - return nil, errors.New("target node required") + var ( + // Assume that we're starting off with a regular payment. + requestHints = routeHints + requestExpiry = finalExpiry + ) + + if blindedPayment != nil { + if err := blindedPayment.Validate(); err != nil { + return nil, fmt.Errorf("invalid blinded payment: %w", + err) + } + + introVertex := route.NewVertex( + blindedPayment.BlindedPath.IntroductionPoint, + ) + if source == introVertex { + return nil, ErrSelfIntro + } + + // Check that the values for a clear path have not been set, + // as this is an ambiguous signal from the caller. + if routeHints != nil { + return nil, ErrHintsAndBlinded + } + + if finalExpiry != 0 { + return nil, ErrExpiryAndBlinded + } + + // If we have a blinded path with 1 hop, the cltv expiry + // will not be included in any hop hints (since we're just + // sending to the introduction node and need no blinded hints). + // In this case, we include it to make sure that the final + // cltv delta is accounted for (since it's part of the blinded + // delta). In the case of a multi-hop route, we set our final + // cltv to zero, since it's going to be accounted for in the + // delta for our hints. + if len(blindedPayment.BlindedPath.BlindedHops) == 1 { + requestExpiry = blindedPayment.CltvExpiryDelta + } + + requestHints = blindedPayment.toRouteHints() + } + + requestTarget, err := getTargetNode(target, blindedPayment) + if err != nil { + return nil, err } return &RouteRequest{ Source: source, - Target: *target, + Target: requestTarget, Amount: amount, TimePreference: timePref, Restrictions: restrictions, CustomRecords: customRecords, - RouteHints: routeHints, - FinalExpiry: finalExpiry, + RouteHints: requestHints, + FinalExpiry: requestExpiry, + BlindedPayment: blindedPayment, }, nil } +func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) ( + route.Vertex, error) { + + var ( + blinded = blindedPayment != nil + targetSet = target != nil + ) + + switch { + case blinded && targetSet: + return route.Vertex{}, ErrTargetAndBlinded + + case blinded: + // If we're dealing with an edge-case blinded path that just + // has an introduction node (first hop expected to be the intro + // hop), then we return the unblinded introduction node as our + // target. + hops := blindedPayment.BlindedPath.BlindedHops + if len(hops) == 1 { + return route.NewVertex( + blindedPayment.BlindedPath.IntroductionPoint, + ), nil + } + + return route.NewVertex(hops[len(hops)-1].BlindedNodePub), nil + + case targetSet: + return *target, nil + + default: + return route.Vertex{}, ErrNoTarget + } +} + // FindRoute attempts to query the ChannelRouter for the optimum path to a // particular target destination to which it is able to send `amt` after // factoring in channel capacities and cumulative fees along the route. diff --git a/routing/router_test.go b/routing/router_test.go index b6bcef350..ea1fec287 100644 --- a/routing/router_test.go +++ b/routing/router_test.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" + sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/clock" @@ -273,7 +274,7 @@ func TestFindRoutesWithFeeLimit(t *testing.T) { req, err := NewRouteRequest( ctx.router.selfNode.PubKeyBytes, &target, paymentAmt, 0, - restrictions, nil, nil, MinCLTVDelta, + restrictions, nil, nil, nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") @@ -1563,7 +1564,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) { req, err := NewRouteRequest( ctx.router.selfNode.PubKeyBytes, &targetPubKeyBytes, - paymentAmt, 0, noRestrictions, nil, nil, MinCLTVDelta, + paymentAmt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") _, _, err = ctx.router.FindRoute(req) @@ -1605,7 +1606,7 @@ func TestAddEdgeUnknownVertexes(t *testing.T) { // updated. req, err = NewRouteRequest( ctx.router.selfNode.PubKeyBytes, &targetPubKeyBytes, - paymentAmt, 0, noRestrictions, nil, nil, MinCLTVDelta, + paymentAmt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") @@ -4612,3 +4613,155 @@ func TestSendToRouteTempFailure(t *testing.T) { payer.AssertExpectations(t) missionControl.AssertExpectations(t) } + +// TestNewRouteRequest tests creation of route requests for blinded and +// unblinded routes. +func TestNewRouteRequest(t *testing.T) { + t.Parallel() + + //nolint:lll + source, err := route.NewVertexFromStr("0367cec75158a4129177bfb8b269cb586efe93d751b43800d456485e81c2620ca6") + require.NoError(t, err) + sourcePubkey, err := btcec.ParsePubKey(source[:]) + require.NoError(t, err) + + //nolint:lll + v1, err := route.NewVertexFromStr("026c43a8ac1cd8519985766e90748e1e06871dab0ff6b8af27e8c1a61640481318") + require.NoError(t, err) + pubkey1, err := btcec.ParsePubKey(v1[:]) + require.NoError(t, err) + + //nolint:lll + v2, err := route.NewVertexFromStr("03c19f0027ffbb0ae0e14a4d958788793f9d74e107462473ec0c3891e4feb12e99") + require.NoError(t, err) + pubkey2, err := btcec.ParsePubKey(v2[:]) + require.NoError(t, err) + + var ( + unblindedCltv uint16 = 500 + blindedCltv uint16 = 1000 + ) + + blindedSelfIntro := &BlindedPayment{ + CltvExpiryDelta: blindedCltv, + BlindedPath: &sphinx.BlindedPath{ + IntroductionPoint: sourcePubkey, + BlindedHops: []*sphinx.BlindedHopInfo{{}}, + }, + } + + blindedOtherIntro := &BlindedPayment{ + CltvExpiryDelta: blindedCltv, + BlindedPath: &sphinx.BlindedPath{ + IntroductionPoint: pubkey1, + BlindedHops: []*sphinx.BlindedHopInfo{ + {}, + }, + }, + } + + blindedMultiHop := &BlindedPayment{ + CltvExpiryDelta: blindedCltv, + BlindedPath: &sphinx.BlindedPath{ + IntroductionPoint: pubkey1, + BlindedHops: []*sphinx.BlindedHopInfo{ + {}, + { + BlindedNodePub: pubkey2, + }, + }, + }, + } + + testCases := []struct { + name string + target *route.Vertex + routeHints RouteHints + blindedPayment *BlindedPayment + finalExpiry uint16 + + expectedTarget route.Vertex + expectedCltv uint16 + err error + }{ + { + name: "blinded and target", + target: &v1, + blindedPayment: blindedOtherIntro, + err: ErrTargetAndBlinded, + }, + { + // For single-hop blinded we have a final cltv. + name: "blinded intro node only", + blindedPayment: blindedOtherIntro, + expectedTarget: v1, + expectedCltv: blindedCltv, + err: nil, + }, + { + // For multi-hop blinded, we have no final cltv. + name: "blinded multi-hop", + blindedPayment: blindedMultiHop, + expectedTarget: v2, + expectedCltv: 0, + err: nil, + }, + { + name: "unblinded", + target: &v2, + finalExpiry: unblindedCltv, + expectedTarget: v2, + expectedCltv: unblindedCltv, + err: nil, + }, + { + name: "source node intro", + blindedPayment: blindedSelfIntro, + err: ErrSelfIntro, + }, + { + name: "hints and blinded", + blindedPayment: blindedMultiHop, + routeHints: make( + map[route.Vertex][]*channeldb.CachedEdgePolicy, + ), + err: ErrHintsAndBlinded, + }, + { + name: "expiry and blinded", + blindedPayment: blindedMultiHop, + finalExpiry: unblindedCltv, + err: ErrExpiryAndBlinded, + }, + { + name: "invalid blinded payment", + blindedPayment: &BlindedPayment{}, + err: ErrNoBlindedPath, + }, + } + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + req, err := NewRouteRequest( + source, testCase.target, 1000, 0, nil, nil, + testCase.routeHints, testCase.blindedPayment, + testCase.finalExpiry, + ) + require.ErrorIs(t, err, testCase.err) + + // Skip request validation if we got a non-nil error. + if err != nil { + return + } + + require.Equal(t, req.Target, testCase.expectedTarget) + require.Equal( + t, req.FinalExpiry, testCase.expectedCltv, + ) + }) + } +}