diff --git a/lnrpc/invoicesrpc/addinvoice.go b/lnrpc/invoicesrpc/addinvoice.go index 4d3d451af..28276c007 100644 --- a/lnrpc/invoicesrpc/addinvoice.go +++ b/lnrpc/invoicesrpc/addinvoice.go @@ -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 { diff --git a/lnrpc/invoicesrpc/addinvoice_test.go b/lnrpc/invoicesrpc/addinvoice_test.go index bd83d53d2..dd2d8680e 100644 --- a/lnrpc/invoicesrpc/addinvoice_test.go +++ b/lnrpc/invoicesrpc/addinvoice_test.go @@ -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 +}