lnrpc/invoicesrpc: build blinded path

This commit adds all the logic for building a blinded path (from a given
route) and packaging it up in a zpay32.BlindedPaymentPath struct so that
it is ready for adding to an invoice. It also includes logic for padding
a path with dummy hops.

Note that in this commit, the logic for choosing an actual path to us
that can then be used in a blinded path is abstracted away. This logic
will be fleshed out in a future commit.
This commit is contained in:
Elle Mouton 2024-07-04 16:40:54 +02:00
parent 0855e3e71a
commit 4b5327f057
No known key found for this signature in database
GPG key ID: D7D916376026F177
2 changed files with 1005 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import (
"fmt"
"math"
mathRand "math/rand"
"slices"
"sort"
"time"
@ -25,6 +26,7 @@ import (
"github.com/lightningnetwork/lnd/netann"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/routing"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/tlv"
"github.com/lightningnetwork/lnd/zpay32"
)
@ -51,6 +53,11 @@ const (
oneMillion = uint32(1_000_000)
)
// errInvalidBlindedPath indicates that the chosen real path is not usable as
// a blinded path.
var errInvalidBlindedPath = errors.New("the chosen path results in an " +
"unusable blinded path")
// AddInvoiceConfig contains dependencies for invoice creation.
type AddInvoiceConfig struct {
// AddInvoice is called to add the invoice to the registry.
@ -844,6 +851,552 @@ func PopulateHopHints(cfg *SelectHopHintsCfg, amtMSat lnwire.MilliSatoshi,
return hopHints, nil
}
// buildBlindedPathCfg defines the various resources and configuration values
// required to build a blinded payment path to this node.
type buildBlindedPathCfg struct {
// findRoutes returns a set of routes to us that can be used for the
// construction of blinded paths. These routes will consist of real
// nodes advertising the route blinding feature bit. They may be of
// various lengths and may even contain only a single hop. Any route
// shorter than minNumHops will be padded with dummy hops during route
// construction.
findRoutes func(value lnwire.MilliSatoshi) ([]*route.Route, error)
// fetchChannelEdgesByID attempts to look up the two directed edges for
// the channel identified by the channel ID.
fetchChannelEdgesByID func(chanID uint64) (*models.ChannelEdgeInfo,
*models.ChannelEdgePolicy, *models.ChannelEdgePolicy, error)
// bestHeight can be used to fetch the best block height that this node
// is aware of.
bestHeight func() (uint32, error)
// addPolicyBuffer is a function that can be used to alter the policy
// values of the given channel edge. The main reason for doing this is
// to add a safety buffer so that if the node makes small policy changes
// during the lifetime of the blinded path, then the path remains valid
// and so probing is more difficult. Note that this will only be called
// for the policies of real nodes and won't be applied to
// dummyHopPolicy.
addPolicyBuffer func(policy *blindedHopPolicy) (*blindedHopPolicy,
error)
// pathID is the secret data to embed in the blinded path data that we
// will receive back as the recipient. This is the equivalent of the
// payment address used in normal payments. It lets the recipient check
// that the path is being used in the correct context.
pathID []byte
// valueMsat is the payment amount in milli-satoshis that must be
// routed. This will be used for selecting appropriate routes to use for
// the blinded path.
valueMsat lnwire.MilliSatoshi
// minFinalCLTVExpiryDelta is the minimum CLTV delta that the recipient
// requires for the final hop of the payment.
//
// NOTE that the caller is responsible for adding additional block
// padding to this value to account for blocks being mined while the
// payment is in-flight.
minFinalCLTVExpiryDelta uint32
// blocksUntilExpiry is the number of blocks that this blinded path
// should remain valid for.
blocksUntilExpiry uint32
// minNumHops is the minimum number of hops that each blinded path
// should be. If the number of hops in a path returned by findRoutes is
// less than this number, then dummy hops will be post-fixed to the
// route.
minNumHops uint8
// dummyHopPolicy holds the policy values that should be used for dummy
// hops. Note that these will _not_ be buffered via addPolicyBuffer.
dummyHopPolicy *blindedHopPolicy
}
// buildBlindedPaymentPaths uses the passed config to construct a set of blinded
// payment paths that can be added to the invoice.
func buildBlindedPaymentPaths(cfg *buildBlindedPathCfg) (
[]*zpay32.BlindedPaymentPath, error) {
if cfg.minFinalCLTVExpiryDelta >= cfg.blocksUntilExpiry {
return nil, fmt.Errorf("blinded path CLTV expiry delta (%d) "+
"must be greater than the minimum final CLTV expiry "+
"delta (%d)", cfg.blocksUntilExpiry,
cfg.minFinalCLTVExpiryDelta)
}
// Find some appropriate routes for the value to be routed. This will
// return a set of routes made up of real nodes.
routes, err := cfg.findRoutes(cfg.valueMsat)
if err != nil {
return nil, err
}
if len(routes) == 0 {
return nil, fmt.Errorf("could not find any routes to self to " +
"use for blinded route construction")
}
// Not every route returned will necessarily result in a usable blinded
// path and so the number of paths returned might be less than the
// number of real routes returned by findRoutes above.
paths := make([]*zpay32.BlindedPaymentPath, 0, len(routes))
// For each route returned, we will construct the associated blinded
// payment path.
for _, route := range routes {
path, err := buildBlindedPaymentPath(
cfg, extractCandidatePath(route),
)
if errors.Is(err, errInvalidBlindedPath) {
log.Debugf("Not using route (%s) as a blinded path "+
"since it resulted in an invalid blinded path",
route)
continue
}
if err != nil {
return nil, err
}
paths = append(paths, path)
}
if len(paths) == 0 {
return nil, fmt.Errorf("could not build any blinded paths")
}
return paths, nil
}
// buildBlindedPaymentPath takes a route from an introduction node to this node
// and uses the given config to convert it into a blinded payment path.
func buildBlindedPaymentPath(cfg *buildBlindedPathCfg, path *candidatePath) (
*zpay32.BlindedPaymentPath, error) {
// Pad the given route with dummy hops until the minimum number of hops
// is met.
err := path.padWithDummyHops(cfg.minNumHops)
if err != nil {
return nil, err
}
hops, minHTLC, maxHTLC, err := collectRelayInfo(cfg, path)
if err != nil {
return nil, fmt.Errorf("could not collect blinded path relay "+
"info: %w", err)
}
relayInfo := make([]*record.PaymentRelayInfo, len(hops))
for i, hop := range hops {
relayInfo[i] = hop.relayInfo
}
// Using the collected relay info, we can calculate the aggregated
// policy values for the route.
baseFee, feeRate, cltvDelta := calcBlindedPathPolicies(
relayInfo, uint16(cfg.minFinalCLTVExpiryDelta),
)
currentHeight, err := cfg.bestHeight()
if err != nil {
return nil, err
}
// The next step is to calculate the payment constraints to communicate
// to each hop and to package up the hop info for each hop. We will
// handle the final hop first since its payload looks a bit different,
// and then we will iterate backwards through the remaining hops.
//
// Note that the +1 here is required because the route won't have the
// introduction node included in the "Hops". But since we want to create
// payloads for all the hops as well as the introduction node, we add 1
// here to get the full hop length along with the introduction node.
hopDataSet := make([]*hopData, 0, len(path.hops)+1)
// Determine the maximum CLTV expiry for the destination node.
cltvExpiry := currentHeight + cfg.blocksUntilExpiry +
cfg.minFinalCLTVExpiryDelta
constraints := &record.PaymentConstraints{
MaxCltvExpiry: cltvExpiry,
HtlcMinimumMsat: minHTLC,
}
// If the blinded route has only a source node (introduction node) and
// no hops, then the destination node is also the source node.
finalHopPubKey := path.introNode
if len(path.hops) > 0 {
finalHopPubKey = path.hops[len(path.hops)-1].pubKey
}
// For the final hop, we only send it the path ID and payment
// constraints.
info, err := buildFinalHopRouteData(
finalHopPubKey, cfg.pathID, constraints,
)
if err != nil {
return nil, err
}
hopDataSet = append(hopDataSet, info)
// Iterate through the remaining (non-final) hops, back to front.
for i := len(hops) - 1; i >= 0; i-- {
hop := hops[i]
cltvExpiry += uint32(hop.relayInfo.CltvExpiryDelta)
constraints = &record.PaymentConstraints{
MaxCltvExpiry: cltvExpiry,
HtlcMinimumMsat: minHTLC,
}
var info *hopData
if hop.nextHopIsDummy {
info, err = buildDummyRouteData(
hop.hopPubKey, hop.relayInfo, constraints,
)
} else {
info, err = buildHopRouteData(
hop.hopPubKey, hop.nextSCID, hop.relayInfo,
constraints,
)
}
if err != nil {
return nil, err
}
hopDataSet = append(hopDataSet, info)
}
// Sort the hop info list in reverse order so that the data for the
// introduction node is first.
slices.Reverse(hopDataSet)
// Add padding to each route data instance until the encrypted data
// blobs are all the same size.
paymentPath, _, err := padHopInfo(hopDataSet, true)
if err != nil {
return nil, err
}
// Derive an ephemeral session key.
sessionKey, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
}
// Encrypt the hop info.
blindedPath, err := sphinx.BuildBlindedPath(sessionKey, paymentPath)
if err != nil {
return nil, err
}
if len(blindedPath.BlindedHops) < 1 {
return nil, fmt.Errorf("blinded path must have at least one " +
"hop")
}
// Overwrite the introduction point's blinded pub key with the real
// pub key since then we can use this more compact format in the
// invoice without needing to encode the un-used blinded node pub key of
// the intro node.
blindedPath.BlindedHops[0].BlindedNodePub =
blindedPath.IntroductionPoint
// Now construct a z32 blinded path.
return &zpay32.BlindedPaymentPath{
FeeBaseMsat: uint32(baseFee),
FeeRate: feeRate,
CltvExpiryDelta: cltvDelta,
HTLCMinMsat: uint64(minHTLC),
HTLCMaxMsat: uint64(maxHTLC),
Features: lnwire.EmptyFeatureVector(),
FirstEphemeralBlindingPoint: blindedPath.BlindingPoint,
Hops: blindedPath.BlindedHops,
}, nil
}
// hopRelayInfo packages together the relay info to send to hop on a blinded
// path along with the pub key of that hop and the SCID that the hop should
// forward the payment on to.
type hopRelayInfo struct {
hopPubKey route.Vertex
nextSCID lnwire.ShortChannelID
relayInfo *record.PaymentRelayInfo
nextHopIsDummy bool
}
// collectRelayInfo collects the relay policy rules for each relay hop on the
// route and applies any policy buffers.
//
// For the blinded route:
//
// C --chan(CB)--> B --chan(BA)--> A
//
// where C is the introduction node, the route.Route struct we are given will
// have SourcePubKey set to C's pub key, and then it will have the following
// route.Hops:
//
// - PubKeyBytes: B, ChannelID: chan(CB)
// - PubKeyBytes: A, ChannelID: chan(BA)
//
// We, however, want to collect the channel policies for the following PubKey
// and ChannelID pairs:
//
// - PubKey: C, ChannelID: chan(CB)
// - PubKey: B, ChannelID: chan(BA)
//
// Therefore, when we go through the route and its hops to collect policies, our
// index for collecting public keys will be trailing that of the channel IDs by
// 1.
func collectRelayInfo(cfg *buildBlindedPathCfg, path *candidatePath) (
[]*hopRelayInfo, lnwire.MilliSatoshi, lnwire.MilliSatoshi, error) {
var (
hops = make([]*hopRelayInfo, 0, len(path.hops))
minHTLC lnwire.MilliSatoshi
maxHTLC lnwire.MilliSatoshi
)
var (
// The first pub key is that of the introduction node.
hopSource = path.introNode
)
for _, hop := range path.hops {
var (
// For dummy hops, we use pre-configured policy values.
policy = cfg.dummyHopPolicy
err error
)
if !hop.isDummy {
// For real hops, retrieve the channel policy for this
// hop's channel ID in the direction pointing away from
// the hopSource node.
policy, err = getNodeChannelPolicy(
cfg, hop.channelID, hopSource,
)
if err != nil {
return nil, 0, 0, err
}
// Apply any policy changes now before caching the
// policy.
policy, err = cfg.addPolicyBuffer(policy)
if err != nil {
return nil, 0, 0, err
}
}
// If this is the first policy we are collecting, then use this
// policy to set the base values for min/max htlc.
if len(hops) == 0 {
minHTLC = policy.minHTLCMsat
maxHTLC = policy.maxHTLCMsat
} else {
if policy.minHTLCMsat > minHTLC {
minHTLC = policy.minHTLCMsat
}
if policy.maxHTLCMsat < maxHTLC {
maxHTLC = policy.maxHTLCMsat
}
}
// From the policy values for this hop, we can collect the
// payment relay info that we will send to this hop.
hops = append(hops, &hopRelayInfo{
hopPubKey: hopSource,
nextSCID: lnwire.NewShortChanIDFromInt(hop.channelID),
relayInfo: &record.PaymentRelayInfo{
FeeRate: policy.feeRate,
BaseFee: policy.baseFee,
CltvExpiryDelta: policy.cltvExpiryDelta,
},
nextHopIsDummy: hop.isDummy,
})
// This hop's pub key will be the policy creator for the next
// hop.
hopSource = hop.pubKey
}
// It can happen that there is no HTLC-range overlap between the various
// hops along the path. We return errInvalidBlindedPath to indicate that
// this route was not usable
if minHTLC > maxHTLC {
return nil, 0, 0, fmt.Errorf("%w: resulting blinded path min "+
"HTLC value is larger than the resulting max HTLC "+
"value", errInvalidBlindedPath)
}
return hops, minHTLC, maxHTLC, nil
}
// buildDummyRouteData constructs the record.BlindedRouteData struct for the
// given a hop in a blinded route where the following hop is a dummy hop.
func buildDummyRouteData(node route.Vertex, relayInfo *record.PaymentRelayInfo,
constraints *record.PaymentConstraints) (*hopData, error) {
nodeID, err := btcec.ParsePubKey(node[:])
if err != nil {
return nil, err
}
return &hopData{
data: record.NewDummyHopRouteData(
nodeID, *relayInfo, *constraints,
),
nodeID: nodeID,
}, nil
}
// buildHopRouteData constructs the record.BlindedRouteData struct for the given
// non-final hop on a blinded path and packages it with the node's ID.
func buildHopRouteData(node route.Vertex, scid lnwire.ShortChannelID,
relayInfo *record.PaymentRelayInfo,
constraints *record.PaymentConstraints) (*hopData, error) {
// Wrap up the data we want to send to this hop.
blindedRouteHopData := record.NewNonFinalBlindedRouteData(
scid, nil, *relayInfo, constraints, nil,
)
nodeID, err := btcec.ParsePubKey(node[:])
if err != nil {
return nil, err
}
return &hopData{
data: blindedRouteHopData,
nodeID: nodeID,
}, nil
}
// buildFinalHopRouteData constructs the record.BlindedRouteData struct for the
// final hop and packages it with the real node ID of the node it is intended
// for.
func buildFinalHopRouteData(node route.Vertex, pathID []byte,
constraints *record.PaymentConstraints) (*hopData, error) {
blindedRouteHopData := record.NewFinalHopBlindedRouteData(
constraints, pathID,
)
nodeID, err := btcec.ParsePubKey(node[:])
if err != nil {
return nil, err
}
return &hopData{
data: blindedRouteHopData,
nodeID: nodeID,
}, nil
}
// getNodeChanPolicy fetches the routing policy info for the given channel and
// node pair.
func getNodeChannelPolicy(cfg *buildBlindedPathCfg, chanID uint64,
nodeID route.Vertex) (*blindedHopPolicy, error) {
// Attempt to fetch channel updates for the given channel. We will have
// at most two updates for a given channel.
_, update1, update2, err := cfg.fetchChannelEdgesByID(chanID)
if err != nil {
return nil, err
}
// Now we need to determine which of the updates was created by the
// node in question. We know the update is the correct one if the
// "ToNode" for the fetched policy is _not_ equal to the node ID in
// question.
var policy *models.ChannelEdgePolicy
switch {
case update1 != nil && !bytes.Equal(update1.ToNode[:], nodeID[:]):
policy = update1
case update2 != nil && !bytes.Equal(update2.ToNode[:], nodeID[:]):
policy = update2
default:
return nil, fmt.Errorf("no channel updates found from node "+
"%s for channel %d", nodeID, chanID)
}
return &blindedHopPolicy{
cltvExpiryDelta: policy.TimeLockDelta,
feeRate: uint32(policy.FeeProportionalMillionths),
baseFee: policy.FeeBaseMSat,
minHTLCMsat: policy.MinHTLC,
maxHTLCMsat: policy.MaxHTLC,
}, nil
}
// candidatePath holds all the information about a route to this node that we
// need in order to build a blinded route.
type candidatePath struct {
introNode route.Vertex
finalNodeID route.Vertex
hops []*blindedPathHop
}
// padWithDummyHops will append n dummy hops to the candidatePath hop set. The
// pub key for the dummy hop will be the same as the pub key for the final hop
// of the path. That way, the final hop will be able to decrypt the data
// encrypted for each dummy hop.
func (c *candidatePath) padWithDummyHops(n uint8) error {
for len(c.hops) < int(n) {
c.hops = append(c.hops, &blindedPathHop{
pubKey: c.finalNodeID,
isDummy: true,
})
}
return nil
}
// blindedPathHop holds the information we need to know about a hop in a route
// in order to use it in the construction of a blinded path.
type blindedPathHop struct {
// pubKey is the real pub key of a node on a blinded path.
pubKey route.Vertex
// channelID is the channel along which the previous hop should forward
// their HTLC in order to reach this hop.
channelID uint64
// isDummy is true if this hop is an appended dummy hop.
isDummy bool
}
// extractCandidatePath extracts the data it needs from the given route.Route in
// order to construct a candidatePath.
func extractCandidatePath(path *route.Route) *candidatePath {
var (
hops = make([]*blindedPathHop, len(path.Hops))
finalNode = path.SourcePubKey
)
for i, hop := range path.Hops {
hops[i] = &blindedPathHop{
pubKey: hop.PubKeyBytes,
channelID: hop.ChannelID,
}
if i == len(path.Hops)-1 {
finalNode = hop.PubKeyBytes
}
}
return &candidatePath{
introNode: path.SourcePubKey,
finalNodeID: finalNode,
hops: hops,
}
}
// blindedHopPolicy holds the set of relay policy values to use for a channel
// in a blinded path.
type blindedHopPolicy struct {

View file

@ -1,6 +1,7 @@
package invoicesrpc
import (
"bytes"
"encoding/hex"
"fmt"
"math/rand"
@ -10,10 +11,13 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/record"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/tlv"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/mock"
@ -1402,3 +1406,451 @@ func genBlindedRouteData(rand *rand.Rand) *record.BlindedRouteData {
return &data
}
// TestBuildBlindedPath tests the logic for constructing a blinded path against
// an example mentioned in this spec document:
// https://github.com/lightning/bolts/blob/master/proposals/route-blinding.md
// This example does not use any dummy hops.
func TestBuildBlindedPath(t *testing.T) {
// Alice chooses the following path to herself for blinded path
// construction:
// Carol -> Bob -> Alice.
// Let's construct the corresponding route.Route for this which will be
// returned from the `findRoutes` config callback.
var (
privC, pkC = btcec.PrivKeyFromBytes([]byte{1})
privB, pkB = btcec.PrivKeyFromBytes([]byte{2})
privA, pkA = btcec.PrivKeyFromBytes([]byte{3})
carol = route.NewVertex(pkC)
bob = route.NewVertex(pkB)
alice = route.NewVertex(pkA)
chanCB = uint64(1)
chanBA = uint64(2)
)
realRoute := &route.Route{
SourcePubKey: carol,
Hops: []*route.Hop{
{
PubKeyBytes: bob,
ChannelID: chanCB,
},
{
PubKeyBytes: alice,
ChannelID: chanBA,
},
},
}
realPolicies := map[uint64]*models.ChannelEdgePolicy{
chanCB: {
ChannelID: chanCB,
ToNode: bob,
},
chanBA: {
ChannelID: chanBA,
ToNode: alice,
},
}
paths, err := buildBlindedPaymentPaths(&buildBlindedPathCfg{
findRoutes: func(_ lnwire.MilliSatoshi) ([]*route.Route,
error) {
return []*route.Route{realRoute}, nil
},
fetchChannelEdgesByID: func(chanID uint64) (
*models.ChannelEdgeInfo, *models.ChannelEdgePolicy,
*models.ChannelEdgePolicy, error) {
return nil, realPolicies[chanID], nil, nil
},
bestHeight: func() (uint32, error) {
return 1000, nil
},
// In the spec example, all the policies get replaced with
// the same static values.
addPolicyBuffer: func(_ *blindedHopPolicy) (
*blindedHopPolicy, error) {
return &blindedHopPolicy{
feeRate: 500,
baseFee: 100,
cltvExpiryDelta: 144,
minHTLCMsat: 1000,
maxHTLCMsat: lnwire.MaxMilliSatoshi,
}, nil
},
pathID: []byte{1, 2, 3},
valueMsat: 1000,
minFinalCLTVExpiryDelta: 12,
blocksUntilExpiry: 200,
})
require.NoError(t, err)
require.Len(t, paths, 1)
path := paths[0]
// Check that all the accumulated policy values are correct.
require.EqualValues(t, 201, path.FeeBaseMsat)
require.EqualValues(t, 1001, path.FeeRate)
require.EqualValues(t, 300, path.CltvExpiryDelta)
require.EqualValues(t, 1000, path.HTLCMinMsat)
require.EqualValues(t, lnwire.MaxMilliSatoshi, path.HTLCMaxMsat)
// Now we check the hops.
require.Len(t, path.Hops, 3)
// Assert that all the encrypted recipient blobs have been padded such
// that they are all the same size.
require.Len(t, path.Hops[0].CipherText, len(path.Hops[1].CipherText))
require.Len(t, path.Hops[1].CipherText, len(path.Hops[2].CipherText))
// The first hop, should have the real pub key of the introduction
// node: Carol.
hop := path.Hops[0]
require.True(t, hop.BlindedNodePub.IsEqual(pkC))
// As Carol, let's decode the hop data and assert that all expected
// values have been included.
var (
blindingPoint = path.FirstEphemeralBlindingPoint
data *record.BlindedRouteData
)
// Check that Carol's info is correct.
data, blindingPoint = decryptAndDecodeHopData(
t, privC, blindingPoint, hop.CipherText,
)
require.Equal(
t, lnwire.NewShortChanIDFromInt(chanCB),
data.ShortChannelID.UnwrapOrFail(t).Val,
)
require.Equal(t, record.PaymentRelayInfo{
CltvExpiryDelta: 144,
FeeRate: 500,
BaseFee: 100,
}, data.RelayInfo.UnwrapOrFail(t).Val)
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1500,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
// Check that all Bob's info is correct.
hop = path.Hops[1]
data, blindingPoint = decryptAndDecodeHopData(
t, privB, blindingPoint, hop.CipherText,
)
require.Equal(
t, lnwire.NewShortChanIDFromInt(chanBA),
data.ShortChannelID.UnwrapOrFail(t).Val,
)
require.Equal(t, record.PaymentRelayInfo{
CltvExpiryDelta: 144,
FeeRate: 500,
BaseFee: 100,
}, data.RelayInfo.UnwrapOrFail(t).Val)
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1356,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
// Check that all Alice's info is correct.
hop = path.Hops[2]
data, _ = decryptAndDecodeHopData(
t, privA, blindingPoint, hop.CipherText,
)
require.True(t, data.ShortChannelID.IsNone())
require.True(t, data.RelayInfo.IsNone())
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1212,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
require.Equal(t, []byte{1, 2, 3}, data.PathID.UnwrapOrFail(t).Val)
}
// TestBuildBlindedPathWithDummyHops tests the construction of a blinded path
// which includes dummy hops.
func TestBuildBlindedPathWithDummyHops(t *testing.T) {
// Alice chooses the following path to herself for blinded path
// construction:
// Carol -> Bob -> Alice.
// Let's construct the corresponding route.Route for this which will be
// returned from the `findRoutes` config callback.
var (
privC, pkC = btcec.PrivKeyFromBytes([]byte{1})
privB, pkB = btcec.PrivKeyFromBytes([]byte{2})
privA, pkA = btcec.PrivKeyFromBytes([]byte{3})
carol = route.NewVertex(pkC)
bob = route.NewVertex(pkB)
alice = route.NewVertex(pkA)
chanCB = uint64(1)
chanBA = uint64(2)
)
realRoute := &route.Route{
SourcePubKey: carol,
Hops: []*route.Hop{
{
PubKeyBytes: bob,
ChannelID: chanCB,
},
{
PubKeyBytes: alice,
ChannelID: chanBA,
},
},
}
realPolicies := map[uint64]*models.ChannelEdgePolicy{
chanCB: {
ChannelID: chanCB,
ToNode: bob,
},
chanBA: {
ChannelID: chanBA,
ToNode: alice,
},
}
paths, err := buildBlindedPaymentPaths(&buildBlindedPathCfg{
findRoutes: func(_ lnwire.MilliSatoshi) ([]*route.Route,
error) {
return []*route.Route{realRoute}, nil
},
fetchChannelEdgesByID: func(chanID uint64) (
*models.ChannelEdgeInfo, *models.ChannelEdgePolicy,
*models.ChannelEdgePolicy, error) {
policy, ok := realPolicies[chanID]
if !ok {
return nil, nil, nil,
fmt.Errorf("edge not found")
}
return nil, policy, nil, nil
},
bestHeight: func() (uint32, error) {
return 1000, nil
},
// In the spec example, all the policies get replaced with
// the same static values.
addPolicyBuffer: func(_ *blindedHopPolicy) (
*blindedHopPolicy, error) {
return &blindedHopPolicy{
feeRate: 500,
baseFee: 100,
cltvExpiryDelta: 144,
minHTLCMsat: 1000,
maxHTLCMsat: lnwire.MaxMilliSatoshi,
}, nil
},
pathID: []byte{1, 2, 3},
valueMsat: 1000,
minFinalCLTVExpiryDelta: 12,
blocksUntilExpiry: 200,
// By setting the minimum number of hops to 4, we force 2 dummy
// hops to be added to the real route.
minNumHops: 4,
dummyHopPolicy: &blindedHopPolicy{
cltvExpiryDelta: 50,
feeRate: 100,
baseFee: 100,
minHTLCMsat: 1000,
maxHTLCMsat: lnwire.MaxMilliSatoshi,
},
})
require.NoError(t, err)
require.Len(t, paths, 1)
path := paths[0]
// Check that all the accumulated policy values are correct.
require.EqualValues(t, 403, path.FeeBaseMsat)
require.EqualValues(t, 1203, path.FeeRate)
require.EqualValues(t, 400, path.CltvExpiryDelta)
require.EqualValues(t, 1000, path.HTLCMinMsat)
require.EqualValues(t, lnwire.MaxMilliSatoshi, path.HTLCMaxMsat)
// Now we check the hops.
require.Len(t, path.Hops, 5)
// Assert that all the encrypted recipient blobs have been padded such
// that they are all the same size.
require.Len(t, path.Hops[0].CipherText, len(path.Hops[1].CipherText))
require.Len(t, path.Hops[1].CipherText, len(path.Hops[2].CipherText))
require.Len(t, path.Hops[2].CipherText, len(path.Hops[3].CipherText))
require.Len(t, path.Hops[3].CipherText, len(path.Hops[4].CipherText))
// The first hop, should have the real pub key of the introduction
// node: Carol.
hop := path.Hops[0]
require.True(t, hop.BlindedNodePub.IsEqual(pkC))
// As Carol, let's decode the hop data and assert that all expected
// values have been included.
var (
blindingPoint = path.FirstEphemeralBlindingPoint
data *record.BlindedRouteData
)
// Check that Carol's info is correct.
data, blindingPoint = decryptAndDecodeHopData(
t, privC, blindingPoint, hop.CipherText,
)
require.Equal(
t, lnwire.NewShortChanIDFromInt(chanCB),
data.ShortChannelID.UnwrapOrFail(t).Val,
)
require.Equal(t, record.PaymentRelayInfo{
CltvExpiryDelta: 144,
FeeRate: 500,
BaseFee: 100,
}, data.RelayInfo.UnwrapOrFail(t).Val)
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1600,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
// Check that all Bob's info is correct.
hop = path.Hops[1]
data, blindingPoint = decryptAndDecodeHopData(
t, privB, blindingPoint, hop.CipherText,
)
require.Equal(
t, lnwire.NewShortChanIDFromInt(chanBA),
data.ShortChannelID.UnwrapOrFail(t).Val,
)
require.Equal(t, record.PaymentRelayInfo{
CltvExpiryDelta: 144,
FeeRate: 500,
BaseFee: 100,
}, data.RelayInfo.UnwrapOrFail(t).Val)
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1456,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
// Check that all Alice's info is correct. The payload should contain
// a next_node_id field that is equal to Alice's public key. This
// indicates to Alice that she should continue peeling the onion.
hop = path.Hops[2]
data, blindingPoint = decryptAndDecodeHopData(
t, privA, blindingPoint, hop.CipherText,
)
require.True(t, data.ShortChannelID.IsNone())
require.True(t, data.RelayInfo.IsSome())
require.True(t, data.Constraints.IsSome())
require.Equal(t, pkA, data.NextNodeID.UnwrapOrFail(t).Val)
// Alice should be able to decrypt the next payload with her private
// key. This next payload is yet another dummy hop.
hop = path.Hops[3]
data, blindingPoint = decryptAndDecodeHopData(
t, privA, blindingPoint, hop.CipherText,
)
require.True(t, data.ShortChannelID.IsNone())
require.True(t, data.RelayInfo.IsSome())
require.True(t, data.Constraints.IsSome())
require.Equal(t, pkA, data.NextNodeID.UnwrapOrFail(t).Val)
// Unwrapping one more time should reveal the final hop info for Alice.
hop = path.Hops[4]
data, _ = decryptAndDecodeHopData(
t, privA, blindingPoint, hop.CipherText,
)
require.True(t, data.ShortChannelID.IsNone())
require.True(t, data.RelayInfo.IsNone())
require.Equal(t, record.PaymentConstraints{
MaxCltvExpiry: 1212,
HtlcMinimumMsat: 1000,
}, data.Constraints.UnwrapOrFail(t).Val)
require.Equal(t, []byte{1, 2, 3}, data.PathID.UnwrapOrFail(t).Val)
}
// TestSingleHopBlindedPath tests that blinded path construction is done
// correctly for the case where the destination node is also the introduction
// node.
func TestSingleHopBlindedPath(t *testing.T) {
var (
_, pkC = btcec.PrivKeyFromBytes([]byte{1})
carol = route.NewVertex(pkC)
)
realRoute := &route.Route{
SourcePubKey: carol,
// No hops since Carol is both the introduction node and the
// final destination node.
Hops: []*route.Hop{},
}
paths, err := buildBlindedPaymentPaths(&buildBlindedPathCfg{
findRoutes: func(_ lnwire.MilliSatoshi) ([]*route.Route,
error) {
return []*route.Route{realRoute}, nil
},
bestHeight: func() (uint32, error) {
return 1000, nil
},
pathID: []byte{1, 2, 3},
valueMsat: 1000,
minFinalCLTVExpiryDelta: 12,
blocksUntilExpiry: 200,
})
require.NoError(t, err)
require.Len(t, paths, 1)
path := paths[0]
// Check that all the accumulated policy values are correct. Since this
// is a unique case where the destination node is also the introduction
// node, the accumulated fee and HTLC values should be zero and the
// CLTV expiry delta should be equal to Carol's minFinalCLTVExpiryDelta.
require.EqualValues(t, 0, path.FeeBaseMsat)
require.EqualValues(t, 0, path.FeeRate)
require.EqualValues(t, 0, path.HTLCMinMsat)
require.EqualValues(t, 0, path.HTLCMaxMsat)
require.EqualValues(t, 12, path.CltvExpiryDelta)
}
func decryptAndDecodeHopData(t *testing.T, priv *btcec.PrivateKey,
ephem *btcec.PublicKey, cipherText []byte) (*record.BlindedRouteData,
*btcec.PublicKey) {
router := sphinx.NewRouter(
&keychain.PrivKeyECDH{PrivKey: priv}, nil,
)
decrypted, err := router.DecryptBlindedHopData(ephem, cipherText)
require.NoError(t, err)
buf := bytes.NewBuffer(decrypted)
routeData, err := record.DecodeBlindedRouteData(buf)
require.NoError(t, err)
nextEphem, err := router.NextEphemeral(ephem)
require.NoError(t, err)
return routeData, nextEphem
}