routerrpc: implement LSP heuristic and multi-LSP worst-case probing
This commit implements a comprehensive LSP (Lightning Service Provider)
detection heuristic and updates the payment probing logic to handle
multiple LSPs with worst-case fee estimation.
Key changes:
1. LSP Detection Heuristic (isLSP function):
Implements three rules to detect LSP setups:
- Rule 1: If invoice target is public → NOT an LSP (route directly)
- Rule 2: If at least one destination hop is public → IS an LSP
- Rule 3: If all destination hops are private → NOT an LSP
2. LSP Route Preparation (prepareLspRouteHints function):
- Groups route hints by unique public LSP nodes
- Filters out non-LSP routes based on the heuristic
- Tracks worst-case fees and CLTV delays for each LSP
- Returns adjusted route hints with LSP hop stripped
3. Multi-LSP Probing (probePaymentRequest updates):
- Probes up to 3 unique LSPs maximum (griefing protection)
- Selects the WORST-CASE (most expensive) route for conservative
fee estimation
- Adds comprehensive debug logging for worst-case selection process
- Properly formats vertex logging using %v (calls Vertex.String())
The worst-case approach ensures users won't be surprised by higher fees
when the actual payment is sent, providing a more conservative and
reliable fee estimate.
This commit also adds extensive unit test coverage for the LSP detection
heuristic and route preparation logic.
TestIsLsp:
- Edge cases: empty route hints, nil scenarios
- Rule 1: Public invoice target (3 tests)
- Rule 2: All private destination hops (4 tests)
- Rule 3: At least one public destination hop (6 tests)
TestPrepareLspRouteHints:
- LSP grouping and filtering logic
- Worst-case fee selection across route hints
- Worst-case CLTV delta tracking
- Adjusted route hints validation (LSP hop stripped)
- Multi-LSP scenarios with different fees
This commit is contained in:
2 files changed
+790
-334
No files matched your search
+304
-157
@@ -1,7 +1,6 @@
|
|||||||
package routerrpc
|
package routerrpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -44,6 +43,12 @@ const (
|
|||||||
// DefaultPaymentTimeout is the default value of time we should spend
|
// DefaultPaymentTimeout is the default value of time we should spend
|
||||||
// when attempting to fulfill the payment.
|
// when attempting to fulfill the payment.
|
||||||
DefaultPaymentTimeout int32 = 60
|
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 (
|
var (
|
||||||
@@ -171,10 +176,9 @@ var (
|
|||||||
DefaultRouterMacFilename = "router.macaroon"
|
DefaultRouterMacFilename = "router.macaroon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FetchChannelEndpoints returns the pubkeys of both endpoints of the
|
// HasNode returns true if the node exists in the graph (i.e., has public
|
||||||
// given channel id if it exists in the graph.
|
// channels), false otherwise.
|
||||||
type FetchChannelEndpoints func(chanID uint64) (route.Vertex, route.Vertex,
|
type HasNode func(nodePub route.Vertex) (bool, error)
|
||||||
error)
|
|
||||||
|
|
||||||
// ServerShell is a shell struct holding a reference to the actual sub-server.
|
// 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
|
// 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
|
// 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
|
// payment won't be blocked along the route to the destination. We send
|
||||||
// a probe payment with unmodified route hints.
|
// 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",
|
log.Infof("No LSP detected, probing destination %x",
|
||||||
probeRequest.Dest)
|
probeRequest.Dest)
|
||||||
|
|
||||||
@@ -569,200 +574,342 @@ func (s *Server) probePaymentRequest(ctx context.Context, paymentRequest string,
|
|||||||
return s.sendProbePayment(ctx, probeRequest)
|
return s.sendProbePayment(ctx, probeRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the heuristic indicates an LSP we modify the route hints to allow
|
// If the heuristic indicates an LSP, we filter and group route hints by
|
||||||
// probing the LSP.
|
// public LSP nodes, then probe each unique LSP separately and return
|
||||||
lspAdjustedRouteHints, lspHint, err := prepareLspRouteHints(
|
// the cheapest route.
|
||||||
hints, *payReq.MilliSat,
|
lspGroups, err := prepareLspRouteHints(
|
||||||
|
hints, *payReq.MilliSat, s.cfg.RouterBackend.HasNode,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the destination to the LSP node ID.
|
log.Infof("LSP detected, found %d unique public LSP node(s) to probe",
|
||||||
lspDest := lspHint.NodeID.SerializeCompressed()
|
len(lspGroups))
|
||||||
probeRequest.Dest = lspDest
|
|
||||||
|
|
||||||
log.Infof("LSP detected, probing LSP with destination: %x", lspDest)
|
// Probe up to MaxLspsToProbe LSPs and track the most expensive route
|
||||||
|
// for worst-case fee estimation.
|
||||||
// The adjusted route hints serve the payment probe to find the last
|
if len(lspGroups) > MaxLspsToProbe {
|
||||||
// public hop to the LSP on the route.
|
log.Debugf("Limiting LSP probes from %d to %d for worst-case "+
|
||||||
if len(lspAdjustedRouteHints) > 0 {
|
"fee estimation", len(lspGroups), MaxLspsToProbe)
|
||||||
probeRequest.RouteHints = invoicesrpc.CreateRPCRouteHints(
|
|
||||||
lspAdjustedRouteHints,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
var (
|
||||||
|
worstCaseResp *RouteFeeResponse
|
||||||
|
worstCaseLspDest route.Vertex
|
||||||
|
probeCount int
|
||||||
|
)
|
||||||
|
|
||||||
// The payment probe will be able to calculate the fee up until the LSP
|
for lspKey, group := range lspGroups {
|
||||||
// node. The fee of the last hop has to be calculated manually. Since
|
if probeCount >= MaxLspsToProbe {
|
||||||
// the last hop's fee amount has to be sent across the payment path we
|
break
|
||||||
// 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
|
probeCount++
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the last hop's fee to the requested payment amount that we want
|
lspHint := group.LspHopHint
|
||||||
// to get an estimate for.
|
|
||||||
probeRequest.AmtMsat += int64(hopFee)
|
|
||||||
|
|
||||||
// Use the hop hint's cltv delta as the payment request's final cltv
|
log.Infof("Probing LSP with destination: %v", lspKey)
|
||||||
// delta. The actual final cltv delta of the invoice will be added to
|
|
||||||
// the payment probe's cltv delta.
|
|
||||||
probeRequest.FinalCltvDelta = int32(lspHint.CLTVExpiryDelta)
|
|
||||||
|
|
||||||
// Dispatch the payment probe with adjusted fee amount.
|
// Create a new probe request for this LSP.
|
||||||
resp, err := s.sendProbePayment(ctx, probeRequest)
|
lspProbeRequest := &SendPaymentRequest{
|
||||||
if err != nil {
|
TimeoutSeconds: probeRequest.TimeoutSeconds,
|
||||||
return nil, fmt.Errorf("failed to send probe payment to "+
|
Dest: lspKey[:],
|
||||||
"LSP with destination %x: %w", lspDest, err)
|
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
|
// Copy the payment address if present.
|
||||||
// leave the probe result params unaltered.
|
if len(probeRequest.PaymentAddr) > 0 {
|
||||||
if resp.FailureReason != lnrpc.PaymentFailureReason_FAILURE_REASON_NONE { //nolint:ll
|
copy(
|
||||||
return resp, nil
|
lspProbeRequest.PaymentAddr,
|
||||||
}
|
probeRequest.PaymentAddr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// The probe succeeded, so we can add the last hop's fee to fee the
|
// Set the adjusted route hints for this LSP.
|
||||||
// payment probe returned.
|
if len(group.AdjustedRouteHints) > 0 {
|
||||||
resp.RoutingFeeMsat += int64(hopFee)
|
lspProbeRequest.RouteHints = invoicesrpc.
|
||||||
|
CreateRPCRouteHints(group.AdjustedRouteHints)
|
||||||
|
}
|
||||||
|
|
||||||
// Add the final cltv delta of the invoice to the payment probe's total
|
// Calculate the hop fee for the last hop manually.
|
||||||
// cltv delta. This is the cltv delta for the hop behind the LSP.
|
hopFee := lspHint.HopFee(*payReq.MilliSat)
|
||||||
resp.TimeLockDelay += int64(payReq.MinFinalCLTVExpiry())
|
|
||||||
|
|
||||||
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
|
// Dispatch the payment probe for this LSP.
|
||||||
// true if the destination hop hint in each route hint has the same node id,
|
resp, err := s.sendProbePayment(ctx, lspProbeRequest)
|
||||||
// false otherwise. If the destination hop hint of any route hint contains a
|
if err != nil {
|
||||||
// public channel, the function returns false because we can directly send a
|
log.Warnf("Failed to probe LSP %v: %v", lspKey, err)
|
||||||
// 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 {
|
|
||||||
continue
|
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
|
log.Debugf("Probe to LSP %v failed with reason: %v",
|
||||||
// channel we can send a probe to it directly, hence we don't
|
lspKey, resp.FailureReason)
|
||||||
// signal an LSP.
|
|
||||||
_, _, err = fetchChannelEndpoints(lastHop.ChannelID)
|
continue
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
matchesDestNode := bytes.Equal(
|
// The probe succeeded, add the last hop's fee.
|
||||||
lastHop.NodeID.SerializeCompressed(),
|
resp.RoutingFeeMsat += int64(hopFee)
|
||||||
destHopHint.NodeID.SerializeCompressed(),
|
|
||||||
)
|
// Add the final cltv delta of the invoice.
|
||||||
if !matchesDestNode {
|
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
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We ensured that the destination hop hint doesn't contain a public
|
for _, hopHints := range routeHints {
|
||||||
// channel, and that all destination hop hints of all route hints match,
|
// Skip empty route hints.
|
||||||
// so we signal an LSP.
|
if len(hopHints) == 0 {
|
||||||
return true
|
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
|
// prepareLspRouteHints assumes that the isLsp heuristic returned true for the
|
||||||
// route hints passed in here. It constructs a modified list of route hints that
|
// route hints passed in here. It filters route hints to only include those with
|
||||||
// allows the caller to probe the LSP, which itself is returned as a separate
|
// public destination nodes, groups them by unique LSP node, and returns a map
|
||||||
// hop hint.
|
// of LSP groups keyed by the LSP node's compressed public key.
|
||||||
func prepareLspRouteHints(routeHints [][]zpay32.HopHint,
|
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 {
|
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
|
// Map to group route hints by LSP node pubkey.
|
||||||
// cltv delta. So we look for the max values amongst all LSP hop hints.
|
lspGroups := make(map[route.Vertex]*LspRouteGroup)
|
||||||
refHint := routeHints[0][len(routeHints[0])-1]
|
|
||||||
refHint.CLTVExpiryDelta = maxLspCltvDelta(routeHints)
|
|
||||||
refHint.FeeBaseMSat, refHint.FeeProportionalMillionths = maxLspFee(
|
|
||||||
routeHints, amt,
|
|
||||||
)
|
|
||||||
|
|
||||||
// We construct a modified list of route hints that allows the caller to
|
for _, routeHint := range routeHints {
|
||||||
// probe the LSP.
|
// Skip empty route hints.
|
||||||
adjustedHints := make([][]zpay32.HopHint, 0, len(routeHints))
|
if len(routeHint) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Strip off the LSP hop hint from all route hints.
|
// Get the destination hop hint (last hop in the route).
|
||||||
for i := 0; i < len(routeHints); i++ {
|
destHop := routeHint[len(routeHint)-1]
|
||||||
hint := routeHints[i]
|
destNodeCompressed := destHop.NodeID.SerializeCompressed()
|
||||||
if len(hint) > 1 {
|
|
||||||
adjustedHints = append(
|
// Check if this destination node is public.
|
||||||
adjustedHints, hint[:len(hint)-1],
|
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
|
if len(lspGroups) == 0 {
|
||||||
}
|
return nil, fmt.Errorf("no public LSP nodes found in " +
|
||||||
|
"route hints")
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
return lspGroups, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// probePaymentStream is a custom implementation of the grpc.ServerStream
|
// probePaymentStream is a custom implementation of the grpc.ServerStream
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package routerrpc
|
package routerrpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcec/v2"
|
"github.com/btcsuite/btcd/btcec/v2"
|
||||||
graphdb "github.com/lightningnetwork/lnd/graph/db"
|
|
||||||
"github.com/lightningnetwork/lnd/lnrpc"
|
"github.com/lightningnetwork/lnd/lnrpc"
|
||||||
"github.com/lightningnetwork/lnd/lnwire"
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
|
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
|
||||||
@@ -220,12 +220,18 @@ func TestTrackPaymentsNoInflightUpdates(t *testing.T) {
|
|||||||
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
|
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIsLsp tests the isLSP heuristic. Combinations of different route hints
|
// TestIsLsp tests the isLSP heuristic. It validates all three LSP detection
|
||||||
// with different fees and cltv deltas are tested to ensure that the heuristic
|
// rules:
|
||||||
// correctly identifies whether a route leads to an LSP or not.
|
// 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) {
|
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()
|
alicePrivKey, err := btcec.NewPrivateKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
alicePubKey := alicePrivKey.PubKey()
|
alicePubKey := alicePrivKey.PubKey()
|
||||||
@@ -242,216 +248,519 @@ func TestIsLsp(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
davePubKey := davePrivKey.PubKey()
|
davePubKey := davePrivKey.PubKey()
|
||||||
|
|
||||||
var (
|
evePrivKey, err := btcec.NewPrivateKey()
|
||||||
aliceHopHint = zpay32.HopHint{
|
require.NoError(t, err)
|
||||||
NodeID: alicePubKey,
|
evePubKey := evePrivKey.PubKey()
|
||||||
FeeBaseMSat: 100,
|
|
||||||
FeeProportionalMillionths: 1_000,
|
|
||||||
ChannelID: 421337,
|
|
||||||
}
|
|
||||||
|
|
||||||
bobHopHint = zpay32.HopHint{
|
// Create hop hints for each node.
|
||||||
NodeID: bobPubKey,
|
aliceHopHint := zpay32.HopHint{
|
||||||
FeeBaseMSat: 2_000,
|
NodeID: alicePubKey,
|
||||||
FeeProportionalMillionths: 2_000,
|
FeeBaseMSat: 100,
|
||||||
CLTVExpiryDelta: 288,
|
FeeProportionalMillionths: 1_000,
|
||||||
ChannelID: 815,
|
CLTVExpiryDelta: 40,
|
||||||
}
|
ChannelID: 1,
|
||||||
|
}
|
||||||
|
|
||||||
carolHopHint = zpay32.HopHint{
|
bobHopHint := zpay32.HopHint{
|
||||||
NodeID: carolPubKey,
|
NodeID: bobPubKey,
|
||||||
FeeBaseMSat: 2_000,
|
FeeBaseMSat: 2_000,
|
||||||
FeeProportionalMillionths: 2_000,
|
FeeProportionalMillionths: 2_000,
|
||||||
ChannelID: 815,
|
CLTVExpiryDelta: 144,
|
||||||
}
|
ChannelID: 2,
|
||||||
|
}
|
||||||
|
|
||||||
daveHopHint = zpay32.HopHint{
|
carolHopHint := zpay32.HopHint{
|
||||||
NodeID: davePubKey,
|
NodeID: carolPubKey,
|
||||||
FeeBaseMSat: 2_000,
|
FeeBaseMSat: 1_500,
|
||||||
FeeProportionalMillionths: 2_000,
|
FeeProportionalMillionths: 1_500,
|
||||||
ChannelID: 815,
|
CLTVExpiryDelta: 144,
|
||||||
}
|
ChannelID: 3,
|
||||||
|
}
|
||||||
|
|
||||||
publicChannelID = uint64(42)
|
daveHopHint := zpay32.HopHint{
|
||||||
daveHopHintPublicChan = zpay32.HopHint{
|
NodeID: davePubKey,
|
||||||
NodeID: davePubKey,
|
FeeBaseMSat: 3_000,
|
||||||
FeeBaseMSat: 2_000,
|
FeeProportionalMillionths: 3_000,
|
||||||
FeeProportionalMillionths: 2_000,
|
CLTVExpiryDelta: 288,
|
||||||
ChannelID: publicChannelID,
|
ChannelID: 4,
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
bobExpensiveCopy := bobHopHint.Copy()
|
eveHopHint := zpay32.HopHint{
|
||||||
bobExpensiveCopy.FeeBaseMSat = 1_000_000
|
NodeID: evePubKey,
|
||||||
bobExpensiveCopy.FeeProportionalMillionths = 1_000_000
|
FeeBaseMSat: 500,
|
||||||
bobExpensiveCopy.CLTVExpiryDelta = bobHopHint.CLTVExpiryDelta - 1
|
FeeProportionalMillionths: 500,
|
||||||
|
CLTVExpiryDelta: 40,
|
||||||
|
ChannelID: 5,
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:ll
|
// Mock hasNode: returns true only for alice and dave.
|
||||||
lspTestCases := []struct {
|
hasNode := func(nodePub route.Vertex) (bool, error) {
|
||||||
name string
|
aliceVertex := route.NewVertex(alicePubKey)
|
||||||
routeHints [][]zpay32.HopHint
|
daveVertex := route.NewVertex(davePubKey)
|
||||||
probeAmtMsat lnwire.MilliSatoshi
|
return bytes.Equal(nodePub[:], aliceVertex[:]) ||
|
||||||
isLsp bool
|
bytes.Equal(nodePub[:], daveVertex[:]), nil
|
||||||
expectedHints [][]zpay32.HopHint
|
}
|
||||||
expectedLspHop *zpay32.HopHint
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
routeHints [][]zpay32.HopHint
|
||||||
|
invoiceTarget []byte
|
||||||
|
expectLSP bool
|
||||||
}{
|
}{
|
||||||
|
// Edge cases.
|
||||||
{
|
{
|
||||||
name: "empty route hints",
|
name: "no route hints",
|
||||||
routeHints: [][]zpay32.HopHint{{}},
|
routeHints: [][]zpay32.HopHint{},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: nil,
|
||||||
isLsp: false,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{},
|
|
||||||
expectedLspHop: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "single route hint",
|
name: "empty route hint array",
|
||||||
routeHints: [][]zpay32.HopHint{{daveHopHint}},
|
routeHints: [][]zpay32.HopHint{{}},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: nil,
|
||||||
isLsp: true,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{},
|
|
||||||
expectedLspHop: &daveHopHint,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Rule 1: Invoice target is public => NOT an LSP.
|
||||||
|
// Rationale: Can route directly to public target.
|
||||||
{
|
{
|
||||||
name: "single route, multiple hints",
|
name: "invoice target is public (alice)",
|
||||||
routeHints: [][]zpay32.HopHint{{
|
|
||||||
aliceHopHint, bobHopHint,
|
|
||||||
}},
|
|
||||||
probeAmtMsat: probeAmtMsat,
|
|
||||||
isLsp: true,
|
|
||||||
expectedHints: [][]zpay32.HopHint{{aliceHopHint}},
|
|
||||||
expectedLspHop: &bobHopHint,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple routes, multiple hints",
|
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{bobHopHint, carolHopHint},
|
||||||
aliceHopHint, bobHopHint,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
carolHopHint, bobHopHint,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: alicePubKey.SerializeCompressed(),
|
||||||
isLsp: true,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{
|
|
||||||
{aliceHopHint}, {carolHopHint},
|
|
||||||
},
|
|
||||||
expectedLspHop: &bobHopHint,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple routes, multiple hints with min length",
|
name: "invoice target is public with public dest hop",
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{bobHopHint, daveHopHint},
|
||||||
bobHopHint,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
carolHopHint, bobHopHint,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: davePubKey.SerializeCompressed(),
|
||||||
isLsp: true,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{
|
|
||||||
{carolHopHint},
|
|
||||||
},
|
|
||||||
expectedLspHop: &bobHopHint,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple routes, multiple hints, diff fees+cltv",
|
name: "invoice target is public with multiple routes",
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{bobHopHint, carolHopHint},
|
||||||
bobHopHint,
|
{aliceHopHint, daveHopHint},
|
||||||
},
|
|
||||||
{
|
|
||||||
carolHopHint, bobExpensiveCopy,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: alicePubKey.SerializeCompressed(),
|
||||||
isLsp: true,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{
|
},
|
||||||
{carolHopHint},
|
|
||||||
},
|
// Rule 2: All destination hop hints are private => NOT an LSP.
|
||||||
expectedLspHop: &zpay32.HopHint{
|
// Rationale: The destination hop hint is private so it cannot
|
||||||
NodeID: bobHopHint.NodeID,
|
// be probed so we default to NOT an LSP.
|
||||||
ChannelID: bobHopHint.ChannelID,
|
{
|
||||||
FeeBaseMSat: bobExpensiveCopy.FeeBaseMSat,
|
name: "single route to private dest",
|
||||||
FeeProportionalMillionths: bobExpensiveCopy.FeeProportionalMillionths,
|
routeHints: [][]zpay32.HopHint{
|
||||||
CLTVExpiryDelta: bobHopHint.CLTVExpiryDelta,
|
{aliceHopHint, bobHopHint},
|
||||||
},
|
},
|
||||||
|
invoiceTarget: bobPubKey.SerializeCompressed(),
|
||||||
|
expectLSP: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple routes, different final hops",
|
name: "multiple routes, all to private dests",
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{aliceHopHint, bobHopHint},
|
||||||
aliceHopHint, bobHopHint,
|
{daveHopHint, carolHopHint},
|
||||||
},
|
|
||||||
{
|
|
||||||
carolHopHint, daveHopHint,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: nil,
|
||||||
isLsp: false,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{},
|
|
||||||
expectedLspHop: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple routes, same public hops",
|
name: "single hop to private node",
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{eveHopHint},
|
||||||
aliceHopHint, daveHopHintPublicChan,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
carolHopHint, daveHopHintPublicChan,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: evePubKey.SerializeCompressed(),
|
||||||
isLsp: false,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{},
|
|
||||||
expectedLspHop: nil,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple routes, same public hops",
|
name: "all routes to same private node",
|
||||||
routeHints: [][]zpay32.HopHint{
|
routeHints: [][]zpay32.HopHint{
|
||||||
{
|
{aliceHopHint, bobHopHint},
|
||||||
aliceHopHint, daveHopHint,
|
{daveHopHint, bobHopHint},
|
||||||
},
|
{carolHopHint, bobHopHint},
|
||||||
{
|
|
||||||
carolHopHint, daveHopHintPublicChan,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
aliceHopHint, daveHopHintPublicChan,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
probeAmtMsat: probeAmtMsat,
|
invoiceTarget: nil,
|
||||||
isLsp: false,
|
expectLSP: false,
|
||||||
expectedHints: [][]zpay32.HopHint{},
|
},
|
||||||
expectedLspHop: nil,
|
|
||||||
|
// 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.
|
for _, tt := range tests {
|
||||||
fetchChannelEndpoints := func(chanID uint64) (route.Vertex,
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
route.Vertex, error) {
|
result := isLSP(
|
||||||
|
tt.routeHints, tt.invoiceTarget, hasNode,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
require.Equal(t, tc.expectedHints, adjustedHints)
|
require.Equal(t, tt.expectLSP, result)
|
||||||
require.Equal(t, tc.expectedLspHop, lspHint)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user