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.
This commit is contained in:
Carla Kirk-Cohen 2022-12-15 16:48:56 -05:00 committed by Olaoluwa Osuntokun
parent 48e36d93d4
commit c9609b8214
7 changed files with 501 additions and 18 deletions

View File

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

View File

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

View File

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

View File

@ -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])
}
}

View File

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

View File

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

View File

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