Merge pull request #10396 from ziggie1984/enhance-lsp-heuritic
Enhance Lsp Heuristic when probing a payment
This commit is contained in:
8 files changed
+955
-377
No files matched your search
+37
-35
@@ -150,52 +150,58 @@ probing.
|
||||
|
||||
The heuristic examines the structure of route hints provided in the invoice to
|
||||
identify characteristic LSP patterns. The detection operates on the principle
|
||||
that LSPs typically maintain private channels to their users and appear as the
|
||||
penultimate hop in payment routing.
|
||||
that LSPs typically maintain private channels to their users and appear as
|
||||
public nodes in the network, while the final destination is private.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Route Hints Received]) --> Empty{Empty Hints?}
|
||||
Empty -->|Yes| NotLSP([Not LSP])
|
||||
Empty -->|No| GetFirst[Get First Hint's Last Hop]
|
||||
|
||||
GetFirst --> CheckPub1{Is Channel<br/>Public?}
|
||||
CheckPub1 -->|Yes| NotLSP
|
||||
CheckPub1 -->|No| SaveNode[Save Node ID]
|
||||
|
||||
SaveNode --> MoreHints{More Hints?}
|
||||
MoreHints -->|No| IsLSP([Detected as LSP])
|
||||
Empty -->|No| CheckTarget{Invoice Target<br/>in Graph?}
|
||||
|
||||
CheckTarget -->|Yes| NotLSP
|
||||
CheckTarget -->|No| GetFirstDest[Get First Hint's<br/>Destination Hop]
|
||||
|
||||
GetFirstDest --> CheckPub1{Destination Node<br/>in Graph?}
|
||||
CheckPub1 -->|Yes| IsLSP([Detected as LSP])
|
||||
CheckPub1 -->|No| MoreHints{More Hints?}
|
||||
|
||||
MoreHints -->|No| NotLSP
|
||||
MoreHints -->|Yes| NextHint[Check Next Hint]
|
||||
|
||||
NextHint --> GetLast[Get Last Hop]
|
||||
GetLast --> CheckPub2{Is Channel<br/>Public?}
|
||||
CheckPub2 -->|Yes| NotLSP
|
||||
CheckPub2 -->|No| SameNode{Same Node ID<br/>as First?}
|
||||
|
||||
SameNode -->|No| NotLSP
|
||||
SameNode -->|Yes| MoreHints
|
||||
|
||||
NextHint --> GetNextDest[Get Destination Hop]
|
||||
GetNextDest --> CheckPub2{Destination Node<br/>in Graph?}
|
||||
CheckPub2 -->|Yes| IsLSP
|
||||
CheckPub2 -->|No| MoreHints
|
||||
```
|
||||
|
||||
The detection criteria are:
|
||||
The detection follows three simple rules applied sequentially:
|
||||
|
||||
- **All route hints must terminate at the same node ID** - This indicates a
|
||||
single destination behind potentially multiple LSP entry points
|
||||
**Rule 1: Public Invoice Target → NOT an LSP**
|
||||
- If the invoice target (destination) is a public node that exists in the
|
||||
channel graph, the payment can be routed directly to it
|
||||
- This means it's not an LSP setup, regardless of what route hints are provided
|
||||
- Example: A well-connected merchant node with route hints for liquidity
|
||||
signaling
|
||||
|
||||
- **Final hop channels must be private** - The channels in the last hop of
|
||||
each route hint must not exist in the public channel graph
|
||||
**Rule 2: Public Destination Hop → IS an LSP**
|
||||
- If at least one route hint has a destination hop (last hop in the route hint)
|
||||
that is a public node in the graph, LSP detection is triggered
|
||||
- This indicates the destination hop is an LSP serving a private client
|
||||
- The private client is reached through the LSP's private channel
|
||||
|
||||
- **No public channels in final hops** - If any route hint contains a public
|
||||
channel in its final hop, LSP detection is disabled entirely
|
||||
|
||||
- **Multiple route hints strengthen detection** - While not required,
|
||||
multiple hints converging on the same destination strongly suggest an LSP
|
||||
configuration
|
||||
**Rule 3: All Private Destination Hops → NOT an LSP**
|
||||
- If all destination hops in all route hints are private nodes (not in the
|
||||
public graph), this is not treated as an LSP setup
|
||||
- The payment will be routed directly to the invoice destination using the
|
||||
route hints as additional path information
|
||||
- This is the standard case for private channel payments
|
||||
|
||||
This pattern effectively distinguishes LSP configurations from other routing
|
||||
scenarios. For instance, some Lightning implementations like CLN include route
|
||||
hints even for public nodes to signal liquidity availability or preferred
|
||||
routing paths. The heuristic correctly identifies these as non-LSP scenarios by
|
||||
detecting the presence of public channels.
|
||||
routing paths. The heuristic correctly identifies these as non-LSP scenarios
|
||||
by Rule 1 (detecting that the invoice target itself is public).
|
||||
|
||||
### How Probing Differs When an LSP is Detected
|
||||
|
||||
@@ -432,10 +438,6 @@ appropriately.
|
||||
The `EstimateRouteFee` implementation continues to evolve based on real-world
|
||||
usage patterns. Ongoing discussions in the LND community focus on:
|
||||
|
||||
**Improved LSP Detection**: Developing more sophisticated heuristics that
|
||||
accurately identify LSP configurations while avoiding false positives for
|
||||
regular private channels.
|
||||
|
||||
**Multi-Path Payment Support**: Extending fee estimation to support MPP
|
||||
scenarios where payments split across multiple routes.
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@
|
||||
|
||||
## RPC Updates
|
||||
|
||||
* The `EstimateRouteFee` RPC now implements an [LSP detection
|
||||
heuristic](https://github.com/lightningnetwork/lnd/pull/10396) that probes up
|
||||
to 3 unique Lightning Service Providers when route hints indicate an LSP
|
||||
setup. The implementation returns worst-case (most expensive) fee estimates
|
||||
for conservative budgeting and includes griefing protection by limiting the
|
||||
number of probed LSPs. It enhances the previous LSP design by being more
|
||||
generic and more flexible.
|
||||
|
||||
## lncli Updates
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
@@ -3431,11 +3431,10 @@ func (c *KVStore) fetchLightningNode(tx kvdb.RTx,
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// HasLightningNode determines if the graph has a vertex identified by the
|
||||
// target node identity public key. If the node exists in the database, a
|
||||
// timestamp of when the data for the node was lasted updated is returned along
|
||||
// with a true boolean. Otherwise, an empty time.Time is returned with a false
|
||||
// boolean.
|
||||
// HasNode determines if the graph has a vertex identified by the target node
|
||||
// identity public key. If the node exists in the database, a timestamp of when
|
||||
// the data for the node was lasted updated is returned along with a true
|
||||
// boolean. Otherwise, an empty time.Time is returned with a false boolean.
|
||||
func (c *KVStore) HasNode(_ context.Context,
|
||||
nodePub [33]byte) (time.Time, bool, error) {
|
||||
|
||||
|
||||
@@ -59,18 +59,38 @@ type estimateRouteFeeTestCase struct {
|
||||
}
|
||||
|
||||
// testEstimateRouteFee tests the estimation of routing fees using either graph
|
||||
// data or sending out a probe payment.
|
||||
// data or sending out a probe payment. This test validates graph-based fee
|
||||
// estimation, probe-based fee estimation with single LSP, probe-based fee
|
||||
// estimation with multiple route hints to same LSP (worst-case fee selection),
|
||||
// probe-based fee estimation with multiple different public LSPs (worst-case
|
||||
// fee selection across LSPs, up to MaxLspsToProbe), and non-LSP probing (all
|
||||
// private destination hops).
|
||||
//
|
||||
// Note: We test with exactly MaxLspsToProbe (3) LSPs. Testing with more LSPs
|
||||
// is not feasible because the LSP selection uses map iteration, which has
|
||||
// non-deterministic order in Go, making it impossible to predict which LSPs
|
||||
// will be probed.
|
||||
func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
// Ensure MaxLspsToProbe is set to 3 as expected by this test. The test
|
||||
// uses exactly 3 LSPs in the multi-LSP test case. If MaxLspsToProbe
|
||||
// changes, this assertion will fail as a reminder to update the test.
|
||||
require.Equal(ht, 3, routerrpc.MaxLspsToProbe,
|
||||
"MaxLspsToProbe should be 3")
|
||||
|
||||
mts := newMppTestScenario(ht)
|
||||
|
||||
// We extend the regular mpp test scenario with a new node Paula. Paula
|
||||
// is connected to Bob and Eve through private channels.
|
||||
// We extend the regular mpp test scenario with two new nodes:
|
||||
// - Paula: connected to Bob and Eve through private channels
|
||||
// - Frank: connected to Dave through a private channel
|
||||
//
|
||||
// /-------------\
|
||||
// _ Eve _ (private) \
|
||||
// / \ \
|
||||
// Alice -- Carol ---- Bob --------- Paula
|
||||
// \ / (private)
|
||||
// \__ Dave ____/
|
||||
// \
|
||||
// \__ Frank (private)
|
||||
//
|
||||
req := &mppOpenChannelRequest{
|
||||
amtAliceCarol: 200_000,
|
||||
@@ -88,6 +108,7 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
probeInitiator = mts.alice
|
||||
|
||||
paula := ht.NewNode("Paula", nil)
|
||||
frank := ht.NewNode("Frank", nil)
|
||||
|
||||
// The channel from Bob to Paula actually doesn't have enough liquidity
|
||||
// to carry out the probe. We assume in normal operation that hop hints
|
||||
@@ -106,6 +127,13 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
Amt: 1_000_000,
|
||||
})
|
||||
|
||||
// Frank is a private node connected to Dave (public LSP).
|
||||
ht.EnsureConnected(mts.dave, frank)
|
||||
ht.OpenChannel(mts.dave, frank, lntest.OpenChannelParams{
|
||||
Private: true,
|
||||
Amt: 1_000_000,
|
||||
})
|
||||
|
||||
bobsPrivChannels := mts.bob.RPC.ListChannels(&lnrpc.ListChannelsRequest{
|
||||
PrivateOnly: true,
|
||||
})
|
||||
@@ -118,6 +146,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
require.Len(ht, evesPrivChannels.Channels, 1)
|
||||
evePaulaChanID := evesPrivChannels.Channels[0].ChanId
|
||||
|
||||
davesPrivChannels := mts.dave.RPC.ListChannels(
|
||||
&lnrpc.ListChannelsRequest{
|
||||
PrivateOnly: true,
|
||||
},
|
||||
)
|
||||
require.Len(ht, davesPrivChannels.Channels, 1)
|
||||
daveFrankChanID := davesPrivChannels.Channels[0].ChanId
|
||||
|
||||
// Let's disable the paths from Alice to Bob through Dave and Eve with
|
||||
// high fees. This ensures that the path estimates are based on Carol's
|
||||
// channel to Bob for the first set of tests.
|
||||
@@ -196,6 +232,33 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
daveHopHint = &lnrpc.HopHint{
|
||||
NodeId: mts.dave.PubKeyStr,
|
||||
FeeBaseMsat: 3_000,
|
||||
FeeProportionalMillionths: 3_000,
|
||||
CltvExpiryDelta: 120,
|
||||
ChanId: daveFrankChanID,
|
||||
}
|
||||
|
||||
// Multiple different public LSPs (Bob, Eve, Dave).
|
||||
multipleLspsRouteHints = []*lnrpc.RouteHint{
|
||||
{
|
||||
HopHints: []*lnrpc.HopHint{
|
||||
bobHopHint,
|
||||
},
|
||||
},
|
||||
{
|
||||
HopHints: []*lnrpc.HopHint{
|
||||
eveHopHint,
|
||||
},
|
||||
},
|
||||
{
|
||||
HopHints: []*lnrpc.HopHint{
|
||||
daveHopHint,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
defaultTimelock := int64(chainreg.DefaultBitcoinTimeLockDelta)
|
||||
@@ -231,6 +294,14 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
feeACEP := feeEP + feeCE
|
||||
deltaACEP := deltaCE + deltaEP
|
||||
|
||||
// For multiple LSPs test, the route with the highest fee should be
|
||||
// selected (Eve). Note that we return both fee and CLTV delta from
|
||||
// the same route (the highest-fee route), not the max fee and max
|
||||
// delta independently. This ensures the returned values represent an
|
||||
// actual viable route.
|
||||
highestFeeRouteFee := feeACEP
|
||||
highestFeeRouteDelta := deltaACEP
|
||||
|
||||
initialBlockHeight := int64(mts.alice.RPC.GetInfo().BlockHeight)
|
||||
|
||||
// Locktime is always composed of the initial block height and the
|
||||
@@ -271,6 +342,19 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
expectedCltvDelta: locktime + deltaCB,
|
||||
expectedFailureReason: failureReasonNone,
|
||||
},
|
||||
// Rule 1: Invoice target is public (Bob), even with public
|
||||
// destination hop hints. Should route directly to Bob, NOT
|
||||
// treat as LSP.
|
||||
{
|
||||
name: "probe based estimate, public " +
|
||||
"target with public hop hints",
|
||||
probing: true,
|
||||
destination: mts.bob,
|
||||
routeHints: singleRouteHint,
|
||||
expectedRoutingFeesMsat: feeStandardSingleHop,
|
||||
expectedCltvDelta: locktime + deltaCB,
|
||||
expectedFailureReason: failureReasonNone,
|
||||
},
|
||||
// We expect the previous probing results adjusted by Paula's
|
||||
// hop data.
|
||||
{
|
||||
@@ -340,6 +424,23 @@ func testEstimateRouteFee(ht *lntest.HarnessTest) {
|
||||
expectedCltvDelta: 0,
|
||||
expectedFailureReason: failureReasonNoRoute,
|
||||
},
|
||||
// Test multiple different public LSPs. The worst-case (most
|
||||
// expensive) route should be returned. Eve has the highest
|
||||
// fees among the 3 LSPs tested. Note: We don't test with more
|
||||
// than MaxLspsToProbe LSPs because map iteration order in Go
|
||||
// is non-deterministic, making it impossible to predict which
|
||||
// LSPs will be selected for probing.
|
||||
{
|
||||
name: "probe based estimate, " +
|
||||
"multiple different public LSPs",
|
||||
probing: true,
|
||||
destination: frank,
|
||||
routeHints: multipleLspsRouteHints,
|
||||
expectedRoutingFeesMsat: highestFeeRouteFee,
|
||||
expectedCltvDelta: locktime +
|
||||
highestFeeRouteDelta,
|
||||
expectedFailureReason: failureReasonNone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -64,6 +64,11 @@ type RouterBackend struct {
|
||||
FetchChannelEndpoints func(chanID uint64) (route.Vertex,
|
||||
route.Vertex, error)
|
||||
|
||||
// HasNode returns true if the node exists in the graph (i.e., has
|
||||
// public channels), false otherwise. This means the node is a public
|
||||
// node and should be reachable.
|
||||
HasNode func(nodePub route.Vertex) (bool, error)
|
||||
|
||||
// FindRoute is a closure that abstracts away how we locate/query for
|
||||
// routes.
|
||||
FindRoute func(*routing.RouteRequest) (*route.Route, float64, error)
|
||||
|
||||
+304
-157
@@ -1,7 +1,6 @@
|
||||
package routerrpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"errors"
|
||||
@@ -44,6 +43,12 @@ const (
|
||||
// DefaultPaymentTimeout is the default value of time we should spend
|
||||
// when attempting to fulfill the payment.
|
||||
DefaultPaymentTimeout int32 = 60
|
||||
|
||||
// MaxLspsToProbe is the maximum number of LSPs to probe when
|
||||
// estimating fees for worst-case fee estimation. This is a
|
||||
// precautionary measure to prevent the estimation from taking too
|
||||
// long, and it is also a griefing protection.
|
||||
MaxLspsToProbe = 3
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -171,10 +176,9 @@ var (
|
||||
DefaultRouterMacFilename = "router.macaroon"
|
||||
)
|
||||
|
||||
// FetchChannelEndpoints returns the pubkeys of both endpoints of the
|
||||
// given channel id if it exists in the graph.
|
||||
type FetchChannelEndpoints func(chanID uint64) (route.Vertex, route.Vertex,
|
||||
error)
|
||||
// HasNode returns true if the node exists in the graph (i.e., has public
|
||||
// channels), false otherwise.
|
||||
type HasNode func(nodePub route.Vertex) (bool, error)
|
||||
|
||||
// ServerShell is a shell struct holding a reference to the actual sub-server.
|
||||
// It is used to register the gRPC sub-server with the root server before we
|
||||
@@ -561,7 +565,8 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
|
||||
// If the hints don't indicate an LSP then chances are that our probe
|
||||
// payment won't be blocked along the route to the destination. We send
|
||||
// a probe payment with unmodified route hints.
|
||||
if !isLSP(hints, s.cfg.RouterBackend.FetchChannelEndpoints) {
|
||||
invoiceTargetCompressed := payReq.Destination.SerializeCompressed()
|
||||
if !isLSP(hints, invoiceTargetCompressed, s.cfg.RouterBackend.HasNode) {
|
||||
log.Infof("No LSP detected, probing destination %x",
|
||||
probeRequest.Dest)
|
||||
|
||||
@@ -569,200 +574,342 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
|
||||
return s.sendProbePayment(ctx, probeRequest)
|
||||
}
|
||||
|
||||
// If the heuristic indicates an LSP we modify the route hints to allow
|
||||
// probing the LSP.
|
||||
lspAdjustedRouteHints, lspHint, err := prepareLspRouteHints(
|
||||
hints, *payReq.MilliSat,
|
||||
// If the heuristic indicates an LSP, we filter and group route hints by
|
||||
// public LSP nodes, then probe each unique LSP separately and return
|
||||
// the cheapest route.
|
||||
lspGroups, err := prepareLspRouteHints(
|
||||
hints, *payReq.MilliSat, s.cfg.RouterBackend.HasNode,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set the destination to the LSP node ID.
|
||||
lspDest := lspHint.NodeID.SerializeCompressed()
|
||||
probeRequest.Dest = lspDest
|
||||
log.Infof("LSP detected, found %d unique public LSP node(s) to probe",
|
||||
len(lspGroups))
|
||||
|
||||
log.Infof("LSP detected, probing LSP with destination: %x", lspDest)
|
||||
|
||||
// The adjusted route hints serve the payment probe to find the last
|
||||
// public hop to the LSP on the route.
|
||||
if len(lspAdjustedRouteHints) > 0 {
|
||||
probeRequest.RouteHints = invoicesrpc.CreateRPCRouteHints(
|
||||
lspAdjustedRouteHints,
|
||||
)
|
||||
// Probe up to MaxLspsToProbe LSPs and track the most expensive route
|
||||
// for worst-case fee estimation.
|
||||
if len(lspGroups) > MaxLspsToProbe {
|
||||
log.Debugf("Limiting LSP probes from %d to %d for worst-case "+
|
||||
"fee estimation", len(lspGroups), MaxLspsToProbe)
|
||||
}
|
||||
var (
|
||||
worstCaseResp *RouteFeeResponse
|
||||
worstCaseLspDest route.Vertex
|
||||
probeCount int
|
||||
)
|
||||
|
||||
// The payment probe will be able to calculate the fee up until the LSP
|
||||
// node. The fee of the last hop has to be calculated manually. Since
|
||||
// the last hop's fee amount has to be sent across the payment path we
|
||||
// have to add it to the original payment amount. Only then will the
|
||||
// payment probe be able to determine the correct fee to the last hop
|
||||
// prior to the private destination. For example, if the user wants to
|
||||
// send 1000 sats to a private destination and the last hop's fee is 10
|
||||
// sats, then 1010 sats will have to arrive at the last hop. This means
|
||||
// that the probe has to be dispatched with 1010 sats to correctly
|
||||
// calculate the routing fee.
|
||||
//
|
||||
// Calculate the hop fee for the last hop manually.
|
||||
hopFee := lspHint.HopFee(*payReq.MilliSat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for lspKey, group := range lspGroups {
|
||||
if probeCount >= MaxLspsToProbe {
|
||||
break
|
||||
}
|
||||
probeCount++
|
||||
|
||||
// Add the last hop's fee to the requested payment amount that we want
|
||||
// to get an estimate for.
|
||||
probeRequest.AmtMsat += int64(hopFee)
|
||||
lspHint := group.LspHopHint
|
||||
|
||||
// Use the hop hint's cltv delta as the payment request's final cltv
|
||||
// delta. The actual final cltv delta of the invoice will be added to
|
||||
// the payment probe's cltv delta.
|
||||
probeRequest.FinalCltvDelta = int32(lspHint.CLTVExpiryDelta)
|
||||
log.Infof("Probing LSP with destination: %v", lspKey)
|
||||
|
||||
// Dispatch the payment probe with adjusted fee amount.
|
||||
resp, err := s.sendProbePayment(ctx, probeRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send probe payment to "+
|
||||
"LSP with destination %x: %w", lspDest, err)
|
||||
}
|
||||
// Create a new probe request for this LSP.
|
||||
lspProbeRequest := &SendPaymentRequest{
|
||||
TimeoutSeconds: probeRequest.TimeoutSeconds,
|
||||
Dest: lspKey[:],
|
||||
MaxParts: probeRequest.MaxParts,
|
||||
AllowSelfPayment: probeRequest.AllowSelfPayment,
|
||||
AmtMsat: amtMsat,
|
||||
PaymentHash: probeRequest.PaymentHash,
|
||||
FeeLimitSat: probeRequest.FeeLimitSat,
|
||||
FinalCltvDelta: int32(lspHint.CLTVExpiryDelta),
|
||||
DestFeatures: probeRequest.DestFeatures,
|
||||
}
|
||||
|
||||
// If the payment probe failed we only return the failure reason and
|
||||
// leave the probe result params unaltered.
|
||||
if resp.FailureReason != lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { //nolint:ll
|
||||
return resp, nil
|
||||
}
|
||||
// Copy the payment address if present.
|
||||
if len(probeRequest.PaymentAddr) > 0 {
|
||||
copy(
|
||||
lspProbeRequest.PaymentAddr,
|
||||
probeRequest.PaymentAddr,
|
||||
)
|
||||
}
|
||||
|
||||
// The probe succeeded, so we can add the last hop's fee to fee the
|
||||
// payment probe returned.
|
||||
resp.RoutingFeeMsat += int64(hopFee)
|
||||
// Set the adjusted route hints for this LSP.
|
||||
if len(group.AdjustedRouteHints) > 0 {
|
||||
lspProbeRequest.RouteHints = invoicesrpc.
|
||||
CreateRPCRouteHints(group.AdjustedRouteHints)
|
||||
}
|
||||
|
||||
// Add the final cltv delta of the invoice to the payment probe's total
|
||||
// cltv delta. This is the cltv delta for the hop behind the LSP.
|
||||
resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry())
|
||||
// Calculate the hop fee for the last hop manually.
|
||||
hopFee := lspHint.HopFee(*payReq.MilliSat)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
// Add the last hop's fee to the probe amount.
|
||||
lspProbeRequest.AmtMsat += int64(hopFee)
|
||||
|
||||
// isLSP checks if the route hints indicate an LSP. An LSP is indicated with
|
||||
// true if the destination hop hint in each route hint has the same node id,
|
||||
// false otherwise. If the destination hop hint of any route hint contains a
|
||||
// public channel, the function returns false because we can directly send a
|
||||
// probe to the final destination.
|
||||
func isLSP(routeHints [][]zpay32.HopHint,
|
||||
fetchChannelEndpoints FetchChannelEndpoints) bool {
|
||||
|
||||
if len(routeHints) == 0 || len(routeHints[0]) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
destHopHint := routeHints[0][len(routeHints[0])-1]
|
||||
|
||||
// If the destination hop hint of the first route hint contains a public
|
||||
// channel we can send a probe to it directly, hence we don't signal an
|
||||
// LSP.
|
||||
_, _, err := fetchChannelEndpoints(destHopHint.ChannelID)
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 1; i < len(routeHints); i++ {
|
||||
// Skip empty route hints.
|
||||
if len(routeHints[i]) == 0 {
|
||||
// Dispatch the payment probe for this LSP.
|
||||
resp, err := s.sendProbePayment(ctx, lspProbeRequest)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to probe LSP %v: %v", lspKey, err)
|
||||
continue
|
||||
}
|
||||
|
||||
lastHop := routeHints[i][len(routeHints[i])-1]
|
||||
// If the probe failed, skip this LSP.
|
||||
if resp.FailureReason !=
|
||||
lnrpc.PaymentFailureReason_FAILURE_REASON_NONE {
|
||||
|
||||
// If the last hop hint of any route hint contains a public
|
||||
// channel we can send a probe to it directly, hence we don't
|
||||
// signal an LSP.
|
||||
_, _, err = fetchChannelEndpoints(lastHop.ChannelID)
|
||||
if err == nil {
|
||||
return false
|
||||
log.Debugf("Probe to LSP %v failed with reason: %v",
|
||||
lspKey, resp.FailureReason)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
matchesDestNode := bytes.Equal(
|
||||
lastHop.NodeID.SerializeCompressed(),
|
||||
destHopHint.NodeID.SerializeCompressed(),
|
||||
)
|
||||
if !matchesDestNode {
|
||||
// The probe succeeded, add the last hop's fee.
|
||||
resp.RoutingFeeMsat += int64(hopFee)
|
||||
|
||||
// Add the final cltv delta of the invoice.
|
||||
resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry())
|
||||
|
||||
log.Infof("Probe to LSP %v succeeded with fee: %d msat",
|
||||
lspKey, resp.RoutingFeeMsat)
|
||||
|
||||
// Track the most expensive route for worst-case estimation.
|
||||
// We solely consider the routing fee for the worst-case
|
||||
// estimation.
|
||||
if worstCaseResp == nil ||
|
||||
resp.RoutingFeeMsat > worstCaseResp.RoutingFeeMsat {
|
||||
|
||||
if worstCaseResp != nil {
|
||||
log.Debugf("LSP %v has higher fee "+
|
||||
"(%d msat) than current worst-case "+
|
||||
"%v (%d msat), updating worst-case "+
|
||||
"estimate", lspKey,
|
||||
resp.RoutingFeeMsat, worstCaseLspDest,
|
||||
worstCaseResp.RoutingFeeMsat)
|
||||
}
|
||||
|
||||
worstCaseResp = resp
|
||||
worstCaseLspDest = lspKey
|
||||
} else {
|
||||
log.Debugf("LSP %v fee (%d msat) is lower than "+
|
||||
"current worst-case %v (%d msat), keeping "+
|
||||
"worst-case estimate", lspKey,
|
||||
resp.RoutingFeeMsat, worstCaseLspDest,
|
||||
worstCaseResp.RoutingFeeMsat)
|
||||
}
|
||||
}
|
||||
|
||||
// If no LSP probe succeeded, return an error.
|
||||
if worstCaseResp == nil {
|
||||
return nil, fmt.Errorf("all LSP probe payments failed")
|
||||
}
|
||||
|
||||
log.Infof("Returning worst-case route via LSP %v with fee: %d msat, "+
|
||||
"timelock: %d", worstCaseLspDest, worstCaseResp.RoutingFeeMsat,
|
||||
worstCaseResp.TimeLockDelay)
|
||||
|
||||
return worstCaseResp, nil
|
||||
}
|
||||
|
||||
// isLSP checks if the route hints indicate an LSP setup. An LSP setup is
|
||||
// identified when the invoice destination is private but the final hop in the
|
||||
// route hints is a public node (the LSP). This function implements three rules:
|
||||
//
|
||||
// 1. If the invoice target is a public node (exists in graph) => isLsp = false
|
||||
// We can route directly to the target, so no LSP is involved.
|
||||
//
|
||||
// 2. If at least one destination hop hint (last hop in route hint) is public
|
||||
// => isLsp = true. The public destination hop is the LSP, and the actual
|
||||
// invoice target is a private node behind it.
|
||||
//
|
||||
// 3. If all destination hop hints are private nodes => isLsp = false.
|
||||
// We assume this is NOT an LSP setup. Instead, we expect the route hints
|
||||
// contain public nodes earlier in the path (not the final hop) that our
|
||||
// pathfinder can route to. For example:
|
||||
// The pathfinder will route to PublicNode and use the hints from there.
|
||||
// Note: If no public nodes exist anywhere in the route hints, the
|
||||
// destination would be unreachable (malformed invoice), but we don't
|
||||
// validate that here.
|
||||
func isLSP(routeHints [][]zpay32.HopHint, invoiceTarget []byte,
|
||||
hasNode HasNode) bool {
|
||||
|
||||
if len(routeHints) == 0 || len(routeHints[0]) == 0 {
|
||||
log.Debugf("No route hints provided, this is not an LSP setup")
|
||||
return false
|
||||
}
|
||||
|
||||
// Rule 1: If the invoice target is a public node (exists in the graph),
|
||||
// we can route directly to it, so it's not an LSP setup.
|
||||
if len(invoiceTarget) > 0 {
|
||||
var targetVertex route.Vertex
|
||||
copy(targetVertex[:], invoiceTarget)
|
||||
|
||||
isPublic, err := hasNode(targetVertex)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to check if invoice target %x is "+
|
||||
"public: %v", invoiceTarget, err)
|
||||
|
||||
return false
|
||||
}
|
||||
if isPublic {
|
||||
log.Infof("Invoice target %x is a public node in the "+
|
||||
"graph, this is NOT an LSP setup",
|
||||
invoiceTarget)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// We ensured that the destination hop hint doesn't contain a public
|
||||
// channel, and that all destination hop hints of all route hints match,
|
||||
// so we signal an LSP.
|
||||
return true
|
||||
for _, hopHints := range routeHints {
|
||||
// Skip empty route hints.
|
||||
if len(hopHints) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
lastHop := hopHints[len(hopHints)-1]
|
||||
lastHopNodeCompressed := lastHop.NodeID.SerializeCompressed()
|
||||
|
||||
// Check if this destination hop hint node is public.
|
||||
// Rule 2: If we find a public node, we can exit early.
|
||||
var lastHopVertex route.Vertex
|
||||
copy(lastHopVertex[:], lastHopNodeCompressed)
|
||||
|
||||
isPublic, err := hasNode(lastHopVertex)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to check if destination hop "+
|
||||
"hint %x is public: %v", lastHopNodeCompressed,
|
||||
err)
|
||||
|
||||
continue
|
||||
}
|
||||
if isPublic {
|
||||
log.Infof("Destination hop hint %x is a public node, "+
|
||||
"this is an LSP setup", lastHopNodeCompressed)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: If all destination hop hints are private nodes (not in the
|
||||
// graph), this is NOT an LSP setup. We assume the route hints contain
|
||||
// public nodes earlier in the path that we can route through using
|
||||
// standard pathfinding with the hints.
|
||||
log.Infof("All destination hop hints are private, this is NOT an " +
|
||||
"LSP setup")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// LspRouteGroup represents a group of route hints that share the same public
|
||||
// LSP destination node. This is needed when probing LSPs separately to find
|
||||
// the cheapest route.
|
||||
type LspRouteGroup struct {
|
||||
// LspHopHint is the hop hint for the LSP node with worst-case fees and
|
||||
// CLTV delta.
|
||||
LspHopHint *zpay32.HopHint
|
||||
|
||||
// AdjustedRouteHints are the route hints with the LSP hop stripped off.
|
||||
AdjustedRouteHints [][]zpay32.HopHint
|
||||
}
|
||||
|
||||
// prepareLspRouteHints assumes that the isLsp heuristic returned true for the
|
||||
// route hints passed in here. It constructs a modified list of route hints that
|
||||
// allows the caller to probe the LSP, which itself is returned as a separate
|
||||
// hop hint.
|
||||
// route hints passed in here. It filters route hints to only include those with
|
||||
// public destination nodes, groups them by unique LSP node, and returns a map
|
||||
// of LSP groups keyed by the LSP node's compressed public key.
|
||||
func prepareLspRouteHints(routeHints [][]zpay32.HopHint,
|
||||
amt lnwire.MilliSatoshi) ([][]zpay32.HopHint, *zpay32.HopHint, error) {
|
||||
amt lnwire.MilliSatoshi,
|
||||
hasNode HasNode) (map[route.Vertex]*LspRouteGroup, error) {
|
||||
|
||||
// This should never happen, but we check for it for completeness.
|
||||
// Because the isLSP heuristic already checked that the route hints are
|
||||
// not empty.
|
||||
if len(routeHints) == 0 {
|
||||
return nil, nil, fmt.Errorf("no route hints provided")
|
||||
return nil, fmt.Errorf("no route hints provided")
|
||||
}
|
||||
|
||||
// Create the LSP hop hint. We are probing for the worst case fee and
|
||||
// cltv delta. So we look for the max values amongst all LSP hop hints.
|
||||
refHint := routeHints[0][len(routeHints[0])-1]
|
||||
refHint.CLTVExpiryDelta = maxLspCltvDelta(routeHints)
|
||||
refHint.FeeBaseMSat, refHint.FeeProportionalMillionths = maxLspFee(
|
||||
routeHints, amt,
|
||||
)
|
||||
// Map to group route hints by LSP node pubkey.
|
||||
lspGroups := make(map[route.Vertex]*LspRouteGroup)
|
||||
|
||||
// We construct a modified list of route hints that allows the caller to
|
||||
// probe the LSP.
|
||||
adjustedHints := make([][]zpay32.HopHint, 0, len(routeHints))
|
||||
for _, routeHint := range routeHints {
|
||||
// Skip empty route hints.
|
||||
if len(routeHint) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip off the LSP hop hint from all route hints.
|
||||
for i := 0; i < len(routeHints); i++ {
|
||||
hint := routeHints[i]
|
||||
if len(hint) > 1 {
|
||||
adjustedHints = append(
|
||||
adjustedHints, hint[:len(hint)-1],
|
||||
// Get the destination hop hint (last hop in the route).
|
||||
destHop := routeHint[len(routeHint)-1]
|
||||
destNodeCompressed := destHop.NodeID.SerializeCompressed()
|
||||
|
||||
// Check if this destination node is public.
|
||||
var destVertex route.Vertex
|
||||
copy(destVertex[:], destNodeCompressed)
|
||||
|
||||
isPublic, err := hasNode(destVertex)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to check if dest hop hint %x is "+
|
||||
"public: %v", destNodeCompressed, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip private destination nodes - we only probe public LSPs.
|
||||
if !isPublic {
|
||||
log.Debugf("Skipping route hint with private dest "+
|
||||
"node %x", destNodeCompressed)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the compressed pubkey as the map key.
|
||||
var lspKey route.Vertex
|
||||
copy(lspKey[:], destNodeCompressed)
|
||||
|
||||
// Get or create the LSP group for this node.
|
||||
group, exists := lspGroups[lspKey]
|
||||
if !exists {
|
||||
//nolint:ll
|
||||
lspHop := zpay32.HopHint{
|
||||
NodeID: destHop.NodeID,
|
||||
ChannelID: destHop.ChannelID,
|
||||
FeeBaseMSat: destHop.FeeBaseMSat,
|
||||
FeeProportionalMillionths: destHop.FeeProportionalMillionths,
|
||||
CLTVExpiryDelta: destHop.CLTVExpiryDelta,
|
||||
}
|
||||
group = &LspRouteGroup{
|
||||
LspHopHint: &lspHop,
|
||||
AdjustedRouteHints: make([][]zpay32.HopHint, 0),
|
||||
}
|
||||
lspGroups[lspKey] = group
|
||||
}
|
||||
|
||||
// Update the LSP hop hint with worst-case (max) fees and CLTV.
|
||||
hopFee := destHop.HopFee(amt)
|
||||
currentMaxFee := group.LspHopHint.HopFee(amt)
|
||||
if hopFee > currentMaxFee {
|
||||
group.LspHopHint.FeeBaseMSat = destHop.FeeBaseMSat
|
||||
group.LspHopHint.FeeProportionalMillionths = destHop.
|
||||
FeeProportionalMillionths
|
||||
}
|
||||
|
||||
if destHop.CLTVExpiryDelta > group.LspHopHint.CLTVExpiryDelta {
|
||||
group.LspHopHint.CLTVExpiryDelta = destHop.
|
||||
CLTVExpiryDelta
|
||||
}
|
||||
|
||||
// Add the route hint with the LSP hop stripped off (if there
|
||||
// are hops before the LSP).
|
||||
if len(routeHint) > 1 {
|
||||
group.AdjustedRouteHints = append(
|
||||
group.AdjustedRouteHints,
|
||||
routeHint[:len(routeHint)-1],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return adjustedHints, &refHint, nil
|
||||
}
|
||||
|
||||
// maxLspFee returns base fee and fee rate amongst all LSP route hints that
|
||||
// results in the overall highest fee for the given amount.
|
||||
func maxLspFee(routeHints [][]zpay32.HopHint, amt lnwire.MilliSatoshi) (uint32,
|
||||
uint32) {
|
||||
|
||||
var maxFeePpm uint32
|
||||
var maxBaseFee uint32
|
||||
var maxTotalFee lnwire.MilliSatoshi
|
||||
for _, rh := range routeHints {
|
||||
lastHop := rh[len(rh)-1]
|
||||
lastHopFee := lastHop.HopFee(amt)
|
||||
if lastHopFee > maxTotalFee {
|
||||
maxTotalFee = lastHopFee
|
||||
maxBaseFee = lastHop.FeeBaseMSat
|
||||
maxFeePpm = lastHop.FeeProportionalMillionths
|
||||
}
|
||||
if len(lspGroups) == 0 {
|
||||
return nil, fmt.Errorf("no public LSP nodes found in " +
|
||||
"route hints")
|
||||
}
|
||||
|
||||
return maxBaseFee, maxFeePpm
|
||||
}
|
||||
log.Infof("Found %d unique public LSP node(s) in route hints",
|
||||
len(lspGroups))
|
||||
|
||||
// maxLspCltvDelta returns the maximum cltv delta amongst all LSP route hints.
|
||||
func maxLspCltvDelta(routeHints [][]zpay32.HopHint) uint16 {
|
||||
var maxCltvDelta uint16
|
||||
for _, rh := range routeHints {
|
||||
rhLastHop := rh[len(rh)-1]
|
||||
if rhLastHop.CLTVExpiryDelta > maxCltvDelta {
|
||||
maxCltvDelta = rhLastHop.CLTVExpiryDelta
|
||||
}
|
||||
}
|
||||
|
||||
return maxCltvDelta
|
||||
return lspGroups, nil
|
||||
}
|
||||
|
||||
// probePaymentStream is a custom implementation of the grpc.ServerStream
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package routerrpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
||||
"github.com/lightningnetwork/lnd/lnrpc"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
|
||||
@@ -220,12 +220,18 @@ func TestTrackPaymentsNoInflightUpdates(t *testing.T) {
|
||||
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
|
||||
}
|
||||
|
||||
// TestIsLsp tests the isLSP heuristic. Combinations of different route hints
|
||||
// with different fees and cltv deltas are tested to ensure that the heuristic
|
||||
// correctly identifies whether a route leads to an LSP or not.
|
||||
// TestIsLsp tests the isLSP heuristic. It validates all three LSP detection
|
||||
// rules:
|
||||
// Rule 1: Invoice target is public => not LSP.
|
||||
// Rule 2: All destination hop hints are private => not LSP (Boltz case).
|
||||
// Rule 3: At least one destination hop hint is public => LSP (Muun case).
|
||||
func TestIsLsp(t *testing.T) {
|
||||
probeAmtMsat := lnwire.MilliSatoshi(1_000_000)
|
||||
|
||||
// Setup test nodes:
|
||||
// - Alice: public node (in graph)
|
||||
// - Bob: private node
|
||||
// - Carol: private node
|
||||
// - Dave: public node (in graph)
|
||||
// - Eve: private node
|
||||
alicePrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
alicePubKey := alicePrivKey.PubKey()
|
||||
@@ -242,216 +248,519 @@ func TestIsLsp(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
davePubKey := davePrivKey.PubKey()
|
||||
|
||||
var (
|
||||
aliceHopHint = zpay32.HopHint{
|
||||
NodeID: alicePubKey,
|
||||
FeeBaseMSat: 100,
|
||||
FeeProportionalMillionths: 1_000,
|
||||
ChannelID: 421337,
|
||||
}
|
||||
evePrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
evePubKey := evePrivKey.PubKey()
|
||||
|
||||
bobHopHint = zpay32.HopHint{
|
||||
NodeID: bobPubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
CLTVExpiryDelta: 288,
|
||||
ChannelID: 815,
|
||||
}
|
||||
// Create hop hints for each node.
|
||||
aliceHopHint := zpay32.HopHint{
|
||||
NodeID: alicePubKey,
|
||||
FeeBaseMSat: 100,
|
||||
FeeProportionalMillionths: 1_000,
|
||||
CLTVExpiryDelta: 40,
|
||||
ChannelID: 1,
|
||||
}
|
||||
|
||||
carolHopHint = zpay32.HopHint{
|
||||
NodeID: carolPubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
ChannelID: 815,
|
||||
}
|
||||
bobHopHint := zpay32.HopHint{
|
||||
NodeID: bobPubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
CLTVExpiryDelta: 144,
|
||||
ChannelID: 2,
|
||||
}
|
||||
|
||||
daveHopHint = zpay32.HopHint{
|
||||
NodeID: davePubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
ChannelID: 815,
|
||||
}
|
||||
carolHopHint := zpay32.HopHint{
|
||||
NodeID: carolPubKey,
|
||||
FeeBaseMSat: 1_500,
|
||||
FeeProportionalMillionths: 1_500,
|
||||
CLTVExpiryDelta: 144,
|
||||
ChannelID: 3,
|
||||
}
|
||||
|
||||
publicChannelID = uint64(42)
|
||||
daveHopHintPublicChan = zpay32.HopHint{
|
||||
NodeID: davePubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
ChannelID: publicChannelID,
|
||||
}
|
||||
)
|
||||
daveHopHint := zpay32.HopHint{
|
||||
NodeID: davePubKey,
|
||||
FeeBaseMSat: 3_000,
|
||||
FeeProportionalMillionths: 3_000,
|
||||
CLTVExpiryDelta: 288,
|
||||
ChannelID: 4,
|
||||
}
|
||||
|
||||
bobExpensiveCopy := bobHopHint.Copy()
|
||||
bobExpensiveCopy.FeeBaseMSat = 1_000_000
|
||||
bobExpensiveCopy.FeeProportionalMillionths = 1_000_000
|
||||
bobExpensiveCopy.CLTVExpiryDelta = bobHopHint.CLTVExpiryDelta - 1
|
||||
eveHopHint := zpay32.HopHint{
|
||||
NodeID: evePubKey,
|
||||
FeeBaseMSat: 500,
|
||||
FeeProportionalMillionths: 500,
|
||||
CLTVExpiryDelta: 40,
|
||||
ChannelID: 5,
|
||||
}
|
||||
|
||||
//nolint:ll
|
||||
lspTestCases := []struct {
|
||||
name string
|
||||
routeHints [][]zpay32.HopHint
|
||||
probeAmtMsat lnwire.MilliSatoshi
|
||||
isLsp bool
|
||||
expectedHints [][]zpay32.HopHint
|
||||
expectedLspHop *zpay32.HopHint
|
||||
// Mock hasNode: returns true only for alice and dave.
|
||||
hasNode := func(nodePub route.Vertex) (bool, error) {
|
||||
aliceVertex := route.NewVertex(alicePubKey)
|
||||
daveVertex := route.NewVertex(davePubKey)
|
||||
return bytes.Equal(nodePub[:], aliceVertex[:]) ||
|
||||
bytes.Equal(nodePub[:], daveVertex[:]), nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
routeHints [][]zpay32.HopHint
|
||||
invoiceTarget []byte
|
||||
expectLSP bool
|
||||
}{
|
||||
// Edge cases.
|
||||
{
|
||||
name: "empty route hints",
|
||||
routeHints: [][]zpay32.HopHint{{}},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: false,
|
||||
expectedHints: [][]zpay32.HopHint{},
|
||||
expectedLspHop: nil,
|
||||
name: "no route hints",
|
||||
routeHints: [][]zpay32.HopHint{},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "single route hint",
|
||||
routeHints: [][]zpay32.HopHint{{daveHopHint}},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: true,
|
||||
expectedHints: [][]zpay32.HopHint{},
|
||||
expectedLspHop: &daveHopHint,
|
||||
name: "empty route hint array",
|
||||
routeHints: [][]zpay32.HopHint{{}},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: false,
|
||||
},
|
||||
|
||||
// Rule 1: Invoice target is public => NOT an LSP.
|
||||
// Rationale: Can route directly to public target.
|
||||
{
|
||||
name: "single route, multiple hints",
|
||||
routeHints: [][]zpay32.HopHint{{
|
||||
aliceHopHint, bobHopHint,
|
||||
}},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: true,
|
||||
expectedHints: [][]zpay32.HopHint{{aliceHopHint}},
|
||||
expectedLspHop: &bobHopHint,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, multiple hints",
|
||||
name: "invoice target is public (alice)",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
aliceHopHint, bobHopHint,
|
||||
},
|
||||
{
|
||||
carolHopHint, bobHopHint,
|
||||
},
|
||||
{bobHopHint, carolHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: true,
|
||||
expectedHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint}, {carolHopHint},
|
||||
},
|
||||
expectedLspHop: &bobHopHint,
|
||||
invoiceTarget: alicePubKey.SerializeCompressed(),
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, multiple hints with min length",
|
||||
name: "invoice target is public with public dest hop",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
bobHopHint,
|
||||
},
|
||||
{
|
||||
carolHopHint, bobHopHint,
|
||||
},
|
||||
{bobHopHint, daveHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: true,
|
||||
expectedHints: [][]zpay32.HopHint{
|
||||
{carolHopHint},
|
||||
},
|
||||
expectedLspHop: &bobHopHint,
|
||||
invoiceTarget: davePubKey.SerializeCompressed(),
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, multiple hints, diff fees+cltv",
|
||||
name: "invoice target is public with multiple routes",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
bobHopHint,
|
||||
},
|
||||
{
|
||||
carolHopHint, bobExpensiveCopy,
|
||||
},
|
||||
{bobHopHint, carolHopHint},
|
||||
{aliceHopHint, daveHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: true,
|
||||
expectedHints: [][]zpay32.HopHint{
|
||||
{carolHopHint},
|
||||
},
|
||||
expectedLspHop: &zpay32.HopHint{
|
||||
NodeID: bobHopHint.NodeID,
|
||||
ChannelID: bobHopHint.ChannelID,
|
||||
FeeBaseMSat: bobExpensiveCopy.FeeBaseMSat,
|
||||
FeeProportionalMillionths: bobExpensiveCopy.FeeProportionalMillionths,
|
||||
CLTVExpiryDelta: bobHopHint.CLTVExpiryDelta,
|
||||
invoiceTarget: alicePubKey.SerializeCompressed(),
|
||||
expectLSP: false,
|
||||
},
|
||||
|
||||
// Rule 2: All destination hop hints are private => NOT an LSP.
|
||||
// Rationale: The destination hop hint is private so it cannot
|
||||
// be probed so we default to NOT an LSP.
|
||||
{
|
||||
name: "single route to private dest",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint, bobHopHint},
|
||||
},
|
||||
invoiceTarget: bobPubKey.SerializeCompressed(),
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, different final hops",
|
||||
name: "multiple routes, all to private dests",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
aliceHopHint, bobHopHint,
|
||||
},
|
||||
{
|
||||
carolHopHint, daveHopHint,
|
||||
},
|
||||
{aliceHopHint, bobHopHint},
|
||||
{daveHopHint, carolHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: false,
|
||||
expectedHints: [][]zpay32.HopHint{},
|
||||
expectedLspHop: nil,
|
||||
invoiceTarget: nil,
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, same public hops",
|
||||
name: "single hop to private node",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
aliceHopHint, daveHopHintPublicChan,
|
||||
},
|
||||
{
|
||||
carolHopHint, daveHopHintPublicChan,
|
||||
},
|
||||
{eveHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: false,
|
||||
expectedHints: [][]zpay32.HopHint{},
|
||||
expectedLspHop: nil,
|
||||
invoiceTarget: evePubKey.SerializeCompressed(),
|
||||
expectLSP: false,
|
||||
},
|
||||
{
|
||||
name: "multiple routes, same public hops",
|
||||
name: "all routes to same private node",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{
|
||||
aliceHopHint, daveHopHint,
|
||||
},
|
||||
{
|
||||
carolHopHint, daveHopHintPublicChan,
|
||||
},
|
||||
{
|
||||
aliceHopHint, daveHopHintPublicChan,
|
||||
},
|
||||
{aliceHopHint, bobHopHint},
|
||||
{daveHopHint, bobHopHint},
|
||||
{carolHopHint, bobHopHint},
|
||||
},
|
||||
probeAmtMsat: probeAmtMsat,
|
||||
isLsp: false,
|
||||
expectedHints: [][]zpay32.HopHint{},
|
||||
expectedLspHop: nil,
|
||||
invoiceTarget: nil,
|
||||
expectLSP: false,
|
||||
},
|
||||
|
||||
// Rule 3: At least one destination hop is public => IS an LSP.
|
||||
// Rationale: As long as there is at least one public
|
||||
// destination route hint, it is an LSP setup and can be probed.
|
||||
{
|
||||
name: "single route to public dest (dave)",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, daveHopHint},
|
||||
},
|
||||
invoiceTarget: evePubKey.SerializeCompressed(),
|
||||
expectLSP: true,
|
||||
},
|
||||
{
|
||||
name: "direct hop to public LSP (alice)",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint},
|
||||
},
|
||||
invoiceTarget: bobPubKey.SerializeCompressed(),
|
||||
expectLSP: true,
|
||||
},
|
||||
{
|
||||
name: "multiple routes to same public LSP (dave)",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, daveHopHint},
|
||||
{carolHopHint, daveHopHint},
|
||||
{eveHopHint, daveHopHint},
|
||||
},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: true,
|
||||
},
|
||||
{
|
||||
name: "multiple routes to different public LSPs",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint},
|
||||
{carolHopHint, daveHopHint},
|
||||
},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: true,
|
||||
},
|
||||
{
|
||||
name: "mixed public and private dest hops",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint, bobHopHint},
|
||||
{carolHopHint, daveHopHint},
|
||||
{bobHopHint, eveHopHint},
|
||||
},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: true,
|
||||
},
|
||||
{
|
||||
name: "first route has public dest, rest private",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint},
|
||||
{carolHopHint, eveHopHint},
|
||||
},
|
||||
invoiceTarget: nil,
|
||||
expectLSP: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Returns ErrEdgeNotFound for private channels.
|
||||
fetchChannelEndpoints := func(chanID uint64) (route.Vertex,
|
||||
route.Vertex, error) {
|
||||
|
||||
if chanID == publicChannelID {
|
||||
return route.Vertex{}, route.Vertex{}, nil
|
||||
}
|
||||
|
||||
return route.Vertex{}, route.Vertex{}, graphdb.ErrEdgeNotFound
|
||||
}
|
||||
|
||||
for _, tc := range lspTestCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
isLsp := isLSP(tc.routeHints, fetchChannelEndpoints)
|
||||
require.Equal(t, tc.isLsp, isLsp)
|
||||
if !tc.isLsp {
|
||||
return
|
||||
}
|
||||
|
||||
adjustedHints, lspHint, _ := prepareLspRouteHints(
|
||||
tc.routeHints, tc.probeAmtMsat,
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isLSP(
|
||||
tt.routeHints, tt.invoiceTarget, hasNode,
|
||||
)
|
||||
require.Equal(t, tc.expectedHints, adjustedHints)
|
||||
require.Equal(t, tc.expectedLspHop, lspHint)
|
||||
require.Equal(t, tt.expectLSP, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareLspRouteHints tests the prepareLspRouteHints function to ensure
|
||||
// it correctly filters, groups, and calculates worst-case fees for LSP routes.
|
||||
func TestPrepareLspRouteHints(t *testing.T) {
|
||||
// Setup test nodes:
|
||||
// - Alice: public LSP node (in graph)
|
||||
// - Bob: private node
|
||||
// - Carol: private node
|
||||
// - Dave: public LSP node (in graph)
|
||||
// - Eve: private node
|
||||
alicePrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
alicePubKey := alicePrivKey.PubKey()
|
||||
|
||||
bobPrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
bobPubKey := bobPrivKey.PubKey()
|
||||
|
||||
carolPrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
carolPubKey := carolPrivKey.PubKey()
|
||||
|
||||
davePrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
davePubKey := davePrivKey.PubKey()
|
||||
|
||||
evePrivKey, err := btcec.NewPrivateKey()
|
||||
require.NoError(t, err)
|
||||
evePubKey := evePrivKey.PubKey()
|
||||
|
||||
// Create hop hints with varying fees and CLTV deltas.
|
||||
aliceHopHint1 := zpay32.HopHint{
|
||||
NodeID: alicePubKey,
|
||||
FeeBaseMSat: 100,
|
||||
FeeProportionalMillionths: 1_000,
|
||||
CLTVExpiryDelta: 40,
|
||||
ChannelID: 1,
|
||||
}
|
||||
|
||||
aliceHopHint2 := zpay32.HopHint{
|
||||
NodeID: alicePubKey,
|
||||
FeeBaseMSat: 200,
|
||||
FeeProportionalMillionths: 2_000,
|
||||
CLTVExpiryDelta: 80,
|
||||
ChannelID: 2,
|
||||
}
|
||||
|
||||
bobHopHint := zpay32.HopHint{
|
||||
NodeID: bobPubKey,
|
||||
FeeBaseMSat: 500,
|
||||
FeeProportionalMillionths: 500,
|
||||
CLTVExpiryDelta: 144,
|
||||
ChannelID: 3,
|
||||
}
|
||||
|
||||
carolHopHint := zpay32.HopHint{
|
||||
NodeID: carolPubKey,
|
||||
FeeBaseMSat: 300,
|
||||
FeeProportionalMillionths: 300,
|
||||
CLTVExpiryDelta: 40,
|
||||
ChannelID: 4,
|
||||
}
|
||||
|
||||
daveHopHint1 := zpay32.HopHint{
|
||||
NodeID: davePubKey,
|
||||
FeeBaseMSat: 1_000,
|
||||
FeeProportionalMillionths: 1_000,
|
||||
CLTVExpiryDelta: 144,
|
||||
ChannelID: 5,
|
||||
}
|
||||
|
||||
daveHopHint2 := zpay32.HopHint{
|
||||
NodeID: davePubKey,
|
||||
FeeBaseMSat: 2_000,
|
||||
FeeProportionalMillionths: 500,
|
||||
CLTVExpiryDelta: 288,
|
||||
ChannelID: 6,
|
||||
}
|
||||
|
||||
eveHopHint := zpay32.HopHint{
|
||||
NodeID: evePubKey,
|
||||
FeeBaseMSat: 100,
|
||||
FeeProportionalMillionths: 100,
|
||||
CLTVExpiryDelta: 40,
|
||||
ChannelID: 7,
|
||||
}
|
||||
|
||||
// Mock hasNode: returns true only for alice and dave.
|
||||
hasNode := func(nodePub route.Vertex) (bool, error) {
|
||||
aliceVertex := route.NewVertex(alicePubKey)
|
||||
daveVertex := route.NewVertex(davePubKey)
|
||||
return bytes.Equal(nodePub[:], aliceVertex[:]) ||
|
||||
bytes.Equal(nodePub[:], daveVertex[:]), nil
|
||||
}
|
||||
|
||||
amt := lnwire.MilliSatoshi(1_000_000)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
routeHints [][]zpay32.HopHint
|
||||
expectedGrps int
|
||||
validateFunc func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup)
|
||||
}{
|
||||
{
|
||||
name: "single public LSP with one route",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint1},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
require.Len(t, groups, 1)
|
||||
|
||||
// Find alice's group.
|
||||
aliceKey := route.NewVertex(alicePubKey)
|
||||
group, ok := groups[aliceKey]
|
||||
require.True(t, ok, "alice group not found")
|
||||
|
||||
// Verify LSP hop hint.
|
||||
require.Equal(t, aliceHopHint1.FeeBaseMSat,
|
||||
group.LspHopHint.FeeBaseMSat)
|
||||
require.Equal(t, aliceHopHint1.CLTVExpiryDelta,
|
||||
group.LspHopHint.CLTVExpiryDelta)
|
||||
|
||||
// Verify adjusted route hints.
|
||||
require.Len(t, group.AdjustedRouteHints, 1)
|
||||
require.Len(t, group.AdjustedRouteHints[0], 1)
|
||||
require.Equal(t, bobHopHint.NodeID,
|
||||
group.AdjustedRouteHints[0][0].NodeID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single LSP with multiple routes, same fees",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint1},
|
||||
{carolHopHint, aliceHopHint1},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
aliceKey := route.NewVertex(alicePubKey)
|
||||
group, ok := groups[aliceKey]
|
||||
require.True(t, ok, "alice group not found")
|
||||
|
||||
// Should have 2 adjusted route hints.
|
||||
require.Len(t, group.AdjustedRouteHints, 2)
|
||||
|
||||
// Fees should match the single hop hint.
|
||||
require.Equal(t, aliceHopHint1.FeeBaseMSat,
|
||||
group.LspHopHint.FeeBaseMSat)
|
||||
require.Equal(t, aliceHopHint1.CLTVExpiryDelta,
|
||||
group.LspHopHint.CLTVExpiryDelta)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single LSP with different fees, uses worst case",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint1},
|
||||
{carolHopHint, aliceHopHint2},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
aliceKey := route.NewVertex(alicePubKey)
|
||||
group, ok := groups[aliceKey]
|
||||
require.True(t, ok, "alice group not found")
|
||||
|
||||
// Should use worst-case (higher) fees.
|
||||
fee1 := aliceHopHint1.HopFee(amt)
|
||||
fee2 := aliceHopHint2.HopFee(amt)
|
||||
require.Greater(t, fee2, fee1,
|
||||
"hint2 should have higher fees")
|
||||
|
||||
// Group should have hint2's fees.
|
||||
require.Equal(t, aliceHopHint2.FeeBaseMSat,
|
||||
group.LspHopHint.FeeBaseMSat)
|
||||
|
||||
//nolint:ll
|
||||
require.Equal(t,
|
||||
aliceHopHint2.FeeProportionalMillionths,
|
||||
group.LspHopHint.FeeProportionalMillionths)
|
||||
|
||||
// Should use worst-case CLTV delta.
|
||||
require.Equal(t, aliceHopHint2.CLTVExpiryDelta,
|
||||
group.LspHopHint.CLTVExpiryDelta)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple public LSPs",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, aliceHopHint1},
|
||||
{carolHopHint, daveHopHint1},
|
||||
},
|
||||
expectedGrps: 2,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
require.Len(t, groups, 2)
|
||||
|
||||
aliceKey := route.NewVertex(alicePubKey)
|
||||
daveKey := route.NewVertex(davePubKey)
|
||||
|
||||
_, hasAlice := groups[aliceKey]
|
||||
_, hasDave := groups[daveKey]
|
||||
require.True(t, hasAlice, "alice group missing")
|
||||
require.True(t, hasDave, "dave group missing")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filters out private dest hops",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint1, bobHopHint},
|
||||
{carolHopHint, daveHopHint1},
|
||||
{bobHopHint, eveHopHint},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
require.Len(t, groups, 1)
|
||||
|
||||
daveKey := route.NewVertex(davePubKey)
|
||||
group, ok := groups[daveKey]
|
||||
require.True(t, ok, "dave group not found")
|
||||
|
||||
// Only one route hint should remain
|
||||
require.Len(t, group.AdjustedRouteHints, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple routes to same LSP with varying CLTV",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{bobHopHint, daveHopHint1},
|
||||
{carolHopHint, daveHopHint2},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
daveKey := route.NewVertex(davePubKey)
|
||||
group, ok := groups[daveKey]
|
||||
require.True(t, ok, "dave group not found")
|
||||
|
||||
// Should use maximum CLTV delta.
|
||||
require.Equal(t, daveHopHint2.CLTVExpiryDelta,
|
||||
group.LspHopHint.CLTVExpiryDelta)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "single hop to public LSP",
|
||||
routeHints: [][]zpay32.HopHint{
|
||||
{aliceHopHint1},
|
||||
},
|
||||
expectedGrps: 1,
|
||||
validateFunc: func(t *testing.T,
|
||||
groups map[route.Vertex]*LspRouteGroup) {
|
||||
|
||||
aliceKey := route.NewVertex(alicePubKey)
|
||||
group, ok := groups[aliceKey]
|
||||
require.True(t, ok, "alice group not found")
|
||||
|
||||
// No adjusted hints since it's a direct hop
|
||||
require.Len(t, group.AdjustedRouteHints, 0)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
groups, err := prepareLspRouteHints(
|
||||
tt.routeHints, amt, hasNode,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, groups, tt.expectedGrps)
|
||||
|
||||
// Run custom validation if provided.
|
||||
if tt.validateFunc != nil {
|
||||
tt.validateFunc(t, groups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Error cases which in operation should never happen because we always
|
||||
// call isLSP first to check if the route hints are an LSP setup.
|
||||
t.Run("error: no route hints", func(t *testing.T) {
|
||||
_, err := prepareLspRouteHints(
|
||||
[][]zpay32.HopHint{}, amt, hasNode,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no route hints")
|
||||
})
|
||||
|
||||
t.Run("error: no public LSP nodes found", func(t *testing.T) {
|
||||
// All private destination hops. If all destination hops are
|
||||
// private we cannot probe any LSPs so we return an error.
|
||||
routeHints := [][]zpay32.HopHint{
|
||||
{aliceHopHint1, bobHopHint},
|
||||
{daveHopHint1, carolHopHint},
|
||||
}
|
||||
_, err := prepareLspRouteHints(routeHints, amt, hasNode)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no public LSP nodes found")
|
||||
})
|
||||
}
|
||||
@@ -695,6 +695,8 @@ func newRPCServer(cfg *Config, interceptorChain *rpcperms.InterceptorChain,
|
||||
// addDeps populates all dependencies needed by the RPC server, and any
|
||||
// of the sub-servers that it maintains. When this is done, the RPC server can
|
||||
// be started, and start accepting RPC calls.
|
||||
//
|
||||
//nolint:funlen
|
||||
func (r *rpcServer) addDeps(ctx context.Context, s *server,
|
||||
macService *macaroons.Service,
|
||||
subServerCgs *subRPCServerConfigs, atpl *autopilot.Manager,
|
||||
@@ -744,6 +746,11 @@ func (r *rpcServer) addDeps(ctx context.Context, s *server,
|
||||
|
||||
return info.NodeKey1Bytes, info.NodeKey2Bytes, nil
|
||||
},
|
||||
HasNode: func(nodePub route.Vertex) (bool, error) {
|
||||
_, exists, err := graph.HasNode(ctx, nodePub)
|
||||
|
||||
return exists, err
|
||||
},
|
||||
FindRoute: s.chanRouter.FindRoute,
|
||||
MissionControl: s.defaultMC,
|
||||
ActiveNetParams: r.cfg.ActiveNetParams.Params,
|
||||
|
||||
Reference in new issue
Block a user