Skip to content

Commit bc670fa

Browse files
authored
Merge pull request #10396 from ziggie1984/enhance-lsp-heuritic
Enhance Lsp Heuristic when probing a payment
2 parents 0a6907d + de0424e commit bc670fa

File tree

8 files changed

+935
-357
lines changed

8 files changed

+935
-357
lines changed

docs/estimate_route_fee.md

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -150,52 +150,58 @@ probing.
150150

151151
The heuristic examines the structure of route hints provided in the invoice to
152152
identify characteristic LSP patterns. The detection operates on the principle
153-
that LSPs typically maintain private channels to their users and appear as the
154-
penultimate hop in payment routing.
153+
that LSPs typically maintain private channels to their users and appear as
154+
public nodes in the network, while the final destination is private.
155155

156156
```mermaid
157157
flowchart TD
158158
Start([Route Hints Received]) --> Empty{Empty Hints?}
159159
Empty -->|Yes| NotLSP([Not LSP])
160-
Empty -->|No| GetFirst[Get First Hint's Last Hop]
161-
162-
GetFirst --> CheckPub1{Is Channel<br/>Public?}
163-
CheckPub1 -->|Yes| NotLSP
164-
CheckPub1 -->|No| SaveNode[Save Node ID]
165-
166-
SaveNode --> MoreHints{More Hints?}
167-
MoreHints -->|No| IsLSP([Detected as LSP])
160+
Empty -->|No| CheckTarget{Invoice Target<br/>in Graph?}
161+
162+
CheckTarget -->|Yes| NotLSP
163+
CheckTarget -->|No| GetFirstDest[Get First Hint's<br/>Destination Hop]
164+
165+
GetFirstDest --> CheckPub1{Destination Node<br/>in Graph?}
166+
CheckPub1 -->|Yes| IsLSP([Detected as LSP])
167+
CheckPub1 -->|No| MoreHints{More Hints?}
168+
169+
MoreHints -->|No| NotLSP
168170
MoreHints -->|Yes| NextHint[Check Next Hint]
169-
170-
NextHint --> GetLast[Get Last Hop]
171-
GetLast --> CheckPub2{Is Channel<br/>Public?}
172-
CheckPub2 -->|Yes| NotLSP
173-
CheckPub2 -->|No| SameNode{Same Node ID<br/>as First?}
174-
175-
SameNode -->|No| NotLSP
176-
SameNode -->|Yes| MoreHints
177-
```
178171
179-
The detection criteria are:
172+
NextHint --> GetNextDest[Get Destination Hop]
173+
GetNextDest --> CheckPub2{Destination Node<br/>in Graph?}
174+
CheckPub2 -->|Yes| IsLSP
175+
CheckPub2 -->|No| MoreHints
176+
```
180177

181-
- **All route hints must terminate at the same node ID** - This indicates a
182-
single destination behind potentially multiple LSP entry points
178+
The detection follows three simple rules applied sequentially:
183179

184-
- **Final hop channels must be private** - The channels in the last hop of
185-
each route hint must not exist in the public channel graph
180+
**Rule 1: Public Invoice Target → NOT an LSP**
181+
- If the invoice target (destination) is a public node that exists in the
182+
channel graph, the payment can be routed directly to it
183+
- This means it's not an LSP setup, regardless of what route hints are provided
184+
- Example: A well-connected merchant node with route hints for liquidity
185+
signaling
186186

187-
- **No public channels in final hops** - If any route hint contains a public
188-
channel in its final hop, LSP detection is disabled entirely
187+
**Rule 2: Public Destination Hop → IS an LSP**
188+
- If at least one route hint has a destination hop (last hop in the route hint)
189+
that is a public node in the graph, LSP detection is triggered
190+
- This indicates the destination hop is an LSP serving a private client
191+
- The private client is reached through the LSP's private channel
189192

190-
- **Multiple route hints strengthen detection** - While not required,
191-
multiple hints converging on the same destination strongly suggest an LSP
192-
configuration
193+
**Rule 3: All Private Destination Hops → NOT an LSP**
194+
- If all destination hops in all route hints are private nodes (not in the
195+
public graph), this is not treated as an LSP setup
196+
- The payment will be routed directly to the invoice destination using the
197+
route hints as additional path information
198+
- This is the standard case for private channel payments
193199

194200
This pattern effectively distinguishes LSP configurations from other routing
195201
scenarios. For instance, some Lightning implementations like CLN include route
196202
hints even for public nodes to signal liquidity availability or preferred
197-
routing paths. The heuristic correctly identifies these as non-LSP scenarios by
198-
detecting the presence of public channels.
203+
routing paths. The heuristic correctly identifies these as non-LSP scenarios
204+
by Rule 1 (detecting that the invoice target itself is public).
199205

200206
### How Probing Differs When an LSP is Detected
201207

@@ -432,10 +438,6 @@ appropriately.
432438
The `EstimateRouteFee` implementation continues to evolve based on real-world
433439
usage patterns. Ongoing discussions in the LND community focus on:
434440

435-
**Improved LSP Detection**: Developing more sophisticated heuristics that
436-
accurately identify LSP configurations while avoiding false positives for
437-
regular private channels.
438-
439441
**Multi-Path Payment Support**: Extending fee estimation to support MPP
440442
scenarios where payments split across multiple routes.
441443

docs/release-notes/release-notes-0.20.1.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@
5555

5656
## RPC Updates
5757

58+
* The `EstimateRouteFee` RPC now implements an [LSP detection
59+
heuristic](https://github.com/lightningnetwork/lnd/pull/10396) that probes up
60+
to 3 unique Lightning Service Providers when route hints indicate an LSP
61+
setup. The implementation returns worst-case (most expensive) fee estimates
62+
for conservative budgeting and includes griefing protection by limiting the
63+
number of probed LSPs. It enhances the previous LSP design by being more
64+
generic and more flexible.
65+
5866
## lncli Updates
5967

6068
## Breaking Changes

graph/db/kv_store.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3431,11 +3431,10 @@ func (c *KVStore) fetchLightningNode(tx kvdb.RTx,
34313431
return node, nil
34323432
}
34333433

3434-
// HasLightningNode determines if the graph has a vertex identified by the
3435-
// target node identity public key. If the node exists in the database, a
3436-
// timestamp of when the data for the node was lasted updated is returned along
3437-
// with a true boolean. Otherwise, an empty time.Time is returned with a false
3438-
// boolean.
3434+
// HasNode determines if the graph has a vertex identified by the target node
3435+
// identity public key. If the node exists in the database, a timestamp of when
3436+
// the data for the node was lasted updated is returned along with a true
3437+
// boolean. Otherwise, an empty time.Time is returned with a false boolean.
34393438
func (c *KVStore) HasNode(_ context.Context,
34403439
nodePub [33]byte) (time.Time, bool, error) {
34413440

itest/lnd_estimate_route_fee_test.go

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,38 @@ type estimateRouteFeeTestCase struct {
5959
}
6060

6161
// testEstimateRouteFee tests the estimation of routing fees using either graph
62-
// data or sending out a probe payment.
62+
// data or sending out a probe payment. This test validates graph-based fee
63+
// estimation, probe-based fee estimation with single LSP, probe-based fee
64+
// estimation with multiple route hints to same LSP (worst-case fee selection),
65+
// probe-based fee estimation with multiple different public LSPs (worst-case
66+
// fee selection across LSPs, up to MaxLspsToProbe), and non-LSP probing (all
67+
// private destination hops).
68+
//
69+
// Note: We test with exactly MaxLspsToProbe (3) LSPs. Testing with more LSPs
70+
// is not feasible because the LSP selection uses map iteration, which has
71+
// non-deterministic order in Go, making it impossible to predict which LSPs
72+
// will be probed.
6373
func testEstimateRouteFee(ht *lntest.HarnessTest) {
74+
// Ensure MaxLspsToProbe is set to 3 as expected by this test. The test
75+
// uses exactly 3 LSPs in the multi-LSP test case. If MaxLspsToProbe
76+
// changes, this assertion will fail as a reminder to update the test.
77+
require.Equal(ht, 3, routerrpc.MaxLspsToProbe,
78+
"MaxLspsToProbe should be 3")
79+
6480
mts := newMppTestScenario(ht)
6581

66-
// We extend the regular mpp test scenario with a new node Paula. Paula
67-
// is connected to Bob and Eve through private channels.
82+
// We extend the regular mpp test scenario with two new nodes:
83+
// - Paula: connected to Bob and Eve through private channels
84+
// - Frank: connected to Dave through a private channel
85+
//
6886
// /-------------\
6987
// _ Eve _ (private) \
7088
// / \ \
7189
// Alice -- Carol ---- Bob --------- Paula
7290
// \ / (private)
7391
// \__ Dave ____/
92+
// \
93+
// \__ Frank (private)
7494
//
7595
req := &mppOpenChannelRequest{
7696
amtAliceCarol: 200_000,
@@ -88,6 +108,7 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
88108
probeInitiator = mts.alice
89109

90110
paula := ht.NewNode("Paula", nil)
111+
frank := ht.NewNode("Frank", nil)
91112

92113
// The channel from Bob to Paula actually doesn't have enough liquidity
93114
// to carry out the probe. We assume in normal operation that hop hints
@@ -106,6 +127,13 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
106127
Amt: 1_000_000,
107128
})
108129

130+
// Frank is a private node connected to Dave (public LSP).
131+
ht.EnsureConnected(mts.dave, frank)
132+
ht.OpenChannel(mts.dave, frank, lntest.OpenChannelParams{
133+
Private: true,
134+
Amt: 1_000_000,
135+
})
136+
109137
bobsPrivChannels := mts.bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{
110138
PrivateOnly: true,
111139
})
@@ -118,6 +146,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
118146
require.Len(ht, evesPrivChannels.Channels, 1)
119147
evePaulaChanID := evesPrivChannels.Channels[0].ChanId
120148

149+
davesPrivChannels := mts.dave.RPC.ListChannels(
150+
&lnrpc.ListChannelsRequest{
151+
PrivateOnly: true,
152+
},
153+
)
154+
require.Len(ht, davesPrivChannels.Channels, 1)
155+
daveFrankChanID := davesPrivChannels.Channels[0].ChanId
156+
121157
// Let's disable the paths from Alice to Bob through Dave and Eve with
122158
// high fees. This ensures that the path estimates are based on Carol's
123159
// channel to Bob for the first set of tests.
@@ -196,6 +232,33 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
196232
},
197233
},
198234
}
235+
236+
daveHopHint = &lnrpc.HopHint{
237+
NodeId: mts.dave.PubKeyStr,
238+
FeeBaseMsat: 3_000,
239+
FeeProportionalMillionths: 3_000,
240+
CltvExpiryDelta: 120,
241+
ChanId: daveFrankChanID,
242+
}
243+
244+
// Multiple different public LSPs (Bob, Eve, Dave).
245+
multipleLspsRouteHints = []*lnrpc.RouteHint{
246+
{
247+
HopHints: []*lnrpc.HopHint{
248+
bobHopHint,
249+
},
250+
},
251+
{
252+
HopHints: []*lnrpc.HopHint{
253+
eveHopHint,
254+
},
255+
},
256+
{
257+
HopHints: []*lnrpc.HopHint{
258+
daveHopHint,
259+
},
260+
},
261+
}
199262
)
200263

201264
defaultTimelock := int64(chainreg.DefaultBitcoinTimeLockDelta)
@@ -231,6 +294,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
231294
feeACEP := feeEP + feeCE
232295
deltaACEP := deltaCE + deltaEP
233296

297+
// For multiple LSPs test, the route with the highest fee should be
298+
// selected (Eve). Note that we return both fee and CLTV delta from
299+
// the same route (the highest-fee route), not the max fee and max
300+
// delta independently. This ensures the returned values represent an
301+
// actual viable route.
302+
highestFeeRouteFee := feeACEP
303+
highestFeeRouteDelta := deltaACEP
304+
234305
initialBlockHeight := int64(mts.alice.RPC.GetInfo().BlockHeight)
235306

236307
// Locktime is always composed of the initial block height and the
@@ -271,6 +342,19 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
271342
expectedCltvDelta: locktime + deltaCB,
272343
expectedFailureReason: failureReasonNone,
273344
},
345+
// Rule 1: Invoice target is public (Bob), even with public
346+
// destination hop hints. Should route directly to Bob, NOT
347+
// treat as LSP.
348+
{
349+
name: "probe based estimate, public " +
350+
"target with public hop hints",
351+
probing: true,
352+
destination: mts.bob,
353+
routeHints: singleRouteHint,
354+
expectedRoutingFeesMsat: feeStandardSingleHop,
355+
expectedCltvDelta: locktime + deltaCB,
356+
expectedFailureReason: failureReasonNone,
357+
},
274358
// We expect the previous probing results adjusted by Paula's
275359
// hop data.
276360
{
@@ -340,6 +424,23 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
340424
expectedCltvDelta: 0,
341425
expectedFailureReason: failureReasonNoRoute,
342426
},
427+
// Test multiple different public LSPs. The worst-case (most
428+
// expensive) route should be returned. Eve has the highest
429+
// fees among the 3 LSPs tested. Note: We don't test with more
430+
// than MaxLspsToProbe LSPs because map iteration order in Go
431+
// is non-deterministic, making it impossible to predict which
432+
// LSPs will be selected for probing.
433+
{
434+
name: "probe based estimate, " +
435+
"multiple different public LSPs",
436+
probing: true,
437+
destination: frank,
438+
routeHints: multipleLspsRouteHints,
439+
expectedRoutingFeesMsat: highestFeeRouteFee,
440+
expectedCltvDelta: locktime +
441+
highestFeeRouteDelta,
442+
expectedFailureReason: failureReasonNone,
443+
},
343444
}
344445

345446
for _, testCase := range testCases {

lnrpc/routerrpc/router_backend.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ type RouterBackend struct {
6464
FetchChannelEndpoints func(chanID uint64) (route.Vertex,
6565
route.Vertex, error)
6666

67+
// HasNode returns true if the node exists in the graph (i.e., has
68+
// public channels), false otherwise. This means the node is a public
69+
// node and should be reachable.
70+
HasNode func(nodePub route.Vertex) (bool, error)
71+
6772
// FindRoute is a closure that abstracts away how we locate/query for
6873
// routes.
6974
FindRoute func(*routing.RouteRequest) (*route.Route, float64, error)

0 commit comments

Comments
 (0)