Merge pull request #8764 from ellemouton/rb-send-via-multi-path

[3/4] Route Blinding: send MPP over multiple blinded paths
This commit is contained in:
Olaoluwa Osuntokun 2024-07-31 19:21:49 -07:00 committed by GitHub
commit 04dde98edc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 647 additions and 210 deletions

View file

@ -136,6 +136,9 @@ commitment when the channel was force closed.
the `lncli addinvoice` command to instruct LND to include blinded paths in the
invoice.
* Add the ability to [send to use multiple blinded payment
paths](https://github.com/lightningnetwork/lnd/pull/8764) in an MP payment.
## Testing
## Database

View file

@ -594,6 +594,10 @@ var allTestCases = []*lntest.TestCase{
Name: "mpp to single blinded path",
TestFunc: testMPPToSingleBlindedPath,
},
{
Name: "mpp to multiple blinded paths",
TestFunc: testMPPToMultipleBlindedPaths,
},
{
Name: "route blinding dummy hops",
TestFunc: testBlindedRouteDummyHops,

View file

@ -1229,3 +1229,166 @@ func testBlindedRouteDummyHops(ht *lntest.HarnessTest) {
ht.AssertNumWaitingClose(hn, 0)
}
}
// testMPPToMultipleBlindedPaths tests that a two-shard MPP payment can be sent
// over a multiple blinded paths. The following network is created where Dave
// is the recipient and Alice the sender. Dave will create an invoice containing
// two blinded paths: one with Bob at the intro node and one with Carol as the
// intro node. Channel liquidity will be set up in such a way that Alice will be
// forced to send one shared via the Bob->Dave route and one over the
// Carol->Dave route.
//
// --- Bob ---
// / \
// Alice Dave
// \ /
// --- Carol ---
func testMPPToMultipleBlindedPaths(ht *lntest.HarnessTest) {
alice, bob := ht.Alice, ht.Bob
// Create a four-node context consisting of Alice, Bob and three new
// nodes.
dave := ht.NewNode("dave", []string{
"--routing.blinding.min-num-real-hops=1",
"--routing.blinding.num-hops=1",
})
carol := ht.NewNode("carol", nil)
// Connect nodes to ensure propagation of channels.
ht.EnsureConnected(alice, carol)
ht.EnsureConnected(alice, bob)
ht.EnsureConnected(carol, dave)
ht.EnsureConnected(bob, dave)
// Fund the new nodes.
ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, carol)
ht.FundCoinsUnconfirmed(btcutil.SatoshiPerBitcoin, dave)
ht.MineBlocksAndAssertNumTxes(1, 2)
const paymentAmt = btcutil.Amount(300000)
nodes := []*node.HarnessNode{alice, bob, carol, dave}
reqs := []*lntest.OpenChannelRequest{
{
Local: alice,
Remote: bob,
Param: lntest.OpenChannelParams{
Amt: paymentAmt * 2 / 3,
},
},
{
Local: alice,
Remote: carol,
Param: lntest.OpenChannelParams{
Amt: paymentAmt * 2 / 3,
},
},
{
Local: bob,
Remote: dave,
Param: lntest.OpenChannelParams{Amt: paymentAmt * 2},
},
{
Local: carol,
Remote: dave,
Param: lntest.OpenChannelParams{Amt: paymentAmt * 2},
},
}
channelPoints := ht.OpenMultiChannelsAsync(reqs)
// Make sure every node has heard every channel.
for _, hn := range nodes {
for _, cp := range channelPoints {
ht.AssertTopologyChannelOpen(hn, cp)
}
// Each node should have exactly 5 edges.
ht.AssertNumEdges(hn, len(channelPoints), false)
}
// Ok now make a payment that must be split to succeed.
// Make Dave create an invoice for Alice to pay
invoice := &lnrpc.Invoice{
Memo: "test",
Value: int64(paymentAmt),
Blind: true,
}
invoiceResp := dave.RPC.AddInvoice(invoice)
// Assert that two blinded paths are included in the invoice.
payReq := dave.RPC.DecodePayReq(invoiceResp.PaymentRequest)
require.Len(ht, payReq.BlindedPaths, 2)
sendReq := &routerrpc.SendPaymentRequest{
PaymentRequest: invoiceResp.PaymentRequest,
MaxParts: 10,
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
}
payment := ht.SendPaymentAssertSettled(alice, sendReq)
preimageBytes, err := hex.DecodeString(payment.PaymentPreimage)
require.NoError(ht, err)
preimage, err := lntypes.MakePreimage(preimageBytes)
require.NoError(ht, err)
hash, err := lntypes.MakeHash(invoiceResp.RHash)
require.NoError(ht, err)
// Make sure we got the preimage.
require.True(ht, preimage.Matches(hash), "preimage doesn't match")
// Check that Alice split the payment in at least two shards. Because
// the hand-off of the htlc to the link is asynchronous (via a mailbox),
// there is some non-determinism in the process. Depending on whether
// the new pathfinding round is started before or after the htlc is
// locked into the channel, different sharding may occur. Therefore we
// can only check if the number of shards isn't below the theoretical
// minimum.
succeeded := 0
for _, htlc := range payment.Htlcs {
if htlc.Status == lnrpc.HTLCAttempt_SUCCEEDED {
succeeded++
}
}
const minExpectedShards = 2
require.GreaterOrEqual(ht, succeeded, minExpectedShards,
"expected shards not reached")
// Make sure Dave show the invoice as settled for the full amount.
inv := dave.RPC.LookupInvoice(invoiceResp.RHash)
require.EqualValues(ht, paymentAmt, inv.AmtPaidSat,
"incorrect payment amt")
require.Equal(ht, lnrpc.Invoice_SETTLED, inv.State,
"Invoice not settled")
settled := 0
for _, htlc := range inv.Htlcs {
if htlc.State == lnrpc.InvoiceHTLCState_SETTLED {
settled++
}
}
require.Equal(ht, succeeded, settled, "num of HTLCs wrong")
// Close all channels without mining the closing transactions.
ht.CloseChannelAssertPending(alice, channelPoints[0], false)
ht.CloseChannelAssertPending(alice, channelPoints[1], false)
ht.CloseChannelAssertPending(bob, channelPoints[2], false)
ht.CloseChannelAssertPending(carol, channelPoints[3], false)
// Now mine a block to include all the closing transactions. (first
// iteration: no blinded paths)
ht.MineBlocksAndAssertNumTxes(1, 4)
// Assert that the channels are closed.
for _, hn := range nodes {
ht.AssertNumWaitingClose(hn, 0)
}
}

View file

@ -280,7 +280,7 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
var (
targetPubKey *route.Vertex
routeHintEdges map[route.Vertex][]routing.AdditionalEdge
blindedPmt *routing.BlindedPayment
blindedPathSet *routing.BlindedPaymentPathSet
// finalCLTVDelta varies depending on whether we're sending to
// a blinded route or an unblinded node. For blinded paths,
@ -297,13 +297,14 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
// Validate that the fields provided in the request are sane depending
// on whether it is using a blinded path or not.
if len(in.BlindedPaymentPaths) > 0 {
blindedPmt, err = parseBlindedPayment(in)
blindedPathSet, err = parseBlindedPaymentPaths(in)
if err != nil {
return nil, err
}
if blindedPmt.Features != nil {
destinationFeatures = blindedPmt.Features.Clone()
pathFeatures := blindedPathSet.Features()
if pathFeatures != nil {
destinationFeatures = pathFeatures.Clone()
}
} else {
// If we do not have a blinded path, a target pubkey must be
@ -387,10 +388,10 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
fromNode, toNode, amt, capacity,
)
},
DestCustomRecords: record.CustomSet(in.DestCustomRecords),
CltvLimit: cltvLimit,
DestFeatures: destinationFeatures,
BlindedPayment: blindedPmt,
DestCustomRecords: record.CustomSet(in.DestCustomRecords),
CltvLimit: cltvLimit,
DestFeatures: destinationFeatures,
BlindedPaymentPathSet: blindedPathSet,
}
// Pass along an outgoing channel restriction if specified.
@ -419,39 +420,24 @@ func (r *RouterBackend) parseQueryRoutesRequest(in *lnrpc.QueryRoutesRequest) (
return routing.NewRouteRequest(
sourcePubKey, targetPubKey, amt, in.TimePref, restrictions,
customRecords, routeHintEdges, blindedPmt, finalCLTVDelta,
customRecords, routeHintEdges, blindedPathSet,
finalCLTVDelta,
)
}
func parseBlindedPayment(in *lnrpc.QueryRoutesRequest) (
*routing.BlindedPayment, error) {
func parseBlindedPaymentPaths(in *lnrpc.QueryRoutesRequest) (
*routing.BlindedPaymentPathSet, error) {
if len(in.PubKey) != 0 {
return nil, fmt.Errorf("target pubkey: %x should not be set "+
"when blinded path is provided", in.PubKey)
}
if len(in.BlindedPaymentPaths) != 1 {
return nil, errors.New("query routes only supports a single " +
"blinded path")
}
blindedPath := in.BlindedPaymentPaths[0]
if len(in.RouteHints) > 0 {
return nil, errors.New("route hints and blinded path can't " +
"both be set")
}
blindedPmt, err := unmarshalBlindedPayment(blindedPath)
if err != nil {
return nil, fmt.Errorf("parse blinded payment: %w", err)
}
if err := blindedPmt.Validate(); err != nil {
return nil, fmt.Errorf("invalid blinded path: %w", err)
}
if in.FinalCltvDelta != 0 {
return nil, errors.New("final cltv delta should be " +
"zero for blinded paths")
@ -466,7 +452,21 @@ func parseBlindedPayment(in *lnrpc.QueryRoutesRequest) (
"be populated in blinded path")
}
return blindedPmt, nil
paths := make([]*routing.BlindedPayment, len(in.BlindedPaymentPaths))
for i, paymentPath := range in.BlindedPaymentPaths {
blindedPmt, err := unmarshalBlindedPayment(paymentPath)
if err != nil {
return nil, fmt.Errorf("parse blinded payment: %w", err)
}
if err := blindedPmt.Validate(); err != nil {
return nil, fmt.Errorf("invalid blinded path: %w", err)
}
paths[i] = blindedPmt
}
return routing.NewBlindedPaymentPathSet(paths)
}
func unmarshalBlindedPayment(rpcPayment *lnrpc.BlindedPaymentPath) (
@ -1001,28 +1001,24 @@ func (r *RouterBackend) extractIntentFromSendRequest(
payIntent.Metadata = payReq.Metadata
if len(payReq.BlindedPaymentPaths) > 0 {
// NOTE: Currently we only choose a single payment path.
// This will be updated in a future PR to handle
// multiple blinded payment paths.
path := payReq.BlindedPaymentPaths[0]
if len(path.Hops) == 0 {
return nil, fmt.Errorf("a blinded payment " +
"must have at least 1 hop")
pathSet, err := BuildBlindedPathSet(
payReq.BlindedPaymentPaths,
)
if err != nil {
return nil, err
}
payIntent.BlindedPathSet = pathSet
finalHop := path.Hops[len(path.Hops)-1]
payIntent.BlindedPayment = MarshalBlindedPayment(path)
// Replace the target node with the blinded public key
// of the blinded path's final node.
// Replace the target node with the target public key
// of the blinded path set.
copy(
payIntent.Target[:],
finalHop.BlindedNodePub.SerializeCompressed(),
pathSet.TargetPubKey().SerializeCompressed(),
)
if !path.Features.IsEmpty() {
payIntent.DestFeatures = path.Features.Clone()
pathFeatures := pathSet.Features()
if !pathFeatures.IsEmpty() {
payIntent.DestFeatures = pathFeatures.Clone()
}
}
} else {
@ -1163,9 +1159,29 @@ func (r *RouterBackend) extractIntentFromSendRequest(
return payIntent, nil
}
// MarshalBlindedPayment marshals a zpay32.BLindedPaymentPath into a
// BuildBlindedPathSet marshals a set of zpay32.BlindedPaymentPath and uses
// the result to build a new routing.BlindedPaymentPathSet.
func BuildBlindedPathSet(paths []*zpay32.BlindedPaymentPath) (
*routing.BlindedPaymentPathSet, error) {
marshalledPaths := make([]*routing.BlindedPayment, len(paths))
for i, path := range paths {
paymentPath := marshalBlindedPayment(path)
err := paymentPath.Validate()
if err != nil {
return nil, err
}
marshalledPaths[i] = paymentPath
}
return routing.NewBlindedPaymentPathSet(marshalledPaths)
}
// marshalBlindedPayment marshals a zpay32.BLindedPaymentPath into a
// routing.BlindedPayment.
func MarshalBlindedPayment(
func marshalBlindedPayment(
path *zpay32.BlindedPaymentPath) *routing.BlindedPayment {
return &routing.BlindedPayment{

View file

@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
@ -25,6 +27,218 @@ var (
ErrHTLCRestrictions = errors.New("invalid htlc minimum and maximum")
)
// BlindedPaymentPathSet groups the data we need to handle sending to a set of
// blinded paths provided by the recipient of a payment.
//
// NOTE: for now this only holds a single BlindedPayment. By the end of the PR
// series, it will handle multiple paths.
type BlindedPaymentPathSet struct {
// paths is the set of blinded payment paths for a single payment.
// NOTE: For now this will always only have a single entry. By the end
// of this PR, it can hold multiple.
paths []*BlindedPayment
// targetPubKey is the ephemeral node pub key that we will inject into
// each path as the last hop. This is only for the sake of path finding.
// Once the path has been found, the original destination pub key is
// used again. In the edge case where there is only a single hop in the
// path (the introduction node is the destination node), then this will
// just be the introduction node's real public key.
targetPubKey *btcec.PublicKey
// features is the set of relay features available for the payment.
// This is extracted from the set of blinded payment paths. At the
// moment we require that all paths for the same payment have the
// same feature set.
features *lnwire.FeatureVector
// finalCLTV is the final hop's expiry delta of _any_ path in the set.
// For any multi-hop path, the final CLTV delta should be seen as zero
// since the final hop's final CLTV delta is accounted for in the
// accumulated path policy values. The only edge case is for when the
// final hop in the path is also the introduction node in which case
// that path's FinalCLTV must be the non-zero min CLTV of the final hop
// so that it is accounted for in path finding. For this reason, if
// we have any single path in the set with only one hop, then we throw
// away all the other paths. This should be fine to do since if there is
// a path where the intro node is also the destination node, then there
// isn't any need to try any other longer blinded path. In other words,
// if this value is non-zero, then there is only one path in this
// blinded path set and that path only has a single hop: the
// introduction node.
finalCLTV uint16
}
// NewBlindedPaymentPathSet constructs a new BlindedPaymentPathSet from a set of
// BlindedPayments.
func NewBlindedPaymentPathSet(paths []*BlindedPayment) (*BlindedPaymentPathSet,
error) {
if len(paths) == 0 {
return nil, ErrNoBlindedPath
}
// For now, we assert that all the paths have the same set of features.
features := paths[0].Features
noFeatures := features == nil || features.IsEmpty()
for i := 1; i < len(paths); i++ {
noFeats := paths[i].Features == nil ||
paths[i].Features.IsEmpty()
if noFeatures && !noFeats {
return nil, fmt.Errorf("all blinded paths must have " +
"the same set of features")
}
if noFeatures {
continue
}
if !features.RawFeatureVector.Equals(
paths[i].Features.RawFeatureVector,
) {
return nil, fmt.Errorf("all blinded paths must have " +
"the same set of features")
}
}
// Derive an ephemeral target priv key that will be injected into each
// blinded path final hop.
targetPriv, err := btcec.NewPrivateKey()
if err != nil {
return nil, err
}
targetPub := targetPriv.PubKey()
var (
pathSet = paths
finalCLTVDelta uint16
)
// If any provided blinded path only has a single hop (ie, the
// destination node is also the introduction node), then we discard all
// other paths since we know the real pub key of the destination node.
// We also then set the final CLTV delta to the path's delta since
// there are no other edge hints that will account for it. For a single
// hop path, there is also no need for the pseudo target pub key
// replacement, so our target pub key in this case just remains the
// real introduction node ID.
for _, path := range paths {
if len(path.BlindedPath.BlindedHops) != 1 {
continue
}
pathSet = []*BlindedPayment{path}
finalCLTVDelta = path.CltvExpiryDelta
targetPub = path.BlindedPath.IntroductionPoint
break
}
return &BlindedPaymentPathSet{
paths: pathSet,
targetPubKey: targetPub,
features: features,
finalCLTV: finalCLTVDelta,
}, nil
}
// TargetPubKey returns the public key to be used as the destination node's
// public key during pathfinding.
func (s *BlindedPaymentPathSet) TargetPubKey() *btcec.PublicKey {
return s.targetPubKey
}
// Features returns the set of relay features available for the payment.
func (s *BlindedPaymentPathSet) Features() *lnwire.FeatureVector {
return s.features
}
// IntroNodeOnlyPath can be called if it is expected that the path set only
// contains a single payment path which itself only has one hop. It errors if
// this is not the case.
func (s *BlindedPaymentPathSet) IntroNodeOnlyPath() (*BlindedPayment, error) {
if len(s.paths) != 1 {
return nil, fmt.Errorf("expected only a single path in the "+
"blinded payment set, got %d", len(s.paths))
}
if len(s.paths[0].BlindedPath.BlindedHops) > 1 {
return nil, fmt.Errorf("an intro node only path cannot have " +
"more than one hop")
}
return s.paths[0], nil
}
// IsIntroNode returns true if the given vertex is an introduction node for one
// of the paths in the blinded payment path set.
func (s *BlindedPaymentPathSet) IsIntroNode(source route.Vertex) bool {
for _, path := range s.paths {
introVertex := route.NewVertex(
path.BlindedPath.IntroductionPoint,
)
if source == introVertex {
return true
}
}
return false
}
// FinalCLTVDelta is the minimum CLTV delta to use for the final hop on the
// route. In most cases this will return zero since the value is accounted for
// in the path's accumulated CLTVExpiryDelta. Only in the edge case of the path
// set only including a single path which only includes an introduction node
// will this return a non-zero value.
func (s *BlindedPaymentPathSet) FinalCLTVDelta() uint16 {
return s.finalCLTV
}
// LargestLastHopPayloadPath returns the BlindedPayment in the set that has the
// largest last-hop payload. This is to be used for onion size estimation in
// path finding.
func (s *BlindedPaymentPathSet) LargestLastHopPayloadPath() *BlindedPayment {
var (
largestPath *BlindedPayment
currentMax int
)
for _, path := range s.paths {
numHops := len(path.BlindedPath.BlindedHops)
lastHop := path.BlindedPath.BlindedHops[numHops-1]
if len(lastHop.CipherText) > currentMax {
largestPath = path
}
}
return largestPath
}
// ToRouteHints converts the blinded path payment set into a RouteHints map so
// that the blinded payment paths can be treated like route hints throughout the
// code base.
func (s *BlindedPaymentPathSet) ToRouteHints() (RouteHints, error) {
hints := make(RouteHints)
for _, path := range s.paths {
pathHints, err := path.toRouteHints(fn.Some(s.targetPubKey))
if err != nil {
return nil, err
}
for from, edges := range pathHints {
hints[from] = append(hints[from], edges...)
}
}
if len(hints) == 0 {
return nil, nil
}
return hints, nil
}
// BlindedPayment provides the path and payment parameters required to send a
// payment along a blinded path.
type BlindedPayment struct {
@ -87,8 +301,11 @@ func (b *BlindedPayment) Validate() error {
// 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, error) {
// node). The pseudoTarget, if provided, will be used to override the pub key
// of the destination node in the path.
func (b *BlindedPayment) toRouteHints(
pseudoTarget fn.Option[*btcec.PublicKey]) (RouteHints, error) {
// 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
@ -136,12 +353,12 @@ func (b *BlindedPayment) toRouteHints() (RouteHints, error) {
ToNodeFeatures: features,
}
edge, err := NewBlindedEdge(edgePolicy, b, 0)
lastEdge, err := NewBlindedEdge(edgePolicy, b, 0)
if err != nil {
return nil, err
}
hints[fromNode] = []AdditionalEdge{edge}
hints[fromNode] = []AdditionalEdge{lastEdge}
// 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
@ -168,13 +385,24 @@ func (b *BlindedPayment) toRouteHints() (RouteHints, error) {
ToNodeFeatures: features,
}
edge, err := NewBlindedEdge(edgePolicy, b, i)
lastEdge, err = NewBlindedEdge(edgePolicy, b, i)
if err != nil {
return nil, err
}
hints[fromNode] = []AdditionalEdge{edge}
hints[fromNode] = []AdditionalEdge{lastEdge}
}
pseudoTarget.WhenSome(func(key *btcec.PublicKey) {
// For the very last hop on the path, switch out the ToNodePub
// for the pseudo target pub key.
lastEdge.policy.ToNodePubKey = func() route.Vertex {
return route.NewVertex(key)
}
// Then override the final hint with this updated edge.
hints[fromNode] = []AdditionalEdge{lastEdge}
})
return hints, nil
}

View file

@ -7,6 +7,7 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
@ -128,7 +129,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
HtlcMaximum: htlcMax,
Features: features,
}
hints, err := blindedPayment.toRouteHints()
hints, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
require.NoError(t, err)
require.Nil(t, hints)
@ -183,7 +184,7 @@ func TestBlindedPaymentToHints(t *testing.T) {
},
}
actual, err := blindedPayment.toRouteHints()
actual, err := blindedPayment.toRouteHints(fn.None[*btcec.PublicKey]())
require.NoError(t, err)
require.Equal(t, len(expected), len(actual))

View file

@ -136,9 +136,8 @@ type finalHopParams struct {
// NOTE: If a non-nil blinded path is provided it is assumed to have been
// validated by the caller.
func newRoute(sourceVertex route.Vertex,
pathEdges []*unifiedEdge, currentHeight uint32,
finalHop finalHopParams, blindedPath *sphinx.BlindedPath) (
*route.Route, error) {
pathEdges []*unifiedEdge, currentHeight uint32, finalHop finalHopParams,
blindedPathSet *BlindedPaymentPathSet) (*route.Route, error) {
var (
hops []*route.Hop
@ -153,6 +152,8 @@ func newRoute(sourceVertex route.Vertex,
// backwards below, this next hop gets closer and closer to the
// sender of the payment.
nextIncomingAmount lnwire.MilliSatoshi
blindedPayment *BlindedPayment
)
pathLength := len(pathEdges)
@ -161,6 +162,15 @@ func newRoute(sourceVertex route.Vertex,
// payload for the hop this edge is leading to.
edge := pathEdges[i].policy
// If this is an edge from a blinded path and the
// blindedPayment variable has not been set yet, then set it now
// by extracting the corresponding blinded payment from the
// edge.
isBlindedEdge := pathEdges[i].blindedPayment != nil
if isBlindedEdge && blindedPayment == nil {
blindedPayment = pathEdges[i].blindedPayment
}
// We'll calculate the amounts, timelocks, and fees for each hop
// in the route. The base case is the final hop which includes
// their amount and timelocks. These values will accumulate
@ -200,20 +210,12 @@ func newRoute(sourceVertex route.Vertex,
// reporting through RPC. Set to zero for the final hop.
fee = 0
// Only include the final hop CLTV delta in the total
// time lock value if this is not a route to a blinded
// path. For blinded paths, the total time-lock from the
// whole path will be deduced from the introduction
// node's CLTV delta. The exception is for the case
// where the final hop is the blinded path introduction
// node.
if blindedPath == nil ||
len(blindedPath.BlindedHops) == 1 {
// As this is the last hop, we'll use the
// specified final CLTV delta value instead of
// the value from the last link in the route.
if blindedPathSet == nil {
totalTimeLock += uint32(finalHop.cltvDelta)
} else {
totalTimeLock += uint32(
blindedPathSet.FinalCLTVDelta(),
)
}
outgoingTimeLock = totalTimeLock
@ -240,7 +242,7 @@ func newRoute(sourceVertex route.Vertex,
metadata = finalHop.metadata
if blindedPath != nil {
if blindedPathSet != nil {
totalAmtMsatBlinded = finalHop.totalAmt
}
} else {
@ -300,11 +302,29 @@ func newRoute(sourceVertex route.Vertex,
// If we are creating a route to a blinded path, we need to add some
// additional data to the route that is required for blinded forwarding.
// We do another pass on our edges to append this data.
if blindedPath != nil {
if blindedPathSet != nil {
// If the passed in BlindedPaymentPathSet is non-nil but no
// edge had a BlindedPayment attached, it means that the path
// chosen was an introduction-node-only path. So in this case,
// we can assume the relevant payment is the only one in the
// payment set.
if blindedPayment == nil {
var err error
blindedPayment, err = blindedPathSet.IntroNodeOnlyPath()
if err != nil {
return nil, err
}
}
var (
inBlindedRoute bool
dataIndex = 0
blindedPath = blindedPayment.BlindedPath
numHops = len(blindedPath.BlindedHops)
realFinal = blindedPath.BlindedHops[numHops-1].
BlindedNodePub
introVertex = route.NewVertex(
blindedPath.IntroductionPoint,
)
@ -332,6 +352,11 @@ func newRoute(sourceVertex route.Vertex,
if i != len(hops)-1 {
hop.AmtToForward = 0
hop.OutgoingTimeLock = 0
} else {
// For the final hop, we swap out the pub key
// bytes to the original destination node pub
// key for that payment path.
hop.PubKeyBytes = route.NewVertex(realFinal)
}
dataIndex++
@ -437,9 +462,9 @@ type RestrictParams struct {
// the payee.
Metadata []byte
// BlindedPayment is necessary to determine the hop size of the
// BlindedPaymentPathSet is necessary to determine the hop size of the
// last/exit hop.
BlindedPayment *BlindedPayment
BlindedPaymentPathSet *BlindedPaymentPathSet
}
// PathFindingConfig defines global parameters that control the trade-off in
@ -1131,7 +1156,7 @@ type blindedPathRestrictions struct {
// path.
type blindedHop struct {
vertex route.Vertex
edgePolicy *models.CachedEdgePolicy
channelID uint64
edgeCapacity btcutil.Amount
}
@ -1271,7 +1296,7 @@ func processNodeForBlindedPath(g Graph, node route.Vertex,
hop := blindedHop{
vertex: channel.OtherNode,
edgePolicy: channel.InPolicy,
channelID: channel.ChannelID,
edgeCapacity: channel.Capacity,
}
@ -1365,9 +1390,11 @@ func getProbabilityBasedDist(weight int64, probability float64,
func lastHopPayloadSize(r *RestrictParams, finalHtlcExpiry int32,
amount lnwire.MilliSatoshi) uint64 {
if r.BlindedPayment != nil {
blindedPath := r.BlindedPayment.BlindedPath.BlindedHops
blindedPoint := r.BlindedPayment.BlindedPath.BlindingPoint
if r.BlindedPaymentPathSet != nil {
paymentPath := r.BlindedPaymentPathSet.
LargestLastHopPayloadPath()
blindedPath := paymentPath.BlindedPath.BlindedHops
blindedPoint := paymentPath.BlindedPath.BlindingPoint
encryptedData := blindedPath[len(blindedPath)-1].CipherText
finalHop := route.Hop{

View file

@ -23,6 +23,7 @@ import (
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/channeldb/models"
"github.com/lightningnetwork/lnd/fn"
"github.com/lightningnetwork/lnd/htlcswitch"
switchhop "github.com/lightningnetwork/lnd/htlcswitch/hop"
"github.com/lightningnetwork/lnd/kvdb"
@ -3277,21 +3278,39 @@ func TestBlindedRouteConstruction(t *testing.T) {
require.NoError(t, blindedPayment.Validate())
blindedPathSet, err := NewBlindedPaymentPathSet(
[]*BlindedPayment{blindedPayment},
)
require.NoError(t, err)
// Generate route hints from our blinded payment and a set of edges
// that make up the graph we'll give to route construction. The hints
// map is keyed by source node, so we can retrieve our blinded edges
// accordingly.
blindedEdges, err := blindedPayment.toRouteHints()
blindedEdges, err := blindedPayment.toRouteHints(
fn.None[*btcec.PublicKey](),
)
require.NoError(t, err)
carolDaveEdge := blindedEdges[carolVertex][0]
daveEveEdge := blindedEdges[daveBlindedVertex][0]
edges := []*unifiedEdge{
{policy: aliceBobEdge},
{policy: bobCarolEdge},
{policy: carolDaveEdge.EdgePolicy()},
{policy: daveEveEdge.EdgePolicy()},
{
policy: aliceBobEdge,
},
{
policy: bobCarolEdge,
blindedPayment: blindedPayment,
},
{
policy: carolDaveEdge.EdgePolicy(),
blindedPayment: blindedPayment,
},
{
policy: daveEveEdge.EdgePolicy(),
blindedPayment: blindedPayment,
},
}
// Total timelock for the route should include:
@ -3382,7 +3401,7 @@ func TestBlindedRouteConstruction(t *testing.T) {
route, err := newRoute(
sourceVertex, edges, currentHeight, finalHopParams,
blindedPath,
blindedPathSet,
)
require.NoError(t, err)
require.Equal(t, expectedRoute, route)
@ -3409,31 +3428,38 @@ func TestLastHopPayloadSize(t *testing.T) {
amtToForward = lnwire.MilliSatoshi(10000)
finalHopExpiry int32 = 144
oneHopBlindedPayment = &BlindedPayment{
BlindedPath: &sphinx.BlindedPath{
BlindedHops: []*sphinx.BlindedHopInfo{
{
CipherText: encrypedData,
},
oneHopPath = &sphinx.BlindedPath{
BlindedHops: []*sphinx.BlindedHopInfo{
{
CipherText: encrypedData,
},
BlindingPoint: blindedPoint,
},
BlindingPoint: blindedPoint,
}
twoHopBlindedPayment = &BlindedPayment{
BlindedPath: &sphinx.BlindedPath{
BlindedHops: []*sphinx.BlindedHopInfo{
{
CipherText: encrypedData,
},
{
CipherText: encrypedData,
},
twoHopPath = &sphinx.BlindedPath{
BlindedHops: []*sphinx.BlindedHopInfo{
{
CipherText: encrypedData,
},
{
CipherText: encrypedData,
},
BlindingPoint: blindedPoint,
},
BlindingPoint: blindedPoint,
}
)
oneHopBlindedPayment, err := NewBlindedPaymentPathSet(
[]*BlindedPayment{{BlindedPath: oneHopPath}},
)
require.NoError(t, err)
twoHopBlindedPayment, err := NewBlindedPaymentPathSet(
[]*BlindedPayment{{BlindedPath: twoHopPath}},
)
require.NoError(t, err)
testCases := []struct {
name string
restrictions *RestrictParams
@ -3454,7 +3480,7 @@ func TestLastHopPayloadSize(t *testing.T) {
{
name: "Blinded final hop introduction point",
restrictions: &RestrictParams{
BlindedPayment: oneHopBlindedPayment,
BlindedPaymentPathSet: oneHopBlindedPayment,
},
amount: amtToForward,
finalHopExpiry: finalHopExpiry,
@ -3462,7 +3488,7 @@ func TestLastHopPayloadSize(t *testing.T) {
{
name: "Blinded final hop of a two hop payment",
restrictions: &RestrictParams{
BlindedPayment: twoHopBlindedPayment,
BlindedPaymentPathSet: twoHopBlindedPayment,
},
amount: amtToForward,
finalHopExpiry: finalHopExpiry,
@ -3490,12 +3516,11 @@ func TestLastHopPayloadSize(t *testing.T) {
}
var finalHop route.Hop
if tc.restrictions.BlindedPayment != nil {
blindedPath := tc.restrictions.BlindedPayment.
BlindedPath.BlindedHops
blindedPoint := tc.restrictions.BlindedPayment.
BlindedPath.BlindingPoint
if tc.restrictions.BlindedPaymentPathSet != nil {
path := tc.restrictions.BlindedPaymentPathSet.
LargestLastHopPayloadPath()
blindedPath := path.BlindedPath.BlindedHops
blindedPoint := path.BlindedPath.BlindingPoint
//nolint:lll
finalHop = route.Hop{

View file

@ -5,7 +5,6 @@ import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btclog"
sphinx "github.com/lightningnetwork/lightning-onion"
"github.com/lightningnetwork/lnd/build"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/channeldb/models"
@ -206,13 +205,13 @@ func newPaymentSession(p *LightningPayment, selfNode route.Vertex,
return nil, err
}
if p.BlindedPayment != nil {
if p.BlindedPathSet != nil {
if len(edges) != 0 {
return nil, fmt.Errorf("cannot have both route hints " +
"and blinded path")
}
edges, err = p.BlindedPayment.toRouteHints()
edges, err = p.BlindedPathSet.ToRouteHints()
if err != nil {
return nil, err
}
@ -342,7 +341,7 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
// can split. Split payments to blinded paths won't have
// MPP records.
if p.payment.PaymentAddr == nil &&
p.payment.BlindedPayment == nil {
p.payment.BlindedPathSet == nil {
p.log.Debugf("not splitting because payment " +
"address is unspecified")
@ -407,11 +406,6 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
return nil, err
}
var blindedPath *sphinx.BlindedPath
if p.payment.BlindedPayment != nil {
blindedPath = p.payment.BlindedPayment.BlindedPath
}
// With the next candidate path found, we'll attempt to turn
// this into a route by applying the time-lock and fee
// requirements.
@ -424,7 +418,7 @@ func (p *paymentSession) RequestRoute(maxAmt, feeLimit lnwire.MilliSatoshi,
records: p.payment.DestCustomRecords,
paymentAddr: p.payment.PaymentAddr,
metadata: p.payment.Metadata,
}, blindedPath,
}, p.payment.BlindedPathSet,
)
if err != nil {
return nil, err

View file

@ -477,10 +477,10 @@ type RouteRequest struct {
// 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
// BlindedPathSet contains a set of optional blinded paths and
// parameters used to reach a target node blinded paths. This field is
// mutually exclusive with the Target field.
BlindedPayment *BlindedPayment
BlindedPathSet *BlindedPaymentPathSet
}
// RouteHints is an alias type for a set of route hints, with the source node
@ -494,7 +494,7 @@ type RouteHints map[route.Vertex][]AdditionalEdge
func NewRouteRequest(source route.Vertex, target *route.Vertex,
amount lnwire.MilliSatoshi, timePref float64,
restrictions *RestrictParams, customRecords record.CustomSet,
routeHints RouteHints, blindedPayment *BlindedPayment,
routeHints RouteHints, blindedPathSet *BlindedPaymentPathSet,
finalExpiry uint16) (*RouteRequest, error) {
var (
@ -504,16 +504,8 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex,
err error
)
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 {
if blindedPathSet != nil {
if blindedPathSet.IsIntroNode(source) {
return nil, ErrSelfIntro
}
@ -527,25 +519,15 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex,
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
}
requestExpiry = blindedPathSet.FinalCLTVDelta()
requestHints, err = blindedPayment.toRouteHints()
requestHints, err = blindedPathSet.ToRouteHints()
if err != nil {
return nil, err
}
}
requestTarget, err := getTargetNode(target, blindedPayment)
requestTarget, err := getTargetNode(target, blindedPathSet)
if err != nil {
return nil, err
}
@ -559,15 +541,15 @@ func NewRouteRequest(source route.Vertex, target *route.Vertex,
CustomRecords: customRecords,
RouteHints: requestHints,
FinalExpiry: requestExpiry,
BlindedPayment: blindedPayment,
BlindedPathSet: blindedPathSet,
}, nil
}
func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) (
route.Vertex, error) {
func getTargetNode(target *route.Vertex,
blindedPathSet *BlindedPaymentPathSet) (route.Vertex, error) {
var (
blinded = blindedPayment != nil
blinded = blindedPathSet != nil
targetSet = target != nil
)
@ -576,18 +558,7 @@ func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) (
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
return route.NewVertex(blindedPathSet.TargetPubKey()), nil
case targetSet:
return *target, nil
@ -597,16 +568,6 @@ func getTargetNode(target *route.Vertex, blindedPayment *BlindedPayment) (
}
}
// blindedPath returns the request's blinded path, which is set if the payment
// is to a blinded route.
func (r *RouteRequest) blindedPath() *sphinx.BlindedPath {
if r.BlindedPayment == nil {
return nil
}
return r.BlindedPayment.BlindedPath
}
// 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.
@ -664,7 +625,7 @@ func (r *ChannelRouter) FindRoute(req *RouteRequest) (*route.Route, float64,
totalAmt: req.Amount,
cltvDelta: req.FinalExpiry,
records: req.CustomRecords,
}, req.blindedPath(),
}, req.BlindedPathSet,
)
if err != nil {
return nil, 0, err
@ -761,7 +722,7 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex,
hops = append(hops, &route.Hop{
PubKeyBytes: path[j].vertex,
ChannelID: path[j-1].edgePolicy.ChannelID,
ChannelID: path[j-1].channelID,
})
prevNode = path[j].vertex
@ -926,14 +887,10 @@ type LightningPayment struct {
// BlindedPayment field.
RouteHints [][]zpay32.HopHint
// BlindedPayment holds the information about a blinded path to the
// payment recipient. This is mutually exclusive to the RouteHints
// BlindedPathSet holds the information about a set of blinded paths to
// the payment recipient. This is mutually exclusive to the RouteHints
// field.
//
// NOTE: a recipient may provide multiple blinded payment paths in the
// same invoice. Currently, LND will only attempt to use the first one.
// A future PR will handle multiple blinded payment paths.
BlindedPayment *BlindedPayment
BlindedPathSet *BlindedPaymentPathSet
// OutgoingChannelIDs is the list of channels that are allowed for the
// first hop. If nil, any channel may be used.

View file

@ -2223,11 +2223,6 @@ func TestNewRouteRequest(t *testing.T) {
finalExpiry: unblindedCltv,
err: ErrExpiryAndBlinded,
},
{
name: "invalid blinded payment",
blindedPayment: &BlindedPayment{},
err: ErrNoBlindedPath,
},
}
for _, testCase := range testCases {
@ -2236,9 +2231,26 @@ func TestNewRouteRequest(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
var (
blindedPathInfo *BlindedPaymentPathSet
expectedTarget = testCase.expectedTarget
)
if testCase.blindedPayment != nil {
blindedPathInfo, err = NewBlindedPaymentPathSet(
[]*BlindedPayment{
testCase.blindedPayment,
},
)
require.NoError(t, err)
expectedTarget = route.NewVertex(
blindedPathInfo.TargetPubKey(),
)
}
req, err := NewRouteRequest(
source, testCase.target, 1000, 0, nil, nil,
testCase.routeHints, testCase.blindedPayment,
testCase.routeHints, blindedPathInfo,
testCase.finalExpiry,
)
require.ErrorIs(t, err, testCase.err)
@ -2248,7 +2260,7 @@ func TestNewRouteRequest(t *testing.T) {
return
}
require.Equal(t, req.Target, testCase.expectedTarget)
require.Equal(t, req.Target, expectedTarget)
require.Equal(
t, req.FinalExpiry, testCase.expectedCltv,
)

View file

@ -5105,7 +5105,7 @@ type rpcPaymentIntent struct {
paymentAddr *[32]byte
payReq []byte
metadata []byte
blindedPayment *routing.BlindedPayment
blindedPathSet *routing.BlindedPaymentPathSet
destCustomRecords record.CustomSet
@ -5242,28 +5242,24 @@ func (r *rpcServer) extractPaymentIntent(rpcPayReq *rpcPaymentRequest) (rpcPayme
payIntent.metadata = payReq.Metadata
if len(payReq.BlindedPaymentPaths) > 0 {
// NOTE: Currently we only choose a single payment path.
// This will be updated in a future PR to handle
// multiple blinded payment paths.
path := payReq.BlindedPaymentPaths[0]
if len(path.Hops) == 0 {
return payIntent, fmt.Errorf("a blinded " +
"payment must have at least 1 hop")
pathSet, err := routerrpc.BuildBlindedPathSet(
payReq.BlindedPaymentPaths,
)
if err != nil {
return payIntent, err
}
payIntent.blindedPathSet = pathSet
finalHop := path.Hops[len(path.Hops)-1]
payIntent.blindedPayment =
routerrpc.MarshalBlindedPayment(path)
// Replace the target node with the blinded public key
// of the blinded path's final node.
// Replace the destination node with the target public
// key of the blinded path set.
copy(
payIntent.dest[:],
finalHop.BlindedNodePub.SerializeCompressed(),
pathSet.TargetPubKey().SerializeCompressed(),
)
if !payReq.BlindedPaymentPaths[0].Features.IsEmpty() {
payIntent.destFeatures = path.Features.Clone()
pathFeatures := pathSet.Features()
if !pathFeatures.IsEmpty() {
payIntent.destFeatures = pathFeatures.Clone()
}
}
@ -5421,7 +5417,7 @@ func (r *rpcServer) dispatchPaymentIntent(
DestFeatures: payIntent.destFeatures,
PaymentAddr: payIntent.paymentAddr,
Metadata: payIntent.metadata,
BlindedPayment: payIntent.blindedPayment,
BlindedPathSet: payIntent.blindedPathSet,
// Don't enable multi-part payments on the main rpc.
// Users need to use routerrpc for that.

View file

@ -4,7 +4,6 @@ import (
"encoding/binary"
"fmt"
"io"
"math"
"github.com/btcsuite/btcd/btcec/v2"
sphinx "github.com/lightningnetwork/lightning-onion"
@ -21,6 +20,12 @@ const (
// proposal](https://github.com/lightning/blips/pull/39) for a detailed
// calculation.
maxNumHopsPerPath = 7
// maxCipherTextLength defines the largest cipher text size allowed.
// This is derived by using the `data_length` upper bound of 639 bytes
// and then assuming the case of a path with only a single hop (meaning
// the cipher text may be as large as possible).
maxCipherTextLength = 535
)
var (
@ -215,6 +220,12 @@ func DecodeBlindedHop(r io.Reader) (*sphinx.BlindedHopInfo, error) {
return nil, err
}
if dataLen > maxCipherTextLength {
return nil, fmt.Errorf("a blinded hop cipher text blob may "+
"not exceed the maximum of %d bytes",
maxCipherTextLength)
}
encryptedData := make([]byte, dataLen)
_, err = r.Read(encryptedData)
if err != nil {
@ -238,9 +249,9 @@ func EncodeBlindedHop(w io.Writer, hop *sphinx.BlindedHopInfo) error {
return err
}
if len(hop.CipherText) > math.MaxUint16 {
if len(hop.CipherText) > maxCipherTextLength {
return fmt.Errorf("encrypted recipient data can not exceed a "+
"length of %d bytes", math.MaxUint16)
"length of %d bytes", maxCipherTextLength)
}
err = tlv.WriteVarInt(w, uint64(len(hop.CipherText)), &[8]byte{})