diff --git a/README.md b/README.md index fa06e49..dfe0fe4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ All of `raiju`'s commands can be listed with the global help flag, `raiju -h`, a List the best nodes to open a channel to from the current node. `candidates` does not automatically open any channels, that needs to be done out-of-band with a different tool such as `lncli`. `candidates` just lists suggestions and is not intended to be automated (for now...). -The current node has distance `0` to itself and distance `1` to the nodes it has channels with. A node with distance `2` has a channel with a node the current node is connected too, but no channel with the current node, and so on. "Distant Neighbors" are distant (greater than `2`) from the current node, but have a channel with the candidate. Theoretically, these most distant nodes with the most distant neighbor connections are the best to open a channel to for some off the beaten path efficient routing (vs. just connecting to the biggest node in the network). +The current node has distance `0` to itself and distance `1` to the nodes it has channels with. A node with distance `2` has a channel with a node the current node is connected too, but no channel with the current node, and so on. "Distant Neighbors" are distant (greater than `2`) from the current node, but have a channel with the candidate. By default, only nodes with clearnet addresses are listed. TOR-only nodes tend to be unreliable due to the nature of TOR. ``` $ raiju candidates @@ -45,7 +45,7 @@ Pubkey Alias The `assume` flag allows you to see the remaining candidates and updated stats assuming channels were opened to the given nodes. This can be used to find a set of nodes to open channels too in single batch transaction in order to minimize on onchain fees. -By default, only nodes with clearnet addresses are listed. TOR-only nodes tend to be unreliable due to the nature of TOR. +From a "make money routing" perspective, theoretically, these most distant nodes with the most distant neighbor connections are good to open a channel to for some off the beaten path efficient routing vs. just connecting to the biggest node in the network. Your node could offer cheaper, better routing between two "clusters" of nodes than the biggest nodes. From a "make the network stronger in general" perspective, the hope is that this strategy creates a more decentralized network vs. everything being dependent on a handful of large hub nodes. ## fees @@ -55,7 +55,9 @@ Set channel fees based on the channel's current liquidity. The idea here is to e 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. -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. +The `-liquidity-stickiness` attempts to avoid extra gossip by waiting for channels to return to a healthier liquidity state before changing fees. If using the same settings as before, plus a stickiness setting of 5%, if a channel moves from 19% liquidity to 23% liquidity it will still have a 500 PPM fee. It needs to move to something better than 25% (20% + 5%) before the fee will change. The stickiness setting only applies to liquidity moving in a healthy (towards center) direction. If you are drastically changing your fee settings, you probably want to set stickiness to 0 temporarily to ensure fees are updated. + +The `-liquidity-thresholds`, `-liquidity-fees`, and `-liquidity-stickiness` 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. 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). diff --git a/cmd/raiju/raiju.go b/cmd/raiju/raiju.go index 1560e83..bcdd1a7 100644 --- a/cmd/raiju/raiju.go +++ b/cmd/raiju/raiju.go @@ -26,7 +26,7 @@ const ( rpcTimeout = time.Minute * 5 ) -func parseFees(thresholds string, fees string) (raiju.LiquidityFees, error) { +func parseFees(thresholds string, fees string, stickiness float64) (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)) @@ -48,7 +48,7 @@ func parseFees(thresholds string, fees string) (raiju.LiquidityFees, error) { ffs[i] = lightning.FeePPM(ff) } - lf, err := raiju.NewLiquidityFees(tfs, ffs) + lf, err := raiju.NewLiquidityFees(tfs, ffs, stickiness) if err != nil { return raiju.LiquidityFees{}, err } @@ -76,8 +76,9 @@ func main() { macPath := rootFlagSet.String("mac-path", "", "Macaroon with necessary permissions for lnd node") network := rootFlagSet.String("network", "mainnet", "The bitcoin network") // fees flags - liquidityThresholds := rootFlagSet.String("liquidity-thresholds", "80,20", "Comma separated local liquidity percent thresholds") + liquidityThresholds := rootFlagSet.String("liquidity-thresholds", "85,15", "Comma separated local liquidity percent thresholds") liquidityFees := rootFlagSet.String("liquidity-fees", "5,50,500", "Comma separated local liquidity-based fees PPM") + liquidityStickiness := rootFlagSet.Float64("liquidity-stickiness", 0, "Percent of a channel capacity beyond threshold to wait before changing fees from settings attempting to improve liquidity") candidatesFlagSet := flag.NewFlagSet("candidates", flag.ExitOnError) minCapacity := candidatesFlagSet.Int64("min-capacity", 1000000, "Minimum capacity of a node in satoshis") @@ -118,7 +119,7 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - f, err := parseFees(*liquidityThresholds, *liquidityFees) + f, err := parseFees(*liquidityThresholds, *liquidityFees, *liquidityStickiness) if err != nil { return err } @@ -178,7 +179,7 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - f, err := parseFees(*liquidityThresholds, *liquidityFees) + f, err := parseFees(*liquidityThresholds, *liquidityFees, *liquidityStickiness) if err != nil { return err } @@ -235,7 +236,7 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - f, err := parseFees(*liquidityThresholds, *liquidityFees) + f, err := parseFees(*liquidityThresholds, *liquidityFees, *liquidityStickiness) if err != nil { return err } @@ -285,7 +286,7 @@ func main() { } c := lnd.New(services.Client, services.Client, services.Router, *network) - f, err := parseFees(*liquidityThresholds, *liquidityFees) + f, err := parseFees(*liquidityThresholds, *liquidityFees, *liquidityStickiness) if err != nil { return err } diff --git a/fees.go b/fees.go index 6132b93..ea53970 100644 --- a/fees.go +++ b/fees.go @@ -16,23 +16,24 @@ import ( type LiquidityFees struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } // 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) + return lf.findFee(liquidity, channel.LocalFee) } // 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) + return lf.findFee(liquidity, channel.LocalFee) } -func (lf LiquidityFees) findFee(liquidity float64) lightning.FeePPM { +func (lf LiquidityFees) findFee(liquidity float64, currentFee lightning.FeePPM) lightning.FeePPM { bucket := 0 for bucket < len(lf.thresholds) { if liquidity > lf.thresholds[bucket] { @@ -43,7 +44,42 @@ func (lf LiquidityFees) findFee(liquidity float64) lightning.FeePPM { } - return lf.fees[bucket] + newFee := lf.fees[bucket] + + // apply stickiness if fee is heading in the right direction, but wanna hold on for a bit to limit gossip + if liquidity < 50 && newFee < currentFee { + lowBucket := 0 + for lowBucket < len(lf.thresholds) { + if liquidity > lf.thresholds[lowBucket]+lf.stickiness { + break + } else { + lowBucket += 1 + } + + } + + if lowBucket != bucket { + fmt.Fprintf(os.Stderr, "keeping fee due to stickiness") + } + newFee = lf.fees[lowBucket] + } else if liquidity >= 50 && newFee > currentFee { + highBucket := 0 + for highBucket < len(lf.thresholds) { + if liquidity > lf.thresholds[highBucket]-lf.stickiness { + break + } else { + highBucket += 1 + } + + } + + if highBucket != bucket { + fmt.Fprintf(os.Stderr, "keeping fee due to stickiness") + } + newFee = lf.fees[highBucket] + } + + return newFee } // RebalanceChannels at the far ends of the spectrum. @@ -81,7 +117,7 @@ func (lf LiquidityFees) PrintSettings() { } // NewLiquidityFees with threshold and fee validation. -func NewLiquidityFees(thresholds []float64, fees []lightning.FeePPM) (LiquidityFees, error) { +func NewLiquidityFees(thresholds []float64, fees []lightning.FeePPM, stickiness float64) (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") @@ -102,8 +138,14 @@ func NewLiquidityFees(thresholds []float64, fees []lightning.FeePPM) (LiquidityF } } + // ensure stickiness percent makes sense + if stickiness > 100 { + return LiquidityFees{}, errors.New("stickiness must be a percent") + } + return LiquidityFees{ thresholds: thresholds, fees: fees, + stickiness: stickiness, }, nil } diff --git a/fees_test.go b/fees_test.go index 836e1ae..9535734 100644 --- a/fees_test.go +++ b/fees_test.go @@ -11,6 +11,7 @@ func TestLiquidityFees_Fee(t *testing.T) { type fields struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } type args struct { channel lightning.Channel @@ -26,6 +27,7 @@ func TestLiquidityFees_Fee(t *testing.T) { fields: fields{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, args: args{ channel: lightning.Channel{ @@ -48,12 +50,97 @@ func TestLiquidityFees_Fee(t *testing.T) { }, want: lightning.FeePPM(500), }, + { + name: "grab fee based on liquidity and beyond stickiness", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 5, + }, + args: args{ + channel: lightning.Channel{ + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 3, + LocalFee: 500, + RemoteBalance: 7, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + want: lightning.FeePPM(50), + }, + { + name: "get same high fee based on stickiness", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 15, + }, + args: args{ + channel: lightning.Channel{ + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 3, + LocalFee: 500, + RemoteBalance: 7, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + want: lightning.FeePPM(500), + }, + { + name: "get same high fee based on stickiness", + fields: fields{ + thresholds: []float64{80, 20}, + fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 15, + }, + args: args{ + channel: lightning.Channel{ + Edge: lightning.Edge{ + Capacity: 10, + Node1: "A", + Node2: "B", + }, + ChannelID: 1, + LocalBalance: 7, + LocalFee: 5, + RemoteBalance: 3, + RemoteNode: lightning.Node{ + PubKey: pubKeyB, + Alias: "B", + Updated: updated, + Addresses: []string{clearnetAddress}, + }, + }, + }, + want: lightning.FeePPM(5), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { lf := LiquidityFees{ thresholds: tt.fields.thresholds, fees: tt.fields.fees, + stickiness: tt.fields.stickiness, } if got := lf.Fee(tt.args.channel); !reflect.DeepEqual(got, tt.want) { t.Errorf("LiquidityFees.Fee() = %v, want %v", got, tt.want) @@ -66,6 +153,7 @@ func TestLiquidityFees_PotentialFee(t *testing.T) { type fields struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } type args struct { channel lightning.Channel @@ -82,6 +170,7 @@ func TestLiquidityFees_PotentialFee(t *testing.T) { fields: fields{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, args: args{ additionalLocal: lightning.Satoshi(3), @@ -111,6 +200,7 @@ func TestLiquidityFees_PotentialFee(t *testing.T) { lf := LiquidityFees{ thresholds: tt.fields.thresholds, fees: tt.fields.fees, + stickiness: tt.fields.stickiness, } 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) @@ -123,6 +213,7 @@ func TestLiquidityFees_RebalanceChannels(t *testing.T) { type fields struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } type args struct { channels lightning.Channels @@ -139,6 +230,7 @@ func TestLiquidityFees_RebalanceChannels(t *testing.T) { fields: fields{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, args: args{ channels: lightning.Channels{ @@ -240,6 +332,7 @@ func TestLiquidityFees_RebalanceChannels(t *testing.T) { lf := LiquidityFees{ thresholds: tt.fields.thresholds, fees: tt.fields.fees, + stickiness: tt.fields.stickiness, } gotHigh, gotLow := lf.RebalanceChannels(tt.args.channels) if !reflect.DeepEqual(gotHigh, tt.wantHigh) { @@ -256,6 +349,7 @@ func TestLiquidityFees_RebalanceFee(t *testing.T) { type fields struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } tests := []struct { name string @@ -267,6 +361,7 @@ func TestLiquidityFees_RebalanceFee(t *testing.T) { fields: fields{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, want: 500, }, @@ -276,6 +371,7 @@ func TestLiquidityFees_RebalanceFee(t *testing.T) { lf := LiquidityFees{ thresholds: tt.fields.thresholds, fees: tt.fields.fees, + stickiness: tt.fields.stickiness, } if got := lf.RebalanceFee(); !reflect.DeepEqual(got, tt.want) { t.Errorf("LiquidityFees.RebalanceFee() = %v, want %v", got, tt.want) @@ -288,6 +384,7 @@ func TestNewLiquidityFees(t *testing.T) { type args struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } tests := []struct { name string @@ -300,10 +397,12 @@ func TestNewLiquidityFees(t *testing.T) { args: args{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, want: LiquidityFees{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, wantErr: false, }, @@ -312,6 +411,7 @@ func TestNewLiquidityFees(t *testing.T) { args: args{ thresholds: []float64{80, 60, 20}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, wantErr: true, }, @@ -320,6 +420,7 @@ func TestNewLiquidityFees(t *testing.T) { args: args{ thresholds: []float64{80, 85}, fees: []lightning.FeePPM{5, 50, 500}, + stickiness: 0, }, wantErr: true, }, @@ -328,13 +429,14 @@ func TestNewLiquidityFees(t *testing.T) { args: args{ thresholds: []float64{80, 20}, fees: []lightning.FeePPM{5, 2, 500}, + stickiness: 0, }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewLiquidityFees(tt.args.thresholds, tt.args.fees) + got, err := NewLiquidityFees(tt.args.thresholds, tt.args.fees, tt.args.stickiness) if (err != nil) != tt.wantErr { t.Errorf("NewLiquidityFees() error = %v, wantErr %v", err, tt.wantErr) return @@ -350,6 +452,7 @@ func TestLiquidityFees_PrintSettings(t *testing.T) { type fields struct { thresholds []float64 fees []lightning.FeePPM + stickiness float64 } tests := []struct { name string @@ -362,6 +465,7 @@ func TestLiquidityFees_PrintSettings(t *testing.T) { lf := LiquidityFees{ thresholds: tt.fields.thresholds, fees: tt.fields.fees, + stickiness: tt.fields.stickiness, } lf.PrintSettings() })