Skip to content
Draft
427 changes: 427 additions & 0 deletions docs/interval_routing.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions docs/release-notes/release-notes-0.22.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@

## Functional Enhancements

* A new [experimental interval
router](https://github.com/lightningnetwork/lnd/pull/11048) can be selected with
`routerrpc.router=interval`. It replaces mission control with a liquidity
interval per directed channel, bounded below by the largest amount it has
watched pass and above by the smallest it has watched fail, with no time decay
anywhere. It also plans the size of an MPP shard together with the route that
carries it rather than halving its way down after a failure, and it retries a
channel at a lower amount rather than stepping around it. The default remains
unchanged, and payments to blinded paths are served by the default router
regardless of this setting. On a node running the native SQL backend the
intervals are persisted across restarts; a bound restored from disk is
applied as soft evidence with a probability floor, so that a belief which
has gone stale can still be corrected by an attempt. The algorithm is
explained in [`docs/interval_routing.md`](../interval_routing.md).

## RPC Additions

* The `routerrpc.EstimateRouteFee` RPC now supports [restricting fee estimates
Expand Down Expand Up @@ -138,6 +153,11 @@

## Database

* A new `liquidity_intervals` table
[stores](https://github.com/lightningnetwork/lnd/pull/11048) the liquidity
beliefs of the experimental interval router, so that they survive a restart
on nodes running the native SQL backend.

## Code Health

## Tooling and Documentation
Expand Down
4 changes: 4 additions & 0 deletions itest/list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,10 @@ var allTestCases = []*lntest.TestCase{
Name: "multi-hop payments",
TestFunc: testMultiHopPayments,
},
{
Name: "interval router multi hop payment",
TestFunc: testIntervalRouterMultiHopPayment,
},
{
Name: "estimate route fee",
TestFunc: testEstimateRouteFee,
Expand Down
110 changes: 110 additions & 0 deletions itest/lnd_interval_router_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package itest

import (
"github.com/btcsuite/btcd/btcutil/v2"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/lntest"
"github.com/stretchr/testify/require"
)

// testIntervalRouterMultiHopPayment tests the experimental interval router end
// to end over a three hop network, with only the sender running it so that the
// test also covers a node routing for peers that do not.
//
// The payment the router is interesting on is the second one. We drain the
// middle hop so that a large payment cannot get through, watch it fail at that
// hop, and then send a small one without resetting anything. The stock router
// would have penalized the node pair and would need the penalty to decay; the
// interval router records the amount that failed as a bound, which leaves every
// smaller amount over the same channel perfectly routable. The retry is
// therefore a test of what the router remembered, not of what it forgot.
func testIntervalRouterMultiHopPayment(ht *lntest.HarnessTest) {
const (
chanAmt = btcutil.Amount(300_000)

// smallPayment fits comfortably inside what is left of the
// middle hop after the drain below.
smallPayment = btcutil.Amount(10_000)

// largePayment is well above it.
largePayment = btcutil.Amount(100_000)

// drainPayment leaves the middle hop with roughly 50k of
// outbound liquidity, less its reserve and fee buffer.
drainPayment = btcutil.Amount(250_000)
)

// Build Alice -> Bob -> Carol, with only Alice routing on intervals.
cfgs := [][]string{{"--routerrpc.router=interval"}, nil, nil}
_, nodes := ht.CreateSimpleNetwork(
cfgs, lntest.OpenChannelParams{Amt: chanAmt},
)
alice, bob, carol := nodes[0], nodes[1], nodes[2]

// sendPayment is a helper that pays a fresh invoice of the given amount
// from Alice to Carol over a single HTLC, so that the outcome is about
// route choice rather than about how the payment was split.
sendPayment := func(amt btcutil.Amount) *routerrpc.SendPaymentRequest {
invoice := carol.RPC.AddInvoice(&lnrpc.Invoice{
Value: int64(amt),
})

return &routerrpc.SendPaymentRequest{
PaymentRequest: invoice.PaymentRequest,
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
MaxParts: 1,
}
}

// With liquidity everywhere, the interval router finds the two hop
// route and the payment settles.
payment := ht.SendPaymentAssertSettled(alice, sendPayment(smallPayment))
require.Len(ht, payment.Htlcs, 1)
require.Len(ht, payment.Htlcs[0].Route.Hops, 2)
require.Equal(
ht, carol.PubKeyStr,
payment.Htlcs[0].Route.Hops[1].PubKey,
)

// Now drain the middle hop by having Bob pay Carol, which leaves Bob
// without the outbound liquidity to forward a large payment onwards.
drainInvoice := carol.RPC.AddInvoice(&lnrpc.Invoice{
Value: int64(drainPayment),
})
ht.SendPaymentAssertSettled(bob, &routerrpc.SendPaymentRequest{
PaymentRequest: drainInvoice.PaymentRequest,
TimeoutSeconds: 60,
FeeLimitMsat: noFeeLimitMsat,
MaxParts: 1,
})

// Alice cannot know that from the graph, so she tries the large payment
// and Bob refuses to forward it. With no second route to Carol and no
// room to split, the payment fails.
ht.SendPaymentAssertFail(
alice, sendPayment(largePayment),
lnrpc.PaymentFailureReason_FAILURE_REASON_NO_ROUTE,
)
ht.AssertLastHTLCError(alice, lnrpc.Failure_TEMPORARY_CHANNEL_FAILURE)

// The failure told Alice's router an amount, not a verdict on the
// channel. Nothing is reset here, and no penalty is waited out: a
// smaller payment over the very same hop settles immediately, because
// the bound the router recorded rules out the amount that failed and
// says nothing against this one.
payment = ht.SendPaymentAssertSettled(alice, sendPayment(smallPayment))
require.Len(ht, payment.Htlcs, 1)
require.Len(ht, payment.Htlcs[0].Route.Hops, 2)
require.Equal(
ht, bob.PubKeyStr, payment.Htlcs[0].Route.Hops[0].PubKey,
)

// The whole of that recovery took one attempt, since the router asked
// for an amount it had no evidence against rather than retrying the one
// it had just watched fail.
require.Equal(
ht, lnrpc.HTLCAttempt_SUCCEEDED, payment.Htlcs[0].Status,
)
}
12 changes: 8 additions & 4 deletions lnrpc/routerrpc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ type Config struct {
// DefaultConfig defines the config defaults.
func DefaultConfig() *Config {
defaultRoutingConfig := RoutingConfig{
PaymentRouter: routing.DefaultPaymentRouter,
ProbabilityEstimatorType: routing.DefaultEstimator,
MinRouteProbability: routing.DefaultMinRouteProbability,

AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
AttemptCostPPM: routing.DefaultAttemptCostPPM,
MaxMcHistory: routing.DefaultMaxMcHistory,
McFlushInterval: routing.DefaultMcFlushInterval,
AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
AttemptCostPPM: routing.DefaultAttemptCostPPM,
MaxMcHistory: routing.DefaultMaxMcHistory,
McFlushInterval: routing.DefaultMcFlushInterval,
IntervalFlushInterval: routing.DefaultIntervalFlushInterval,
AprioriConfig: &AprioriConfig{
HopProbability: routing.DefaultAprioriHopProbability,
Weight: routing.DefaultAprioriWeight,
Expand All @@ -85,12 +87,14 @@ func DefaultConfig() *Config {
// GetRoutingConfig returns the routing config based on this sub server config.
func GetRoutingConfig(cfg *Config) *RoutingConfig {
return &RoutingConfig{
PaymentRouter: cfg.PaymentRouter,
ProbabilityEstimatorType: cfg.ProbabilityEstimatorType,
MinRouteProbability: cfg.MinRouteProbability,
AttemptCost: cfg.AttemptCost,
AttemptCostPPM: cfg.AttemptCostPPM,
MaxMcHistory: cfg.MaxMcHistory,
McFlushInterval: cfg.McFlushInterval,
IntervalFlushInterval: cfg.IntervalFlushInterval,
AprioriConfig: &AprioriConfig{
HopProbability: cfg.AprioriConfig.HopProbability,
Weight: cfg.AprioriConfig.Weight,
Expand Down
58 changes: 58 additions & 0 deletions lnrpc/routerrpc/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package routerrpc

import (
"testing"
"time"

"github.com/lightningnetwork/lnd/routing"
"github.com/stretchr/testify/require"
)

// TestDefaultRouter tests that the payment router defaults to lnd's production
// stack, so that a node which says nothing about routing keeps the behaviour it
// had before the interval router existed.
func TestDefaultRouter(t *testing.T) {
t.Parallel()

cfg := DefaultConfig()
require.Equal(t, routing.DefaultPaymentRouter, cfg.PaymentRouter)

// The selection survives the trip through GetRoutingConfig, which is
// what the server actually reads.
require.Equal(
t, routing.DefaultPaymentRouter,
GetRoutingConfig(cfg).PaymentRouter,
)

cfg.PaymentRouter = routing.IntervalPaymentRouter
require.Equal(
t, routing.IntervalPaymentRouter,
GetRoutingConfig(cfg).PaymentRouter,
)
}

// TestIntervalFlushInterval tests that the interval router's flush cadence has
// a default of its own and survives the trip through GetRoutingConfig, so that
// tuning it does not mean tuning mission control's cadence as well.
func TestIntervalFlushInterval(t *testing.T) {
t.Parallel()

cfg := DefaultConfig()
require.Equal(
t, routing.DefaultIntervalFlushInterval,
cfg.IntervalFlushInterval,
)
require.Equal(
t, routing.DefaultIntervalFlushInterval,
GetRoutingConfig(cfg).IntervalFlushInterval,
)

// The two cadences move independently.
cfg.IntervalFlushInterval = 42 * time.Second
routingCfg := GetRoutingConfig(cfg)

require.Equal(t, 42*time.Second, routingCfg.IntervalFlushInterval)
require.Equal(
t, routing.DefaultMcFlushInterval, routingCfg.McFlushInterval,
)
}
18 changes: 18 additions & 0 deletions lnrpc/routerrpc/routing_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import (
//
//nolint:ll
type RoutingConfig struct {
// PaymentRouter selects the routing algorithm used to send payments.
// The default router is Dijkstra over a probability estimator, with
// mission control behind it and the shard amount halved whenever no
// route is found. The interval router replaces all of that with per
// directed channel liquidity intervals, and plans the shard amount and
// the route together.
//
// NOTE: this is named PaymentRouter rather than Router because the
// router sub server config already carries a Router field holding the
// channel router itself.
PaymentRouter string `long:"router" choice:"default" choice:"interval" description:"Routing algorithm used to send payments. The interval router is experimental."`

// ProbabilityEstimatorType sets the estimator to use.
ProbabilityEstimatorType string `long:"estimator" choice:"apriori" choice:"bimodal" description:"Probability estimator used for pathfinding." `

Expand All @@ -36,6 +48,12 @@ type RoutingConfig struct {
// control state to the DB.
McFlushInterval time.Duration `long:"mcflushinterval" description:"the timer interval to use to flush mission control state to the DB"`

// IntervalFlushInterval defines the timer interval to use to flush the
// interval router's liquidity beliefs to the DB. It is only used when
// the interval router is selected and the node runs the native SQL
// backend.
IntervalFlushInterval time.Duration `long:"intervalflushinterval" description:"the timer interval to use to flush the interval router's liquidity beliefs to the DB"`

// AprioriConfig defines parameters for the apriori probability.
AprioriConfig *AprioriConfig `group:"apriori" namespace:"apriori" description:"configuration for the apriori pathfinding probability estimator"`

Expand Down
Loading
Loading