Merge pull request #6815 from bitromortac/2205-bimodal

pathfinding: probability for bimodal distribution
This commit is contained in:
Oliver Gugger 2023-02-15 14:19:32 +01:00 committed by GitHub
commit 38dc67e1ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 3237 additions and 963 deletions

View file

@ -7,6 +7,7 @@ import (
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/urfave/cli"
)
@ -45,26 +46,12 @@ var setCfgCommand = cli.Command{
Category: "Mission Control",
Usage: "Set mission control's config.",
Description: `
Update the config values being used by mission control to calculate
the probability that payment routes will succeed.
`,
Update the config values being used by mission control to calculate the
probability that payment routes will succeed. The estimator type must be
provided to set estimator-related parameters.`,
Flags: []cli.Flag{
cli.DurationFlag{
Name: "halflife",
Usage: "the amount of time taken to restore a node " +
"or channel to 50% probability of success.",
},
cli.Float64Flag{
Name: "hopprob",
Usage: "the probability of success assigned " +
"to hops that we have no information about",
},
cli.Float64Flag{
Name: "weight",
Usage: "the degree to which mission control should " +
"rely on historical results, expressed as " +
"value in [0;1]",
}, cli.UintFlag{
// General settings.
cli.UintFlag{
Name: "pmtnr",
Usage: "the number of payments mission control " +
"should store",
@ -74,6 +61,48 @@ var setCfgCommand = cli.Command{
Usage: "the amount of time to wait after a failure " +
"before raising failure amount",
},
// Probability estimator.
cli.StringFlag{
Name: "estimator",
Usage: "the probability estimator to use, choose " +
"between 'apriori' or 'bimodal'",
},
// Apriori config.
cli.DurationFlag{
Name: "apriorihalflife",
Usage: "the amount of time taken to restore a node " +
"or channel to 50% probability of success.",
},
cli.Float64Flag{
Name: "apriorihopprob",
Usage: "the probability of success assigned " +
"to hops that we have no information about",
},
cli.Float64Flag{
Name: "aprioriweight",
Usage: "the degree to which mission control should " +
"rely on historical results, expressed as " +
"value in [0, 1]",
},
// Bimodal config.
cli.DurationFlag{
Name: "bimodaldecaytime",
Usage: "the time span after which we phase out " +
"learnings from previous payment attempts",
},
cli.Uint64Flag{
Name: "bimodalscale",
Usage: "controls the assumed channel liquidity " +
"imbalance in the network, measured in msat. " +
"a low value (compared to typical channel " +
"capacity) anticipates unbalanced channels.",
},
cli.Float64Flag{
Name: "bimodalweight",
Usage: "controls the degree to which the probability " +
"estimator takes into account other channels " +
"of a router",
},
},
Action: actionDecorator(setCfg),
}
@ -85,51 +114,140 @@ func setCfg(ctx *cli.Context) error {
client := routerrpc.NewRouterClient(conn)
resp, err := client.GetMissionControlConfig(
// Fetch current mission control config which we update to create our
// response.
mcCfg, err := client.GetMissionControlConfig(
ctxc, &routerrpc.GetMissionControlConfigRequest{},
)
if err != nil {
return err
}
// haveValue is a helper variable to determine if a flag has been set or
// the help should be displayed.
var haveValue bool
if ctx.IsSet("halflife") {
haveValue = true
resp.Config.HalfLifeSeconds = uint64(ctx.Duration(
"halflife",
).Seconds())
}
if ctx.IsSet("hopprob") {
haveValue = true
resp.Config.HopProbability = float32(ctx.Float64("hopprob"))
}
if ctx.IsSet("weight") {
haveValue = true
resp.Config.Weight = float32(ctx.Float64("weight"))
}
// Handle general mission control settings.
if ctx.IsSet("pmtnr") {
haveValue = true
resp.Config.MaximumPaymentResults = uint32(ctx.Int("pmtnr"))
mcCfg.Config.MaximumPaymentResults = uint32(ctx.Int("pmtnr"))
}
if ctx.IsSet("failrelax") {
haveValue = true
resp.Config.MinimumFailureRelaxInterval = uint64(ctx.Duration(
mcCfg.Config.MinimumFailureRelaxInterval = uint64(ctx.Duration(
"failrelax",
).Seconds())
}
// We switch between estimators and set corresponding configs. If
// estimator is not set, we ignore the values.
if ctx.IsSet("estimator") {
switch ctx.String("estimator") {
case routing.AprioriEstimatorName:
haveValue = true
// If we switch from another estimator, initialize with
// default values.
if mcCfg.Config.Model !=
routerrpc.MissionControlConfig_APRIORI {
dCfg := routing.DefaultAprioriConfig()
aParams := &routerrpc.AprioriParameters{
HalfLifeSeconds: uint64(
dCfg.PenaltyHalfLife.Seconds(),
),
HopProbability: dCfg.AprioriHopProbability, //nolint:lll
Weight: dCfg.AprioriWeight,
}
// We make sure the correct config is set.
mcCfg.Config.EstimatorConfig =
&routerrpc.MissionControlConfig_Apriori{
Apriori: aParams,
}
}
// We update all values for the apriori estimator.
mcCfg.Config.Model = routerrpc.
MissionControlConfig_APRIORI
aCfg := mcCfg.Config.GetApriori()
if ctx.IsSet("apriorihalflife") {
aCfg.HalfLifeSeconds = uint64(ctx.Duration(
"apriorihalflife",
).Seconds())
}
if ctx.IsSet("apriorihopprob") {
aCfg.HopProbability = ctx.Float64(
"apriorihopprob",
)
}
if ctx.IsSet("aprioriweight") {
aCfg.Weight = ctx.Float64("aprioriweight")
}
case routing.BimodalEstimatorName:
haveValue = true
// If we switch from another estimator, initialize with
// default values.
if mcCfg.Config.Model !=
routerrpc.MissionControlConfig_BIMODAL {
dCfg := routing.DefaultBimodalConfig()
bParams := &routerrpc.BimodalParameters{
DecayTime: uint64(
dCfg.BimodalDecayTime.Seconds(),
),
ScaleMsat: uint64(
dCfg.BimodalScaleMsat,
),
NodeWeight: dCfg.BimodalNodeWeight,
}
// We make sure the correct config is set.
mcCfg.Config.EstimatorConfig =
&routerrpc.MissionControlConfig_Bimodal{
Bimodal: bParams,
}
}
// We update all values for the bimodal estimator.
mcCfg.Config.Model = routerrpc.
MissionControlConfig_BIMODAL
bCfg := mcCfg.Config.GetBimodal()
if ctx.IsSet("bimodaldecaytime") {
bCfg.DecayTime = uint64(ctx.Duration(
"bimodaldecaytime",
).Seconds())
}
if ctx.IsSet("bimodalscale") {
bCfg.ScaleMsat = ctx.Uint64("bimodalscale")
}
if ctx.IsSet("bimodalweight") {
bCfg.NodeWeight = ctx.Float64(
"bimodalweight",
)
}
default:
return fmt.Errorf("unknown estimator %v",
ctx.String("estimator"))
}
}
if !haveValue {
return cli.ShowCommandHelp(ctx, "setmccfg")
}
_, err = client.SetMissionControlConfig(
ctxc, &routerrpc.SetMissionControlConfigRequest{
Config: resp.Config,
Config: mcCfg.Config,
},
)
return err

View file

@ -465,6 +465,9 @@ type Config struct {
// ActiveNetParams contains parameters of the target chain.
ActiveNetParams chainreg.BitcoinNetParams
// Estimator is used to estimate routing probabilities.
Estimator routing.Estimator
}
// DefaultConfig returns all default values for the Config struct.

View file

@ -408,6 +408,16 @@ in the lnwire package](https://github.com/lightningnetwork/lnd/pull/7303)
* [Pathfinding takes capacity of edges into account to improve success
probability estimation.](https://github.com/lightningnetwork/lnd/pull/6857)
* [A new probability model ("bimodal") is added which models channel based
liquidities within a probability theory framework.](
https://github.com/lightningnetwork/lnd/pull/6815)
## Configuration
* Note that [this pathfinding change](https://github.com/lightningnetwork/lnd/pull/6815)
introduces a breaking change in lnd.conf apriori parameters under the routing
section, see sample-lnd.conf for an updated configuration. The behavior of
`lncli setmccfg/getmccfg` is altered as well.
### Tooling and documentation
@ -465,6 +475,7 @@ refactor the itest for code health and maintenance.
* Andras Banki-Horvath
* andreihod
* Antoni Spaanderman
* bitromortac
* Carla Kirk-Cohen
* Carsten Otto
* Chris Geihsler

View file

@ -42,14 +42,23 @@ type Config struct {
// DefaultConfig defines the config defaults.
func DefaultConfig() *Config {
defaultRoutingConfig := RoutingConfig{
AprioriHopProbability: routing.DefaultAprioriHopProbability,
AprioriWeight: routing.DefaultAprioriWeight,
MinRouteProbability: routing.DefaultMinRouteProbability,
PenaltyHalfLife: routing.DefaultPenaltyHalfLife,
AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
AttemptCostPPM: routing.DefaultAttemptCostPPM,
MaxMcHistory: routing.DefaultMaxMcHistory,
McFlushInterval: routing.DefaultMcFlushInterval,
ProbabilityEstimatorType: routing.DefaultEstimator,
MinRouteProbability: routing.DefaultMinRouteProbability,
AttemptCost: routing.DefaultAttemptCost.ToSatoshis(),
AttemptCostPPM: routing.DefaultAttemptCostPPM,
MaxMcHistory: routing.DefaultMaxMcHistory,
McFlushInterval: routing.DefaultMcFlushInterval,
AprioriConfig: &AprioriConfig{
HopProbability: routing.DefaultAprioriHopProbability,
Weight: routing.DefaultAprioriWeight,
PenaltyHalfLife: routing.DefaultPenaltyHalfLife,
},
BimodalConfig: &BimodalConfig{
Scale: int64(routing.DefaultBimodalScaleMsat),
NodeWeight: routing.DefaultBimodalNodeWeight,
DecayTime: routing.DefaultBimodalDecayTime,
},
}
return &Config{
@ -60,13 +69,21 @@ func DefaultConfig() *Config {
// GetRoutingConfig returns the routing config based on this sub server config.
func GetRoutingConfig(cfg *Config) *RoutingConfig {
return &RoutingConfig{
AprioriHopProbability: cfg.AprioriHopProbability,
AprioriWeight: cfg.AprioriWeight,
MinRouteProbability: cfg.MinRouteProbability,
AttemptCost: cfg.AttemptCost,
AttemptCostPPM: cfg.AttemptCostPPM,
PenaltyHalfLife: cfg.PenaltyHalfLife,
MaxMcHistory: cfg.MaxMcHistory,
McFlushInterval: cfg.McFlushInterval,
ProbabilityEstimatorType: cfg.ProbabilityEstimatorType,
MinRouteProbability: cfg.MinRouteProbability,
AttemptCost: cfg.AttemptCost,
AttemptCostPPM: cfg.AttemptCostPPM,
MaxMcHistory: cfg.MaxMcHistory,
McFlushInterval: cfg.McFlushInterval,
AprioriConfig: &AprioriConfig{
HopProbability: cfg.AprioriConfig.HopProbability,
Weight: cfg.AprioriConfig.Weight,
PenaltyHalfLife: cfg.AprioriConfig.PenaltyHalfLife,
},
BimodalConfig: &BimodalConfig{
Scale: cfg.BimodalConfig.Scale,
NodeWeight: cfg.BimodalConfig.NodeWeight,
DecayTime: cfg.BimodalConfig.DecayTime,
},
}
}

File diff suppressed because it is too large Load diff

View file

@ -467,6 +467,93 @@ message SetMissionControlConfigResponse {
}
message MissionControlConfig {
/*
Deprecated, use AprioriParameters. The amount of time mission control will
take to restore a penalized node or channel back to 50% success probability,
expressed in seconds. Setting this value to a higher value will penalize
failures for longer, making mission control less likely to route through
nodes and channels that we have previously recorded failures for.
*/
uint64 half_life_seconds = 1 [deprecated = true];
/*
Deprecated, use AprioriParameters. The probability of success mission
control should assign to hop in a route where it has no other information
available. Higher values will make mission control more willing to try hops
that we have no information about, lower values will discourage trying these
hops.
*/
float hop_probability = 2 [deprecated = true];
/*
Deprecated, use AprioriParameters. The importance that mission control
should place on historical results, expressed as a value in [0;1]. Setting
this value to 1 will ignore all historical payments and just use the hop
probability to assess the probability of success for each hop. A zero value
ignores hop probability completely and relies entirely on historical
results, unless none are available.
*/
float weight = 3 [deprecated = true];
/*
The maximum number of payment results that mission control will store.
*/
uint32 maximum_payment_results = 4;
/*
The minimum time that must have passed since the previously recorded failure
before we raise the failure amount.
*/
uint64 minimum_failure_relax_interval = 5;
enum ProbabilityModel {
APRIORI = 0;
BIMODAL = 1;
}
/*
ProbabilityModel defines which probability estimator should be used in
pathfinding.
*/
ProbabilityModel Model = 6;
/*
EstimatorConfig is populated dependent on the estimator type.
*/
oneof EstimatorConfig {
AprioriParameters apriori = 7;
BimodalParameters bimodal = 8;
}
}
message BimodalParameters {
/*
NodeWeight defines how strongly other previous forwardings on channels of a
router should be taken into account when computing a channel's probability
to route. The allowed values are in the range [0, 1], where a value of 0
means that only direct information about a channel is taken into account.
*/
double node_weight = 1;
/*
ScaleMsat describes the scale over which channels statistically have some
liquidity left. The value determines how quickly the bimodal distribution
drops off from the edges of a channel. A larger value (compared to typical
channel capacities) means that the drop off is slow and that channel
balances are distributed more uniformly. A small value leads to the
assumption of very unbalanced channels.
*/
uint64 scale_msat = 2;
/*
DecayTime describes the information decay of knowledge about previous
successes and failures in channels. The smaller the decay time, the quicker
we forget about past forwardings.
*/
uint64 decay_time = 3;
}
message AprioriParameters {
/*
The amount of time mission control will take to restore a penalized node
or channel back to 50% success probability, expressed in seconds. Setting
@ -482,7 +569,7 @@ message MissionControlConfig {
control more willing to try hops that we have no information about, lower
values will discourage trying these hops.
*/
float hop_probability = 2;
double hop_probability = 2;
/*
The importance that mission control should place on historical results,
@ -492,18 +579,7 @@ message MissionControlConfig {
completely and relies entirely on historical results, unless none are
available.
*/
float weight = 3;
/*
The maximum number of payment results that mission control will store.
*/
uint32 maximum_payment_results = 4;
/*
The minimum time that must have passed since the previously recorded failure
before we raise the failure amount.
*/
uint64 minimum_failure_relax_interval = 5;
double weight = 3;
}
message QueryProbabilityRequest {

View file

@ -593,6 +593,14 @@
],
"default": "IN_FLIGHT"
},
"MissionControlConfigProbabilityModel": {
"type": "string",
"enum": [
"APRIORI",
"BIMODAL"
],
"default": "APRIORI"
},
"lnrpcAMPRecord": {
"type": "object",
"properties": {
@ -1073,6 +1081,46 @@
}
}
},
"routerrpcAprioriParameters": {
"type": "object",
"properties": {
"half_life_seconds": {
"type": "string",
"format": "uint64",
"description": "The amount of time mission control will take to restore a penalized node\nor channel back to 50% success probability, expressed in seconds. Setting\nthis value to a higher value will penalize failures for longer, making\nmission control less likely to route through nodes and channels that we\nhave previously recorded failures for."
},
"hop_probability": {
"type": "number",
"format": "double",
"description": "The probability of success mission control should assign to hop in a route\nwhere it has no other information available. Higher values will make mission\ncontrol more willing to try hops that we have no information about, lower\nvalues will discourage trying these hops."
},
"weight": {
"type": "number",
"format": "double",
"description": "The importance that mission control should place on historical results,\nexpressed as a value in [0;1]. Setting this value to 1 will ignore all\nhistorical payments and just use the hop probability to assess the\nprobability of success for each hop. A zero value ignores hop probability\ncompletely and relies entirely on historical results, unless none are\navailable."
}
}
},
"routerrpcBimodalParameters": {
"type": "object",
"properties": {
"node_weight": {
"type": "number",
"format": "double",
"description": "NodeWeight defines how strongly other previous forwardings on channels of a\nrouter should be taken into account when computing a channel's probability\nto route. The allowed values are in the range [0, 1], where a value of 0\nmeans that only direct information about a channel is taken into account."
},
"scale_msat": {
"type": "string",
"format": "uint64",
"description": "ScaleMsat describes the scale over which channels statistically have some\nliquidity left. The value determines how quickly the bimodal distribution\ndrops off from the edges of a channel. A larger value (compared to typical\nchannel capacities) means that the drop off is slow and that channel\nbalances are distributed more uniformly. A small value leads to the\nassumption of very unbalanced channels."
},
"decay_time": {
"type": "string",
"format": "uint64",
"description": "DecayTime describes the information decay of knowledge about previous\nsuccesses and failures in channels. The smaller the decay time, the quicker\nwe forget about past forwardings."
}
}
},
"routerrpcBuildRouteRequest": {
"type": "object",
"properties": {
@ -1400,17 +1448,17 @@
"half_life_seconds": {
"type": "string",
"format": "uint64",
"description": "The amount of time mission control will take to restore a penalized node\nor channel back to 50% success probability, expressed in seconds. Setting\nthis value to a higher value will penalize failures for longer, making\nmission control less likely to route through nodes and channels that we\nhave previously recorded failures for."
"description": "Deprecated, use AprioriParameters. The amount of time mission control will\ntake to restore a penalized node or channel back to 50% success probability,\nexpressed in seconds. Setting this value to a higher value will penalize\nfailures for longer, making mission control less likely to route through\nnodes and channels that we have previously recorded failures for."
},
"hop_probability": {
"type": "number",
"format": "float",
"description": "The probability of success mission control should assign to hop in a route\nwhere it has no other information available. Higher values will make mission\ncontrol more willing to try hops that we have no information about, lower\nvalues will discourage trying these hops."
"description": "Deprecated, use AprioriParameters. The probability of success mission\ncontrol should assign to hop in a route where it has no other information\navailable. Higher values will make mission control more willing to try hops\nthat we have no information about, lower values will discourage trying these\nhops."
},
"weight": {
"type": "number",
"format": "float",
"description": "The importance that mission control should place on historical results,\nexpressed as a value in [0;1]. Setting this value to 1 will ignore all\nhistorical payments and just use the hop probability to assess the\nprobability of success for each hop. A zero value ignores hop probability\ncompletely and relies entirely on historical results, unless none are\navailable."
"description": "Deprecated, use AprioriParameters. The importance that mission control\nshould place on historical results, expressed as a value in [0;1]. Setting\nthis value to 1 will ignore all historical payments and just use the hop\nprobability to assess the probability of success for each hop. A zero value\nignores hop probability completely and relies entirely on historical\nresults, unless none are available."
},
"maximum_payment_results": {
"type": "integer",
@ -1421,6 +1469,16 @@
"type": "string",
"format": "uint64",
"description": "The minimum time that must have passed since the previously recorded failure\nbefore we raise the failure amount."
},
"Model": {
"$ref": "#/definitions/MissionControlConfigProbabilityModel",
"description": "ProbabilityModel defines which probability estimator should be used in\npathfinding."
},
"apriori": {
"$ref": "#/definitions/routerrpcAprioriParameters"
},
"bimodal": {
"$ref": "#/definitions/routerrpcBimodalParameters"
}
}
},

View file

@ -454,39 +454,137 @@ func (s *Server) GetMissionControlConfig(ctx context.Context,
req *GetMissionControlConfigRequest) (*GetMissionControlConfigResponse,
error) {
// Query the current mission control config.
cfg := s.cfg.RouterBackend.MissionControl.GetConfig()
return &GetMissionControlConfigResponse{
resp := &GetMissionControlConfigResponse{
Config: &MissionControlConfig{
HalfLifeSeconds: uint64(cfg.PenaltyHalfLife.Seconds()),
HopProbability: float32(cfg.AprioriHopProbability),
Weight: float32(cfg.AprioriWeight),
MaximumPaymentResults: uint32(cfg.MaxMcHistory),
MinimumFailureRelaxInterval: uint64(cfg.MinFailureRelaxInterval.Seconds()),
MaximumPaymentResults: uint32(cfg.MaxMcHistory),
MinimumFailureRelaxInterval: uint64(
cfg.MinFailureRelaxInterval.Seconds(),
),
},
}, nil
}
// We only populate fields based on the current estimator.
switch v := cfg.Estimator.Config().(type) {
case routing.AprioriConfig:
resp.Config.Model = MissionControlConfig_APRIORI
aCfg := AprioriParameters{
HalfLifeSeconds: uint64(v.PenaltyHalfLife.Seconds()),
HopProbability: v.AprioriHopProbability,
Weight: v.AprioriWeight,
}
// Populate deprecated fields.
resp.Config.HalfLifeSeconds = uint64(
v.PenaltyHalfLife.Seconds(),
)
resp.Config.HopProbability = float32(v.AprioriHopProbability)
resp.Config.Weight = float32(v.AprioriWeight)
resp.Config.EstimatorConfig = &MissionControlConfig_Apriori{
Apriori: &aCfg,
}
case routing.BimodalConfig:
resp.Config.Model = MissionControlConfig_BIMODAL
bCfg := BimodalParameters{
NodeWeight: v.BimodalNodeWeight,
ScaleMsat: uint64(v.BimodalScaleMsat),
DecayTime: uint64(v.BimodalDecayTime.Seconds()),
}
resp.Config.EstimatorConfig = &MissionControlConfig_Bimodal{
Bimodal: &bCfg,
}
default:
return nil, fmt.Errorf("unknown estimator config type %T", v)
}
return resp, nil
}
// SetMissionControlConfig returns our current mission control config.
// SetMissionControlConfig sets parameters in the mission control config.
func (s *Server) SetMissionControlConfig(ctx context.Context,
req *SetMissionControlConfigRequest) (*SetMissionControlConfigResponse,
error) {
cfg := &routing.MissionControlConfig{
ProbabilityEstimatorCfg: routing.ProbabilityEstimatorCfg{
PenaltyHalfLife: time.Duration(
req.Config.HalfLifeSeconds,
) * time.Second,
AprioriHopProbability: float64(req.Config.HopProbability),
AprioriWeight: float64(req.Config.Weight),
},
mcCfg := &routing.MissionControlConfig{
MaxMcHistory: int(req.Config.MaximumPaymentResults),
MinFailureRelaxInterval: time.Duration(
req.Config.MinimumFailureRelaxInterval,
) * time.Second,
}
switch req.Config.Model {
case MissionControlConfig_APRIORI:
var aprioriConfig routing.AprioriConfig
// Determine the apriori config with backward compatibility
// should the api use deprecated fields.
switch v := req.Config.EstimatorConfig.(type) {
case *MissionControlConfig_Bimodal:
return nil, fmt.Errorf("bimodal config " +
"provided, but apriori model requested")
case *MissionControlConfig_Apriori:
aprioriConfig = routing.AprioriConfig{
PenaltyHalfLife: time.Duration(
v.Apriori.HalfLifeSeconds,
) * time.Second,
AprioriHopProbability: v.Apriori.HopProbability,
AprioriWeight: v.Apriori.Weight,
}
default:
aprioriConfig = routing.AprioriConfig{
PenaltyHalfLife: time.Duration(
int64(req.Config.HalfLifeSeconds),
) * time.Second,
AprioriHopProbability: float64(
req.Config.HopProbability,
),
AprioriWeight: float64(req.Config.Weight),
}
}
estimator, err := routing.NewAprioriEstimator(aprioriConfig)
if err != nil {
return nil, err
}
mcCfg.Estimator = estimator
case MissionControlConfig_BIMODAL:
cfg, ok := req.Config.
EstimatorConfig.(*MissionControlConfig_Bimodal)
if !ok {
return nil, fmt.Errorf("bimodal estimator requested " +
"but corresponding config not set")
}
bCfg := cfg.Bimodal
bimodalConfig := routing.BimodalConfig{
BimodalDecayTime: time.Duration(
bCfg.DecayTime,
) * time.Second,
BimodalScaleMsat: lnwire.MilliSatoshi(bCfg.ScaleMsat),
BimodalNodeWeight: bCfg.NodeWeight,
}
estimator, err := routing.NewBimodalEstimator(bimodalConfig)
if err != nil {
return nil, err
}
mcCfg.Estimator = estimator
default:
return nil, fmt.Errorf("unknown estimator type %v",
req.Config.Model)
}
return &SetMissionControlConfigResponse{},
s.cfg.RouterBackend.MissionControl.SetConfig(cfg)
s.cfg.RouterBackend.MissionControl.SetConfig(mcCfg)
}
// QueryMissionControl exposes the internal mission control state to callers. It

View file

@ -7,28 +7,16 @@ import (
)
// RoutingConfig contains the configurable parameters that control routing.
//
//nolint:lll
type RoutingConfig struct {
// ProbabilityEstimatorType sets the estimator to use.
ProbabilityEstimatorType string `long:"estimator" choice:"apriori" choice:"bimodal" description:"Probability estimator used for pathfinding." `
// MinRouteProbability is the minimum required route success probability
// to attempt the payment.
MinRouteProbability float64 `long:"minrtprob" description:"Minimum required route success probability to attempt the payment"`
// AprioriHopProbability is the assumed success probability of a hop in
// a route when no other information is available.
AprioriHopProbability float64 `long:"apriorihopprob" description:"Assumed success probability of a hop in a route when no other information is available."`
// AprioriWeight is a value in the range [0, 1] that defines to what
// extent historical results should be extrapolated to untried
// connections. Setting it to one will completely ignore historical
// results and always assume the configured a priori probability for
// untried connections. A value of zero will ignore the a priori
// probability completely and only base the probability on historical
// results, unless there are none available.
AprioriWeight float64 `long:"aprioriweight" description:"Weight of the a priori probability in success probability estimation. Valid values are in [0, 1]."`
// PenaltyHalfLife defines after how much time a penalized node or
// channel is back at 50% probability.
PenaltyHalfLife time.Duration `long:"penaltyhalflife" description:"Defines the duration after which a penalized node or channel is back at 50% probability"`
// AttemptCost is the fixed virtual cost in path finding of a failed
// payment attempt. It is used to trade off potentially better routes
// against their probability of succeeding.
@ -47,4 +35,51 @@ type RoutingConfig struct {
// McFlushInterval defines the timer interval to use to flush mission
// control state to the DB.
McFlushInterval time.Duration `long:"mcflushinterval" description:"the timer interval to use to flush mission control state to the DB"`
// AprioriConfig defines parameters for the apriori probability.
AprioriConfig *AprioriConfig `group:"apriori" namespace:"apriori" description:"configuration for the apriori pathfinding probability estimator"`
// BimodalConfig defines parameters for the bimodal probability.
BimodalConfig *BimodalConfig `group:"bimodal" namespace:"bimodal" description:"configuration for the bimodal pathfinding probability estimator"`
}
// AprioriConfig defines parameters for the apriori probability.
//
//nolint:lll
type AprioriConfig struct {
// HopProbability is the assumed success probability of a hop in a route
// when no other information is available.
HopProbability float64 `long:"hopprob" description:"Assumed success probability of a hop in a route when no other information is available."`
// Weight is a value in the range [0, 1] that defines to what extent
// historical results should be extrapolated to untried connections.
// Setting it to one will completely ignore historical results and
// always assume the configured a priori probability for untried
// connections. A value of zero will ignore the a priori probability
// completely and only base the probability on historical results,
// unless there are none available.
Weight float64 `long:"weight" description:"Weight of the a priori probability in success probability estimation. Valid values are in [0, 1]."`
// PenaltyHalfLife defines after how much time a penalized node or
// channel is back at 50% probability.
PenaltyHalfLife time.Duration `long:"penaltyhalflife" description:"Defines the duration after which a penalized node or channel is back at 50% probability"`
}
// BimodalConfig defines parameters for the bimodal probability.
//
//nolint:lll
type BimodalConfig struct {
// Scale describes the scale over which channels still have some
// liquidity left on both channel ends. A value of 0 means that we
// assume perfectly unbalanced channels, a very high value means
// randomly balanced channels.
Scale int64 `long:"scale" description:"Defines the unbalancedness assumed for the network, the amount defined in msat."`
// NodeWeight defines how strongly non-routed channels should be taken
// into account for probability estimation. Valid values are in [0,1].
NodeWeight float64 `long:"nodeweight" description:"Defines how strongly non-routed channels should be taken into account for probability estimation. Valid values are in [0, 1]."`
// DecayTime is the scale for the exponential information decay over
// time for previous successes or failures.
DecayTime time.Duration `long:"decaytime" description:"Describes the information decay of knowledge about previous successes and failures in channels."`
}

View file

@ -86,6 +86,20 @@ func (h *HarnessRPC) SetMissionControlConfig(
h.NoError(err, "SetMissionControlConfig")
}
// SetMissionControlConfigAssertErr makes a RPC call to the node's
// SetMissionControlConfig and asserts that we error.
func (h *HarnessRPC) SetMissionControlConfigAssertErr(
config *routerrpc.MissionControlConfig) {
ctxt, cancel := context.WithTimeout(h.runCtx, DefaultTimeout)
defer cancel()
req := &routerrpc.SetMissionControlConfigRequest{Config: config}
_, err := h.Router.SetMissionControlConfig(ctxt, req)
require.Error(h, err, "expect an error from setting import mission "+
"control")
}
// ResetMissionControl makes a RPC call to the node's ResetMissionControl and
// asserts.
func (h *HarnessRPC) ResetMissionControl() {

View file

@ -1003,25 +1003,85 @@ func testQueryRoutes(ht *lntemp.HarnessTest) {
func testMissionControlCfg(t *testing.T, hn *node.HarnessNode) {
t.Helper()
startCfg := hn.RPC.GetMissionControlConfig()
// Getting and setting does not alter the configuration.
startCfg := hn.RPC.GetMissionControlConfig().Config
hn.RPC.SetMissionControlConfig(startCfg)
resp := hn.RPC.GetMissionControlConfig()
require.True(t, proto.Equal(startCfg, resp.Config))
// We test that setting and getting leads to the same config if all
// fields are set.
cfg := &routerrpc.MissionControlConfig{
HalfLifeSeconds: 8000,
HopProbability: 0.8,
Weight: 0.3,
MaximumPaymentResults: 30,
MinimumFailureRelaxInterval: 60,
Model: routerrpc.
MissionControlConfig_APRIORI,
EstimatorConfig: &routerrpc.MissionControlConfig_Apriori{
Apriori: &routerrpc.AprioriParameters{
HalfLifeSeconds: 8000,
HopProbability: 0.8,
Weight: 0.3,
},
},
}
hn.RPC.SetMissionControlConfig(cfg)
resp := hn.RPC.GetMissionControlConfig()
require.True(t, proto.Equal(cfg, resp.Config))
// The deprecated fields should be populated.
cfg.HalfLifeSeconds = 8000
cfg.HopProbability = 0.8
cfg.Weight = 0.3
respCfg := hn.RPC.GetMissionControlConfig().Config
require.True(t, proto.Equal(cfg, respCfg))
hn.RPC.SetMissionControlConfig(startCfg.Config)
// Switching to another estimator is possible.
cfg = &routerrpc.MissionControlConfig{
Model: routerrpc.
MissionControlConfig_BIMODAL,
EstimatorConfig: &routerrpc.MissionControlConfig_Bimodal{
Bimodal: &routerrpc.BimodalParameters{
ScaleMsat: 1_000,
DecayTime: 500,
},
},
}
hn.RPC.SetMissionControlConfig(cfg)
respCfg = hn.RPC.GetMissionControlConfig().Config
require.NotNil(t, respCfg.GetBimodal())
// If parameters are not set in the request, they will have zero values
// after.
require.Zero(t, respCfg.MaximumPaymentResults)
require.Zero(t, respCfg.MinimumFailureRelaxInterval)
require.Zero(t, respCfg.GetBimodal().NodeWeight)
// Setting deprecated values will initialize the apriori estimator.
cfg = &routerrpc.MissionControlConfig{
MaximumPaymentResults: 30,
MinimumFailureRelaxInterval: 60,
HopProbability: 0.8,
Weight: 0.3,
HalfLifeSeconds: 8000,
}
hn.RPC.SetMissionControlConfig(cfg)
respCfg = hn.RPC.GetMissionControlConfig().Config
require.NotNil(t, respCfg.GetApriori())
// Setting the wrong config results in an error.
cfg = &routerrpc.MissionControlConfig{
Model: routerrpc.
MissionControlConfig_APRIORI,
EstimatorConfig: &routerrpc.MissionControlConfig_Bimodal{
Bimodal: &routerrpc.BimodalParameters{
ScaleMsat: 1_000,
},
},
}
hn.RPC.SetMissionControlConfigAssertErr(cfg)
// Undo any changes.
hn.RPC.SetMissionControlConfig(startCfg)
resp = hn.RPC.GetMissionControlConfig()
require.True(t, proto.Equal(startCfg.Config, resp.Config))
require.True(t, proto.Equal(startCfg, resp.Config))
}
// testMissionControlImport tests import of mission control results from an

View file

@ -11,6 +11,7 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
)
const (
@ -68,6 +69,14 @@ func newIntegratedRoutingContext(t *testing.T) *integratedRoutingContext {
// defaults would break the unit tests. The actual values picked aren't
// critical to excite certain behavior, but do need to be aligned with
// the test case assertions.
aCfg := AprioriConfig{
PenaltyHalfLife: 30 * time.Minute,
AprioriHopProbability: 0.6,
AprioriWeight: 0.5,
}
estimator, err := NewAprioriEstimator(aCfg)
require.NoError(t, err)
ctx := integratedRoutingContext{
t: t,
graph: graph,
@ -75,11 +84,7 @@ func newIntegratedRoutingContext(t *testing.T) *integratedRoutingContext {
finalExpiry: 40,
mcCfg: MissionControlConfig{
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
PenaltyHalfLife: 30 * time.Minute,
AprioriHopProbability: 0.6,
AprioriWeight: 0.5,
},
Estimator: estimator,
},
pathFindingCfg: PathFindingConfig{

View file

@ -67,7 +67,10 @@ func TestProbabilityExtrapolation(t *testing.T) {
// If we use a static value for the node probability (no extrapolation
// of data from other channels), all ten bad channels will be tried
// first before switching to the paid channel.
ctx.mcCfg.AprioriWeight = 1
estimator, ok := ctx.mcCfg.Estimator.(*AprioriEstimator)
if ok {
estimator.AprioriWeight = 1
}
attempts, err = ctx.testPayment(1)
require.NoError(t, err, "payment failed")
if len(attempts) != 11 {

View file

@ -103,7 +103,7 @@ type MissionControl struct {
// estimator is the probability estimator that is used with the payment
// results that mission control collects.
estimator *probabilityEstimator
estimator Estimator
sync.Mutex
@ -116,9 +116,8 @@ type MissionControl struct {
// MissionControlConfig defines parameters that control mission control
// behaviour.
type MissionControlConfig struct {
// ProbabilityEstimatorConfig is the config we will use for probability
// calculations.
ProbabilityEstimatorCfg
// Estimator gives probability estimates for node pairs.
Estimator Estimator
// MaxMcHistory defines the maximum number of payment results that are
// held on disk.
@ -135,10 +134,6 @@ type MissionControlConfig struct {
}
func (c *MissionControlConfig) validate() error {
if err := c.ProbabilityEstimatorCfg.validate(); err != nil {
return err
}
if c.MaxMcHistory < 0 {
return ErrInvalidMcHistory
}
@ -152,11 +147,8 @@ func (c *MissionControlConfig) validate() error {
// String returns a string representation of a mission control config.
func (c *MissionControlConfig) String() string {
return fmt.Sprintf("Penalty Half Life: %v, Apriori Hop "+
"Probablity: %v, Maximum History: %v, Apriori Weight: %v, "+
"Minimum Failure Relax Interval: %v", c.PenaltyHalfLife,
c.AprioriHopProbability, c.MaxMcHistory, c.AprioriWeight,
c.MinFailureRelaxInterval)
return fmt.Sprintf("maximum history: %v, minimum failure relax "+
"interval: %v", c.MaxMcHistory, c.MinFailureRelaxInterval)
}
// TimedPairResult describes a timestamped pair result.
@ -212,7 +204,8 @@ type paymentResult struct {
func NewMissionControl(db kvdb.Backend, self route.Vertex,
cfg *MissionControlConfig) (*MissionControl, error) {
log.Debugf("Instantiating mission control with config: %v", cfg)
log.Debugf("Instantiating mission control with config: %v, %v", cfg,
cfg.Estimator)
if err := cfg.validate(); err != nil {
return nil, err
@ -225,17 +218,12 @@ func NewMissionControl(db kvdb.Backend, self route.Vertex,
return nil, err
}
estimator := &probabilityEstimator{
ProbabilityEstimatorCfg: cfg.ProbabilityEstimatorCfg,
prevSuccessProbability: prevSuccessProbability,
}
mc := &MissionControl{
state: newMissionControlState(cfg.MinFailureRelaxInterval),
now: time.Now,
selfNode: self,
store: store,
estimator: estimator,
estimator: cfg.Estimator,
}
if err := mc.init(); err != nil {
@ -284,7 +272,7 @@ func (m *MissionControl) GetConfig() *MissionControlConfig {
defer m.Unlock()
return &MissionControlConfig{
ProbabilityEstimatorCfg: m.estimator.ProbabilityEstimatorCfg,
Estimator: m.estimator,
MaxMcHistory: m.store.maxRecords,
McFlushInterval: m.store.flushInterval,
MinFailureRelaxInterval: m.state.minFailureRelaxInterval,
@ -305,11 +293,12 @@ func (m *MissionControl) SetConfig(cfg *MissionControlConfig) error {
m.Lock()
defer m.Unlock()
log.Infof("Updating mission control cfg: %v", cfg)
log.Infof("Active mission control cfg: %v, estimator: %v", cfg,
cfg.Estimator)
m.store.maxRecords = cfg.MaxMcHistory
m.state.minFailureRelaxInterval = cfg.MinFailureRelaxInterval
m.estimator.ProbabilityEstimatorCfg = cfg.ProbabilityEstimatorCfg
m.estimator = cfg.Estimator
return nil
}
@ -344,10 +333,10 @@ func (m *MissionControl) GetProbability(fromNode, toNode route.Vertex,
// Use a distinct probability estimation function for local channels.
if fromNode == m.selfNode {
return m.estimator.getLocalPairProbability(now, results, toNode)
return m.estimator.LocalPairProbability(now, results, toNode)
}
return m.estimator.getPairProbability(
return m.estimator.PairProbability(
now, results, toNode, amt, capacity,
)
}

View file

@ -90,15 +90,17 @@ func (ctx *mcTestContext) restartMc() {
require.NoError(ctx.t, ctx.mc.store.storeResults())
}
aCfg := AprioriConfig{
PenaltyHalfLife: testPenaltyHalfLife,
AprioriHopProbability: testAprioriHopProbability,
AprioriWeight: testAprioriWeight,
}
estimator, err := NewAprioriEstimator(aCfg)
require.NoError(ctx.t, err)
mc, err := NewMissionControl(
ctx.db, mcTestSelf,
&MissionControlConfig{
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
PenaltyHalfLife: testPenaltyHalfLife,
AprioriHopProbability: testAprioriHopProbability,
AprioriWeight: testAprioriWeight,
},
},
&MissionControlConfig{Estimator: estimator},
)
if err != nil {
ctx.t.Fatal(err)

View file

@ -46,6 +46,10 @@ type pathFinder = func(g *graphParams, r *RestrictParams,
[]*channeldb.CachedEdgePolicy, float64, error)
var (
// DefaultEstimator is the default estimator used for computing
// probabilities in pathfinding.
DefaultEstimator = AprioriEstimatorName
// DefaultAttemptCost is the default fixed virtual cost in path finding
// of a failed payment attempt. It is used to trade off potentially
// better routes against their probability of succeeding.

View file

@ -0,0 +1,346 @@
package routing
import (
"errors"
"fmt"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
const (
// capacityCutoffFraction and capacitySmearingFraction define how
// capacity-related probability reweighting works.
// capacityCutoffFraction defines the fraction of the channel capacity
// at which the effect roughly sets in and capacitySmearingFraction
// defines over which range the factor changes from 1 to 0.
//
// We may fall below the minimum required probability
// (DefaultMinRouteProbability) when the amount comes close to the
// available capacity of a single channel of the route in case of no
// prior knowledge about the channels. We want such routes still to be
// available and therefore a probability reduction should not completely
// drop the total probability below DefaultMinRouteProbability.
// For this to hold for a three-hop route we require:
// (DefaultAprioriHopProbability)^3 * minCapacityFactor >
// DefaultMinRouteProbability
//
// For DefaultAprioriHopProbability = 0.6 and
// DefaultMinRouteProbability = 0.01 this results in
// minCapacityFactor ~ 0.05. The following combination of parameters
// fulfill the requirement with capacityFactor(cap, cap) ~ 0.076 (see
// tests).
// The capacityCutoffFraction is a trade-off between usage of the
// provided capacity and expected probability reduction when we send the
// full amount. The success probability in the random balance model can
// be approximated with P(a) = 1 - a/c, for amount a and capacity c. If
// we require a probability P(a) > 0.25, this translates into a value of
// 0.75 for a/c.
capacityCutoffFraction = 0.75
// We don't want to have a sharp drop of the capacity factor to zero at
// capacityCutoffFraction, but a smooth smearing such that some residual
// probability is left when spending the whole amount, see above.
capacitySmearingFraction = 0.1
// AprioriEstimatorName is used to identify the apriori probability
// estimator.
AprioriEstimatorName = "apriori"
)
var (
// ErrInvalidHalflife is returned when we get an invalid half life.
ErrInvalidHalflife = errors.New("penalty half life must be >= 0")
// ErrInvalidHopProbability is returned when we get an invalid hop
// probability.
ErrInvalidHopProbability = errors.New("hop probability must be in " +
"[0, 1]")
// ErrInvalidAprioriWeight is returned when we get an apriori weight
// that is out of range.
ErrInvalidAprioriWeight = errors.New("apriori weight must be in [0, 1]")
)
// AprioriConfig contains configuration for our probability estimator.
type AprioriConfig struct {
// PenaltyHalfLife defines after how much time a penalized node or
// channel is back at 50% probability.
PenaltyHalfLife time.Duration
// AprioriHopProbability is the assumed success probability of a hop in
// a route when no other information is available.
AprioriHopProbability float64
// AprioriWeight is a value in the range [0, 1] that defines to what
// extent historical results should be extrapolated to untried
// connections. Setting it to one will completely ignore historical
// results and always assume the configured a priori probability for
// untried connections. A value of zero will ignore the a priori
// probability completely and only base the probability on historical
// results, unless there are none available.
AprioriWeight float64
}
// validate checks the configuration of the estimator for allowed values.
func (p AprioriConfig) validate() error {
if p.PenaltyHalfLife < 0 {
return ErrInvalidHalflife
}
if p.AprioriHopProbability < 0 || p.AprioriHopProbability > 1 {
return ErrInvalidHopProbability
}
if p.AprioriWeight < 0 || p.AprioriWeight > 1 {
return ErrInvalidAprioriWeight
}
return nil
}
// DefaultAprioriConfig returns the default configuration for the estimator.
func DefaultAprioriConfig() AprioriConfig {
return AprioriConfig{
PenaltyHalfLife: DefaultPenaltyHalfLife,
AprioriHopProbability: DefaultAprioriHopProbability,
AprioriWeight: DefaultAprioriWeight,
}
}
// AprioriEstimator returns node and pair probabilities based on historical
// payment results. It uses a preconfigured success probability value for
// untried hops (AprioriHopProbability) and returns a high success probability
// for hops that could previously conduct a payment (prevSuccessProbability).
// Successful edges are retried until proven otherwise. Recently failed hops are
// penalized by an exponential time decay (PenaltyHalfLife), after which they
// are reconsidered for routing. If information was learned about a forwarding
// node, the information is taken into account to estimate a per node
// probability that mixes with the a priori probability (AprioriWeight).
type AprioriEstimator struct {
// AprioriConfig contains configuration options for our estimator.
AprioriConfig
// prevSuccessProbability is the assumed probability for node pairs that
// successfully relayed the previous attempt.
prevSuccessProbability float64
}
// NewAprioriEstimator creates a new AprioriEstimator.
func NewAprioriEstimator(cfg AprioriConfig) (*AprioriEstimator, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
return &AprioriEstimator{
AprioriConfig: cfg,
prevSuccessProbability: prevSuccessProbability,
}, nil
}
// Compile-time checks that interfaces are implemented.
var _ Estimator = (*AprioriEstimator)(nil)
var _ estimatorConfig = (*AprioriConfig)(nil)
// Config returns the estimator's configuration.
func (p *AprioriEstimator) Config() estimatorConfig {
return p.AprioriConfig
}
// String returns the estimator's configuration as a string representation.
func (p *AprioriEstimator) String() string {
return fmt.Sprintf("estimator type: %v, penalty halflife time: %v, "+
"apriori hop probability: %v, apriori weight: %v, previous "+
"success probability: %v", AprioriEstimatorName,
p.PenaltyHalfLife, p.AprioriHopProbability, p.AprioriWeight,
p.prevSuccessProbability)
}
// getNodeProbability calculates the probability for connections from a node
// that have not been tried before. The results parameter is a list of last
// payment results for that node.
func (p *AprioriEstimator) getNodeProbability(now time.Time,
results NodeResults, amt lnwire.MilliSatoshi,
capacity btcutil.Amount) float64 {
// We reduce the apriori hop probability if the amount comes close to
// the capacity.
apriori := p.AprioriHopProbability * capacityFactor(amt, capacity)
// If the channel history is not to be taken into account, we can return
// early here with the configured a priori probability.
if p.AprioriWeight == 1 {
return apriori
}
// If there is no channel history, our best estimate is still the a
// priori probability.
if len(results) == 0 {
return apriori
}
// The value of the apriori weight is in the range [0, 1]. Convert it to
// a factor that properly expresses the intention of the weight in the
// following weight average calculation. When the apriori weight is 0,
// the apriori factor is also 0. This means it won't have any effect on
// the weighted average calculation below. When the apriori weight
// approaches 1, the apriori factor goes to infinity. It will heavily
// outweigh any observations that have been collected.
aprioriFactor := 1/(1-p.AprioriWeight) - 1
// Calculate a weighted average consisting of the apriori probability
// and historical observations. This is the part that incentivizes nodes
// to make sure that all (not just some) of their channels are in good
// shape. Senders will steer around nodes that have shown a few
// failures, even though there may be many channels still untried.
//
// If there is just a single observation and the apriori weight is 0,
// this single observation will totally determine the node probability.
// The node probability is returned for all other channels of the node.
// This means that one failure will lead to the success probability
// estimates for all other channels being 0 too. The probability for the
// channel that was tried will not even recover, because it is
// recovering to the node probability (which is zero). So one failure
// effectively prunes all channels of the node forever. This is the most
// aggressive way in which we can penalize nodes and unlikely to yield
// good results in a real network.
probabilitiesTotal := apriori * aprioriFactor
totalWeight := aprioriFactor
for _, result := range results {
switch {
// Weigh success with a constant high weight of 1. There is no
// decay. Amt is never zero, so this clause is never executed
// when result.SuccessAmt is zero.
case amt <= result.SuccessAmt:
totalWeight++
probabilitiesTotal += p.prevSuccessProbability
// Weigh failures in accordance with their age. The base
// probability of a failure is considered zero, so nothing needs
// to be added to probabilitiesTotal.
case !result.FailTime.IsZero() && amt >= result.FailAmt:
age := now.Sub(result.FailTime)
totalWeight += p.getWeight(age)
}
}
return probabilitiesTotal / totalWeight
}
// getWeight calculates a weight in the range [0, 1] that should be assigned to
// a payment result. Weight follows an exponential curve that starts at 1 when
// the result is fresh and asymptotically approaches zero over time. The rate at
// which this happens is controlled by the penaltyHalfLife parameter.
func (p *AprioriEstimator) getWeight(age time.Duration) float64 {
exp := -age.Hours() / p.PenaltyHalfLife.Hours()
return math.Pow(2, exp)
}
// capacityFactor is a multiplier that can be used to reduce the probability
// depending on how much of the capacity is sent. The limits are 1 for amt == 0
// and 0 for amt >> cutoffMsat. The function drops significantly when amt
// reaches cutoffMsat. smearingMsat determines over which scale the reduction
// takes place.
func capacityFactor(amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
// If we don't have information about the capacity, which can be the
// case for hop hints or local channels, we return unity to not alter
// anything.
if capacity == 0 {
return 1.0
}
capMsat := float64(lnwire.NewMSatFromSatoshis(capacity))
amtMsat := float64(amt)
if amtMsat > capMsat {
return 0
}
cutoffMsat := capacityCutoffFraction * capMsat
smearingMsat := capacitySmearingFraction * capMsat
// We compute a logistic function mirrored around the y axis, centered
// at cutoffMsat, decaying over the smearingMsat scale.
denominator := 1 + math.Exp(-(amtMsat-cutoffMsat)/smearingMsat)
return 1 - 1/denominator
}
// PairProbability estimates the probability of successfully traversing to
// toNode based on historical payment outcomes for the from node. Those outcomes
// are passed in via the results parameter.
func (p *AprioriEstimator) PairProbability(now time.Time,
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
capacity btcutil.Amount) float64 {
nodeProbability := p.getNodeProbability(now, results, amt, capacity)
return p.calculateProbability(
now, results, nodeProbability, toNode, amt,
)
}
// LocalPairProbability estimates the probability of successfully traversing
// our own local channels to toNode.
func (p *AprioriEstimator) LocalPairProbability(
now time.Time, results NodeResults, toNode route.Vertex) float64 {
// For local channels that have never been tried before, we assume them
// to be successful. We have accurate balance and online status
// information on our own channels, so when we select them in a route it
// is close to certain that those channels will work.
nodeProbability := p.prevSuccessProbability
return p.calculateProbability(
now, results, nodeProbability, toNode, lnwire.MaxMilliSatoshi,
)
}
// calculateProbability estimates the probability of successfully traversing to
// toNode based on historical payment outcomes and a fall-back node probability.
func (p *AprioriEstimator) calculateProbability(
now time.Time, results NodeResults,
nodeProbability float64, toNode route.Vertex,
amt lnwire.MilliSatoshi) float64 {
// Retrieve the last pair outcome.
lastPairResult, ok := results[toNode]
// If there is no history for this pair, return the node probability
// that is a probability estimate for untried channel.
if !ok {
return nodeProbability
}
// For successes, we have a fixed (high) probability. Those pairs will
// be assumed good until proven otherwise. Amt is never zero, so this
// clause is never executed when lastPairResult.SuccessAmt is zero.
if amt <= lastPairResult.SuccessAmt {
return p.prevSuccessProbability
}
// Take into account a minimum penalize amount. For balance errors, a
// failure may be reported with such a minimum to prevent too aggressive
// penalization. If the current amount is smaller than the amount that
// previously triggered a failure, we act as if this is an untried
// channel.
if lastPairResult.FailTime.IsZero() || amt < lastPairResult.FailAmt {
return nodeProbability
}
timeSinceLastFailure := now.Sub(lastPairResult.FailTime)
// Calculate success probability based on the weight of the last
// failure. When the failure is fresh, its weight is 1 and we'll return
// probability 0. Over time the probability recovers to the node
// probability. It would be as if this channel was never tried before.
weight := p.getWeight(timeSinceLastFailure)
probability := nodeProbability * (1 - weight)
return probability
}

View file

@ -0,0 +1,289 @@
package routing
import (
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
const (
// Define node identifiers.
node1 = 1
node2 = 2
node3 = 3
// untriedNode is a node id for which we don't record any results in
// this test. This can be used to assert the probability for untried
// ndoes.
untriedNode = 255
// Define test estimator parameters.
aprioriHopProb = 0.6
aprioriWeight = 0.75
aprioriPrevSucProb = 0.95
// testCapacity is used to define a capacity for some channels.
testCapacity = btcutil.Amount(100_000)
testAmount = lnwire.MilliSatoshi(50_000_000)
// Defines the capacityFactor for testAmount and testCapacity.
capFactor = 0.9241
)
type estimatorTestContext struct {
t *testing.T
estimator *AprioriEstimator
// results contains a list of last results. Every element in the list
// corresponds to the last result towards a node. The list index equals
// the node id. So the first element in the list is the result towards
// node 0.
results map[int]TimedPairResult
}
func newEstimatorTestContext(t *testing.T) *estimatorTestContext {
return &estimatorTestContext{
t: t,
estimator: &AprioriEstimator{
AprioriConfig: AprioriConfig{
AprioriHopProbability: aprioriHopProb,
AprioriWeight: aprioriWeight,
PenaltyHalfLife: time.Hour,
},
prevSuccessProbability: aprioriPrevSucProb,
},
}
}
// assertPairProbability asserts that the calculated success probability is
// correct.
func (c *estimatorTestContext) assertPairProbability(now time.Time,
toNode byte, amt lnwire.MilliSatoshi, capacity btcutil.Amount,
expectedProb float64) {
c.t.Helper()
results := make(NodeResults)
for i, r := range c.results {
results[route.Vertex{byte(i)}] = r
}
const tolerance = 0.01
p := c.estimator.PairProbability(
now, results, route.Vertex{toNode}, amt, capacity,
)
diff := p - expectedProb
if diff > tolerance || diff < -tolerance {
c.t.Fatalf("expected probability %v for node %v, but got %v",
expectedProb, toNode, p)
}
}
// TestProbabilityEstimatorNoResults tests the probability estimation when no
// results are available.
func TestProbabilityEstimatorNoResults(t *testing.T) {
t.Parallel()
ctx := newEstimatorTestContext(t)
// A zero amount does not trigger capacity rescaling.
ctx.assertPairProbability(
testTime, 0, 0, testCapacity, aprioriHopProb,
)
// We expect a reduced probability when a higher amount is used.
expected := aprioriHopProb * capFactor
ctx.assertPairProbability(
testTime, 0, testAmount, testCapacity, expected,
)
}
// TestProbabilityEstimatorOneSuccess tests the probability estimation for nodes
// that have a single success result.
func TestProbabilityEstimatorOneSuccess(t *testing.T) {
t.Parallel()
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
SuccessAmt: testAmount,
},
}
// Because of the previous success, this channel keep reporting a high
// probability.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, aprioriPrevSucProb,
)
// The apriori success probability indicates that in the past we were
// able to send the full amount. We don't want to reduce this
// probability with the capacity factor, which is tested here.
ctx.assertPairProbability(
testTime, node1, testAmount, testCapacity, aprioriPrevSucProb,
)
// Untried channels are also influenced by the success. With a
// aprioriWeight of 0.75, the a priori probability is assigned weight 3.
expectedP := (3*aprioriHopProb + 1*aprioriPrevSucProb) / 4
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedP,
)
// Check that the correct probability is computed for larger amounts.
apriori := aprioriHopProb * capFactor
expectedP = (3*apriori + 1*aprioriPrevSucProb) / 4
ctx.assertPairProbability(
testTime, untriedNode, testAmount, testCapacity, expectedP,
)
}
// TestProbabilityEstimatorOneFailure tests the probability estimation for nodes
// that have a single failure.
func TestProbabilityEstimatorOneFailure(t *testing.T) {
t.Parallel()
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
FailTime: testTime.Add(-time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
},
}
// For an untried node, we expected the node probability. The weight for
// the failure after one hour is 0.5. This makes the node probability
// 0.51:
expectedNodeProb := (3*aprioriHopProb + 0.5*0) / 3.5
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
)
// The pair probability decays back to the node probability. With the
// weight at 0.5, we expected a pair probability of 0.5 * 0.51 = 0.25.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, expectedNodeProb/2,
)
}
// TestProbabilityEstimatorMix tests the probability estimation for nodes for
// which a mix of successes and failures is recorded.
func TestProbabilityEstimatorMix(t *testing.T) {
t.Parallel()
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
SuccessAmt: lnwire.MilliSatoshi(1000),
},
node2: {
FailTime: testTime.Add(-2 * time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
},
node3: {
FailTime: testTime.Add(-3 * time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
},
}
// We expect the probability for a previously successful channel to
// remain high.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, prevSuccessProbability,
)
// For an untried node, we expected the node probability to be returned.
// This is a weighted average of the results above and the a priori
// probability: 0.62.
expectedNodeProb := (3*aprioriHopProb + 1*prevSuccessProbability) /
(3 + 1 + 0.25 + 0.125)
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
)
// For the previously failed connection with node 1, we expect 0.75 *
// the node probability = 0.47.
ctx.assertPairProbability(
testTime, node2, 100, testCapacity, expectedNodeProb*0.75,
)
}
// TestCapacityCutoff tests the mathematical expression and limits for the
// capacity factor.
func TestCapacityCutoff(t *testing.T) {
t.Parallel()
capacitySat := 1_000_000
capacityMSat := capacitySat * 1000
tests := []struct {
name string
amountMsat int
expectedFactor float64
}{
{
name: "zero amount",
expectedFactor: 1,
},
{
name: "low amount",
amountMsat: capacityMSat / 10,
expectedFactor: 0.998,
},
{
name: "half amount",
amountMsat: capacityMSat / 2,
expectedFactor: 0.924,
},
{
name: "cutoff amount",
amountMsat: int(
capacityCutoffFraction * float64(capacityMSat),
),
expectedFactor: 0.5,
},
{
name: "high amount",
amountMsat: capacityMSat * 80 / 100,
expectedFactor: 0.377,
},
{
// Even when we spend the full capacity, we still want
// to have some residual probability to not throw away
// routes due to a min probability requirement of the
// whole path.
name: "full amount",
amountMsat: capacityMSat,
expectedFactor: 0.076,
},
{
name: "more than capacity",
amountMsat: capacityMSat + 1,
expectedFactor: 0.0,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := capacityFactor(
lnwire.MilliSatoshi(test.amountMsat),
btcutil.Amount(capacitySat),
)
require.InDelta(t, test.expectedFactor, got, 0.001)
})
}
}

View file

@ -0,0 +1,536 @@
package routing
import (
"fmt"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/go-errors/errors"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
const (
// DefaultBimodalScaleMsat is the default value for BimodalScaleMsat in
// BimodalConfig. It describes the distribution of funds in the LN based
// on empirical findings. We assume an unbalanced network by default.
DefaultBimodalScaleMsat = lnwire.MilliSatoshi(300_000_000)
// DefaultBimodalNodeWeight is the default value for the
// BimodalNodeWeight in BimodalConfig. It is chosen such that past
// forwardings on other channels of a router are only slightly taken
// into account.
DefaultBimodalNodeWeight = 0.2
// DefaultBimodalDecayTime is the default value for BimodalDecayTime.
// We will forget about previous learnings about channel liquidity on
// the timescale of about a week.
DefaultBimodalDecayTime = 7 * 24 * time.Hour
// BimodalScaleMsatLimit is the maximum value for BimodalScaleMsat to
// avoid numerical issues.
BimodalScaleMsatMax = lnwire.MilliSatoshi(21e17)
// BimodalEstimatorName is used to identify the bimodal estimator.
BimodalEstimatorName = "bimodal"
)
var (
// ErrInvalidScale is returned when we get a scale below or equal zero.
ErrInvalidScale = errors.New("scale must be >= 0 and sane")
// ErrInvalidNodeWeight is returned when we get a node weight that is
// out of range.
ErrInvalidNodeWeight = errors.New("node weight must be in [0, 1]")
// ErrInvalidDecayTime is returned when we get a decay time below zero.
ErrInvalidDecayTime = errors.New("decay time must be larger than zero")
)
// BimodalConfig contains configuration for our probability estimator.
type BimodalConfig struct {
// BimodalNodeWeight defines how strongly other previous forwardings on
// channels of a router should be taken into account when computing a
// channel's probability to route. The allowed values are in the range
// [0, 1], where a value of 0 means that only direct information about a
// channel is taken into account.
BimodalNodeWeight float64
// BimodalScaleMsat describes the scale over which channels
// statistically have some liquidity left. The value determines how
// quickly the bimodal distribution drops off from the edges of a
// channel. A larger value (compared to typical channel capacities)
// means that the drop off is slow and that channel balances are
// distributed more uniformly. A small value leads to the assumption of
// very unbalanced channels.
BimodalScaleMsat lnwire.MilliSatoshi
// BimodalDecayTime is the scale for the exponential information decay
// over time for previous successes or failures.
BimodalDecayTime time.Duration
}
// validate checks the configuration of the estimator for allowed values.
func (p BimodalConfig) validate() error {
if p.BimodalDecayTime <= 0 {
return fmt.Errorf("%v: %w", BimodalEstimatorName,
ErrInvalidDecayTime)
}
if p.BimodalNodeWeight < 0 || p.BimodalNodeWeight > 1 {
return fmt.Errorf("%v: %w", BimodalEstimatorName,
ErrInvalidNodeWeight)
}
if p.BimodalScaleMsat == 0 || p.BimodalScaleMsat > BimodalScaleMsatMax {
return fmt.Errorf("%v: %w", BimodalEstimatorName,
ErrInvalidScale)
}
return nil
}
// DefaultBimodalConfig returns the default configuration for the estimator.
func DefaultBimodalConfig() BimodalConfig {
return BimodalConfig{
BimodalNodeWeight: DefaultBimodalNodeWeight,
BimodalScaleMsat: DefaultBimodalScaleMsat,
BimodalDecayTime: DefaultBimodalDecayTime,
}
}
// BimodalEstimator returns node and pair probabilities based on historical
// payment results based on a liquidity distribution model of the LN. The main
// function is to estimate the direct channel probability based on a depleted
// liquidity distribution model, with additional information decay over time. A
// per-node probability can be mixed with the direct probability, taking into
// account successes/failures on other channels of the forwarder.
type BimodalEstimator struct {
// BimodalConfig contains configuration options for our estimator.
BimodalConfig
}
// NewBimodalEstimator creates a new BimodalEstimator.
func NewBimodalEstimator(cfg BimodalConfig) (*BimodalEstimator, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
return &BimodalEstimator{
BimodalConfig: cfg,
}, nil
}
// Compile-time checks that interfaces are implemented.
var _ Estimator = (*BimodalEstimator)(nil)
var _ estimatorConfig = (*BimodalConfig)(nil)
// config returns the current configuration of the estimator.
func (p *BimodalEstimator) Config() estimatorConfig {
return p.BimodalConfig
}
// String returns the estimator's configuration as a string representation.
func (p *BimodalEstimator) String() string {
return fmt.Sprintf("estimator type: %v, decay time: %v, liquidity "+
"scale: %v, node weight: %v", BimodalEstimatorName,
p.BimodalDecayTime, p.BimodalScaleMsat, p.BimodalNodeWeight)
}
// PairProbability estimates the probability of successfully traversing to
// toNode based on historical payment outcomes for the from node. Those outcomes
// are passed in via the results parameter.
func (p *BimodalEstimator) PairProbability(now time.Time,
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
capacity btcutil.Amount) float64 {
// We first compute the probability for the desired hop taking into
// account previous knowledge.
directProbability := p.directProbability(
now, results, toNode, amt, lnwire.NewMSatFromSatoshis(capacity),
)
// The final probability is computed by taking into account other
// channels of the from node.
return p.calculateProbability(directProbability, now, results, toNode)
}
// LocalPairProbability computes the probability to reach toNode given a set of
// previous learnings.
func (p *BimodalEstimator) LocalPairProbability(now time.Time,
results NodeResults, toNode route.Vertex) float64 {
// For direct local probabilities we assume to know exactly how much we
// can send over a channel, which assumes that channels are active and
// have enough liquidity.
directProbability := 1.0
// If we had an unexpected failure for this node, we reduce the
// probability for some time to avoid infinite retries.
result, ok := results[toNode]
if ok && !result.FailTime.IsZero() {
timeAgo := now.Sub(result.FailTime)
// We only expect results in the past to get a probability
// between 0 and 1.
if timeAgo < 0 {
timeAgo = 0
}
exponent := -float64(timeAgo) / float64(p.BimodalDecayTime)
directProbability -= math.Exp(exponent)
}
return directProbability
}
// directProbability computes the probability to reach a node based on the
// liquidity distribution in the LN.
func (p *BimodalEstimator) directProbability(now time.Time,
results NodeResults, toNode route.Vertex, amt lnwire.MilliSatoshi,
capacity lnwire.MilliSatoshi) float64 {
// We first determine the time-adjusted success and failure amounts to
// then compute a probability. We know that we can send a zero amount.
successAmount := lnwire.MilliSatoshi(0)
// We know that we cannot send the full capacity.
failAmount := capacity
// If we have information about past successes or failures, we modify
// them with a time decay.
result, ok := results[toNode]
if ok {
// Apply a time decay for the amount we cannot send.
if !result.FailTime.IsZero() {
failAmount = cannotSend(
result.FailAmt, capacity, now, result.FailTime,
p.BimodalDecayTime,
)
}
// Apply a time decay for the amount we can send.
if !result.SuccessTime.IsZero() {
successAmount = canSend(
result.SuccessAmt, now, result.SuccessTime,
p.BimodalDecayTime,
)
}
}
// Compute the direct channel probability.
probability, err := p.probabilityFormula(
capacity, successAmount, failAmount, amt,
)
if err != nil {
log.Errorf("error computing probability: %v", err)
return 0.0
}
return probability
}
// calculateProbability computes the total hop probability combining the channel
// probability and historic forwarding data of other channels of the node we try
// to send from.
//
// Goals:
// * We want to incentivize good routing nodes: the more routable channels a
// node has, the more we want to incentivize (vice versa for failures).
// -> We reduce/increase the direct probability depending on past
// failures/successes for other channels of the node.
//
// * We want to be forgiving/give other nodes a chance as well: we want to
// forget about (non-)routable channels over time.
// -> We weight the successes/failures with a time decay such that they will not
// influence the total probability if a long time went by.
//
// * If we don't have other info, we want to solely rely on the direct
// probability.
//
// * We want to be able to specify how important the other channels are compared
// to the direct channel.
// -> Introduce a node weight factor that weights the direct probability against
// the node-wide average. The larger the node weight, the more important other
// channels of the node are.
//
// How do failures on low fee nodes redirect routing to higher fee nodes?
// Assumptions:
// * attemptCostPPM of 1000 PPM
// * constant direct channel probability of P0 (usually 0.5 for large amounts)
// * node weight w of 0.2
//
// The question we want to answer is:
// How often would a zero-fee node be tried (even if there were failures for its
// other channels) over trying a high-fee node with 2000 PPM and no direct
// knowledge about the channel to send over?
//
// The probability of a route of length l is P(l) = l * P0.
//
// The total probability after n failures (with the implemented method here) is:
// P(l, n) = P(l-1) * P(n)
// = P(l-1) * (P0 + n*0) / (1 + n*w)
// = P(l) / (1 + n*w)
//
// Condition for a high-fee channel to overcome a low fee channel in the
// Dijkstra weight function (only looking at fee and probability PPM terms):
// highFeePPM + attemptCostPPM * 1/P(l) = 0PPM + attemptCostPPM * 1/P(l, n)
// highFeePPM/attemptCostPPM = 1/P(l, n) - 1/P(l) =
// = (1 + n*w)/P(l) - 1/P(l) =
// = n*w/P(l)
//
// Therefore:
// n = (highFeePPM/attemptCostPPM) * (P(l)/w) =
// = (2000/1000) * 0.5 * l / w = l/w
//
// For a one-hop route we get:
// n = 1/0.2 = 5 tolerated failures
//
// For a three-hop route we get:
// n = 3/0.2 = 15 tolerated failures
//
// For more details on the behavior see tests.
func (p *BimodalEstimator) calculateProbability(directProbability float64,
now time.Time, results NodeResults, toNode route.Vertex) float64 {
// If we don't take other channels into account, we can return early.
if p.BimodalNodeWeight == 0.0 {
return directProbability
}
// w is a parameter which determines how strongly the other channels of
// a node should be incorporated, the higher the stronger.
w := p.BimodalNodeWeight
// dt determines the timeliness of the previous successes/failures
// to be taken into account.
dt := float64(p.BimodalDecayTime)
// The direct channel probability is weighted fully, all other results
// are weighted according to how recent the information is.
totalProbabilities := directProbability
totalWeights := 1.0
for peer, result := range results {
// We don't include the direct hop probability here because it
// is already included in totalProbabilities.
if peer == toNode {
continue
}
// We add probabilities weighted by how recent the info is.
var weight float64
if result.SuccessAmt > 0 {
exponent := -float64(now.Sub(result.SuccessTime)) / dt
weight = math.Exp(exponent)
totalProbabilities += w * weight
totalWeights += w * weight
}
if result.FailAmt > 0 {
exponent := -float64(now.Sub(result.FailTime)) / dt
weight = math.Exp(exponent)
// Failures don't add to total success probability.
totalWeights += w * weight
}
}
return totalProbabilities / totalWeights
}
// canSend returns the sendable amount over the channel, respecting time decay.
// canSend approaches zero, if we wait for a much longer time than the decay
// time.
func canSend(successAmount lnwire.MilliSatoshi, now, successTime time.Time,
decayConstant time.Duration) lnwire.MilliSatoshi {
// The factor approaches 0 for successTime a long time in the past,
// is 1 when the successTime is now.
factor := math.Exp(
-float64(now.Sub(successTime)) / float64(decayConstant),
)
canSend := factor * float64(successAmount)
return lnwire.MilliSatoshi(canSend)
}
// cannotSend returns the not sendable amount over the channel, respecting time
// decay. cannotSend approaches the capacity, if we wait for a much longer time
// than the decay time.
func cannotSend(failAmount, capacity lnwire.MilliSatoshi, now,
failTime time.Time, decayConstant time.Duration) lnwire.MilliSatoshi {
if failAmount > capacity {
failAmount = capacity
}
// The factor approaches 0 for failTime a long time in the past and it
// is 1 when the failTime is now.
factor := math.Exp(
-float64(now.Sub(failTime)) / float64(decayConstant),
)
cannotSend := capacity - lnwire.MilliSatoshi(
factor*float64(capacity-failAmount),
)
return cannotSend
}
// primitive computes the indefinite integral of our assumed (normalized)
// liquidity probability distribution. The distribution of liquidity x here is
// the function P(x) ~ exp(-x/s) + exp((x-c)/s), i.e., two exponentials residing
// at the ends of channels. This means that we expect liquidity to be at either
// side of the channel with capacity c. The s parameter (scale) defines how far
// the liquidity leaks into the channel. A very low scale assumes completely
// unbalanced channels, a very high scale assumes a random distribution. More
// details can be found in
// https://github.com/lightningnetwork/lnd/issues/5988#issuecomment-1131234858.
func (p *BimodalEstimator) primitive(c, x float64) float64 {
s := float64(p.BimodalScaleMsat)
// The indefinite integral of P(x) is given by
// Int P(x) dx = H(x) = s * (-e(-x/s) + e((x-c)/s)),
// and its norm from 0 to c can be computed from it,
// norm = [H(x)]_0^c = s * (-e(-c/s) + 1 -(1 + e(-c/s))).
ecs := math.Exp(-c / s)
exs := math.Exp(-x / s)
// It would be possible to split the next term and reuse the factors
// from before, but this can lead to numerical issues with large
// numbers.
excs := math.Exp((x - c) / s)
// norm can only become zero, if c is zero, which we sorted out before
// calling this method.
norm := -2*ecs + 2
// We end up with the primitive function of the normalized P(x).
return (-exs + excs) / norm
}
// integral computes the integral of our liquidity distribution from the lower
// to the upper value.
func (p *BimodalEstimator) integral(capacity, lower, upper float64) float64 {
if lower < 0 || lower > upper {
log.Errorf("probability integral limits nonsensical: capacity:"+
"%v lower: %v upper: %v", capacity, lower, upper)
return 0.0
}
return p.primitive(capacity, upper) - p.primitive(capacity, lower)
}
// probabilityFormula computes the expected probability for a payment of
// amountMsat given prior learnings for a channel of certain capacity.
// successAmountMsat and failAmountMsat stand for the unsettled success and
// failure amounts, respectively. The formula is derived using the formalism
// presented in Pickhardt et al., https://arxiv.org/abs/2103.08576.
func (p *BimodalEstimator) probabilityFormula(capacityMsat, successAmountMsat,
failAmountMsat, amountMsat lnwire.MilliSatoshi) (float64, error) {
// Convert to positive-valued floats.
capacity := float64(capacityMsat)
successAmount := float64(successAmountMsat)
failAmount := float64(failAmountMsat)
amount := float64(amountMsat)
// Capacity being zero is a sentinel value to ignore the probability
// estimation, we'll return the full probability here.
if capacity == 0.0 {
return 1.0, nil
}
// We cannot send more than the capacity.
if amount > capacity {
return 0.0, nil
}
// Mission control may have some outdated values, we correct them here.
// TODO(bitromortac): there may be better decisions to make in these
// cases, e.g., resetting failAmount=cap and successAmount=0.
// failAmount should be capacity at max.
if failAmount > capacity {
failAmount = capacity
}
// successAmount should be capacity at max.
if successAmount > capacity {
successAmount = capacity
}
// The next statement is a safety check against an illogical condition,
// otherwise the renormalization integral would become zero. This may
// happen if a large channel gets closed and smaller ones remain, but
// it should recover with the time decay.
if failAmount <= successAmount {
log.Tracef("fail amount (%v) is larger than or equal the "+
"success amount (%v) for capacity (%v)",
failAmountMsat, successAmountMsat, capacityMsat)
return 0.0, nil
}
// We cannot send more than the fail amount.
if amount >= failAmount {
return 0.0, nil
}
// The success probability for payment amount a is the integral over the
// prior distribution P(x), the probability to find liquidity between
// the amount a and channel capacity c (or failAmount a_f):
// P(X >= a | X < a_f) = Integral_{a}^{a_f} P(x) dx
prob := p.integral(capacity, amount, failAmount)
if math.IsNaN(prob) {
return 0.0, fmt.Errorf("non-normalized probability is NaN, "+
"capacity: %v, amount: %v, fail amount: %v",
capacity, amount, failAmount)
}
// If we have payment information, we need to adjust the prior
// distribution P(x) and get the posterior distribution by renormalizing
// the prior distribution in such a way that the probability mass lies
// between a_s and a_f.
reNorm := p.integral(capacity, successAmount, failAmount)
if math.IsNaN(reNorm) {
return 0.0, fmt.Errorf("normalization factor is NaN, "+
"capacity: %v, success amount: %v, fail amount: %v",
capacity, successAmount, failAmount)
}
// The normalization factor can only be zero if the success amount is
// equal or larger than the fail amount. This should not happen as we
// have checked this scenario above.
if reNorm == 0.0 {
return 0.0, fmt.Errorf("normalization factor is zero, "+
"capacity: %v, success amount: %v, fail amount: %v",
capacity, successAmount, failAmount)
}
prob /= reNorm
// Note that for payment amounts smaller than successAmount, we can get
// a value larger than unity, which we cap here to get a proper
// probability.
if prob > 1.0 {
if amount > successAmount {
return 0.0, fmt.Errorf("unexpected large probability "+
"(%v) capacity: %v, amount: %v, success "+
"amount: %v, fail amount: %v", prob, capacity,
amount, successAmount, failAmount)
}
return 1.0, nil
} else if prob < 0.0 {
return 0.0, fmt.Errorf("negative probability "+
"(%v) capacity: %v, amount: %v, success "+
"amount: %v, fail amount: %v", prob, capacity,
amount, successAmount, failAmount)
}
return prob, nil
}

View file

@ -0,0 +1,664 @@
package routing
import (
"math"
"testing"
"time"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
const (
smallAmount = lnwire.MilliSatoshi(400_000)
largeAmount = lnwire.MilliSatoshi(5_000_000)
capacity = lnwire.MilliSatoshi(10_000_000)
scale = lnwire.MilliSatoshi(400_000)
)
// TestSuccessProbability tests that we get correct probability estimates for
// the direct channel probability.
func TestSuccessProbability(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expectedProbability float64
tolerance float64
successAmount lnwire.MilliSatoshi
failAmount lnwire.MilliSatoshi
amount lnwire.MilliSatoshi
capacity lnwire.MilliSatoshi
}{
// We can't send more than the capacity.
{
name: "no info, larger than capacity",
capacity: capacity,
successAmount: 0,
failAmount: capacity,
amount: capacity + 1,
expectedProbability: 0.0,
},
// With the current model we don't prefer any channels if the
// send amount is large compared to the scale but small compared
// to the capacity.
{
name: "no info, large amount",
capacity: capacity,
successAmount: 0,
failAmount: capacity,
amount: largeAmount,
expectedProbability: 0.5,
},
// We always expect to be able to "send" an amount of 0.
{
name: "no info, zero amount",
capacity: capacity,
successAmount: 0,
failAmount: capacity,
amount: 0,
expectedProbability: 1.0,
},
// We can't send the whole capacity.
{
name: "no info, full capacity",
capacity: capacity,
successAmount: 0,
failAmount: capacity,
amount: capacity,
expectedProbability: 0.0,
},
// Sending a small amount will have a higher probability to go
// through than a large amount.
{
name: "no info, small amount",
capacity: capacity,
successAmount: 0,
failAmount: capacity,
amount: smallAmount,
expectedProbability: 0.684,
tolerance: 0.001,
},
// If we had an unsettled success, we are sure we can send a
// lower amount.
{
name: "previous success, lower amount",
capacity: capacity,
successAmount: largeAmount,
failAmount: capacity,
amount: smallAmount,
expectedProbability: 1.0,
},
// If we had an unsettled success, we are sure we can send the
// same amount.
{
name: "previous success, success amount",
capacity: capacity,
successAmount: largeAmount,
failAmount: capacity,
amount: largeAmount,
expectedProbability: 1.0,
},
// If we had an unsettled success with a small amount, we know
// with increased probability that we can send a comparable
// higher amount.
{
name: "previous success, larger amount",
capacity: capacity,
successAmount: smallAmount / 2,
failAmount: capacity,
amount: smallAmount,
expectedProbability: 0.851,
tolerance: 0.001,
},
// If we had a large unsettled success before, we know we can
// send even larger payments with high probability.
{
name: "previous large success, larger " +
"amount",
capacity: capacity,
successAmount: largeAmount / 2,
failAmount: capacity,
amount: largeAmount,
expectedProbability: 0.998,
tolerance: 0.001,
},
// If we had a failure before, we can't send with the fail
// amount.
{
name: "previous failure, fail amount",
capacity: capacity,
failAmount: largeAmount,
amount: largeAmount,
expectedProbability: 0.0,
},
// We can't send a higher amount than the fail amount either.
{
name: "previous failure, larger fail " +
"amount",
capacity: capacity,
failAmount: largeAmount,
amount: largeAmount + smallAmount,
expectedProbability: 0.0,
},
// We expect a diminished non-zero probability if we try to send
// an amount that's lower than the last fail amount.
{
name: "previous failure, lower than fail " +
"amount",
capacity: capacity,
failAmount: largeAmount,
amount: smallAmount,
expectedProbability: 0.368,
tolerance: 0.001,
},
// From here on we deal with mixed previous successes and
// failures.
// We expect to be always able to send a tiny amount.
{
name: "previous f/s, very small amount",
capacity: capacity,
failAmount: largeAmount,
successAmount: smallAmount,
amount: 0,
expectedProbability: 1.0,
},
// We expect to be able to send up to the previous success
// amount will full certainty.
{
name: "previous f/s, success amount",
capacity: capacity,
failAmount: largeAmount,
successAmount: smallAmount,
amount: smallAmount,
expectedProbability: 1.0,
},
// This tests a random value between small amount and large
// amount.
{
name: "previous f/s, between f/s",
capacity: capacity,
failAmount: largeAmount,
successAmount: smallAmount,
amount: smallAmount + largeAmount/10,
expectedProbability: 0.287,
tolerance: 0.001,
},
// We still can't send the fail amount.
{
name: "previous f/s, fail amount",
capacity: capacity,
failAmount: largeAmount,
successAmount: smallAmount,
amount: largeAmount,
expectedProbability: 0.0,
},
// Same success and failure amounts (illogical).
{
name: "previous f/s, same",
capacity: capacity,
failAmount: largeAmount,
successAmount: largeAmount,
amount: largeAmount,
expectedProbability: 0.0,
},
// Higher success than failure amount (illogical).
{
name: "previous f/s, higher success",
capacity: capacity,
failAmount: smallAmount,
successAmount: largeAmount,
expectedProbability: 0.0,
},
}
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{BimodalScaleMsat: scale},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
p, err := estimator.probabilityFormula(
test.capacity, test.successAmount,
test.failAmount, test.amount,
)
require.InDelta(t, test.expectedProbability, p,
test.tolerance)
require.NoError(t, err)
})
}
}
// TestIntegral tests certain limits of the probability distribution integral.
func TestIntegral(t *testing.T) {
t.Parallel()
defaultScale := lnwire.NewMSatFromSatoshis(300_000)
tests := []struct {
name string
capacity float64
lower float64
upper float64
scale lnwire.MilliSatoshi
expected float64
}{
{
name: "all zero",
expected: math.NaN(),
scale: defaultScale,
},
{
name: "all same",
capacity: 1,
lower: 1,
upper: 1,
scale: defaultScale,
},
{
name: "large numbers, low lower",
capacity: 21e17,
lower: 0,
upper: 21e17,
expected: 1,
scale: defaultScale,
},
{
name: "large numbers, high lower",
capacity: 21e17,
lower: 21e17,
upper: 21e17,
scale: defaultScale,
},
{
name: "same scale and capacity",
capacity: 21e17,
lower: 21e17,
upper: 21e17,
scale: 21e17,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{
BimodalScaleMsat: test.scale,
},
}
p := estimator.integral(
test.capacity, test.lower, test.upper,
)
require.InDelta(t, test.expected, p, 0.001)
})
}
}
// TestCanSend tests that the success amount drops to zero over time.
func TestCanSend(t *testing.T) {
t.Parallel()
successAmount := lnwire.MilliSatoshi(1_000_000)
successTime := time.Unix(1_000, 0)
now := time.Unix(2_000, 0)
decayTime := time.Duration(1_000) * time.Second
infinity := time.Unix(10_000_000_000, 0)
// Test an immediate retry.
require.Equal(t, successAmount, canSend(
successAmount, successTime, successTime, decayTime,
))
// Test that after the decay time, the successAmount is 1/e of its
// value.
decayAmount := lnwire.MilliSatoshi(float64(successAmount) / math.E)
require.Equal(t, decayAmount, canSend(
successAmount, now, successTime, decayTime,
))
// After a long time, we want the amount to approach 0.
require.Equal(t, lnwire.MilliSatoshi(0), canSend(
successAmount, infinity, successTime, decayTime,
))
}
// TestCannotSend tests that the fail amount approaches the capacity over time.
func TestCannotSend(t *testing.T) {
t.Parallel()
failAmount := lnwire.MilliSatoshi(1_000_000)
failTime := time.Unix(1_000, 0)
now := time.Unix(2_000, 0)
decayTime := time.Duration(1_000) * time.Second
infinity := time.Unix(10_000_000_000, 0)
capacity := lnwire.MilliSatoshi(3_000_000)
// Test immediate retry.
require.EqualValues(t, failAmount, cannotSend(
failAmount, capacity, failTime, failTime, decayTime,
))
// After the decay time we want to be between the fail amount and
// the capacity.
summand := lnwire.MilliSatoshi(float64(capacity-failAmount) / math.E)
expected := capacity - summand
require.Equal(t, expected, cannotSend(
failAmount, capacity, now, failTime, decayTime,
))
// After a long time, we want the amount to approach the capacity.
require.Equal(t, capacity, cannotSend(
failAmount, capacity, infinity, failTime, decayTime,
))
}
// TestComputeProbability tests the inclusion of previous forwarding results of
// other channels of the node into the total probability.
func TestComputeProbability(t *testing.T) {
t.Parallel()
nodeWeight := 1 / 5.
toNode := route.Vertex{10}
tolerance := 0.01
decayTime := time.Duration(1) * time.Hour * 24
// makeNodeResults prepares forwarding data for the other channels of
// the node.
makeNodeResults := func(successes []bool, now time.Time) NodeResults {
results := make(NodeResults, len(successes))
for i, s := range successes {
vertex := route.Vertex{byte(i)}
results[vertex] = TimedPairResult{
FailTime: now, FailAmt: 1,
}
if s {
results[vertex] = TimedPairResult{
SuccessTime: now, SuccessAmt: 1,
}
}
}
return results
}
tests := []struct {
name string
directProbability float64
otherResults []bool
expectedProbability float64
delay time.Duration
}{
// If no other information is available, use the direct
// probability.
{
name: "unknown, only direct",
directProbability: 0.5,
expectedProbability: 0.5,
},
// If there was a single success, expect increased success
// probability.
{
name: "unknown, single success",
directProbability: 0.5,
otherResults: []bool{true},
expectedProbability: 0.583,
},
// If there were many successes, expect even higher success
// probability.
{
name: "unknown, many successes",
directProbability: 0.5,
otherResults: []bool{
true, true, true, true, true,
},
expectedProbability: 0.75,
},
// If there was a single failure, we expect a slightly decreased
// probability.
{
name: "unknown, single failure",
directProbability: 0.5,
otherResults: []bool{false},
expectedProbability: 0.416,
},
// If there were many failures, we expect a strongly decreased
// probability.
{
name: "unknown, many failures",
directProbability: 0.5,
otherResults: []bool{
false, false, false, false, false,
},
expectedProbability: 0.25,
},
// A success and a failure neutralize themselves.
{
name: "unknown, mixed even",
directProbability: 0.5,
otherResults: []bool{true, false},
expectedProbability: 0.5,
},
// A mixed result history leads to increase/decrease of the most
// experienced successes/failures.
{
name: "unknown, mixed uneven",
directProbability: 0.5,
otherResults: []bool{
true, true, false, false, false,
},
expectedProbability: 0.45,
},
// Many successes don't elevate the probability above 1.
{
name: "success, successes",
directProbability: 1.0,
otherResults: []bool{
true, true, true, true, true,
},
expectedProbability: 1.0,
},
// Five failures on a very certain channel will lower its
// success probability to the unknown probability.
{
name: "success, failures",
directProbability: 1.0,
otherResults: []bool{
false, false, false, false, false,
},
expectedProbability: 0.5,
},
// If we are sure that the channel can send, a single failure
// will not decrease the outcome significantly.
{
name: "success, single failure",
directProbability: 1.0,
otherResults: []bool{false},
expectedProbability: 0.8333,
},
{
name: "success, many failures",
directProbability: 1.0,
otherResults: []bool{
false, false, false, false, false, false, false,
},
expectedProbability: 0.416,
},
// Failures won't decrease the probability below zero.
{
name: "fail, failures",
directProbability: 0.0,
otherResults: []bool{false, false, false},
expectedProbability: 0.0,
},
{
name: "fail, successes",
directProbability: 0.0,
otherResults: []bool{
true, true, true, true, true,
},
expectedProbability: 0.5,
},
// We test forgetting information with the time decay.
// A past success won't alter the certain success probability.
{
name: "success, single success, decay " +
"time",
directProbability: 1.0,
otherResults: []bool{true},
delay: decayTime,
expectedProbability: 1.00,
},
// A failure that was experienced some time ago won't influence
// as much as a recent one.
{
name: "success, single fail, decay time",
directProbability: 1.0,
otherResults: []bool{false},
delay: decayTime,
expectedProbability: 0.9314,
},
// Information from a long time ago doesn't have any effect.
{
name: "success, single fail, long ago",
directProbability: 1.0,
otherResults: []bool{false},
delay: 10 * decayTime,
expectedProbability: 1.0,
},
{
name: "fail, successes decay time",
directProbability: 0.0,
otherResults: []bool{
true, true, true, true, true,
},
delay: decayTime,
expectedProbability: 0.269,
},
// Very recent info approaches the case with no time decay.
{
name: "unknown, successes close",
directProbability: 0.5,
otherResults: []bool{
true, true, true, true, true,
},
delay: decayTime / 10,
expectedProbability: 0.741,
},
}
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{
BimodalScaleMsat: scale, BimodalNodeWeight: nodeWeight,
BimodalDecayTime: decayTime,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
then := time.Unix(0, 0)
results := makeNodeResults(test.otherResults, then)
now := then.Add(test.delay)
p := estimator.calculateProbability(
test.directProbability, now, results, toNode,
)
require.InDelta(t, test.expectedProbability, p,
tolerance)
})
}
}
// TestLocalPairProbability tests that we reduce probability for failed direct
// neighbors.
func TestLocalPairProbability(t *testing.T) {
t.Parallel()
decayTime := time.Hour
now := time.Unix(1000000000, 0)
toNode := route.Vertex{1}
createFailedResult := func(timeAgo time.Duration) NodeResults {
return NodeResults{
toNode: TimedPairResult{
FailTime: now.Add(-timeAgo),
},
}
}
tests := []struct {
name string
expectedProbability float64
results NodeResults
}{
{
name: "no results",
expectedProbability: 1.0,
},
{
name: "recent failure",
results: createFailedResult(0),
expectedProbability: 0.0,
},
{
name: "after decay time",
results: createFailedResult(decayTime),
expectedProbability: 1 - 1/math.E,
},
{
name: "long ago",
results: createFailedResult(10 * decayTime),
expectedProbability: 1.0,
},
}
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{BimodalDecayTime: decayTime},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
p := estimator.LocalPairProbability(
now, test.results, toNode,
)
require.InDelta(t, test.expectedProbability, p, 0.001)
})
}
}
// FuzzProbability checks that we don't encounter errors related to NaNs.
func FuzzProbability(f *testing.F) {
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{BimodalScaleMsat: scale},
}
f.Add(uint64(0), uint64(0), uint64(0), uint64(0))
f.Fuzz(func(t *testing.T, capacity, successAmt, failAmt, amt uint64) {
_, err := estimator.probabilityFormula(
lnwire.MilliSatoshi(capacity),
lnwire.MilliSatoshi(successAmt),
lnwire.MilliSatoshi(failAmt), lnwire.MilliSatoshi(amt),
)
require.NoError(t, err, "c: %v s: %v f: %v a: %v", capacity,
successAmt, failAmt, amt)
})
}

View file

@ -1,8 +1,6 @@
package routing
import (
"errors"
"math"
"time"
"github.com/btcsuite/btcd/btcutil"
@ -10,285 +8,30 @@ import (
"github.com/lightningnetwork/lnd/routing/route"
)
const (
// capacityCutoffFraction and capacitySmearingFraction define how
// capacity-related probability reweighting works.
// capacityCutoffFraction defines the fraction of the channel capacity
// at which the effect roughly sets in and capacitySmearingFraction
// defines over which range the factor changes from 1 to 0.
//
// We may fall below the minimum required probability
// (DefaultMinRouteProbability) when the amount comes close to the
// available capacity of a single channel of the route in case of no
// prior knowledge about the channels. We want such routes still to be
// available and therefore a probability reduction should not completely
// drop the total probability below DefaultMinRouteProbability.
// For this to hold for a three-hop route we require:
// (DefaultAprioriHopProbability)^3 * minCapacityFactor >
// DefaultMinRouteProbability
//
// For DefaultAprioriHopProbability = 0.6 and
// DefaultMinRouteProbability = 0.01 this results in
// minCapacityFactor ~ 0.05. The following combination of parameters
// fulfill the requirement with capacityFactor(cap, cap) ~ 0.076 (see
// tests).
// Estimator estimates the probability to reach a node.
type Estimator interface {
// PairProbability estimates the probability of successfully traversing
// to toNode based on historical payment outcomes for the from node.
// Those outcomes are passed in via the results parameter.
PairProbability(now time.Time, results NodeResults,
toNode route.Vertex, amt lnwire.MilliSatoshi,
capacity btcutil.Amount) float64
// The capacityCutoffFraction is a trade-off between usage of the
// provided capacity and expected probability reduction when we send the
// full amount. The success probability in the random balance model can
// be approximated with P(a) = 1 - a/c, for amount a and capacity c. If
// we require a probability P(a) > 0.25, this translates into a value of
// 0.75 for a/c.
capacityCutoffFraction = 0.75
// LocalPairProbability estimates the probability of successfully
// traversing our own local channels to toNode.
LocalPairProbability(now time.Time, results NodeResults,
toNode route.Vertex) float64
// We don't want to have a sharp drop of the capacity factor to zero at
// capacityCutoffFraction, but a smooth smearing such that some residual
// probability is left when spending the whole amount, see above.
capacitySmearingFraction = 0.1
)
// Config returns the estimator's configuration.
Config() estimatorConfig
var (
// ErrInvalidHalflife is returned when we get an invalid half life.
ErrInvalidHalflife = errors.New("penalty half life must be >= 0")
// ErrInvalidHopProbability is returned when we get an invalid hop
// probability.
ErrInvalidHopProbability = errors.New("hop probability must be in [0;1]")
// ErrInvalidAprioriWeight is returned when we get an apriori weight
// that is out of range.
ErrInvalidAprioriWeight = errors.New("apriori weight must be in [0;1]")
)
// ProbabilityEstimatorCfg contains configuration for our probability estimator.
type ProbabilityEstimatorCfg struct {
// PenaltyHalfLife defines after how much time a penalized node or
// channel is back at 50% probability.
PenaltyHalfLife time.Duration
// AprioriHopProbability is the assumed success probability of a hop in
// a route when no other information is available.
AprioriHopProbability float64
// AprioriWeight is a value in the range [0, 1] that defines to what
// extent historical results should be extrapolated to untried
// connections. Setting it to one will completely ignore historical
// results and always assume the configured a priori probability for
// untried connections. A value of zero will ignore the a priori
// probability completely and only base the probability on historical
// results, unless there are none available.
AprioriWeight float64
// String returns the string representation of the estimator's
// configuration.
String() string
}
func (p ProbabilityEstimatorCfg) validate() error {
if p.PenaltyHalfLife < 0 {
return ErrInvalidHalflife
}
if p.AprioriHopProbability < 0 || p.AprioriHopProbability > 1 {
return ErrInvalidHopProbability
}
if p.AprioriWeight < 0 || p.AprioriWeight > 1 {
return ErrInvalidAprioriWeight
}
return nil
}
// probabilityEstimator returns node and pair probabilities based on historical
// payment results.
type probabilityEstimator struct {
// ProbabilityEstimatorCfg contains configuration options for our
// estimator.
ProbabilityEstimatorCfg
// prevSuccessProbability is the assumed probability for node pairs that
// successfully relayed the previous attempt.
prevSuccessProbability float64
}
// getNodeProbability calculates the probability for connections from a node
// that have not been tried before. The results parameter is a list of last
// payment results for that node.
func (p *probabilityEstimator) getNodeProbability(now time.Time,
results NodeResults, amt lnwire.MilliSatoshi,
capacity btcutil.Amount) float64 {
// We reduce the apriori hop probability if the amount comes close to
// the capacity.
apriori := p.AprioriHopProbability * capacityFactor(amt, capacity)
// If the channel history is not to be taken into account, we can return
// early here with the configured a priori probability.
if p.AprioriWeight == 1 {
return apriori
}
// If there is no channel history, our best estimate is still the a
// priori probability.
if len(results) == 0 {
return apriori
}
// The value of the apriori weight is in the range [0, 1]. Convert it to
// a factor that properly expresses the intention of the weight in the
// following weight average calculation. When the apriori weight is 0,
// the apriori factor is also 0. This means it won't have any effect on
// the weighted average calculation below. When the apriori weight
// approaches 1, the apriori factor goes to infinity. It will heavily
// outweigh any observations that have been collected.
aprioriFactor := 1/(1-p.AprioriWeight) - 1
// Calculate a weighted average consisting of the apriori probability
// and historical observations. This is the part that incentivizes nodes
// to make sure that all (not just some) of their channels are in good
// shape. Senders will steer around nodes that have shown a few
// failures, even though there may be many channels still untried.
//
// If there is just a single observation and the apriori weight is 0,
// this single observation will totally determine the node probability.
// The node probability is returned for all other channels of the node.
// This means that one failure will lead to the success probability
// estimates for all other channels being 0 too. The probability for the
// channel that was tried will not even recover, because it is
// recovering to the node probability (which is zero). So one failure
// effectively prunes all channels of the node forever. This is the most
// aggressive way in which we can penalize nodes and unlikely to yield
// good results in a real network.
probabilitiesTotal := apriori * aprioriFactor
totalWeight := aprioriFactor
for _, result := range results {
switch {
// Weigh success with a constant high weight of 1. There is no
// decay. Amt is never zero, so this clause is never executed
// when result.SuccessAmt is zero.
case amt <= result.SuccessAmt:
totalWeight++
probabilitiesTotal += p.prevSuccessProbability
// Weigh failures in accordance with their age. The base
// probability of a failure is considered zero, so nothing needs
// to be added to probabilitiesTotal.
case !result.FailTime.IsZero() && amt >= result.FailAmt:
age := now.Sub(result.FailTime)
totalWeight += p.getWeight(age)
}
}
return probabilitiesTotal / totalWeight
}
// getWeight calculates a weight in the range [0, 1] that should be assigned to
// a payment result. Weight follows an exponential curve that starts at 1 when
// the result is fresh and asymptotically approaches zero over time. The rate at
// which this happens is controlled by the penaltyHalfLife parameter.
func (p *probabilityEstimator) getWeight(age time.Duration) float64 {
exp := -age.Hours() / p.PenaltyHalfLife.Hours()
return math.Pow(2, exp)
}
// capacityFactor is a multiplier that can be used to reduce the probability
// depending on how much of the capacity is sent. The limits are 1 for amt == 0
// and 0 for amt >> cutoffMsat. The function drops significantly when amt
// reaches cutoffMsat. smearingMsat determines over which scale the reduction
// takes place.
func capacityFactor(amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
// If we don't have information about the capacity, which can be the
// case for hop hints or local channels, we return unity to not alter
// anything.
if capacity == 0 {
return 1.0
}
capMsat := float64(lnwire.NewMSatFromSatoshis(capacity))
amtMsat := float64(amt)
if amtMsat > capMsat {
return 0
}
cutoffMsat := capacityCutoffFraction * capMsat
smearingMsat := capacitySmearingFraction * capMsat
// We compute a logistic function mirrored around the y axis, centered
// at cutoffMsat, decaying over the smearingMsat scale.
denominator := 1 + math.Exp(-(amtMsat-cutoffMsat)/smearingMsat)
return 1 - 1/denominator
}
// getPairProbability estimates the probability of successfully traversing to
// toNode based on historical payment outcomes for the from node. Those outcomes
// are passed in via the results parameter.
func (p *probabilityEstimator) getPairProbability(
now time.Time, results NodeResults, toNode route.Vertex,
amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 {
nodeProbability := p.getNodeProbability(now, results, amt, capacity)
return p.calculateProbability(
now, results, nodeProbability, toNode, amt,
)
}
// getLocalPairProbability estimates the probability of successfully traversing
// our own local channels to toNode.
func (p *probabilityEstimator) getLocalPairProbability(
now time.Time, results NodeResults, toNode route.Vertex) float64 {
// For local channels that have never been tried before, we assume them
// to be successful. We have accurate balance and online status
// information on our own channels, so when we select them in a route it
// is close to certain that those channels will work.
nodeProbability := p.prevSuccessProbability
return p.calculateProbability(
now, results, nodeProbability, toNode, lnwire.MaxMilliSatoshi,
)
}
// calculateProbability estimates the probability of successfully traversing to
// toNode based on historical payment outcomes and a fall-back node probability.
func (p *probabilityEstimator) calculateProbability(
now time.Time, results NodeResults,
nodeProbability float64, toNode route.Vertex,
amt lnwire.MilliSatoshi) float64 {
// Retrieve the last pair outcome.
lastPairResult, ok := results[toNode]
// If there is no history for this pair, return the node probability
// that is a probability estimate for untried channel.
if !ok {
return nodeProbability
}
// For successes, we have a fixed (high) probability. Those pairs will
// be assumed good until proven otherwise. Amt is never zero, so this
// clause is never executed when lastPairResult.SuccessAmt is zero.
if amt <= lastPairResult.SuccessAmt {
return p.prevSuccessProbability
}
// Take into account a minimum penalize amount. For balance errors, a
// failure may be reported with such a minimum to prevent too aggressive
// penalization. If the current amount is smaller than the amount that
// previously triggered a failure, we act as if this is an untried
// channel.
if lastPairResult.FailTime.IsZero() || amt < lastPairResult.FailAmt {
return nodeProbability
}
timeSinceLastFailure := now.Sub(lastPairResult.FailTime)
// Calculate success probability based on the weight of the last
// failure. When the failure is fresh, its weight is 1 and we'll return
// probability 0. Over time the probability recovers to the node
// probability. It would be as if this channel was never tried before.
weight := p.getWeight(timeSinceLastFailure)
probability := nodeProbability * (1 - weight)
return probability
// estimatorConfig represents a configuration for a probability estimator.
type estimatorConfig interface {
// validate checks that all configuration parameters are sane.
validate() error
}

View file

@ -1,281 +1,95 @@
package routing
import (
"math"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
const (
// Define node identifiers.
node1 = 1
node2 = 2
node3 = 3
// untriedNode is a node id for which we don't record any results in
// this test. This can be used to assert the probability for untried
// ndoes.
untriedNode = 255
// Define test estimator parameters.
aprioriHopProb = 0.6
aprioriWeight = 0.75
aprioriPrevSucProb = 0.95
// testCapacity is used to define a capacity for some channels.
testCapacity = btcutil.Amount(100_000)
testAmount = lnwire.MilliSatoshi(50_000_000)
// Defines the capacityFactor for testAmount and testCapacity.
capFactor = 0.9241
)
type estimatorTestContext struct {
t *testing.T
estimator *probabilityEstimator
// results contains a list of last results. Every element in the list
// corresponds to the last result towards a node. The list index equals
// the node id. So the first element in the list is the result towards
// node 0.
results map[int]TimedPairResult
// Create a set of test results.
var resultTime = time.Unix(1674169200, 0) // 20.01.2023
var now = time.Unix(1674190800, 0) // 6 hours later
var results = NodeResults{
route.Vertex{byte(0)}: TimedPairResult{
FailAmt: 200_000_000,
FailTime: resultTime,
SuccessAmt: 100_000_000,
SuccessTime: resultTime,
},
route.Vertex{byte(1)}: TimedPairResult{
FailAmt: 200_000_000,
FailTime: resultTime,
SuccessAmt: 100_000_000,
SuccessTime: resultTime,
},
route.Vertex{byte(2)}: TimedPairResult{
FailAmt: 200_000_000,
FailTime: resultTime,
SuccessAmt: 100_000_000,
SuccessTime: resultTime,
},
route.Vertex{byte(3)}: TimedPairResult{
FailAmt: 200_000_000,
FailTime: resultTime,
SuccessAmt: 100_000_000,
SuccessTime: resultTime,
},
route.Vertex{byte(4)}: TimedPairResult{
FailAmt: 200_000_000,
FailTime: resultTime,
SuccessAmt: 100_000_000,
SuccessTime: resultTime,
},
}
func newEstimatorTestContext(t *testing.T) *estimatorTestContext {
return &estimatorTestContext{
t: t,
estimator: &probabilityEstimator{
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
AprioriHopProbability: aprioriHopProb,
AprioriWeight: aprioriWeight,
PenaltyHalfLife: time.Hour,
},
prevSuccessProbability: aprioriPrevSucProb,
},
}
}
// probability is a package level variable to prevent the compiler from
// optimizing the benchmark.
var probability float64
// assertPairProbability asserts that the calculated success probability is
// correct.
func (c *estimatorTestContext) assertPairProbability(now time.Time,
toNode byte, amt lnwire.MilliSatoshi, capacity btcutil.Amount,
expectedProb float64) {
c.t.Helper()
results := make(NodeResults)
for i, r := range c.results {
results[route.Vertex{byte(i)}] = r
}
const tolerance = 0.01
p := c.estimator.getPairProbability(
now, results, route.Vertex{toNode}, amt, capacity,
)
diff := p - expectedProb
if diff > tolerance || diff < -tolerance {
c.t.Fatalf("expected probability %v for node %v, but got %v",
expectedProb, toNode, p)
}
}
// TestProbabilityEstimatorNoResults tests the probability estimation when no
// results are available.
func TestProbabilityEstimatorNoResults(t *testing.T) {
ctx := newEstimatorTestContext(t)
// A zero amount does not trigger capacity rescaling.
ctx.assertPairProbability(
testTime, 0, 0, testCapacity, aprioriHopProb,
)
// We expect a reduced probability when a higher amount is used.
expected := aprioriHopProb * capFactor
ctx.assertPairProbability(
testTime, 0, testAmount, testCapacity, expected,
)
}
// TestProbabilityEstimatorOneSuccess tests the probability estimation for nodes
// that have a single success result.
func TestProbabilityEstimatorOneSuccess(t *testing.T) {
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
SuccessAmt: testAmount,
// BenchmarkBimodalPairProbability benchmarks the probability calculation.
func BenchmarkBimodalPairProbability(b *testing.B) {
estimator := BimodalEstimator{
BimodalConfig: BimodalConfig{
BimodalScaleMsat: scale,
BimodalNodeWeight: 0.2,
BimodalDecayTime: 48 * time.Hour,
},
}
// Because of the previous success, this channel keep reporting a high
// probability.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, aprioriPrevSucProb,
)
// The apriori success probability indicates that in the past we were
// able to send the full amount. We don't want to reduce this
// probability with the capacity factor, which is tested here.
ctx.assertPairProbability(
testTime, node1, testAmount, testCapacity, aprioriPrevSucProb,
)
// Untried channels are also influenced by the success. With a
// aprioriWeight of 0.75, the a priori probability is assigned weight 3.
expectedP := (3*aprioriHopProb + 1*aprioriPrevSucProb) / 4
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedP,
)
// Check that the correct probability is computed for larger amounts.
apriori := aprioriHopProb * capFactor
expectedP = (3*apriori + 1*aprioriPrevSucProb) / 4
ctx.assertPairProbability(
testTime, untriedNode, testAmount, testCapacity, expectedP,
)
toNode := route.Vertex{byte(0)}
var p float64
for i := 0; i < b.N; i++ {
p = estimator.PairProbability(now, results, toNode,
150_000_000, 300_000)
}
probability = p
}
// TestProbabilityEstimatorOneFailure tests the probability estimation for nodes
// that have a single failure.
func TestProbabilityEstimatorOneFailure(t *testing.T) {
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
FailTime: testTime.Add(-time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
// BenchmarkAprioriPairProbability benchmarks the probability calculation.
func BenchmarkAprioriPairProbability(b *testing.B) {
estimator := AprioriEstimator{
AprioriConfig: AprioriConfig{
AprioriWeight: 0.2,
PenaltyHalfLife: 48 * time.Hour,
AprioriHopProbability: 0.5,
},
}
// For an untried node, we expected the node probability. The weight for
// the failure after one hour is 0.5. This makes the node probability
// 0.51:
expectedNodeProb := (3*aprioriHopProb + 0.5*0) / 3.5
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
)
// The pair probability decays back to the node probability. With the
// weight at 0.5, we expected a pair probability of 0.5 * 0.51 = 0.25.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, expectedNodeProb/2,
)
toNode := route.Vertex{byte(0)}
var p float64
for i := 0; i < b.N; i++ {
p = estimator.PairProbability(now, results, toNode,
150_000_000, 300_000)
}
probability = p
}
// TestProbabilityEstimatorMix tests the probability estimation for nodes for
// which a mix of successes and failures is recorded.
func TestProbabilityEstimatorMix(t *testing.T) {
ctx := newEstimatorTestContext(t)
ctx.results = map[int]TimedPairResult{
node1: {
SuccessAmt: lnwire.MilliSatoshi(1000),
},
node2: {
FailTime: testTime.Add(-2 * time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
},
node3: {
FailTime: testTime.Add(-3 * time.Hour),
FailAmt: lnwire.MilliSatoshi(50),
},
}
// We expect the probability for a previously successful channel to
// remain high.
ctx.assertPairProbability(
testTime, node1, 100, testCapacity, prevSuccessProbability,
)
// For an untried node, we expected the node probability to be returned.
// This is a weighted average of the results above and the a priori
// probability: 0.62.
expectedNodeProb := (3*aprioriHopProb + 1*prevSuccessProbability) /
(3 + 1 + 0.25 + 0.125)
ctx.assertPairProbability(
testTime, untriedNode, 100, testCapacity, expectedNodeProb,
)
// For the previously failed connection with node 1, we expect 0.75 *
// the node probability = 0.47.
ctx.assertPairProbability(
testTime, node2, 100, testCapacity, expectedNodeProb*0.75,
)
}
// TestCapacityCutoff tests the mathematical expression and limits for the
// capacity factor.
func TestCapacityCutoff(t *testing.T) {
t.Parallel()
capacitySat := 1_000_000
capacityMSat := capacitySat * 1000
tests := []struct {
name string
amountMsat int
expectedFactor float64
}{
{
name: "zero amount",
expectedFactor: 1,
},
{
name: "low amount",
amountMsat: capacityMSat / 10,
expectedFactor: 0.998,
},
{
name: "half amount",
amountMsat: capacityMSat / 2,
expectedFactor: 0.924,
},
{
name: "cutoff amount",
amountMsat: int(
capacityCutoffFraction * float64(capacityMSat),
),
expectedFactor: 0.5,
},
{
name: "high amount",
amountMsat: capacityMSat * 80 / 100,
expectedFactor: 0.377,
},
{
// Even when we spend the full capacity, we still want
// to have some residual probability to not throw away
// routes due to a min probability requirement of the
// whole path.
name: "full amount",
amountMsat: capacityMSat,
expectedFactor: 0.076,
},
{
name: "more than capacity",
amountMsat: capacityMSat + 1,
expectedFactor: 0.0,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got := capacityFactor(
lnwire.MilliSatoshi(test.amountMsat),
btcutil.Amount(capacitySat),
)
require.InDelta(t, test.expectedFactor, got, 0.001)
})
// BenchmarkExp benchmarks the exponential function as provided by the math
// library.
func BenchmarkExp(b *testing.B) {
for i := 0; i < b.N; i++ {
math.Exp(0.1)
}
}

View file

@ -119,13 +119,15 @@ func createTestCtxFromGraphInstanceAssumeValid(t *testing.T,
AttemptCost: 100,
}
mcConfig := &MissionControlConfig{
ProbabilityEstimatorCfg: ProbabilityEstimatorCfg{
PenaltyHalfLife: time.Hour,
AprioriHopProbability: 0.9,
AprioriWeight: 0.5,
},
aCfg := AprioriConfig{
PenaltyHalfLife: time.Hour,
AprioriHopProbability: 0.9,
AprioriWeight: 0.5,
}
estimator, err := NewAprioriEstimator(aCfg)
require.NoError(t, err)
mcConfig := &MissionControlConfig{Estimator: estimator}
mc, err := NewMissionControl(
graphInstance.graphBackend, route.Vertex{}, mcConfig,

View file

@ -1105,21 +1105,23 @@ litecoin.node=ltcd
[routerrpc]
; Probability estimator used for pathfinding. (default: apriori)
; routerrpc.estimator=[apriori|bimodal]
; Minimum required route success probability to attempt the payment (default:
; 0.01)
; routerrpc.minrtprob=1
; Assumed success probability of a hop in a route when no other information is
; available. (default: 0.6)
; routerrpc.apriorihopprob=0.2
; The maximum number of payment results that are held on disk by mission control
; (default: 1000)
; routerrpc.maxmchistory=900
; Weight of the a priori probability in success probability estimation. Valid
; values are in [0, 1]. (default: 0.5)
; routerrpc.aprioriweight=0.3
; The time interval with which the MC store state is flushed to the database
; (default: 1s)
; routerrpc.mcflushinterval=1m
; Defines the duration after which a penalized node or channel is back at 50%
; probability (default: 1h0m0s)
; routerrpc.penaltyhalflife=2h
; Path to the router macaroon
; routerrpc.routermacaroonpath=~/.lnd/data/chain/bitcoin/simnet/router.macaroon
; The (virtual) fixed cost in sats of a failed payment attempt (default: 100)
; routerrpc.attemptcost=90
@ -1128,15 +1130,32 @@ litecoin.node=ltcd
; attempt (default: 1000)
; routerrpc.attemptcostppm=900
; The maximum number of payment results that are held on disk by mission control
; (default: 1000)
; routerrpc.maxmchistory=900
; Assumed success probability of a hop in a route when no other information is
; available. (default: 0.6)
; routerrpc.apriori.hopprob=0.2
; The time interval with which the MC store state is flushed to the DB.
; routerrpc.mcflushinterval=1m
; Weight of the a priori probability in success probability estimation. Valid
; values are in [0, 1]. (default: 0.5)
; routerrpc.apriori.weight=0.3
; Path to the router macaroon
; routerrpc.routermacaroonpath=~/.lnd/data/chain/bitcoin/simnet/router.macaroon
; Defines the duration after which a penalized node or channel is back at 50%
; probability (default: 1h0m0s)
; routerrpc.apriori.penaltyhalflife=2h
; Describes the scale over which channels still have some liquidity left on
; both channel ends. A very low value (compared to typical channel capacities)
; means that we assume unbalanced channels, a very high value means randomly
; balanced channels. Value in msat. (default: 300000000)
; routerrpc.bimodal.scale=1000000000
; Defines how strongly non-routed channels of forwarders should be taken into
; account for probability estimation. A weight of zero disables this feature.
; Valid values are in [0, 1]. (default: 0.2)
; routerrpc.bimodal.nodeweight=0.3
; Defines the information decay of knowledge about previous successes and
; failures in channels. (default: 168h0m0s)
; routerrpc.bimodal.decaytime=72h
[workers]

View file

@ -868,20 +868,58 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
// servers, the mission control instance itself can be moved there too.
routingConfig := routerrpc.GetRoutingConfig(cfg.SubRPCServers.RouterRPC)
estimatorCfg := routing.ProbabilityEstimatorCfg{
AprioriHopProbability: routingConfig.AprioriHopProbability,
PenaltyHalfLife: routingConfig.PenaltyHalfLife,
AprioriWeight: routingConfig.AprioriWeight,
// We only initialize a probability estimator if there's no custom one.
var estimator routing.Estimator
if cfg.Estimator != nil {
estimator = cfg.Estimator
} else {
switch routingConfig.ProbabilityEstimatorType {
case routing.AprioriEstimatorName:
aCfg := routingConfig.AprioriConfig
aprioriConfig := routing.AprioriConfig{
AprioriHopProbability: aCfg.HopProbability,
PenaltyHalfLife: aCfg.PenaltyHalfLife,
AprioriWeight: aCfg.Weight,
}
estimator, err = routing.NewAprioriEstimator(
aprioriConfig,
)
if err != nil {
return nil, err
}
case routing.BimodalEstimatorName:
bCfg := routingConfig.BimodalConfig
bimodalConfig := routing.BimodalConfig{
BimodalNodeWeight: bCfg.NodeWeight,
BimodalScaleMsat: lnwire.MilliSatoshi(
bCfg.Scale,
),
BimodalDecayTime: bCfg.DecayTime,
}
estimator, err = routing.NewBimodalEstimator(
bimodalConfig,
)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown estimator type %v",
routingConfig.ProbabilityEstimatorType)
}
}
mcCfg := &routing.MissionControlConfig{
Estimator: estimator,
MaxMcHistory: routingConfig.MaxMcHistory,
McFlushInterval: routingConfig.McFlushInterval,
MinFailureRelaxInterval: routing.DefaultMinFailureRelaxInterval,
}
s.missionControl, err = routing.NewMissionControl(
dbs.ChanStateDB, selfNode.PubKeyBytes,
&routing.MissionControlConfig{
ProbabilityEstimatorCfg: estimatorCfg,
MaxMcHistory: routingConfig.MaxMcHistory,
McFlushInterval: routingConfig.McFlushInterval,
MinFailureRelaxInterval: routing.DefaultMinFailureRelaxInterval,
},
dbs.ChanStateDB, selfNode.PubKeyBytes, mcCfg,
)
if err != nil {
return nil, fmt.Errorf("can't create mission control: %v", err)