From 00c2d4e5f0a9d299d70e0ca12e8d22a74d1e33b6 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:26 +0200 Subject: [PATCH 1/8] liquidity: generalize threshold rule variable names The current wording in this function is very specific to loop out. In this commit, we refactor to use `target` and `reserve` rather than inbound and outbound so that it can be used more generically. --- liquidity/threshold_rule.go | 92 ++++++++++++++++++++------------ liquidity/threshold_rule_test.go | 17 +++--- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/liquidity/threshold_rule.go b/liquidity/threshold_rule.go index 47ef735..2db9f6c 100644 --- a/liquidity/threshold_rule.go +++ b/liquidity/threshold_rule.go @@ -65,73 +65,99 @@ func (r *ThresholdRule) validate() error { // swapAmount suggests a swap based on the liquidity thresholds configured, // returning zero if no swap is recommended. func (r *ThresholdRule) swapAmount(channel *balances, - outRestrictions *Restrictions) btcutil.Amount { + restrictions *Restrictions) btcutil.Amount { + + var ( + // For loop out swaps, we want to adjust our incoming liquidity + // so the channel's incoming balance is our target. + targetBalance = channel.incoming + + // For loop out swaps, we target a minimum amount of incoming + // liquidity, so the minimum incoming threshold is our target + // percentage. + targetPercentage = uint64(r.MinimumIncoming) + + // For loop out swaps, we may want to preserve some of our + // outgoing balance, so the channel's outgoing balance is our + // reserve. + reserveBalance = channel.outgoing + + // For loop out swaps, we may want to preserve some percentage + // of our outgoing balance, so the minimum outgoing threshold + // is our reserve percentage. + reservePercentage = uint64(r.MinimumOutgoing) + ) // Examine our total balance and required ratios to decide whether we // need to swap. - amount := loopOutSwapAmount( - channel, r.MinimumIncoming, r.MinimumOutgoing, + amount := calculateSwapAmount( + targetBalance, reserveBalance, channel.capacity, + targetPercentage, reservePercentage, ) // Limit our swap amount by the minimum/maximum thresholds set. switch { - case amount < outRestrictions.Minimum: + case amount < restrictions.Minimum: return 0 - case amount > outRestrictions.Maximum: - return outRestrictions.Maximum + case amount > restrictions.Maximum: + return restrictions.Maximum default: return amount } } -// loopOutSwapAmount determines whether we can perform a loop out swap, and -// returns the amount we need to swap to reach the desired liquidity balance -// specified by the incoming and outgoing thresholds. -func loopOutSwapAmount(balances *balances, incomingThresholdPercent, - outgoingThresholdPercent int) btcutil.Amount { - - minimumIncoming := btcutil.Amount(uint64( - balances.capacity) * - uint64(incomingThresholdPercent) / 100, +// calculateSwapAmount calculates amount for a swap based on thresholds. +// This function can be used for loop out or loop in, but the concept is the +// same - we want liquidity in one (target) direction, while preserving some +// minimum in the other (reserve) direction. +// * target: this is the side of the channel(s) where we want to acquire some +// liquidity. We aim for this liquidity to reach the threshold amount set. +// * reserve: this is the side of the channel(s) that we will move liquidity +// away from. This may not drop below a certain reserve threshold. +func calculateSwapAmount(targetAmount, reserveAmount, + capacity btcutil.Amount, targetThresholdPercentage, + reserveThresholdPercentage uint64) btcutil.Amount { + + targetGoal := btcutil.Amount( + uint64(capacity) * targetThresholdPercentage / 100, ) - minimumOutgoing := btcutil.Amount( - uint64(balances.capacity) * - uint64(outgoingThresholdPercent) / 100, + reserveMinimum := btcutil.Amount( + uint64(capacity) * reserveThresholdPercentage / 100, ) switch { - // If we have sufficient incoming capacity, we do not need to loop out. - case balances.incoming >= minimumIncoming: + // If we have sufficient target capacity, we do not need to swap. + case targetAmount >= targetGoal: return 0 - // If we are already below the threshold set for outgoing capacity, we + // If we are already below the threshold set for reserve capacity, we // cannot take any further action. - case balances.outgoing <= minimumOutgoing: + case reserveAmount <= reserveMinimum: return 0 } - // Express our minimum outgoing amount as a maximum incoming amount. + // Express our minimum reserve amount as a maximum target amount. // We will use this value to limit the amount that we swap, so that we - // do not dip below our outgoing threshold. - maximumIncoming := balances.capacity - minimumOutgoing + // do not dip below our reserve threshold. + maximumTarget := capacity - reserveMinimum - // Calculate the midpoint between our minimum and maximum incoming - // values. We will aim to swap this amount so that we do not tip our - // outgoing balance beneath the desired level. - midpoint := (minimumIncoming + maximumIncoming) / 2 + // Calculate the midpoint between our minimum and maximum target values. + // We will aim to swap this amount so that we do not tip our reserve + // balance beneath the desired level. + midpoint := (targetGoal + maximumTarget) / 2 - // Calculate the amount of incoming balance we need to shift to reach + // Calculate the amount of target balance we need to shift to reach // this desired midpoint. - required := midpoint - balances.incoming + required := midpoint - targetAmount // Since we can have pending htlcs on our channel, we check the amount - // of outbound capacity that we can shift before we fall below our + // of reserve capacity that we can shift before we fall below our // threshold. - available := balances.outgoing - minimumOutgoing + available := reserveAmount - reserveMinimum // If we do not have enough balance available to reach our midpoint, we // take no action. This is the case when we have a large portion of diff --git a/liquidity/threshold_rule_test.go b/liquidity/threshold_rule_test.go index b90c6c6..a460def 100644 --- a/liquidity/threshold_rule_test.go +++ b/liquidity/threshold_rule_test.go @@ -93,18 +93,18 @@ func TestValidateThreshold(t *testing.T) { } } -// TestLoopOutAmount tests assessing of a set of balances to determine whether -// we should perform a loop out. -func TestLoopOutAmount(t *testing.T) { +// TestCalculateAmount tests calculation of the amount we recommend for a given +// set of balances and threshold rule. +func TestCalculateAmount(t *testing.T) { tests := []struct { name string - minIncoming int - minOutgoing int + minIncoming uint64 + minOutgoing uint64 balances *balances amt btcutil.Amount }{ { - name: "insufficient surplus", + name: "insufficient outgoing", balances: &balances{ capacity: 100, incoming: 20, @@ -166,8 +166,9 @@ func TestLoopOutAmount(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - amt := loopOutSwapAmount( - test.balances, test.minIncoming, + amt := calculateSwapAmount( + test.balances.incoming, test.balances.outgoing, + test.balances.capacity, test.minIncoming, test.minOutgoing, ) require.Equal(t, test.amt, amt) From 25b8d20f7538e2abefb103709cd281a7bf6c8052 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:27 +0200 Subject: [PATCH 2/8] liquidity: add type to rules In preparation for adding loop in swaps, we relate liquidity rules to a specific type of swap that we want to dispatch. This allows us to use a single rule format for multiple swap types. --- liquidity/autoloop_test.go | 8 +-- liquidity/liquidity.go | 14 +++--- liquidity/liquidity_test.go | 98 +++++++++++++++++++++++++------------ liquidity/threshold_rule.go | 7 +++ loopd/swapclient_server.go | 19 ++++--- 5 files changed, 95 insertions(+), 51 deletions(-) diff --git a/liquidity/autoloop_test.go b/liquidity/autoloop_test.go index 0cb6480..72e0e5a 100644 --- a/liquidity/autoloop_test.go +++ b/liquidity/autoloop_test.go @@ -27,7 +27,7 @@ func TestAutoLoopDisabled(t *testing.T) { } params := defaultParameters - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } @@ -95,7 +95,7 @@ func TestAutoLoopEnabled(t *testing.T) { swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner, prepayAmount, 20000, ), - ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ + ChannelRules: map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, }, @@ -312,10 +312,10 @@ func TestCompositeRules(t *testing.T) { MaxAutoInFlight: 2, FailureBackOff: time.Hour, SweepConfTarget: 10, - ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ + ChannelRules: map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, }, - PeerRules: map[route.Vertex]*ThresholdRule{ + PeerRules: map[route.Vertex]*SwapRule{ peer2: chanRule, }, } diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index f1bfe0c..e2102c9 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -97,8 +97,8 @@ var ( defaultParameters = Parameters{ AutoFeeBudget: defaultBudget, MaxAutoInFlight: defaultMaxInFlight, - ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), - PeerRules: make(map[route.Vertex]*ThresholdRule), + ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule), + PeerRules: make(map[route.Vertex]*SwapRule), FailureBackOff: defaultFailureBackoff, SweepConfTarget: defaultConfTarget, FeeLimit: defaultFeePortion(), @@ -216,13 +216,13 @@ type Parameters struct { // ChannelRules maps a short channel ID to a rule that describes how we // would like liquidity to be managed. These rules and PeerRules are // exclusively set to prevent overlap between peer and channel rules. - ChannelRules map[lnwire.ShortChannelID]*ThresholdRule + ChannelRules map[lnwire.ShortChannelID]*SwapRule // PeerRules maps a peer's pubkey to a rule that applies to all the // channels that we have with the peer collectively. These rules and // ChannelRules are exclusively set to prevent overlap between peer // and channel rules map to avoid ambiguity. - PeerRules map[route.Vertex]*ThresholdRule + PeerRules map[route.Vertex]*SwapRule } // String returns the string representation of our parameters. @@ -473,7 +473,7 @@ func (m *Manager) SetParameters(ctx context.Context, params Parameters) error { func cloneParameters(params Parameters) Parameters { paramCopy := params paramCopy.ChannelRules = make( - map[lnwire.ShortChannelID]*ThresholdRule, + map[lnwire.ShortChannelID]*SwapRule, len(params.ChannelRules), ) @@ -483,7 +483,7 @@ func cloneParameters(params Parameters) Parameters { } paramCopy.PeerRules = make( - map[route.Vertex]*ThresholdRule, + map[route.Vertex]*SwapRule, len(params.PeerRules), ) @@ -841,7 +841,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( // suggestSwap checks whether we can currently perform a swap, and creates a // swap request for the rule provided. func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, - balance *balances, rule *ThresholdRule, restrictions *Restrictions, + balance *balances, rule *SwapRule, restrictions *Restrictions, autoloop bool) (swapSuggestion, error) { // First, check whether this peer/channel combination is already in use diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index ccded3a..d2c2f13 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -47,7 +47,10 @@ var ( } // chanRule is a rule that produces chan1Rec. - chanRule = NewThresholdRule(50, 0) + chanRule = &SwapRule{ + ThresholdRule: NewThresholdRule(50, 0), + Type: swap.TypeOut, + } testQuote = &loop.LoopOutQuote{ SwapFee: btcutil.Amount(5), @@ -188,7 +191,10 @@ func TestParameters(t *testing.T) { require.Equal(t, defaultParameters, startParams) // Mutate the parameters returned by our get function. - startParams.ChannelRules[chanID] = NewThresholdRule(1, 1) + startParams.ChannelRules[chanID] = &SwapRule{ + ThresholdRule: NewThresholdRule(1, 1), + Type: swap.TypeOut, + } // Make sure that we have not mutated the liquidity manager's params // by making this change. @@ -197,9 +203,13 @@ func TestParameters(t *testing.T) { // Provide a valid set of parameters and validate assert that they are // set. - originalRule := NewThresholdRule(10, 10) + originalRule := &SwapRule{ + ThresholdRule: NewThresholdRule(10, 10), + Type: swap.TypeOut, + } + expected := defaultParameters - expected.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID: originalRule, } @@ -208,15 +218,21 @@ func TestParameters(t *testing.T) { // Check that changing the parameters we just set does not mutate // our liquidity manager's parameters. - expected.ChannelRules[chanID] = NewThresholdRule(11, 11) + expected.ChannelRules[chanID] = &SwapRule{ + ThresholdRule: NewThresholdRule(11, 11), + Type: swap.TypeOut, + } params = manager.GetParameters() require.NoError(t, err) require.Equal(t, originalRule, params.ChannelRules[chanID]) // Set invalid parameters and assert that we fail. - expected.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ - lnwire.NewShortChanIDFromInt(0): NewThresholdRule(1, 2), + expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ + lnwire.NewShortChanIDFromInt(0): { + ThresholdRule: NewThresholdRule(1, 2), + Type: swap.TypeOut, + }, } err = manager.SetParameters(context.Background(), expected) require.Equal(t, ErrZeroChannelID, err) @@ -310,7 +326,7 @@ func TestRestrictedSuggestions(t *testing.T) { ), } - chanRules = map[lnwire.ShortChannelID]*ThresholdRule{ + chanRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } @@ -321,8 +337,8 @@ func TestRestrictedSuggestions(t *testing.T) { channels []lndclient.ChannelInfo loopOut []*loopdb.LoopOut loopIn []*loopdb.LoopIn - chanRules map[lnwire.ShortChannelID]*ThresholdRule - peerRules map[route.Vertex]*ThresholdRule + chanRules map[lnwire.ShortChannelID]*SwapRule + peerRules map[route.Vertex]*SwapRule expected *Suggestions }{ { @@ -511,8 +527,11 @@ func TestRestrictedSuggestions(t *testing.T) { Contract: chan1Out, }, }, - peerRules: map[route.Vertex]*ThresholdRule{ - peer1: NewThresholdRule(0, 50), + peerRules: map[route.Vertex]*SwapRule{ + peer1: { + ThresholdRule: NewThresholdRule(0, 50), + Type: swap.TypeOut, + }, }, expected: &Suggestions{ DisqualifiedChans: noneDisqualified, @@ -629,7 +648,7 @@ func TestSweepFeeLimit(t *testing.T) { ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultRoutingFeePPM) - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } @@ -654,21 +673,21 @@ func TestSuggestSwaps(t *testing.T) { tests := []struct { name string channels []lndclient.ChannelInfo - rules map[lnwire.ShortChannelID]*ThresholdRule - peerRules map[route.Vertex]*ThresholdRule + rules map[lnwire.ShortChannelID]*SwapRule + peerRules map[route.Vertex]*SwapRule suggestions *Suggestions err error }{ { name: "no rules", channels: singleChannel, - rules: map[lnwire.ShortChannelID]*ThresholdRule{}, + rules: map[lnwire.ShortChannelID]*SwapRule{}, err: ErrNoRules, }, { name: "loop out", channels: singleChannel, - rules: map[lnwire.ShortChannelID]*ThresholdRule{ + rules: map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, }, suggestions: &Suggestions{ @@ -682,8 +701,11 @@ func TestSuggestSwaps(t *testing.T) { { name: "no rule for channel", channels: singleChannel, - rules: map[lnwire.ShortChannelID]*ThresholdRule{ - chanID2: NewThresholdRule(10, 10), + rules: map[lnwire.ShortChannelID]*SwapRule{ + chanID2: { + ThresholdRule: NewThresholdRule(10, 10), + Type: swap.TypeOut, + }, }, suggestions: &Suggestions{ DisqualifiedChans: noneDisqualified, @@ -715,9 +737,15 @@ func TestSuggestSwaps(t *testing.T) { RemoteBalance: 3000, }, }, - peerRules: map[route.Vertex]*ThresholdRule{ - peer1: NewThresholdRule(80, 0), - peer2: NewThresholdRule(40, 50), + peerRules: map[route.Vertex]*SwapRule{ + peer1: { + ThresholdRule: NewThresholdRule(80, 0), + Type: swap.TypeOut, + }, + peer2: { + ThresholdRule: NewThresholdRule(40, 50), + Type: swap.TypeOut, + }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ @@ -869,7 +897,7 @@ func TestFeeLimits(t *testing.T) { ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultRoutingFeePPM) - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } @@ -1061,7 +1089,7 @@ func TestFeeBudget(t *testing.T) { } params := defaultParameters - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } @@ -1100,7 +1128,7 @@ func TestInFlightLimit(t *testing.T) { existingSwaps []*loopdb.LoopOut // peerRules will only be set (instead of test default values) // is it is non-nil. - peerRules map[route.Vertex]*ThresholdRule + peerRules map[route.Vertex]*SwapRule suggestions *Suggestions }{ { @@ -1189,9 +1217,15 @@ func TestInFlightLimit(t *testing.T) { // Create two peer-level rules, both in need of a swap, // but peer 1 needs a larger swap so will be // prioritized. - peerRules: map[route.Vertex]*ThresholdRule{ - peer1: NewThresholdRule(50, 0), - peer2: NewThresholdRule(40, 0), + peerRules: map[route.Vertex]*SwapRule{ + peer1: { + ThresholdRule: NewThresholdRule(50, 0), + Type: swap.TypeOut, + }, + peer2: { + ThresholdRule: NewThresholdRule(40, 0), + Type: swap.TypeOut, + }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ @@ -1224,7 +1258,7 @@ func TestInFlightLimit(t *testing.T) { params.PeerRules = testCase.peerRules } else { params.ChannelRules = - map[lnwire.ShortChannelID]*ThresholdRule{ + map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } @@ -1364,7 +1398,7 @@ func TestSizeRestrictions(t *testing.T) { params := defaultParameters params.ClientRestrictions = testCase.clientRestrictions - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } @@ -1522,7 +1556,7 @@ func TestFeePercentage(t *testing.T) { params := defaultParameters params.FeeLimit = NewFeePortion(testCase.feePPM) - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } @@ -1572,7 +1606,7 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup, } params := defaultParameters - params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } diff --git a/liquidity/threshold_rule.go b/liquidity/threshold_rule.go index 2db9f6c..8c09fe3 100644 --- a/liquidity/threshold_rule.go +++ b/liquidity/threshold_rule.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop/swap" ) var ( @@ -19,6 +20,12 @@ var ( "percentages must be < 100") ) +// SwapRule is a liquidity rule with a specific swap type. +type SwapRule struct { + *ThresholdRule + swap.Type +} + // ThresholdRule is a liquidity rule that implements minimum incoming and // outgoing liquidity threshold. type ThresholdRule struct { diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 9318bca..b2eb185 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -750,7 +750,7 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context, } func newRPCRule(channelID uint64, peer []byte, - rule *liquidity.ThresholdRule) *looprpc.LiquidityRule { + rule *liquidity.SwapRule) *looprpc.LiquidityRule { return &looprpc.LiquidityRule{ ChannelId: channelID, @@ -781,10 +781,10 @@ func (s *swapClientServer) SetLiquidityParams(ctx context.Context, AutoFeeBudget: btcutil.Amount(in.Parameters.AutoloopBudgetSat), MaxAutoInFlight: int(in.Parameters.AutoMaxInFlight), ChannelRules: make( - map[lnwire.ShortChannelID]*liquidity.ThresholdRule, + map[lnwire.ShortChannelID]*liquidity.SwapRule, ), PeerRules: make( - map[route.Vertex]*liquidity.ThresholdRule, + map[route.Vertex]*liquidity.SwapRule, ), ClientRestrictions: liquidity.Restrictions{ Minimum: btcutil.Amount(in.Parameters.MinSwapAmount), @@ -890,16 +890,19 @@ func rpcToFee(req *looprpc.LiquidityParameters) (liquidity.FeeLimit, } // rpcToRule switches on rpc rule type to convert to our rule interface. -func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.ThresholdRule, error) { +func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.SwapRule, error) { switch rule.Type { case looprpc.LiquidityRuleType_UNKNOWN: return nil, fmt.Errorf("rule type field must be set") case looprpc.LiquidityRuleType_THRESHOLD: - return liquidity.NewThresholdRule( - int(rule.IncomingThreshold), - int(rule.OutgoingThreshold), - ), nil + return &liquidity.SwapRule{ + ThresholdRule: liquidity.NewThresholdRule( + int(rule.IncomingThreshold), + int(rule.OutgoingThreshold), + ), + Type: swap.TypeOut, + }, nil default: return nil, fmt.Errorf("unknown rule: %T", rule) From 91a90968d0e5754007f4dac1e3060d61117a1c74 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:28 +0200 Subject: [PATCH 3/8] multi: surface swap type on rpc --- cmd/loop/liquidity.go | 1 + loopd/swapclient_server.go | 16 ++++++- looprpc/client.pb.go | 93 +++++++++++++++++++++---------------- looprpc/client.proto | 3 ++ looprpc/client.swagger.json | 4 ++ 5 files changed, 75 insertions(+), 42 deletions(-) diff --git a/cmd/loop/liquidity.go b/cmd/loop/liquidity.go index 50e5121..5413b21 100644 --- a/cmd/loop/liquidity.go +++ b/cmd/loop/liquidity.go @@ -168,6 +168,7 @@ func setRule(ctx *cli.Context) error { newRule := &looprpc.LiquidityRule{ ChannelId: chanID, Type: looprpc.LiquidityRuleType_THRESHOLD, + SwapType: looprpc.SwapType_LOOP_OUT, } if pubkeyRule { diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index b2eb185..752c2ce 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -752,13 +752,20 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context, func newRPCRule(channelID uint64, peer []byte, rule *liquidity.SwapRule) *looprpc.LiquidityRule { - return &looprpc.LiquidityRule{ + rpcRule := &looprpc.LiquidityRule{ ChannelId: channelID, Pubkey: peer, Type: looprpc.LiquidityRuleType_THRESHOLD, IncomingThreshold: uint32(rule.MinimumIncoming), OutgoingThreshold: uint32(rule.MinimumOutgoing), + SwapType: looprpc.SwapType_LOOP_OUT, } + + if rule.Type == swap.TypeIn { + rpcRule.SwapType = looprpc.SwapType_LOOP_IN + } + + return rpcRule } // SetLiquidityParams attempts to set our current liquidity manager's @@ -891,6 +898,11 @@ func rpcToFee(req *looprpc.LiquidityParameters) (liquidity.FeeLimit, // rpcToRule switches on rpc rule type to convert to our rule interface. func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.SwapRule, error) { + swapType := swap.TypeOut + if rule.SwapType == looprpc.SwapType_LOOP_IN { + swapType = swap.TypeIn + } + switch rule.Type { case looprpc.LiquidityRuleType_UNKNOWN: return nil, fmt.Errorf("rule type field must be set") @@ -901,7 +913,7 @@ func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.SwapRule, error) { int(rule.IncomingThreshold), int(rule.OutgoingThreshold), ), - Type: swap.TypeOut, + Type: swapType, }, nil default: diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index f4c7258..a4f21e5 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -2299,6 +2299,8 @@ type LiquidityRule struct { //The short channel ID of the channel that this rule should be applied to. //This field may not be set when the pubkey field is set. ChannelId uint64 `protobuf:"varint,1,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + // The type of swap that will be dispatched for this rule. + SwapType SwapType `protobuf:"varint,6,opt,name=swap_type,json=swapType,proto3,enum=looprpc.SwapType" json:"swap_type,omitempty"` // //The public key of the peer that this rule should be applied to. This field //may not be set when the channel id field is set. @@ -2358,6 +2360,13 @@ func (x *LiquidityRule) GetChannelId() uint64 { return 0 } +func (x *LiquidityRule) GetSwapType() SwapType { + if x != nil { + return x.SwapType + } + return SwapType_LOOP_OUT +} + func (x *LiquidityRule) GetPubkey() []byte { if x != nil { return x.Pubkey @@ -2905,10 +2914,13 @@ var file_client_proto_rawDesc = []byte{ 0x6e, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x6d, 0x61, 0x78, 0x53, 0x77, 0x61, 0x70, 0x41, 0x6d, 0x6f, - 0x75, 0x6e, 0x74, 0x22, 0xd4, 0x01, 0x0a, 0x0d, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, + 0x75, 0x6e, 0x74, 0x22, 0x84, 0x02, 0x0a, 0x0d, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x63, 0x68, 0x61, 0x6e, 0x6e, - 0x65, 0x6c, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x05, + 0x65, 0x6c, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x09, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x11, 0x2e, 0x6c, 0x6f, 0x6f, 0x70, 0x72, 0x70, + 0x63, 0x2e, 0x53, 0x77, 0x61, 0x70, 0x54, 0x79, 0x70, 0x65, 0x52, 0x08, 0x73, 0x77, 0x61, 0x70, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x06, 0x70, 0x75, 0x62, 0x6b, 0x65, 0x79, 0x12, 0x2e, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e, 0x6c, 0x6f, 0x6f, 0x70, 0x72, 0x70, 0x63, 0x2e, 0x4c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x52, 0x75, @@ -3122,44 +3134,45 @@ var file_client_proto_depIdxs = []int32{ 32, // 5: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint 23, // 6: looprpc.TokensResponse.tokens:type_name -> looprpc.LsatToken 26, // 7: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule - 3, // 8: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 25, // 9: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 4, // 10: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 5, // 11: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 30, // 12: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 5, // 13: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 6, // 14: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 8, // 15: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 10, // 16: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 12, // 17: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 13, // 18: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 16, // 19: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 13, // 20: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 16, // 21: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 19, // 22: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 21, // 23: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 24, // 24: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 27, // 25: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 29, // 26: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 7, // 27: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 7, // 28: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 9, // 29: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 11, // 30: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 9, // 31: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 15, // 32: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 18, // 33: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 14, // 34: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 17, // 35: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 20, // 36: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 22, // 37: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 25, // 38: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 28, // 39: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 31, // 40: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 27, // [27:41] is the sub-list for method output_type - 13, // [13:27] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 0, // 8: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 3, // 9: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 25, // 10: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 4, // 11: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 5, // 12: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 30, // 13: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 5, // 14: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 6, // 15: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 8, // 16: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 10, // 17: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 12, // 18: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 13, // 19: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 16, // 20: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 13, // 21: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 16, // 22: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 19, // 23: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 21, // 24: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 24, // 25: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 27, // 26: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 29, // 27: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 7, // 28: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 7, // 29: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 9, // 30: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 11, // 31: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 9, // 32: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 15, // 33: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 18, // 34: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 14, // 35: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 17, // 36: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 20, // 37: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 22, // 38: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 25, // 39: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 28, // 40: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 31, // 41: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 28, // [28:42] is the sub-list for method output_type + 14, // [14:28] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_client_proto_init() } diff --git a/looprpc/client.proto b/looprpc/client.proto index f90d3f9..5d2dfee 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -843,6 +843,9 @@ message LiquidityRule { */ uint64 channel_id = 1; + // The type of swap that will be dispatched for this rule. + SwapType swap_type = 6; + /* The public key of the peer that this rule should be applied to. This field may not be set when the channel id field is set. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index 95a5288..11b2ad8 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -672,6 +672,10 @@ "format": "uint64", "description": "The short channel ID of the channel that this rule should be applied to.\nThis field may not be set when the pubkey field is set." }, + "swap_type": { + "$ref": "#/definitions/looprpcSwapType", + "description": "The type of swap that will be dispatched for this rule." + }, "pubkey": { "type": "string", "format": "byte", From 3e7782e1abcb28e4e8ea3f1fa4e7920645aa24b7 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:29 +0200 Subject: [PATCH 4/8] liquidity: use builder in single place In preparation of supporting multiple swap types, we move our swap builder into a single place, so that we can check our `maySwap` restriction per-swap (since we'll now have different checks for different swap types. To save ourselves from making multiple calls to the loop server for the restrictions placed on each swap type, we still pass a single set of restrictions in. --- liquidity/liquidity.go | 62 ++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index e2102c9..dd2cf3c 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -386,10 +386,6 @@ type Manager struct { // current liquidity balance. cfg *Config - // builder is the swap builder responsible for creating swaps of our - // chosen type for us. - builder swapBuilder - // params is the set of parameters we are currently using. These may be // updated at runtime. params Parameters @@ -428,9 +424,8 @@ func (m *Manager) Run(ctx context.Context) error { // NewManager creates a liquidity manager which has no rules set. func NewManager(cfg *Config) *Manager { return &Manager{ - cfg: cfg, - params: defaultParameters, - builder: newLoopOutBuilder(cfg), + cfg: cfg, + params: defaultParameters, } } @@ -617,23 +612,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( return m.singleReasonSuggestion(ReasonBudgetNotStarted), nil } - // Before we get any swap suggestions, we check what the current fee - // estimate is to sweep within our target number of confirmations. If - // This fee exceeds the fee limit we have set, we will not suggest any - // swaps at present. - if err := m.builder.maySwap(ctx, m.params); err != nil { - var reasonErr *reasonError - if errors.As(err, &reasonErr) { - return m.singleReasonSuggestion(reasonErr.reason), nil - - } - - return nil, err - } - - // Get the current server side restrictions, combined with the client - // set restrictions, if any. - restrictions, err := m.getSwapRestrictions(ctx, m.builder.swapType()) + // Get restrictions placed on swaps by the server. + outRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeOut) if err != nil { return nil, err } @@ -721,7 +701,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( } suggestion, err := m.suggestSwap( - ctx, traffic, balances, rule, restrictions, autoloop, + ctx, traffic, balances, rule, outRestrictions, + autoloop, ) var reasonErr *reasonError if errors.As(err, &reasonErr) { @@ -746,7 +727,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( } suggestion, err := m.suggestSwap( - ctx, traffic, balance, rule, restrictions, autoloop, + ctx, traffic, balance, rule, outRestrictions, + autoloop, ) var reasonErr *reasonError @@ -841,12 +823,34 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( // suggestSwap checks whether we can currently perform a swap, and creates a // swap request for the rule provided. func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, - balance *balances, rule *SwapRule, restrictions *Restrictions, + balance *balances, rule *SwapRule, outRestrictions *Restrictions, autoloop bool) (swapSuggestion, error) { + var ( + builder swapBuilder + restrictions *Restrictions + ) + + switch rule.Type { + case swap.TypeOut: + builder = newLoopOutBuilder(m.cfg) + restrictions = outRestrictions + + default: + return nil, fmt.Errorf("unsupported swap type: %v", rule.Type) + } + + // Before we get any swap suggestions, we check what the current fee + // estimate is to sweep within our target number of confirmations. If + // This fee exceeds the fee limit we have set, we will not suggest any + // swaps at present. + if err := builder.maySwap(ctx, m.params); err != nil { + return nil, err + } + // First, check whether this peer/channel combination is already in use // for our swap. - err := m.builder.inUse(traffic, balance.pubkey, balance.channels) + err := builder.inUse(traffic, balance.pubkey, balance.channels) if err != nil { return nil, err } @@ -858,7 +862,7 @@ func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic, return nil, newReasonError(ReasonLiquidityOk) } - return m.builder.buildSwap( + return builder.buildSwap( ctx, balance.pubkey, balance.channels, amount, autoloop, m.params, ) From c067169e6fa4557ce2639dfb4b502305e7a06352 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:30 +0200 Subject: [PATCH 5/8] liquidity: add loop fee estimation and swap interface impl Add an implementation of our swap interface which can be used for loop in, and fee estimation. For fee estimation, we always want to calculate worst case loop in fees, so that autoloop never goes over its budget. However, for loop in we can't estimate how much a timeout would cost, because we must sweep the output (can't set a limit like loop out), and fee estimation in a few hundred blocks (when we'd sweep the timeout) is totally unreliable. Instead, we use a high fee rate as our best-effort fee rate for the future. We can also be confident that that loop in swaps will succeed, since once the htlc is locked in, all that is required is for the server to sweep. --- liquidity/liquidity.go | 6 ++++ liquidity/loopin.go | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 liquidity/loopin.go diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index dd2cf3c..7e776ca 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -81,6 +81,12 @@ const ( // autoloopSwapInitiator is the value we send in the initiator field of // a swap request when issuing an automatic swap. autoloopSwapInitiator = "autoloop" + + // We use a static fee rate to estimate our sweep fee, because we + // can't realistically estimate what our fee estimate will be by the + // time we reach timeout. We set this to a high estimate so that we can + // account for worst-case fees, (1250 * 4 / 1000) = 50 sat/byte. + defaultLoopInSweepFee = chainfee.SatPerKWeight(1250) ) var ( diff --git a/liquidity/loopin.go b/liquidity/loopin.go new file mode 100644 index 0000000..5591e09 --- /dev/null +++ b/liquidity/loopin.go @@ -0,0 +1,82 @@ +package liquidity + +import ( + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" +) + +// Compile time assertion that loop in suggestions satisfy our interface. +var _ swapSuggestion = (*loopInSwapSuggestion)(nil) + +type loopInSwapSuggestion struct { + loop.LoopInRequest +} + +// amount returns the amount of the swap suggestion. +func (l *loopInSwapSuggestion) amount() btcutil.Amount { + return l.Amount +} + +// fees returns the highest fees that we could pay for the swap suggestion. +func (l *loopInSwapSuggestion) fees() btcutil.Amount { + return worstCaseInFees( + l.MaxMinerFee, l.MaxSwapFee, defaultLoopInSweepFee, + ) +} + +// channels returns no channels for loop in swap suggestions because we do not +// restrict loop in swaps by channel id. +func (l *loopInSwapSuggestion) channels() []lnwire.ShortChannelID { + return nil +} + +// peers returns the peer that a loop in swap is restricted to, if it is set. +func (l *loopInSwapSuggestion) peers(_ map[uint64]route.Vertex) []route.Vertex { + if l.LastHop == nil { + return nil + } + + return []route.Vertex{ + *l.LastHop, + } +} + +// worstCaseInFees returns the largest possible fees for a loop in swap. +func worstCaseInFees(maxMinerFee, swapFee btcutil.Amount, + sweepEst chainfee.SatPerKWeight) btcutil.Amount { + + failureFee := maxMinerFee + loopInSweepFee(sweepEst) + successFee := maxMinerFee + swapFee + + if failureFee > successFee { + return failureFee + } + + return successFee +} + +// loopInSweepFee provides an estimated fee for our sweep transaction, based +// on the fee rate provided. We can calculate our fees for htlcv2 and p2wkh +// timeout addresses because automated loop ins will be handled entirely by the +// client, so we know what types will be used. +func loopInSweepFee(fee chainfee.SatPerKWeight) btcutil.Amount { + var estimator input.TxWeightEstimator + + // We sweep loop in swaps to wpkh addresses provided by lnd. + estimator.AddP2WKHOutput() + + // Create a htlcv2, which is what all autoloops will use, so that we + // can get our maximum timeout witness size. + htlc := swap.HtlcScriptV2{} + maxSize := htlc.MaxTimeoutWitnessSize() + + estimator.AddWitnessInput(maxSize) + weight := int64(estimator.Weight()) + + return fee.FeeForWeight(weight) +} From 0f0be285998e8d4f675557a1a924c83d9ff55176 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:31 +0200 Subject: [PATCH 6/8] liquidity: add loop in fees to fee categories --- liquidity/fees.go | 60 ++++++++++++++++++++++++++++++++++++++++++ liquidity/interface.go | 5 ++++ 2 files changed, 65 insertions(+) diff --git a/liquidity/fees.go b/liquidity/fees.go index b3e3419..86dc527 100644 --- a/liquidity/fees.go +++ b/liquidity/fees.go @@ -223,6 +223,27 @@ func (f *FeeCategoryLimit) loopOutLimits(amount btcutil.Amount, return nil } +func (f *FeeCategoryLimit) loopInLimits(amount btcutil.Amount, + quote *loop.LoopInQuote) error { + + maxServerFee := ppmToSat(amount, f.MaximumSwapFeePPM) + if quote.SwapFee > maxServerFee { + log.Debugf("quoted swap fee: %v > maximum swap fee: %v", + quote.SwapFee, maxServerFee) + + return newReasonError(ReasonSwapFee) + } + + if quote.MinerFee > f.MaximumMinerFee { + log.Debugf("quoted miner fee: %v > maximum miner "+ + "fee: %v", quote.MinerFee, f.MaximumMinerFee) + + return newReasonError(ReasonMinerFee) + } + + return nil +} + // loopOutFees returns the prepay and routing and miner fees we are willing to // pay for a loop out swap. func (f *FeeCategoryLimit) loopOutFees(amount btcutil.Amount, @@ -384,3 +405,42 @@ func splitOffChain(available, prepayAmt, func scaleMinerFee(estimate btcutil.Amount) btcutil.Amount { return estimate * btcutil.Amount(minerMultiplier) } + +func (f *FeePortion) loopInLimits(amount btcutil.Amount, + quote *loop.LoopInQuote) error { + + // Calculate the total amount that this swap may spend in fees, as a + // portion of the swap amount. + totalFeeSpend := ppmToSat(amount, f.PartsPerMillion) + + // Check individual fee components so that we can give more specific + // feedback. + if quote.MinerFee > totalFeeSpend { + log.Debugf("miner fee: %v greater than fee limit: %v, at "+ + "%v ppm", quote.MinerFee, totalFeeSpend, + f.PartsPerMillion) + + return newReasonError(ReasonMinerFee) + } + + if quote.SwapFee > totalFeeSpend { + log.Debugf("swap fee: %v greater than fee limit: %v, at "+ + "%v ppm", quote.SwapFee, totalFeeSpend, + f.PartsPerMillion) + + return newReasonError(ReasonSwapFee) + } + + fees := worstCaseInFees( + quote.MinerFee, quote.SwapFee, defaultLoopInSweepFee, + ) + + if fees > totalFeeSpend { + log.Debugf("total fees for swap: %v > fee limit: %v, at "+ + "%v ppm", fees, totalFeeSpend, f.PartsPerMillion) + + return newReasonError(ReasonFeePPMInsufficient) + } + + return nil +} diff --git a/liquidity/interface.go b/liquidity/interface.go index 047fe97..40ad166 100644 --- a/liquidity/interface.go +++ b/liquidity/interface.go @@ -32,6 +32,11 @@ type FeeLimit interface { // a swap amount and quote. loopOutFees(amount btcutil.Amount, quote *loop.LoopOutQuote) ( btcutil.Amount, btcutil.Amount, btcutil.Amount) + + // loopInLimits checks whether the quote provided is within our fee + // limits for the swap amount. + loopInLimits(amount btcutil.Amount, + quote *loop.LoopInQuote) error } // swapBuilder is an interface used to build our different swap types. From 965b99d4556754842128945d365c0d4b2cadb377 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:32 +0200 Subject: [PATCH 7/8] liquidity: add existing loop in swaps to budget calculations --- liquidity/liquidity.go | 27 ++++- liquidity/liquidity_test.go | 196 +++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 6 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 7e776ca..361736a 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -639,7 +639,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( // Get a summary of our existing swaps so that we can check our autoloop // budget. - summary, err := m.checkExistingAutoLoops(ctx, loopOut) + summary, err := m.checkExistingAutoLoops(ctx, loopOut, loopIn) if err != nil { return nil, err } @@ -958,7 +958,8 @@ func (e *existingAutoLoopSummary) totalFees() btcutil.Amount { // total for our set of ongoing, automatically dispatched swaps as well as a // current in-flight count. func (m *Manager) checkExistingAutoLoops(ctx context.Context, - loopOuts []*loopdb.LoopOut) (*existingAutoLoopSummary, error) { + loopOuts []*loopdb.LoopOut, loopIns []*loopdb.LoopIn) ( + *existingAutoLoopSummary, error) { var summary existingAutoLoopSummary @@ -997,6 +998,28 @@ func (m *Manager) checkExistingAutoLoops(ctx context.Context, } } + for _, in := range loopIns { + if in.Contract.Label != labels.AutoloopLabel(swap.TypeIn) { + continue + } + + pending := in.State().State.Type() == loopdb.StateTypePending + inBudget := !in.LastUpdateTime().Before(m.params.AutoFeeStartDate) + + // If an autoloop is in a pending state, we always count it in + // our current budget, and record the worst-case fees for it, + // because we do not know how it will resolve. + if pending { + summary.inFlightCount++ + summary.pendingFees += worstCaseInFees( + in.Contract.MaxMinerFee, in.Contract.MaxSwapFee, + defaultLoopInSweepFee, + ) + } else if inBudget { + summary.spentFees += in.State().Cost.Total() + } + } + return &summary, nil } diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index d2c2f13..076d473 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -107,6 +107,13 @@ var ( OutgoingChanSet: loopdb.ChannelSet{999}, } + autoInContract = &loopdb.LoopInContract{ + SwapContract: loopdb.SwapContract{ + Label: labels.AutoloopLabel(swap.TypeIn), + InitiationTime: testBudgetStart, + }, + } + testRestrictions = NewRestrictions(1, 10000) // noneDisqualified can be used in tests where we don't have any @@ -1123,9 +1130,10 @@ func TestFeeBudget(t *testing.T) { // that are allowed. func TestInFlightLimit(t *testing.T) { tests := []struct { - name string - maxInFlight int - existingSwaps []*loopdb.LoopOut + name string + maxInFlight int + existingSwaps []*loopdb.LoopOut + existingInSwaps []*loopdb.LoopIn // peerRules will only be set (instead of test default values) // is it is non-nil. peerRules map[route.Vertex]*SwapRule @@ -1194,8 +1202,10 @@ func TestInFlightLimit(t *testing.T) { { Contract: autoOutContract, }, + }, + existingInSwaps: []*loopdb.LoopIn{ { - Contract: autoOutContract, + Contract: autoInContract, }, }, suggestions: &Suggestions{ @@ -1247,6 +1257,9 @@ func TestInFlightLimit(t *testing.T) { cfg.ListLoopOut = func() ([]*loopdb.LoopOut, error) { return testCase.existingSwaps, nil } + cfg.ListLoopIn = func() ([]*loopdb.LoopIn, error) { + return testCase.existingInSwaps, nil + } lnd.Channels = []lndclient.ChannelInfo{ channel1, channel2, @@ -1568,6 +1581,181 @@ func TestFeePercentage(t *testing.T) { } } +// TestBudgetWithLoopin tests that our autoloop budget accounts for loop in +// swaps that have been automatically dispatched. It tests out swaps that have +// already completed and those that are pending, inside and outside of our +// budget period to ensure that we account for all relevant swaps. +func TestBudgetWithLoopin(t *testing.T) { + var ( + budget btcutil.Amount = 10000 + + outsideBudget = testBudgetStart.Add(-5) + insideBudget = testBudgetStart.Add(5) + + contractOutsideBudget = &loopdb.LoopInContract{ + SwapContract: loopdb.SwapContract{ + InitiationTime: outsideBudget, + MaxSwapFee: budget, + }, + Label: labels.AutoloopLabel(swap.TypeIn), + } + + // Set our spend equal to our budget so we don't need to + // calculate exact costs. + eventOutsideBudget = &loopdb.LoopEvent{ + SwapStateData: loopdb.SwapStateData{ + Cost: loopdb.SwapCost{ + Server: budget, + }, + State: loopdb.StateSuccess, + }, + Time: outsideBudget, + } + + successWithinBudget = &loopdb.LoopEvent{ + SwapStateData: loopdb.SwapStateData{ + Cost: loopdb.SwapCost{ + Server: budget, + }, + State: loopdb.StateSuccess, + }, + Time: insideBudget, + } + + okQuote = &loop.LoopOutQuote{ + SwapFee: 15, + PrepayAmount: 30, + MinerFee: 1, + } + + rec = loop.OutRequest{ + Amount: 7500, + OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, + MaxMinerFee: scaleMinerFee(okQuote.MinerFee), + MaxSwapFee: okQuote.SwapFee, + MaxPrepayAmount: okQuote.PrepayAmount, + SweepConfTarget: defaultConfTarget, + Initiator: autoloopSwapInitiator, + } + + testPPM uint64 = 100000 + ) + + rec.MaxPrepayRoutingFee, rec.MaxSwapRoutingFee = testPPMFees( + testPPM, okQuote, 7500, + ) + + tests := []struct { + name string + + // loopIns is the set of loop in swaps that the client has + // performed. + loopIns []*loopdb.LoopIn + + // suggestions is the set of swaps that we expect to be + // suggested given our current traffic. + suggestions *Suggestions + }{ + { + name: "completed swap outside of budget", + loopIns: []*loopdb.LoopIn{ + { + Loop: loopdb.Loop{ + Events: []*loopdb.LoopEvent{ + eventOutsideBudget, + }, + }, + Contract: contractOutsideBudget, + }, + }, + suggestions: &Suggestions{ + OutSwaps: []loop.OutRequest{ + rec, + }, + DisqualifiedChans: noneDisqualified, + DisqualifiedPeers: noPeersDisqualified, + }, + }, + { + name: "completed within budget", + loopIns: []*loopdb.LoopIn{ + { + Loop: loopdb.Loop{ + Events: []*loopdb.LoopEvent{ + successWithinBudget, + }, + }, + Contract: contractOutsideBudget, + }, + }, + suggestions: &Suggestions{ + DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ + chanID1: ReasonBudgetElapsed, + }, + DisqualifiedPeers: noPeersDisqualified, + }, + }, + { + name: "pending created before budget", + loopIns: []*loopdb.LoopIn{ + { + Contract: contractOutsideBudget, + }, + }, + suggestions: &Suggestions{ + DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ + chanID1: ReasonBudgetElapsed, + }, + DisqualifiedPeers: noPeersDisqualified, + }, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + cfg, lnd := newTestConfig() + + // Set our channel and rules so that we will need to + // swap 7500 sats and our fee limit is 10% of that + // amount (750 sats). + lnd.Channels = []lndclient.ChannelInfo{ + channel1, + } + + cfg.ListLoopIn = func() ([]*loopdb.LoopIn, error) { + return testCase.loopIns, nil + } + + cfg.LoopOutQuote = func(_ context.Context, + _ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, + error) { + + return okQuote, nil + } + + params := defaultParameters + params.AutoFeeBudget = budget + params.AutoFeeStartDate = testBudgetStart + + params.FeeLimit = NewFeePortion(testPPM) + params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ + chanID1: chanRule, + } + + // Allow more than one in flight swap, to ensure that + // we restrict based on budget, not in-flight. + params.MaxAutoInFlight = 2 + + testSuggestSwaps( + t, newSuggestSwapsSetup(cfg, lnd, params), + testCase.suggestions, nil, + ) + }) + } +} + // testSuggestSwapsSetup contains the elements that are used to create a // suggest swaps test. type testSuggestSwapsSetup struct { From 8113c34bce80c6258ab0003a089e406fb9ccd343 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 30 Nov 2021 13:18:32 +0200 Subject: [PATCH 8/8] liquidity: add failed loop in swaps to swap traffic --- liquidity/liquidity.go | 25 +++-- liquidity/liquidity_test.go | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 6 deletions(-) diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 361736a..97548b9 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -1084,17 +1084,28 @@ func (m *Manager) currentSwapTraffic(loopOut []*loopdb.LoopOut, } for _, in := range loopIn { - // Skip completed swaps, they can't affect our channel balances. - if in.State().State.Type() != loopdb.StateTypePending { - continue - } - // Skip over swaps that may come through any peer. if in.Contract.LastHop == nil { continue } - traffic.ongoingLoopIn[*in.Contract.LastHop] = true + pubkey := *in.Contract.LastHop + + switch { + // Include any pending swaps in our ongoing set of swaps. + case in.State().State.Type() == loopdb.StateTypePending: + traffic.ongoingLoopIn[pubkey] = true + + // If a swap failed with an on-chain timeout, the server could + // not route to us. We add it to our backoff list so that + // there's some time for routing conditions to improve. + case in.State().State == loopdb.StateFailTimeout: + failedAt := in.LastUpdate().Time + + if failedAt.After(failureCutoff) { + traffic.failedLoopIn[pubkey] = failedAt + } + } } return traffic @@ -1105,6 +1116,7 @@ type swapTraffic struct { ongoingLoopOut map[lnwire.ShortChannelID]bool ongoingLoopIn map[route.Vertex]bool failedLoopOut map[lnwire.ShortChannelID]time.Time + failedLoopIn map[route.Vertex]time.Time } func newSwapTraffic() *swapTraffic { @@ -1112,6 +1124,7 @@ func newSwapTraffic() *swapTraffic { ongoingLoopOut: make(map[lnwire.ShortChannelID]bool), ongoingLoopIn: make(map[route.Vertex]bool), failedLoopOut: make(map[lnwire.ShortChannelID]time.Time), + failedLoopIn: make(map[route.Vertex]time.Time), } } diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index 076d473..203e2c4 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -1817,3 +1817,184 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup, require.Equal(t, expectedErr, err) require.Equal(t, expected, actual) } + +// TestCurrentTraffic tests recording of our current set of ongoing swaps. +func TestCurrentTraffic(t *testing.T) { + var ( + backoff = time.Hour * 5 + withinBackoff = testTime.Add(time.Hour * -1) + outsideBackoff = testTime.Add(backoff * -2) + + success = []*loopdb.LoopEvent{ + { + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateSuccess, + }, + }, + } + + failedInBackoff = []*loopdb.LoopEvent{ + { + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateFailOffchainPayments, + }, + Time: withinBackoff, + }, + } + + failedOutsideBackoff = []*loopdb.LoopEvent{ + { + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateFailOffchainPayments, + }, + Time: outsideBackoff, + }, + } + + failedTimeoutInBackoff = []*loopdb.LoopEvent{ + { + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateFailTimeout, + }, + Time: withinBackoff, + }, + } + + failedTimeoutOutsideBackoff = []*loopdb.LoopEvent{ + { + SwapStateData: loopdb.SwapStateData{ + State: loopdb.StateFailTimeout, + }, + Time: outsideBackoff, + }, + } + ) + + tests := []struct { + name string + loopOut []*loopdb.LoopOut + loopIn []*loopdb.LoopIn + expected *swapTraffic + }{ + { + name: "completed swaps ignored", + loopOut: []*loopdb.LoopOut{ + { + Loop: loopdb.Loop{ + Events: success, + }, + Contract: &loopdb.LoopOutContract{}, + }, + }, + loopIn: []*loopdb.LoopIn{ + { + Loop: loopdb.Loop{ + Events: success, + }, + Contract: &loopdb.LoopInContract{}, + }, + }, + expected: newSwapTraffic(), + }, + { + // No events indicates that the swap is still pending. + name: "pending swaps included", + loopOut: []*loopdb.LoopOut{ + { + Contract: &loopdb.LoopOutContract{ + OutgoingChanSet: []uint64{ + chanID1.ToUint64(), + }, + }, + }, + }, + loopIn: []*loopdb.LoopIn{ + { + Contract: &loopdb.LoopInContract{ + LastHop: &peer2, + }, + }, + }, + expected: &swapTraffic{ + ongoingLoopOut: map[lnwire.ShortChannelID]bool{ + chanID1: true, + }, + ongoingLoopIn: map[route.Vertex]bool{ + peer2: true, + }, + // Make empty maps so that we can assert equal. + failedLoopOut: make( + map[lnwire.ShortChannelID]time.Time, + ), + failedLoopIn: make(map[route.Vertex]time.Time), + }, + }, + { + name: "failure backoff included", + loopOut: []*loopdb.LoopOut{ + { + Contract: &loopdb.LoopOutContract{ + OutgoingChanSet: []uint64{ + chanID1.ToUint64(), + }, + }, + Loop: loopdb.Loop{ + Events: failedInBackoff, + }, + }, + { + Contract: &loopdb.LoopOutContract{ + OutgoingChanSet: []uint64{ + chanID2.ToUint64(), + }, + }, + Loop: loopdb.Loop{ + Events: failedOutsideBackoff, + }, + }, + }, + loopIn: []*loopdb.LoopIn{ + { + Contract: &loopdb.LoopInContract{ + LastHop: &peer1, + }, + Loop: loopdb.Loop{ + Events: failedTimeoutInBackoff, + }, + }, + { + Contract: &loopdb.LoopInContract{ + LastHop: &peer2, + }, + Loop: loopdb.Loop{ + Events: failedTimeoutOutsideBackoff, + }, + }, + }, + expected: &swapTraffic{ + ongoingLoopOut: make( + map[lnwire.ShortChannelID]bool, + ), + ongoingLoopIn: make(map[route.Vertex]bool), + failedLoopOut: map[lnwire.ShortChannelID]time.Time{ + chanID1: withinBackoff, + }, + failedLoopIn: map[route.Vertex]time.Time{ + peer1: withinBackoff, + }, + }, + }, + } + + for _, testCase := range tests { + cfg, _ := newTestConfig() + m := NewManager(cfg) + + params := m.GetParameters() + params.FailureBackOff = backoff + require.NoError(t, m.SetParameters(context.Background(), params)) + + actual := m.currentSwapTraffic(testCase.loopOut, testCase.loopIn) + require.Equal(t, testCase.expected, actual) + } +}