Merge pull request #9127 from MPins/issue-8993

Add the option on path creator to specify the incoming channel on blinded path
This commit is contained in:
Oliver Gugger authored and GitHub committed 2025-06-05 08:47:05 +02:00
commit fc906f2a65
15 files changed
+1694 -1197

No files matched your search

+24 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/hex"
"fmt"
"strconv"
"strings"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/urfave/cli"
@@ -116,6 +117,12 @@ var AddInvoiceCommand = cli.Command{
"use on a blinded path. The flag may be " +
"specified multiple times.",
},
cli.StringFlag{
Name: "blinded_path_incoming_channel_list",
Usage: "The chained channels specified via channel " +
"id (separated by commas), starting from a " +
"channel which points to the self node.",
},
},
Action: actionDecorator(addInvoice),
}
@@ -202,7 +209,8 @@ func parseBlindedPathCfg(ctx *cli.Context) (*lnrpc.BlindedPathConfig, error) {
if ctx.IsSet("min_real_blinded_hops") ||
ctx.IsSet("num_blinded_hops") ||
ctx.IsSet("max_blinded_paths") ||
ctx.IsSet("blinded_path_omit_node") {
ctx.IsSet("blinded_path_omit_node") ||
ctx.IsSet("blinded_path_incoming_channel_list") {
return nil, fmt.Errorf("blinded path options are " +
"only used if the `--blind` options is set")
@@ -239,6 +247,21 @@ func parseBlindedPathCfg(ctx *cli.Context) (*lnrpc.BlindedPathConfig, error) {
)
}
if ctx.IsSet("blinded_path_incoming_channel_list") {
channels := strings.Split(
ctx.String("blinded_path_incoming_channel_list"), ",",
)
for _, channelID := range channels {
chanID, err := strconv.ParseUint(channelID, 10, 64)
if err != nil {
return nil, err
}
blindCfg.IncomingChannelList = append(
blindCfg.IncomingChannelList, chanID,
)
}
}
return &blindCfg, nil
}
@@ -26,6 +26,13 @@
## RPC Additions
* The `lncli addinvoice --blind` command now has the option to include a
[chained channels](https://github.com/lightningnetwork/lnd/pull/9127)
incoming list `--blinded_path_incoming_channel_list` which gives users the
control of specifying the channels they prefer to receive the payment on. With
the option to specify multiple channels this control can be extended to
multiple hops leading to the node.
## lncli Additions
* [`lncli sendpayment` and `lncli queryroutes` now support the
@@ -86,3 +93,5 @@
## Tooling and Documentation
# Contributors (Alphabetical Order)
Pins
+4
View File
@@ -707,6 +707,10 @@ var allTestCases = []*lntest.TestCase{
Name: "bump fee low budget",
TestFunc: testBumpFeeLowBudget,
},
{
Name: "partially specified route blinded invoice",
TestFunc: testPartiallySpecifiedBlindedPath,
},
}
// appendPrefixed is used to add a prefix to each test name in the subtests
+176
View File
@@ -1420,3 +1420,179 @@ func testBlindedPaymentHTLCReForward(ht *lntest.HarnessTest) {
require.Fail(ht, "timeout waiting for sending payment")
}
}
// testPartiallySpecifiedBlindedPath tests lnd's ability to:
// - Assert the error when attempting to create a blinded payment with an
// invalid partially specified path.
// - Create a blinded payment path when the blinded path is partially
// pre-specified.
func testPartiallySpecifiedBlindedPath(ht *lntest.HarnessTest) {
// Create a six hop network:
// Alice -> Bob -> Carol -> Dave -> Eve -> Frank.
chanAmt := btcutil.Amount(100000)
cfgs := [][]string{nil, nil, nil, nil, nil, nil}
chanPoints, nodes := ht.CreateSimpleNetwork(
cfgs, lntest.OpenChannelParams{Amt: chanAmt},
)
alice, bob, carol, dave, eve := nodes[0], nodes[1], nodes[2], nodes[3],
nodes[4]
chanPointAliceBob, chanPointBobCarol, chanPointCarolDave :=
chanPoints[0], chanPoints[1], chanPoints[2]
// Lookup full channel info so that we have channel ids for our route.
aliceBobChan := ht.GetChannelByChanPoint(alice, chanPointAliceBob)
bobCarolChan := ht.GetChannelByChanPoint(bob, chanPointBobCarol)
carolDaveChan := ht.GetChannelByChanPoint(carol, chanPointCarolDave)
// Let carol set no incoming channel restrictions.
var (
minNumRealHops uint32 = 1
numHops uint32 = 1
)
invoice := carol.RPC.AddInvoice(&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
},
})
introNodesFound := make([][]byte, 0)
introNodesExpected := [][]byte{bob.PubKey[:], dave.PubKey[:]}
// Assert that it contains two blinded path with only 2 hops each one.
payReq := carol.RPC.DecodePayReq(invoice.PaymentRequest)
require.Len(ht, payReq.BlindedPaths, 2)
path := payReq.BlindedPaths[0].BlindedPath
require.Len(ht, path.BlindedHops, 2)
introNodesFound = append(introNodesFound, path.IntroductionNode)
path = payReq.BlindedPaths[1].BlindedPath
require.Len(ht, path.BlindedHops, 2)
introNodesFound = append(introNodesFound, path.IntroductionNode)
// Assert the introduction nodes without caring about the routes order.
require.ElementsMatch(ht, introNodesExpected, introNodesFound)
// Let carol choose the wrong incoming channel.
var (
incomingChannelList = []uint64{aliceBobChan.ChanId}
)
err := fmt.Sprintf("incoming channel %v is not seen as owned by node",
aliceBobChan.ChanId)
carol.RPC.AddInvoiceAssertErr(
&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
IncomingChannelList: incomingChannelList,
},
},
err,
)
// Let Carol set the incoming channel list greater than minimum number
// of real hops.
incomingChannelList = []uint64{aliceBobChan.ChanId, bobCarolChan.ChanId}
err = fmt.Sprintf("minimum number of blinded path hops (%d) must be "+
"greater than or equal to the number of hops specified on "+
"the chained channels (%d)", minNumRealHops,
len(incomingChannelList),
)
carol.RPC.AddInvoiceAssertErr(
&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
IncomingChannelList: incomingChannelList,
},
},
err,
)
// Let Carol choose an incoming channel that points to a node in the
// omission set.
incomingChannelList = []uint64{bobCarolChan.ChanId}
var nodeOmissionList [][]byte
nodeOmissionList = append(nodeOmissionList, bob.PubKey[:])
err = fmt.Sprintf("cannot simultaneously be included in the omission " +
"set and in the partially specified path",
)
carol.RPC.AddInvoiceAssertErr(
&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
IncomingChannelList: incomingChannelList,
NodeOmissionList: nodeOmissionList,
},
},
err,
)
// Let carol restrict bob as incoming channel.
incomingChannelList = []uint64{bobCarolChan.ChanId}
invoice = carol.RPC.AddInvoice(&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
IncomingChannelList: incomingChannelList,
},
})
// Assert that it contains a single blinded path with only
// 2 hops, with bob as the introduction node.
payReq = carol.RPC.DecodePayReq(invoice.PaymentRequest)
require.Len(ht, payReq.BlindedPaths, 1)
path = payReq.BlindedPaths[0].BlindedPath
require.Len(ht, path.BlindedHops, 2)
require.EqualValues(ht, path.IntroductionNode, bob.PubKey[:])
// Now let alice pay the invoice.
ht.CompletePaymentRequests(alice, []string{invoice.PaymentRequest})
// Let carol restrict dave as incoming channel and max Hops as 2
numHops = 2
incomingChannelList = []uint64{carolDaveChan.ChanId}
invoice = carol.RPC.AddInvoice(&lnrpc.Invoice{
Memo: "test",
ValueMsat: 10_000_000,
IsBlinded: true,
BlindedPathConfig: &lnrpc.BlindedPathConfig{
MinNumRealHops: &minNumRealHops,
NumHops: &numHops,
IncomingChannelList: incomingChannelList,
},
})
// Assert that it contains one path with 3 hops, with dave as the
// introduction node. The path alice -> bob -> carol is discarded
// because alice is a dead-end.
payReq = carol.RPC.DecodePayReq(invoice.PaymentRequest)
require.Len(ht, payReq.BlindedPaths, 1)
path = payReq.BlindedPaths[0].BlindedPath
require.Len(ht, path.BlindedHops, 3)
require.EqualValues(ht, path.IntroductionNode, eve.PubKey[:])
}
+4
View File
@@ -34,6 +34,10 @@ func (r *Routing) Validate() error {
"number of hops expected to be included in each path")
}
if r.BlindedPaths.MaxNumPaths == 0 {
return fmt.Errorf("blinded max num paths cannot be 0")
}
if r.BlindedPaths.PolicyIncreaseMultiplier < 1 {
return fmt.Errorf("the blinded route policy increase " +
"multiplier must be greater than or equal to 1")
+8
View File
@@ -534,6 +534,14 @@
"format": "byte"
},
"description": "A list of node IDs of nodes that should not be used in any of our generated\nblinded paths."
},
"incoming_channel_list": {
"type": "array",
"items": {
"type": "string",
"format": "uint64"
},
"description": "The chained channels list specified via channel id (separated by commas),\nstarting from a channel owned by the receiver node."
}
}
},
+1188 -1174
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -4002,6 +4002,12 @@ message BlindedPathConfig {
blinded paths.
*/
repeated bytes node_omission_list = 4;
/*
The chained channels list specified via channel id (separated by commas),
starting from a channel owned by the receiver node.
*/
repeated uint64 incoming_channel_list = 5;
}
enum InvoiceHTLCState {
+8
View File
@@ -3688,6 +3688,14 @@
"format": "byte"
},
"description": "A list of node IDs of nodes that should not be used in any of our generated\nblinded paths."
},
"incoming_channel_list": {
"type": "array",
"items": {
"type": "string",
"format": "uint64"
},
"description": "The chained channels list specified via channel id (separated by commas),\nstarting from a channel owned by the receiver node."
}
}
},
+10
View File
@@ -293,6 +293,16 @@ func (h *HarnessRPC) AddInvoice(req *lnrpc.Invoice) *lnrpc.AddInvoiceResponse {
return invoice
}
// AddInvoiceAssertErr makes a RPC call to AddInvoice and asserts an error
// has returned with a specific error message.
func (h *HarnessRPC) AddInvoiceAssertErr(req *lnrpc.Invoice, errStr string) {
ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout)
defer cancel()
_, err := h.LN.AddInvoice(ctxt, req)
require.ErrorContains(h, err, errStr)
}
// AbandonChannel makes a RPC call to AbandonChannel and asserts.
func (h *HarnessRPC) AbandonChannel(
req *lnrpc.AbandonChannelRequest) *lnrpc.AbandonChannelResponse {
+85 -11
View File
@@ -1192,6 +1192,11 @@ type blindedPathRestrictions struct {
// nodeOmissionSet holds a set of node IDs of nodes that we should
// ignore during blinded path selection.
nodeOmissionSet fn.Set[route.Vertex]
// incomingChainedChannels holds the chained channels list specified
// via scid (short channel id) starting from a channel which points to
// the receiver node.
incomingChainedChannels []uint64
}
// blindedHop holds the information about a hop we have selected for a blinded
@@ -1221,11 +1226,72 @@ func findBlindedPaths(g Graph, target route.Vertex,
restrictions.minNumHops)
}
var (
// The target node is always the last hop in the path.
incomingPath = []blindedHop{{vertex: target}}
whiteListedNodes = map[route.Vertex]bool{target: true}
visited = make(map[route.Vertex]bool)
errChanFound = errors.New("found incoming channel")
nextTarget = target
)
for _, chanID := range restrictions.incomingChainedChannels {
visited[nextTarget] = true
err := g.ForEachNodeDirectedChannel(nextTarget,
func(channel *graphdb.DirectedChannel) error {
// Not the right channel, continue to the node's
// other channels.
if channel.ChannelID != chanID {
return nil
}
incomingPath = append([]blindedHop{
{
vertex: channel.OtherNode,
channelID: channel.ChannelID,
edgeCapacity: channel.Capacity,
},
}, incomingPath...)
// Update the target node.
nextTarget = channel.OtherNode
return errChanFound
},
)
if err == nil {
return nil, fmt.Errorf("incoming channel %d is not "+
"seen as owned by node %v", chanID, nextTarget)
}
if !errors.Is(err, errChanFound) {
return nil, err
}
// Check that the user didn't accidentally add a channel that
// is owned by a node in the node omission set
if restrictions.nodeOmissionSet.Contains(nextTarget) {
return nil, fmt.Errorf("node %v cannot simultaneously "+
"be included in the omission set and in the "+
"partially specified path", nextTarget)
}
if whiteListedNodes[nextTarget] {
return nil, fmt.Errorf("a circular route cannot be " +
"specified for the incoming blinded path")
}
whiteListedNodes[nextTarget] = true
}
// If the node is not the destination node, then it is required that the
// node advertise the route blinding feature-bit in order for it to be
// chosen as a node on the blinded path.
// We skip checking the target node, as accepting blinded payments
// (via invoice) doesn't imply support for routing them (via node
// announcement).
// We skip checking incomingChainedChannels nodes, as we might not yet
// have an updated node announcement for them.
supportsRouteBlinding := func(node route.Vertex) (bool, error) {
if node == target {
if whiteListedNodes[node] {
return true, nil
}
@@ -1243,27 +1309,35 @@ func findBlindedPaths(g Graph, target route.Vertex,
// a node that doesn't have any other edges - in that final case, the
// whole path should be ignored.
paths, _, err := processNodeForBlindedPath(
g, target, supportsRouteBlinding, nil, restrictions,
g, nextTarget, supportsRouteBlinding, visited, restrictions,
)
if err != nil {
return nil, err
}
// Reverse each path so that the order is correct (from introduction
// node to last hop node) and then append this node on as the
// destination of each path.
orderedPaths := make([][]blindedHop, len(paths))
for i, path := range paths {
sort.Slice(path, func(i, j int) bool {
return j < i
})
orderedPaths[i] = append(path, blindedHop{vertex: target})
// When there is no path to add, but incomingChainedChannels can be
// used.
lenChainedChannels := int8(len(restrictions.incomingChainedChannels))
if len(paths) == 0 && lenChainedChannels > 0 {
orderedPaths = [][]blindedHop{incomingPath}
} else {
// Reverse each path so that the order is correct (from
// introduction node to last hop node) and then append this node
// on as the destination of each path.
for i, path := range paths {
sort.Slice(path, func(i, j int) bool {
return j < i
})
orderedPaths[i] = append(path, incomingPath...)
}
}
// Handle the special case that allows a blinded path with the
// introduction node as the destination node.
if restrictions.minNumHops == 0 {
if restrictions.minNumHops == 0 && lenChainedChannels == 0 {
singleHopPath := [][]blindedHop{{{vertex: target}}}
//nolint:makezero
+90
View File
@@ -3855,4 +3855,94 @@ func TestFindBlindedPaths(t *testing.T) {
assertPaths(paths, []string{
"dave",
})
// 6) Restrict the min & max path length such that we only include paths
// with one being only the intro-node and the others with one hop other
// than the destination hop.
paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 0,
maxNumHops: 1,
})
require.NoError(t, err)
// We expect the following paths:
// - D
// - B, D
// - C, D
// The (A, D) path is not chosen since A does not advertise the route
// blinding feature bit. The (G, D) path is not chosen since G does not
// have any other known channels.
assertPaths(paths, []string{
"dave",
"bob,dave",
"charlie,dave",
})
// 7) Restrict the min & max path length such that we only include paths
// with one hop other than the destination hop. Now with bob-dave as the
// incoming channel.
paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 1,
maxNumHops: 1,
incomingChainedChannels: []uint64{2},
})
require.NoError(t, err)
// Expect only B->D path.
assertPaths(paths, []string{
"bob,dave",
})
// 8) Extend the search to include 2 hops other than the destination,
// with bob-dave as the incoming channel.
paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 1,
maxNumHops: 2,
incomingChainedChannels: []uint64{2},
})
require.NoError(t, err)
// We expect the following paths:
// - F, B, D
// - E, B, D
assertPaths(paths, []string{
"frank,bob,dave",
"eve,bob,dave",
})
// 9) Extend the search even further and also increase the minimum path
// length, but this time with charlie-dave as the incoming channel.
paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 2,
maxNumHops: 3,
incomingChainedChannels: []uint64{3},
})
require.NoError(t, err)
// We expect the following paths:
// - E, C, D
// - B, E, C, D
assertPaths(paths, []string{
"eve,charlie,dave",
"bob,eve,charlie,dave",
})
// 10) Repeat the above test but instruct the function to never use
// charlie.
_, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 2,
maxNumHops: 3,
nodeOmissionSet: fn.NewSet(ctx.keyFromAlias("charlie")),
incomingChainedChannels: []uint64{3},
})
require.ErrorContains(t, err, "cannot simultaneously be included in "+
"the omission set and in the partially specified path")
// 11) Test the circular route error.
_, err = ctx.findBlindedPaths(&blindedPathRestrictions{
minNumHops: 2,
maxNumHops: 3,
incomingChainedChannels: []uint64{2, 7, 6, 3},
})
require.ErrorContains(t, err, "a circular route cannot be specified")
}
+16
View File
@@ -773,3 +773,19 @@ func (r *Route) String() string {
b.String(), r.TotalTimeLock,
)
}
// ChanIDString returns the route's channel IDs as a formatted string.
func ChanIDString(r *Route) string {
var b strings.Builder
for i, hop := range r.Hops {
b.WriteString(fmt.Sprintf("%v",
strconv.FormatUint(hop.ChannelID, 10),
))
if i != len(r.Hops)-1 {
b.WriteString(" -> ")
}
}
return b.String()
}
+27 -11
View File
@@ -610,6 +610,11 @@ type BlindedPathRestrictions struct {
// NodeOmissionSet is a set of nodes that should not be used within any
// of the blinded paths that we generate.
NodeOmissionSet fn.Set[route.Vertex]
// IncomingChainedChannels holds the chained channels list (specified
// via channel id) starting from a channel which points to the receiver
// node.
IncomingChainedChannels []uint64
}
// FindBlindedPaths finds a selection of paths to the destination node that can
@@ -620,11 +625,14 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex,
// First, find a set of candidate paths given the destination node and
// path length restrictions.
incomingChainedChannels := restrictions.IncomingChainedChannels
minDistanceFromIntroNode := restrictions.MinDistanceFromIntroNode
paths, err := findBlindedPaths(
r.cfg.RoutingGraph, destination, &blindedPathRestrictions{
minNumHops: restrictions.MinDistanceFromIntroNode,
maxNumHops: restrictions.NumHops,
nodeOmissionSet: restrictions.NodeOmissionSet,
minNumHops: minDistanceFromIntroNode,
maxNumHops: restrictions.NumHops,
nodeOmissionSet: restrictions.NodeOmissionSet,
incomingChainedChannels: incomingChainedChannels,
},
)
if err != nil {
@@ -676,19 +684,27 @@ func (r *ChannelRouter) FindBlindedPaths(destination route.Vertex,
prevNode = path[j].vertex
}
// Don't bother adding a route if its success probability less
// minimum that can be assigned to any single pair.
if totalRouteProbability <= DefaultMinRouteProbability {
continue
}
routes = append(routes, &routeWithProbability{
routeWithProbability := &routeWithProbability{
route: &route.Route{
SourcePubKey: introNode,
Hops: hops,
},
probability: totalRouteProbability,
})
}
// Don't bother adding a route if its success probability less
// minimum that can be assigned to any single pair.
if totalRouteProbability <= DefaultMinRouteProbability {
log.Debugf("Not using route (%v) as a blinded "+
"path since it resulted in an low "+
"probability path(%.3f)",
route.ChanIDString(routeWithProbability.route),
routeWithProbability.probability,
)
continue
}
routes = append(routes, routeWithProbability)
}
// Sort the routes based on probability.
+39
View File
@@ -6199,6 +6199,10 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
blindingRestrictions.NumHops = uint8(*blindCfg.NumHops)
}
if blindCfg.MaxNumPaths != nil {
if *blindCfg.MaxNumPaths == 0 {
return nil, fmt.Errorf("blinded max num " +
"paths cannot be 0")
}
blindingRestrictions.MaxNumPaths =
uint8(*blindCfg.MaxNumPaths)
}
@@ -6211,6 +6215,41 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
blindingRestrictions.NodeOmissionSet.Add(vertex)
}
blindingRestrictions.IncomingChainedChannels = append(
blindingRestrictions.IncomingChainedChannels,
blindCfg.IncomingChannelList...,
)
numChainedChannels :=
uint8(len(blindingRestrictions.IncomingChainedChannels))
// When selecting the blinded incoming channel list parameter
// the maximum number of hops is implictitly set.
if numChainedChannels > blindingRestrictions.NumHops {
rpcsLog.Warnf("Changing the num_blinded_hops "+
"from (%d) to (%d)",
blindingRestrictions.NumHops,
numChainedChannels)
blindingRestrictions.NumHops =
numChainedChannels
}
// The MinDistanceFromIntroNode must be greater than or equal to
// the number of hops specified on the chained channels.
minNumHops := blindingRestrictions.MinDistanceFromIntroNode
if minNumHops < numChainedChannels {
// Ensure MinimumPath is at least the size of the
// chained path to avoid shorter routes being returned
// by the pathfinder.
return nil, fmt.Errorf("minimum number of blinded "+
"path hops (%d) must be greater than or equal "+
"to the number of hops specified on the "+
"chained channels (%d)", minNumHops,
numChainedChannels)
}
}
if blindingRestrictions.MinDistanceFromIntroNode >