diff --git a/README.md b/README.md index 8c419d6..fee53b8 100644 --- a/README.md +++ b/README.md @@ -51,15 +51,13 @@ By default, only nodes with clearnet addresses are listed. TOR-only nodes tend t **Passively manage channel liquidity** -Set channel fees based on the channel's current liquidity. +Set channel fees based on the channel's current liquidity. The idea here is to encourage passive channel re-balancing through fees. If a channel has a too much local liquidity (high), fees are lowered in order to encourage relatively more outbound transactions. Visa versa for a channel with too little local liquidity (low). -The strategy for fee amounts is hardcoded (although I might try to add some more in the future) all fees are derived from the `-standard-liquidity-fee-ppm` flag. Channels are bucketed into three coarse grained groups: *high liquidity*, *standard liquidity*, and *low liquidity*. The idea here is to encourage passive channel re-balancing through fees. If a channel has a too much local liquidity (high), fees are lowered in order to encourage relatively more outbound transactions. Visa versa for a channel with too little local liquidity (low). So `fees` applies the `standard-liquidity-fee-ppm` to standard channels, `standard-liquidity-fee-ppm / 10` to high channels, and `standard-liquidity-fee-ppm * 10` to low channels. The following example sets fees based on a `200 ppm` standard fee: +The global `-liquidity-thresholds` flag determines how channels are grouped into liquidity buckets, while the `-liquidity-fees` flag determines the fee settings applied to those groups. For example, if thresholds are set to `80,20` and fees set to `5,50,500`, then channels with over 80% local liquidity will have a 5 PPM fee, channels between 80% and 20% local liquidity will have a 50 PPM fee, and channels with less than 20% liquidity will have a 500 PPM fee. -``` -$ raiju fees -standard-liquidity-fee-ppm 200 -``` +The `-liquidity-thresholds` and `-liquidity-fees` are global (not `fees` specific) because they are also used in the `rebalance` command to help coordinate the right amount of fees to pay in active rebalancing. -`fees` supports a `-daemon` flag which keeps keeps the process alive listening for channel updates that trigger fee updates (e.g. a channel's liquidity sinks below the low level and needs its fees updated). This is helpful when used with the `rebalance` command which *actively* balances channel liquidity. Without the daemon, there is a worst case scenario of: 1. pay a lot of fees to actively `rebalance` channel's liquidity from low to standard 2. update the channel's fees to standard 3. have a large payment immediately cancel out the rebalance and it only pays standard fees (instead of higher ones which would have canceled out the cost of the rebalance). +The `-daemon` flag keeps keeps the process alive listening for channel updates that trigger fee updates (e.g. a channel's liquidity sinks below the low level and needs its fees updated). This is helpful when used with the `rebalance` command which *actively* balances channel liquidity. Without the daemon, there is a worst case scenario of: 1. pay a lot of fees to actively `rebalance` channel's liquidity from low to standard 2. update the channel's fees to standard 3. have a large payment immediately cancel out the rebalance and it only pays standard fees (instead of higher ones which would have canceled out the cost of the rebalance). `fees` follows the [zero-base-fee movement](http://www.rene-pickhardt.de/). I am honestly not sure if this is financially sound, but I appreciate the simpler mental model of only thinking in ppm. @@ -80,7 +78,7 @@ Restart=always Environment=RAIJU_HOST=localhost:10009 Environment=RAIJU_MAC_PATH=/home/lightning/.lnd/data/chain/bitcoin/mainnet/admin.macaroon Environment=RAIJU_TLS_PATH=/home/lightning/.lnd/tls.cert -Environment=RAIJU_STANDARD_LIQUIDITY_FEE_PPM=200 +Environment=RAIJU_LIQUIDITY_FEES=5,50,500 ExecStart=/usr/local/bin/raiju fees -daemon [Install] @@ -91,7 +89,7 @@ WantedBy=multi-user.target **Actively manage channel liquidity** -Circular rebalance a channel or all channels that aren't doing so hot liquidity-wise. +Circular rebalance channels that aren't doing so hot liquidity-wise or force rebalance a single channel. Where the `fees` command attempts to balance channels passively, this is an *active* approach where liquidity is manually pushed. The cost of active rebalancing are the lightning payment fees. While this command could be used to push large amounts of liquidity, the default settings are intended to just prod things in the right direction. @@ -103,7 +101,7 @@ The command takes two arguments: A smaller step percentage will increase the likely hood of a successful payment, but might also increase fees a bit if the payment collects a lot of `base_fees` on its route. -If no out channel and last hop pubkey are given, the command will roll through all channels with high liquidity (as defined by `raiju`) and attempt to push it through channels of low liquidity (as defined by `raiju`). +If no out channel and last hop pubkey are given, the command will roll through channels with high liquidity and attempt to push it through channels of low liquidity. High and low are defined by the defined by the global `-liquidity-thresholds` flag. For example, if liquidity thresholds is set to `80,20`, channels with local liquidity over 80% are considered "high" and channels with local liquidity under 20% are considered "low". ``` $ raiju rebalance 1 1 @@ -131,10 +129,8 @@ Group=lightning Environment=RAIJU_HOST=localhost:10009 Environment=RAIJU_MAC_PATH=/home/lightning/.lnd/data/chain/bitcoin/mainnet/admin.macaroon Environment=RAIJU_TLS_PATH=/home/lightning/.lnd/tls.cert -Environment=RAIJU_STANDARD_LIQUIDITY_FEE_PPM=200 +Environment=RAIJU_LIQUIDITY_FEES=5,50,500 ExecStart=/usr/local/bin/raiju rebalance 1 5 -# Optionally run fees afterward to "lock in" new liquidities -ExecStartPost=/usr/local/bin/raiju fees ``` Example `rebalance.timer`: @@ -175,7 +171,7 @@ $ go install github.com/nyonson/raiju/cmd/raiju@latest If a container is preferred, `raiju` images are published at `ghcr.io/nyonson/raiju`. ``` -docker pull ghcr.io/nyonson/raiju:v0.3.2 +docker pull ghcr.io/nyonson/raiju:v0.6.0 ``` A little more configuration is required to pass along settings to the container. @@ -183,7 +179,7 @@ A little more configuration is required to pass along settings to the container. ``` docker run -it \ -v /admin.macaroon:/admin.macaroon:ro -v /tls.cert:/tls.cert:ro \ - ghcr.io/nyonson/raiju:v0.3.2 \ + ghcr.io/nyonson/raiju:v0.6.0 \ -host 192.168.1.187:10009 -mac-path admin.macaroon -tls-path tls.cert candidates ``` diff --git a/cmd/raiju/raiju.go b/cmd/raiju/raiju.go index 431c0b8..4014586 100644 --- a/cmd/raiju/raiju.go +++ b/cmd/raiju/raiju.go @@ -26,6 +26,31 @@ const ( rpcTimeout = time.Minute * 5 ) +func parseFees(thresholds string, fees string) (raiju.LiquidityFees, error) { + // using FieldsFunc to handle empty string case correctly + rawThresholds := strings.FieldsFunc(thresholds, func(c rune) bool { return c == ',' }) + tfs := make([]float64, len(rawThresholds)) + for i, t := range rawThresholds { + tf, err := strconv.ParseFloat(t, 64) + if err != nil { + return raiju.LiquidityFees{}, err + } + tfs[i] = tf + } + + rawFees := strings.FieldsFunc(fees, func(c rune) bool { return c == ',' }) + ffs := make([]lightning.FeePPM, len(rawFees)) + for i, f := range rawFees { + ff, err := strconv.ParseFloat(f, 64) + if err != nil { + return raiju.LiquidityFees{}, err + } + ffs[i] = lightning.FeePPM(ff) + } + + return raiju.NewLiquidityFees(tfs, ffs) +} + func main() { cmdLog := log.New(os.Stderr, "raiju: ", 0) @@ -43,8 +68,9 @@ func main() { tlsPath := rootFlagSet.String("tls-path", "", "LND node tls certificate") macPath := rootFlagSet.String("mac-path", "", "Macaroon with necessary permissions for lnd node") network := rootFlagSet.String("network", "mainnet", "The bitcoin network") - // liquidity flags - standardLiquidityFeePPM := rootFlagSet.Float64("standard-liquidity-fee-ppm", 200, "Default fee in PPM for standard liquidity channels which is shared by subcommands") + // fees flags + liquidityThresholds := rootFlagSet.String("liquidity-thresholds", "80,20", "Comma separated local liquidity percent thresholds") + liquidityFees := rootFlagSet.String("liquidity-fees", "5,50,500", "Comma separated local liquidity-based fees PPM") candidatesFlagSet := flag.NewFlagSet("candidates", flag.ExitOnError) minCapacity := candidatesFlagSet.Int64("min-capacity", 1000000, "Minimum capacity of a node in satoshis") @@ -85,13 +111,18 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - r := raiju.New(c) + f, err := parseFees(*liquidityThresholds, *liquidityFees) + if err != nil { + return err + } + + r := raiju.New(c, f) // using FieldsFunc to handle empty string case correctly - raw := strings.FieldsFunc(*assume, func(c rune) bool { return c == ',' }) - assume := make([]lightning.PubKey, len(raw)) - for i, a := range raw { - assume[i] = lightning.PubKey(a) + a := strings.FieldsFunc(*assume, func(c rune) bool { return c == ',' }) + assume := make([]lightning.PubKey, len(a)) + for i, p := range a { + assume[i] = lightning.PubKey(p) } request := raiju.CandidatesRequest{ @@ -119,7 +150,7 @@ func main() { Name: "fees", ShortUsage: "raiju fees", ShortHelp: "Set channel fees based on liquidity to passively rebalance channels", - LongHelp: "Channels are grouped into three coarse grained buckets: standard, high, and low. Channels with standard liquidity will have the standard fee applied. Channels with high liquidity will have a 10x the standard fee applied to discourage routing. And channels with low liquidity will have 1/10 the standard fee applied to encourage routing.", + LongHelp: "Channels are grouped depending on the local liquidity thresholds setting and have fees applied based on the local liquidity fees setting.", FlagSet: feesFlagSet, Exec: func(ctx context.Context, args []string) error { if len(args) != 0 { @@ -140,9 +171,14 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - r := raiju.New(c) + f, err := parseFees(*liquidityThresholds, *liquidityFees) + if err != nil { + return err + } - _, err = r.Fees(ctx, raiju.NewLiquidityFees(*standardLiquidityFeePPM), *daemon) + r := raiju.New(c, f) + + _, err = r.Fees(ctx, *daemon) return err }, @@ -157,7 +193,7 @@ func main() { Name: "rebalance", ShortUsage: "raiju rebalance ", ShortHelp: "Send circular payment(s) to actively rebalance channels", - LongHelp: "If the output and input flags are set, a rebalance is attempted (both must be set together). If not, channels are grouped into three coarse grained buckets: standard, high, and low. Standard channels will be ignored since their liquidity is good. High channels will attempt to push the percent of their capacity at a time to the low channels, stopping if their liquidity improves enough or if all channels have been tried.", + LongHelp: "By default, attempts to move liquidity from the channels with the highest local liquidity to the lowest. If an out channel and last hop node are specified however, this is and implicit force command and attempts to move the liquidity damn whatever the current local amounts.", FlagSet: rebalanceFlagSet, Exec: func(ctx context.Context, args []string) error { if len(args) != 2 { @@ -192,12 +228,15 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - r := raiju.New(c) + f, err := parseFees(*liquidityThresholds, *liquidityFees) + if err != nil { + return err + } - fees := raiju.NewLiquidityFees(*standardLiquidityFeePPM) + r := raiju.New(c, f) // default to low liquidity fee, override with flag - maxFee := fees.Low() + maxFee := f.RebalanceFee() if *maxFeePPM != 0 { maxFee = lightning.FeePPM(*maxFeePPM) } @@ -239,7 +278,12 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - r := raiju.New(c) + f, err := parseFees(*liquidityThresholds, *liquidityFees) + if err != nil { + return err + } + + r := raiju.New(c, f) _, err = r.Reaper(ctx) diff --git a/fees.go b/fees.go new file mode 100644 index 0000000..8c9b199 --- /dev/null +++ b/fees.go @@ -0,0 +1,93 @@ +package raiju + +import ( + "errors" + + "github.com/nyonson/raiju/lightning" +) + +// LiquidityFees for channels. +// +// Defining channel liquidity percentage based on (local capacity / total capacity). +// When liquidity is low, there is too much inbound. +// When liquidity is high, there is too much outbound. +type LiquidityFees struct { + thresholds []float64 + fees []lightning.FeePPM +} + +// Fee for channel based on its current liquidity. +func (lf LiquidityFees) Fee(channel lightning.Channel) lightning.FeePPM { + liquidity := float64(channel.LocalBalance) / float64(channel.Capacity) * 100 + + return lf.findFee(liquidity) +} + +// PotentialFee for channel based on its current liquidity. +func (lf LiquidityFees) PotentialFee(channel lightning.Channel, additionalLocal lightning.Satoshi) lightning.FeePPM { + liquidity := float64(channel.LocalBalance+additionalLocal) / float64(channel.Capacity) * 100 + + return lf.findFee(liquidity) +} + +func (lf LiquidityFees) findFee(liquidity float64) lightning.FeePPM { + bucket := 0 + for bucket < len(lf.thresholds) { + if liquidity > lf.thresholds[bucket] { + break + } else { + bucket += 1 + } + + } + + return lf.fees[bucket] +} + +// RebalanceChannels at the far ends of the spectrum. +func (lf LiquidityFees) RebalanceChannels(channels lightning.Channels) (high lightning.Channels, low lightning.Channels) { + for _, c := range channels { + l := c.Liquidity() + if l > lf.thresholds[0] { + high = append(high, c) + } + + if l <= lf.thresholds[len(lf.thresholds)-1] { + low = append(low, c) + } + } + + return high, low +} + +func (lf LiquidityFees) RebalanceFee() lightning.FeePPM { + return lf.fees[len(lf.fees)-1] +} + +// NewLiquidityFees with threshold and fee validation. +func NewLiquidityFees(thresholds []float64, fees []lightning.FeePPM) (LiquidityFees, error) { + // ensure every bucket has a fee + if len(thresholds)+1 != len(fees) { + return LiquidityFees{}, errors.New("fees must have one more value than thresholds to ensure each bucket has a defined fee") + + } + + // ensure thresholds are descending + for i := 0; i < len(thresholds)-1; i++ { + if thresholds[i] <= thresholds[i+1] { + return LiquidityFees{}, errors.New("thresholds must be descending") + } + } + + // ensure fees are ascending + for i := 0; i < len(fees)-1; i++ { + if fees[i] > fees[i+1] { + return LiquidityFees{}, errors.New("fees must be ascending") + } + } + + return LiquidityFees{ + thresholds: thresholds, + fees: fees, + }, nil +} diff --git a/fees_test.go b/fees_test.go new file mode 100644 index 0000000..a65c8e0 --- /dev/null +++ b/fees_test.go @@ -0,0 +1,347 @@ +package raiju + +import ( + "reflect" + "testing" + + "github.com/nyonson/raiju/lightning" +) + +func TestLiquidityFees_Fee(t *testing.T) { + type fields struct { + thresholds []float64 + fees []lightning.FeePPM + } + type args struct { + channel lightning.Channel + } + tests := []struct { + name string + fields fields + args args + want lightning.FeePPM + }{ + { + name: "grab fee based on liquidity", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + args: args{ + channel: lightning.Channel{ + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 1, + LocalFee: 50, + RemoteBalance: 9, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + want: lightning.FeePPM(500), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lf := LiquidityFees{ + thresholds: tt.fields.thresholds, + fees: tt.fields.fees, + } + if got := lf.Fee(tt.args.channel); !reflect.DeepEqual(got, tt.want) { + t.Errorf("LiquidityFees.Fee() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLiquidityFees_PotentialFee(t *testing.T) { + type fields struct { + thresholds []float64 + fees []lightning.FeePPM + } + type args struct { + channel lightning.Channel + additionalLocal lightning.Satoshi + } + tests := []struct { + name string + fields fields + args args + want lightning.FeePPM + }{ + { + name: "grab fee based on potential liquidity", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + args: args{ + additionalLocal: lightning.Satoshi(3), + channel: lightning.Channel{ + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 1, + LocalFee: 50, + RemoteBalance: 9, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + want: 50, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lf := LiquidityFees{ + thresholds: tt.fields.thresholds, + fees: tt.fields.fees, + } + if got := lf.PotentialFee(tt.args.channel, tt.args.additionalLocal); !reflect.DeepEqual(got, tt.want) { + t.Errorf("LiquidityFees.PotentialFee() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLiquidityFees_RebalanceChannels(t *testing.T) { + type fields struct { + thresholds []float64 + fees []lightning.FeePPM + } + type args struct { + channels lightning.Channels + } + tests := []struct { + name string + fields fields + args args + wantHigh lightning.Channels + wantLow lightning.Channels + }{ + { + name: "get the highest and lowest liquidity channels", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + args: args{ + channels: lightning.Channels{ + { + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 9, + LocalFee: 50, + RemoteBalance: 1, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + { + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "C", + }, + ChannelID: 2, + LocalBalance: 5, + LocalFee: 50, + RemoteBalance: 5, + RemoteNode: lightning.Node{ + PubKey: pubKeyC, + Alias: "C", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + { + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "D", + }, + ChannelID: 3, + LocalBalance: 1, + LocalFee: 50, + RemoteBalance: 9, + RemoteNode: lightning.Node{ + PubKey: pubKeyD, + Alias: "D", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + }, + wantHigh: []lightning.Channel{ + { + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 9, + LocalFee: 50, + RemoteBalance: 1, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + wantLow: []lightning.Channel{ + { + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "D", + }, + ChannelID: 3, + LocalBalance: 1, + LocalFee: 50, + RemoteBalance: 9, + RemoteNode: lightning.Node{ + PubKey: pubKeyD, + Alias: "D", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lf := LiquidityFees{ + thresholds: tt.fields.thresholds, + fees: tt.fields.fees, + } + gotHigh, gotLow := lf.RebalanceChannels(tt.args.channels) + if !reflect.DeepEqual(gotHigh, tt.wantHigh) { + t.Errorf("LiquidityFees.RebalanceChannels() gotHigh = %v, want %v", gotHigh, tt.wantHigh) + } + if !reflect.DeepEqual(gotLow, tt.wantLow) { + t.Errorf("LiquidityFees.RebalanceChannels() gotLow = %v, want %v", gotLow, tt.wantLow) + } + }) + } +} + +func TestLiquidityFees_RebalanceFee(t *testing.T) { + type fields struct { + thresholds []float64 + fees []lightning.FeePPM + } + tests := []struct { + name string + fields fields + want lightning.FeePPM + }{ + { + name: "get lowest liquidity fee", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + want: 500, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lf := LiquidityFees{ + thresholds: tt.fields.thresholds, + fees: tt.fields.fees, + } + if got := lf.RebalanceFee(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("LiquidityFees.RebalanceFee() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNewLiquidityFees(t *testing.T) { + type args struct { + thresholds []float64 + fees []lightning.FeePPM + } + tests := []struct { + name string + args args + want LiquidityFees + wantErr bool + }{ + { + name: "happy case", + args: args{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + want: LiquidityFees{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + wantErr: false, + }, + { + name: "missing fees or thresholds", + args: args{ + thresholds: []float64{80, 60, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + wantErr: true, + }, + { + name: "thresholds must descend", + args: args{ + thresholds: []float64{80, 85}, + fees: []lightning.FeePPM{5, 50, 500}, + }, + wantErr: true, + }, + { + name: "fees must ascend", + args: args{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 2, 500}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewLiquidityFees(tt.args.thresholds, tt.args.fees) + if (err != nil) != tt.wantErr { + t.Errorf("NewLiquidityFees() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewLiquidityFees() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/lightning/lightning.go b/lightning/lightning.go index 20098e4..d21e5d4 100644 --- a/lightning/lightning.go +++ b/lightning/lightning.go @@ -74,24 +74,6 @@ type Graph struct { Edges []Edge } -// ChannelLiquidityLevel coarse-grained bucket based on current liquidity. -type ChannelLiquidityLevel string - -// ChannelLiquidityLevels -const ( - LowLiquidity ChannelLiquidityLevel = "low" - StandardLiquidity ChannelLiquidityLevel = "standard" - HighLiquidity ChannelLiquidityLevel = "high" -) - -// Defining channel liquidity percentage based on (local capacity / total capacity). -// When liquidity is low, there is too much inbound. -// When liquidity is high, there is too much outbound. -const ( - lowLiquidityThreshold = 25 - highLiquidityThreshold = 75 -) - // Channel between local and remote node. type Channel struct { Edge @@ -102,63 +84,14 @@ type Channel struct { RemoteNode Node } -// Liquidity of the channel. +// Liquidity percent of the channel that is local. func (c Channel) Liquidity() float64 { return float64(c.LocalBalance) / float64(c.Capacity) * 100 } -// LiquidityLevel of the channel. -func (c Channel) LiquidityLevel() ChannelLiquidityLevel { - if c.Liquidity() < lowLiquidityThreshold { - return LowLiquidity - } else if c.Liquidity() > highLiquidityThreshold { - return HighLiquidity - } - - return StandardLiquidity -} - -func (c Channel) PotentialLiquidityLevel(additional Satoshi) ChannelLiquidityLevel { - potentialLiquidity := float64(c.LocalBalance+additional) / float64(c.Capacity) * 100 - - if potentialLiquidity < lowLiquidityThreshold { - return LowLiquidity - } else if potentialLiquidity > highLiquidityThreshold { - return HighLiquidity - } - - return StandardLiquidity -} - // Channels of node. type Channels []Channel -// LowLiquidity channels of node. -func (cs Channels) LowLiquidity() Channels { - ll := make(Channels, 0) - - for _, c := range cs { - if c.LiquidityLevel() == LowLiquidity { - ll = append(ll, c) - } - } - - return ll -} - -// HighLiquidity channels of node. -func (cs Channels) HighLiquidity() Channels { - hl := make(Channels, 0) - - for _, c := range cs { - if c.LiquidityLevel() == HighLiquidity { - hl = append(hl, c) - } - } - - return hl -} - // Info of a node. type Info struct { PubKey PubKey diff --git a/lightning/lightning_test.go b/lightning/lightning_test.go index a776a00..9a15b89 100644 --- a/lightning/lightning_test.go +++ b/lightning/lightning_test.go @@ -2,7 +2,6 @@ package lightning import ( - "reflect" "testing" "time" ) @@ -160,268 +159,3 @@ func TestChannel_Liquidity(t *testing.T) { }) } } - -func TestChannel_LiquidityLevel(t *testing.T) { - type fields struct { - Edge Edge - ChannelID ChannelID - LocalBalance Satoshi - RemoteBalance Satoshi - RemoteNode Node - } - tests := []struct { - name string - fields fields - want ChannelLiquidityLevel - }{ - { - name: "detect low liquidity", - fields: fields{ - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 1, - RemoteBalance: 9, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: time.Now(), - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - want: LowLiquidity, - }, - { - name: "detect standard liquidity", - fields: fields{ - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 5, - RemoteBalance: 5, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: time.Now(), - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - want: StandardLiquidity, - }, - { - name: "detect high liquidity", - fields: fields{ - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 9, - RemoteBalance: 1, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: time.Now(), - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - want: HighLiquidity, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := Channel{ - Edge: tt.fields.Edge, - ChannelID: tt.fields.ChannelID, - LocalBalance: tt.fields.LocalBalance, - RemoteBalance: tt.fields.RemoteBalance, - RemoteNode: tt.fields.RemoteNode, - } - if got := c.LiquidityLevel(); got != tt.want { - t.Errorf("Channel.LiquidityLevel() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestChannels_LowLiquidity(t *testing.T) { - tests := []struct { - name string - cs Channels - want Channels - }{ - { - name: "filter low liquidity channels", - cs: []Channel{ - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 1, - RemoteBalance: 9, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "C", - }, - ChannelID: 0, - LocalBalance: 5, - RemoteBalance: 5, - RemoteNode: Node{ - PubKey: "C", - Alias: "C", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "D", - }, - ChannelID: 0, - LocalBalance: 9, - RemoteBalance: 1, - RemoteNode: Node{ - PubKey: "D", - Alias: "D", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - }, - want: []Channel{ - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 1, - RemoteBalance: 9, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cs.LowLiquidity(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Channels.LowLiquidity() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestChannels_HighLiquidity(t *testing.T) { - tests := []struct { - name string - cs Channels - want Channels - }{ - - { - name: "filter high liquidity channels", - cs: []Channel{ - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "B", - }, - ChannelID: 0, - LocalBalance: 1, - RemoteBalance: 9, - RemoteNode: Node{ - PubKey: "B", - Alias: "B", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "C", - }, - ChannelID: 0, - LocalBalance: 5, - RemoteBalance: 5, - RemoteNode: Node{ - PubKey: "C", - Alias: "C", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "D", - }, - ChannelID: 0, - LocalBalance: 9, - RemoteBalance: 1, - RemoteNode: Node{ - PubKey: "D", - Alias: "D", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - }, - want: []Channel{ - { - Edge: Edge{ - Capacity: 10, - Node1: "A", - Node2: "D", - }, - ChannelID: 0, - LocalBalance: 9, - RemoteBalance: 1, - RemoteNode: Node{ - PubKey: "D", - Alias: "D", - Updated: updated, - Addresses: []string{"123.12.123.123:1231"}, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cs.HighLiquidity(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Channels.HighLiquidity() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/raiju.go b/raiju.go index 61b8fdb..cada870 100644 --- a/raiju.go +++ b/raiju.go @@ -29,39 +29,14 @@ type lightninger interface { // Raiju app. type Raiju struct { l lightninger + f LiquidityFees } // New instance of raiju. -func New(l lightninger) Raiju { +func New(l lightninger, r LiquidityFees) Raiju { return Raiju{ l: l, - } -} - -// LiquidityFees for channels with high, standard, and low liquidity. -type LiquidityFees struct { - standard float64 -} - -// High liquidity channel fee to encourage more payments. -func (l LiquidityFees) High() lightning.FeePPM { - return lightning.FeePPM(l.standard / 10) -} - -// Standard liquidity channel fee. -func (l LiquidityFees) Standard() lightning.FeePPM { - return lightning.FeePPM(l.standard) -} - -// Low liquidity channel fee to discourage payments. -func (l LiquidityFees) Low() lightning.FeePPM { - return lightning.FeePPM(l.standard * 10) -} - -// NewLiquidityFees based on the standard fee. -func NewLiquidityFees(standard float64) LiquidityFees { - return LiquidityFees{ - standard: standard, + f: r, } } @@ -285,27 +260,27 @@ func (r Raiju) Candidates(ctx context.Context, request CandidatesRequest) ([]Rel // Fees to encourage a balanced channel. // // Daemon mode continuously updates policies as channel liquidity changes. -func (r Raiju) Fees(ctx context.Context, fees LiquidityFees, daemon bool) (map[lightning.ChannelID]lightning.ChannelLiquidityLevel, error) { +func (r Raiju) Fees(ctx context.Context, daemon bool) (map[lightning.ChannelID]lightning.FeePPM, error) { channels, err := r.l.ListChannels(ctx) if err != nil { - return map[lightning.ChannelID]lightning.ChannelLiquidityLevel{}, err + return map[lightning.ChannelID]lightning.FeePPM{}, err } - c, err := r.setFees(ctx, fees, channels) + updates, err := r.setFees(ctx, channels) if err != nil { - return map[lightning.ChannelID]lightning.ChannelLiquidityLevel{}, err + return map[lightning.ChannelID]lightning.FeePPM{}, err } if daemon { cc, ce, err := r.l.SubscribeChannelUpdates(ctx) if err != nil { - return map[lightning.ChannelID]lightning.ChannelLiquidityLevel{}, err + return map[lightning.ChannelID]lightning.FeePPM{}, err } for { select { case channels = <-cc: - _, err = r.setFees(ctx, fees, channels) + _, err = r.setFees(ctx, channels) if err != nil { fmt.Fprintf(os.Stderr, "error setting fees %v\n", err) } @@ -315,44 +290,22 @@ func (r Raiju) Fees(ctx context.Context, fees LiquidityFees, daemon bool) (map[l } } - return c, nil + return updates, nil } // setFees on channels who's liquidity has changed, return updated channels and their new liquidity level. -func (r Raiju) setFees(ctx context.Context, fees LiquidityFees, channels lightning.Channels) (map[lightning.ChannelID]lightning.ChannelLiquidityLevel, error) { - updates := make(map[lightning.ChannelID]lightning.ChannelLiquidityLevel) +func (r Raiju) setFees(ctx context.Context, channels lightning.Channels) (map[lightning.ChannelID]lightning.FeePPM, error) { + updates := make(map[lightning.ChannelID]lightning.FeePPM) // update channel fees based on liquidity, but only change if necessary for _, c := range channels { - switch c.LiquidityLevel() { - case lightning.LowLiquidity: - if c.LocalFee != fees.Low() { - fmt.Fprintf(os.Stderr, "channel %s (%d) now has low liquidity %g, setting fee to %g\n", c.RemoteNode.Alias, c.ChannelID, c.Liquidity(), fees.Low()) - err := r.l.SetFees(ctx, c.ChannelID, fees.Low()) - if err != nil { - fmt.Fprintf(os.Stderr, "error updating fees %v\n", err) - } else { - updates[c.ChannelID] = lightning.LowLiquidity - } - } - case lightning.StandardLiquidity: - if c.LocalFee != fees.Standard() { - fmt.Fprintf(os.Stderr, "channel %s (%d) now has standard liquidity %g, setting fee to %g\n", c.RemoteNode.Alias, c.ChannelID, c.Liquidity(), fees.Standard()) - err := r.l.SetFees(ctx, c.ChannelID, fees.Standard()) - if err != nil { - fmt.Fprintf(os.Stderr, "error updating fees %v\n", err) - } else { - updates[c.ChannelID] = lightning.StandardLiquidity - } - } - case lightning.HighLiquidity: - if c.LocalFee != fees.High() { - fmt.Fprintf(os.Stderr, "channel %s (%d) now has high liquidity %g, setting fee to %g\n", c.RemoteNode.Alias, c.ChannelID, c.Liquidity(), fees.High()) - err := r.l.SetFees(ctx, c.ChannelID, fees.High()) - if err != nil { - fmt.Fprintf(os.Stderr, "error updating fees %v\n", err) - } else { - updates[c.ChannelID] = lightning.HighLiquidity - } + fee := r.f.Fee(c) + if c.LocalFee != fee { + fmt.Fprintf(os.Stderr, "channel %s (%d) now has liquidity %g, setting fee to %g\n", c.RemoteNode.Alias, c.ChannelID, c.Liquidity(), fee) + err := r.l.SetFees(ctx, c.ChannelID, fee) + if err != nil { + fmt.Fprintf(os.Stderr, "error updating fees %v\n", err) + } else { + updates[c.ChannelID] = fee } } } @@ -411,8 +364,7 @@ func (r Raiju) RebalanceAll(ctx context.Context, stepPercent float64, maxPercent return err } - hlcs := channels.HighLiquidity() - llcs := channels.LowLiquidity() + hlcs, llcs := r.f.RebalanceChannels(channels) // Shuffle arrays so different combos are tried rand.Shuffle(len(hlcs), func(i, j int) { @@ -444,7 +396,7 @@ func (r Raiju) RebalanceAll(ctx context.Context, stepPercent float64, maxPercent return err } potentialLocal := lightning.Satoshi(float64(h.Capacity) * maxPercent) - if ul.PotentialLiquidityLevel(potentialLocal) == lightning.LowLiquidity { + if r.f.PotentialFee(ul, potentialLocal) != r.f.Fee(ul) { // don't really care if error or not, just continue on p, f, _ := r.Rebalance(ctx, h.ChannelID, lastHopPubkey, stepPercent, (maxPercent - percentRebalanced), maxFee) percentRebalanced += p diff --git a/raiju_test.go b/raiju_test.go index 1a8ddb9..54c4d2d 100644 --- a/raiju_test.go +++ b/raiju_test.go @@ -306,149 +306,6 @@ func TestRaiju_Candidates(t *testing.T) { } } -func TestNew(t *testing.T) { - type args struct { - l lightninger - } - tests := []struct { - name string - args args - want Raiju - }{ - { - name: "happy init", - args: args{ - l: nil, - }, - want: Raiju{ - l: nil, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := New(tt.args.l); !reflect.DeepEqual(got, tt.want) { - t.Errorf("New() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestLiquidityFees_High(t *testing.T) { - type fields struct { - standard float64 - } - tests := []struct { - name string - fields fields - want lightning.FeePPM - }{ - { - name: "high fees should be 10%", - fields: fields{ - standard: 10, - }, - want: 1, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - l := LiquidityFees{ - standard: tt.fields.standard, - } - if got := l.High(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("LiquidityFees.High() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestLiquidityFees_Standard(t *testing.T) { - type fields struct { - standard float64 - } - tests := []struct { - name string - fields fields - want lightning.FeePPM - }{ - { - name: "standard fees should be 100%", - fields: fields{ - standard: 10, - }, - want: 10, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - l := LiquidityFees{ - standard: tt.fields.standard, - } - if got := l.Standard(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("LiquidityFees.Standard() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestLiquidityFees_Low(t *testing.T) { - type fields struct { - standard float64 - } - tests := []struct { - name string - fields fields - want lightning.FeePPM - }{ - { - name: "low fees should be 1000%", - fields: fields{ - standard: 10, - }, - want: 100, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - l := LiquidityFees{ - standard: tt.fields.standard, - } - if got := l.Low(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("LiquidityFees.Low() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestNewLiquidityFees(t *testing.T) { - type args struct { - standard float64 - } - tests := []struct { - name string - args args - want LiquidityFees - }{ - { - name: "happy init", - args: args{ - standard: 0, - }, - want: LiquidityFees{ - standard: 0, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NewLiquidityFees(tt.args.standard); !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewLiquidityFees() = %v, want %v", got, tt.want) - } - }) - } -} - func Test_sortDistance_Less(t *testing.T) { type args struct { i int @@ -988,17 +845,17 @@ func TestRaiju_Rebalance(t *testing.T) { func TestRaiju_Fees(t *testing.T) { type fields struct { l lightninger + f LiquidityFees } type args struct { ctx context.Context - fees LiquidityFees daemon bool } tests := []struct { name string fields fields args args - want map[lightning.ChannelID]lightning.ChannelLiquidityLevel + want map[lightning.ChannelID]lightning.FeePPM wantErr bool }{ { @@ -1047,15 +904,16 @@ func TestRaiju_Fees(t *testing.T) { return nil }, }, + f: LiquidityFees{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 10, 100}, + }, }, args: args{ - fees: LiquidityFees{ - standard: 10, - }, daemon: false, }, - want: map[lightning.ChannelID]lightning.ChannelLiquidityLevel{ - lightning.ChannelID(1): lightning.LowLiquidity, + want: map[lightning.ChannelID]lightning.FeePPM{ + lightning.ChannelID(1): lightning.FeePPM(100), }, wantErr: false, }, @@ -1064,8 +922,9 @@ func TestRaiju_Fees(t *testing.T) { t.Run(tt.name, func(t *testing.T) { r := Raiju{ l: tt.fields.l, + f: tt.fields.f, } - got, err := r.Fees(tt.args.ctx, tt.args.fees, tt.args.daemon) + got, err := r.Fees(tt.args.ctx, tt.args.daemon) if (err != nil) != tt.wantErr { t.Errorf("Raiju.Fees() error = %v, wantErr %v", err, tt.wantErr) return @@ -1076,3 +935,40 @@ func TestRaiju_Fees(t *testing.T) { }) } } + +func TestNew(t *testing.T) { + type args struct { + l lightninger + r LiquidityFees + } + tests := []struct { + name string + args args + want Raiju + }{ + { + name: "happy init", + args: args{ + l: nil, + r: LiquidityFees{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{}, + }, + }, + want: Raiju{ + l: nil, + f: LiquidityFees{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := New(tt.args.l, tt.args.r); !reflect.DeepEqual(got, tt.want) { + t.Errorf("New() = %v, want %v", got, tt.want) + } + }) + } +}