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
767 lines
20 KiB
Go
767 lines
20 KiB
Go
package routerrpc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
paymentsdb "github.com/lightningnetwork/lnd/payments/db"
|
|
"github.com/lightningnetwork/lnd/queue"
|
|
"github.com/lightningnetwork/lnd/routing"
|
|
"github.com/lightningnetwork/lnd/routing/route"
|
|
"github.com/lightningnetwork/lnd/zpay32"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
type streamMock struct {
|
|
grpc.ServerStream
|
|
ctx context.Context
|
|
sentFromServer chan *lnrpc.Payment
|
|
}
|
|
|
|
func makeStreamMock(ctx context.Context) *streamMock {
|
|
return &streamMock{
|
|
ctx: ctx,
|
|
sentFromServer: make(chan *lnrpc.Payment, 10),
|
|
}
|
|
}
|
|
|
|
func (m *streamMock) Context() context.Context {
|
|
return m.ctx
|
|
}
|
|
|
|
func (m *streamMock) Send(p *lnrpc.Payment) error {
|
|
m.sentFromServer <- p
|
|
return nil
|
|
}
|
|
|
|
type controlTowerSubscriberMock struct {
|
|
updates <-chan interface{}
|
|
}
|
|
|
|
func (s controlTowerSubscriberMock) Updates() <-chan interface{} {
|
|
return s.updates
|
|
}
|
|
|
|
func (s controlTowerSubscriberMock) Close() {
|
|
}
|
|
|
|
type controlTowerMock struct {
|
|
queue *queue.ConcurrentQueue
|
|
routing.ControlTower
|
|
}
|
|
|
|
func makeControlTowerMock() *controlTowerMock {
|
|
towerMock := &controlTowerMock{
|
|
queue: queue.NewConcurrentQueue(20),
|
|
}
|
|
towerMock.queue.Start()
|
|
|
|
return towerMock
|
|
}
|
|
|
|
func (t *controlTowerMock) SubscribeAllPayments() (
|
|
routing.ControlTowerSubscriber, error) {
|
|
|
|
return &controlTowerSubscriberMock{
|
|
updates: t.queue.ChanOut(),
|
|
}, nil
|
|
}
|
|
|
|
// TestTrackPaymentsReturnsOnCancelContext tests whether TrackPayments returns
|
|
// when the stream context is cancelled.
|
|
func TestTrackPaymentsReturnsOnCancelContext(t *testing.T) {
|
|
// Setup mocks and request.
|
|
request := &TrackPaymentsRequest{
|
|
NoInflightUpdates: false,
|
|
}
|
|
towerMock := makeControlTowerMock()
|
|
|
|
streamCtx, cancelStream := context.WithCancel(t.Context())
|
|
stream := makeStreamMock(streamCtx)
|
|
|
|
server := &Server{
|
|
cfg: &Config{
|
|
RouterBackend: &RouterBackend{
|
|
Tower: towerMock,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Cancel stream immediately
|
|
cancelStream()
|
|
|
|
// Make sure the call returns.
|
|
err := server.TrackPayments(request, stream)
|
|
require.Equal(t, context.Canceled, err)
|
|
}
|
|
|
|
// TestTrackPaymentsInflightUpdate tests whether all updates from the control
|
|
// tower are propagated to the client.
|
|
func TestTrackPaymentsInflightUpdates(t *testing.T) {
|
|
// Setup mocks and request.
|
|
request := &TrackPaymentsRequest{
|
|
NoInflightUpdates: false,
|
|
}
|
|
towerMock := makeControlTowerMock()
|
|
|
|
streamCtx, cancelStream := context.WithCancel(t.Context())
|
|
stream := makeStreamMock(streamCtx)
|
|
defer cancelStream()
|
|
|
|
server := &Server{
|
|
cfg: &Config{
|
|
RouterBackend: &RouterBackend{
|
|
Tower: towerMock,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Listen to payment updates in a goroutine.
|
|
go func() {
|
|
err := server.TrackPayments(request, stream)
|
|
require.Equal(t, context.Canceled, err)
|
|
}()
|
|
|
|
// Enqueue some payment updates on the mock.
|
|
towerMock.queue.ChanIn() <- &paymentsdb.MPPayment{
|
|
Info: &paymentsdb.PaymentCreationInfo{},
|
|
Status: paymentsdb.StatusInFlight,
|
|
}
|
|
towerMock.queue.ChanIn() <- &paymentsdb.MPPayment{
|
|
Info: &paymentsdb.PaymentCreationInfo{},
|
|
Status: paymentsdb.StatusSucceeded,
|
|
}
|
|
|
|
// Wait until there's 2 updates or the deadline is exceeded.
|
|
deadline := time.Now().Add(1 * time.Second)
|
|
for {
|
|
if len(stream.sentFromServer) == 2 {
|
|
break
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
require.FailNow(t, "deadline exceeded.")
|
|
}
|
|
}
|
|
|
|
// Both updates should be sent to the client.
|
|
require.Len(t, stream.sentFromServer, 2)
|
|
|
|
// The updates should be in the right order.
|
|
payment := <-stream.sentFromServer
|
|
require.Equal(t, lnrpc.Payment_IN_FLIGHT, payment.Status)
|
|
payment = <-stream.sentFromServer
|
|
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
|
|
}
|
|
|
|
// TestTrackPaymentsInflightUpdate tests whether only final updates from the
|
|
// control tower are propagated to the client when noInflightUpdates = true.
|
|
func TestTrackPaymentsNoInflightUpdates(t *testing.T) {
|
|
// Setup mocks and request.
|
|
request := &TrackPaymentsRequest{
|
|
NoInflightUpdates: true,
|
|
}
|
|
towerMock := &controlTowerMock{
|
|
queue: queue.NewConcurrentQueue(20),
|
|
}
|
|
towerMock.queue.Start()
|
|
|
|
streamCtx, cancelStream := context.WithCancel(t.Context())
|
|
stream := makeStreamMock(streamCtx)
|
|
defer cancelStream()
|
|
|
|
server := &Server{
|
|
cfg: &Config{
|
|
RouterBackend: &RouterBackend{
|
|
Tower: towerMock,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Listen to payment updates in a goroutine.
|
|
go func() {
|
|
err := server.TrackPayments(request, stream)
|
|
require.Equal(t, context.Canceled, err)
|
|
}()
|
|
|
|
// Enqueue some payment updates on the mock.
|
|
towerMock.queue.ChanIn() <- &paymentsdb.MPPayment{
|
|
Info: &paymentsdb.PaymentCreationInfo{},
|
|
Status: paymentsdb.StatusInFlight,
|
|
}
|
|
towerMock.queue.ChanIn() <- &paymentsdb.MPPayment{
|
|
Info: &paymentsdb.PaymentCreationInfo{},
|
|
Status: paymentsdb.StatusSucceeded,
|
|
}
|
|
|
|
// Wait until there's 1 update or the deadline is exceeded.
|
|
deadline := time.Now().Add(1 * time.Second)
|
|
for {
|
|
if len(stream.sentFromServer) == 1 {
|
|
break
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
require.FailNow(t, "deadline exceeded.")
|
|
}
|
|
}
|
|
|
|
// Only 1 update should be sent to the client.
|
|
require.Len(t, stream.sentFromServer, 1)
|
|
|
|
// Only the final states should be sent to the client.
|
|
payment := <-stream.sentFromServer
|
|
require.Equal(t, lnrpc.Payment_SUCCEEDED, payment.Status)
|
|
}
|
|
|
|
// 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) {
|
|
// 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()
|
|
|
|
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 for each node.
|
|
aliceHopHint := zpay32.HopHint{
|
|
NodeID: alicePubKey,
|
|
FeeBaseMSat: 100,
|
|
FeeProportionalMillionths: 1_000,
|
|
CLTVExpiryDelta: 40,
|
|
ChannelID: 1,
|
|
}
|
|
|
|
bobHopHint := zpay32.HopHint{
|
|
NodeID: bobPubKey,
|
|
FeeBaseMSat: 2_000,
|
|
FeeProportionalMillionths: 2_000,
|
|
CLTVExpiryDelta: 144,
|
|
ChannelID: 2,
|
|
}
|
|
|
|
carolHopHint := zpay32.HopHint{
|
|
NodeID: carolPubKey,
|
|
FeeBaseMSat: 1_500,
|
|
FeeProportionalMillionths: 1_500,
|
|
CLTVExpiryDelta: 144,
|
|
ChannelID: 3,
|
|
}
|
|
|
|
daveHopHint := zpay32.HopHint{
|
|
NodeID: davePubKey,
|
|
FeeBaseMSat: 3_000,
|
|
FeeProportionalMillionths: 3_000,
|
|
CLTVExpiryDelta: 288,
|
|
ChannelID: 4,
|
|
}
|
|
|
|
eveHopHint := zpay32.HopHint{
|
|
NodeID: evePubKey,
|
|
FeeBaseMSat: 500,
|
|
FeeProportionalMillionths: 500,
|
|
CLTVExpiryDelta: 40,
|
|
ChannelID: 5,
|
|
}
|
|
|
|
// 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: "no route hints",
|
|
routeHints: [][]zpay32.HopHint{},
|
|
invoiceTarget: nil,
|
|
expectLSP: false,
|
|
},
|
|
{
|
|
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: "invoice target is public (alice)",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{bobHopHint, carolHopHint},
|
|
},
|
|
invoiceTarget: alicePubKey.SerializeCompressed(),
|
|
expectLSP: false,
|
|
},
|
|
{
|
|
name: "invoice target is public with public dest hop",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{bobHopHint, daveHopHint},
|
|
},
|
|
invoiceTarget: davePubKey.SerializeCompressed(),
|
|
expectLSP: false,
|
|
},
|
|
{
|
|
name: "invoice target is public with multiple routes",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{bobHopHint, carolHopHint},
|
|
{aliceHopHint, daveHopHint},
|
|
},
|
|
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, all to private dests",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{aliceHopHint, bobHopHint},
|
|
{daveHopHint, carolHopHint},
|
|
},
|
|
invoiceTarget: nil,
|
|
expectLSP: false,
|
|
},
|
|
{
|
|
name: "single hop to private node",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{eveHopHint},
|
|
},
|
|
invoiceTarget: evePubKey.SerializeCompressed(),
|
|
expectLSP: false,
|
|
},
|
|
{
|
|
name: "all routes to same private node",
|
|
routeHints: [][]zpay32.HopHint{
|
|
{aliceHopHint, bobHopHint},
|
|
{daveHopHint, bobHopHint},
|
|
{carolHopHint, bobHopHint},
|
|
},
|
|
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,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := isLSP(
|
|
tt.routeHints, tt.invoiceTarget, hasNode,
|
|
)
|
|
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")
|
|
})
|
|
}
|