diff --git a/liquidity/autoloop_test.go b/liquidity/autoloop_test.go index 329be96..1b5424d 100644 --- a/liquidity/autoloop_test.go +++ b/liquidity/autoloop_test.go @@ -28,7 +28,7 @@ func TestAutoLoopDisabled(t *testing.T) { chanID1: chanRule, } - c := newAutoloopTestCtx(t, params, channels) + c := newAutoloopTestCtx(t, params, channels, testRestrictions) c.start() // We expect a single quote to be required for our swap on channel 1. @@ -93,7 +93,7 @@ func TestAutoLoopEnabled(t *testing.T) { }, } - c := newAutoloopTestCtx(t, params, channels) + c := newAutoloopTestCtx(t, params, channels, testRestrictions) c.start() // Calculate our maximum allowed fees and create quotes that fall within diff --git a/liquidity/autoloop_testcontext_test.go b/liquidity/autoloop_testcontext_test.go index 464b811..945e3ba 100644 --- a/liquidity/autoloop_testcontext_test.go +++ b/liquidity/autoloop_testcontext_test.go @@ -58,7 +58,8 @@ type autoloopTestCtx struct { // newAutoloopTestCtx creates a test context with custom liquidity manager // parameters and lnd channels. func newAutoloopTestCtx(t *testing.T, parameters Parameters, - channels []lndclient.ChannelInfo) *autoloopTestCtx { + channels []lndclient.ChannelInfo, + server *Restrictions) *autoloopTestCtx { // Create a mock lnd and set our expected fee rate for sweeps to our // sweep fee rate limit value. @@ -121,11 +122,20 @@ func newAutoloopTestCtx(t *testing.T, parameters Parameters, Clock: testCtx.testClock, } + // SetParameters needs to make a call to our mocked restrictions call, + // which will block, so we push our test values in a goroutine. + done := make(chan struct{}) + go func() { + testCtx.loopOutRestrictions <- server + close(done) + }() + // Create a manager with our test config and set our starting set of // parameters. testCtx.manager = NewManager(cfg) - assert.NoError(t, testCtx.manager.SetParameters(parameters)) - + err := testCtx.manager.SetParameters(context.Background(), parameters) + assert.NoError(t, err) + <-done return testCtx } diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 1d103b9..b072be6 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -162,6 +162,21 @@ var ( // ErrZeroInFlight is returned is a zero in flight swaps value is set. ErrZeroInFlight = errors.New("max in flight swaps must be >=0") + + // ErrMinimumExceedsMaximumAmt is returned when the minimum configured + // swap amount is more than the maximum. + ErrMinimumExceedsMaximumAmt = errors.New("minimum swap amount " + + "exceeds maximum") + + // ErrMaxExceedsServer is returned if the maximum swap amount set is + // more than the server offers. + ErrMaxExceedsServer = errors.New("maximum swap amount is more than " + + "server maximum") + + // ErrMinLessThanServer is returned if the minimum swap amount set is + // less than the server minimum. + ErrMinLessThanServer = errors.New("minimum swap amount is less than " + + "server minimum") ) // Config contains the external functionality required to run the @@ -264,6 +279,10 @@ type Parameters struct { // sweep during a fee spike. MaximumMinerFee btcutil.Amount + // ClientRestrictions are the restrictions placed on swap size by the + // client. + ClientRestrictions Restrictions + // ChannelRules maps a short channel ID to a rule that describes how we // would like liquidity to be managed. ChannelRules map[lnwire.ShortChannelID]*ThresholdRule @@ -283,17 +302,19 @@ func (p Parameters) String() string { "fee rate limit: %v, sweep conf target: %v, maximum prepay: "+ "%v, maximum miner fee: %v, maximum swap fee ppm: %v, maximum "+ "routing fee ppm: %v, maximum prepay routing fee ppm: %v, "+ - "auto budget: %v, budget start: %v, max auto in flight: %v", + "auto budget: %v, budget start: %v, max auto in flight: %v, "+ + "minimum swap size=%v, maximum swap size=%v", strings.Join(channelRules, ","), p.FailureBackOff, p.SweepFeeRateLimit, p.SweepConfTarget, p.MaximumPrepay, p.MaximumMinerFee, p.MaximumSwapFeePPM, p.MaximumRoutingFeePPM, p.MaximumPrepayRoutingFeePPM, - p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight) + p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, + p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) } // validate checks whether a set of parameters is valid. It takes the minimum // confirmations we allow for sweep confirmation target as a parameter. -func (p Parameters) validate(minConfs int32) error { +func (p Parameters) validate(minConfs int32, server *Restrictions) error { for channel, rule := range p.ChannelRules { if channel.ToUint64() == 0 { return ErrZeroChannelID @@ -347,6 +368,47 @@ func (p Parameters) validate(minConfs int32) error { return ErrZeroInFlight } + err := validateRestrictions(server, &p.ClientRestrictions) + if err != nil { + return err + } + + return nil +} + +// validateRestrictions checks that client restrictions fall within the server's +// restrictions. +func validateRestrictions(server, client *Restrictions) error { + zeroMin := client.Minimum == 0 + zeroMax := client.Maximum == 0 + + if zeroMin && zeroMax { + return nil + } + + // If we have a non-zero maximum, we need to ensure it is greater than + // our minimum (which is fine if min is zero), and does not exceed the + // server's maximum. + if !zeroMax { + if client.Minimum > client.Maximum { + return ErrMinimumExceedsMaximumAmt + } + + if client.Maximum > server.Maximum { + return ErrMaxExceedsServer + } + } + + if zeroMin { + return nil + } + + // If the client set a minimum, ensure it is at least equal to the + // server's limit. + if client.Minimum < server.Minimum { + return ErrMinLessThanServer + } + return nil } @@ -403,8 +465,14 @@ func (m *Manager) GetParameters() Parameters { // SetParameters updates our current set of parameters if the new parameters // provided are valid. -func (m *Manager) SetParameters(params Parameters) error { - if err := params.validate(m.cfg.MinimumConfirmations); err != nil { +func (m *Manager) SetParameters(ctx context.Context, params Parameters) error { + restrictions, err := m.cfg.LoopOutRestrictions(ctx) + if err != nil { + return err + } + + err = params.validate(m.cfg.MinimumConfirmations, restrictions) + if err != nil { return err } @@ -517,8 +585,9 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoOut bool) ( return nil, nil } - // Get the current server side restrictions. - outRestrictions, err := m.cfg.LoopOutRestrictions(ctx) + // Get the current server side restrictions, combined with the client + // set restrictions, if any. + outRestrictions, err := m.getLoopOutRestrictions(ctx) if err != nil { return nil, err } @@ -674,6 +743,41 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoOut bool) ( return inBudget, nil } +// getLoopOutRestrictions queries the server for its latest swap size +// restrictions, validates client restrictions (if present) against these +// values and merges the client's custom requirements with the server's limits +// to produce a single set of limitations for our swap. +func (m *Manager) getLoopOutRestrictions(ctx context.Context) (*Restrictions, + error) { + + restrictions, err := m.cfg.LoopOutRestrictions(ctx) + if err != nil { + return nil, err + } + + // It is possible that the server has updated its restrictions since + // we validated our client restrictions, so we validate again to ensure + // that our restrictions are within the server's bounds. + err = validateRestrictions(restrictions, &m.params.ClientRestrictions) + if err != nil { + return nil, err + } + + // If our minimum is more than the server's minimum, we set it. + if m.params.ClientRestrictions.Minimum > restrictions.Minimum { + restrictions.Minimum = m.params.ClientRestrictions.Minimum + } + + // If our maximum set and is less than the server's maximum, we set it. + if m.params.ClientRestrictions.Maximum != 0 && + m.params.ClientRestrictions.Maximum < restrictions.Maximum { + + restrictions.Maximum = m.params.ClientRestrictions.Maximum + } + + return restrictions, nil +} + // makeLoopOutRequest creates a loop out request from a suggestion. Since we // do not get any information about our off-chain routing fees when we request // a quote, we just set our prepay and route maximum fees directly from the diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index 5d80578..5f46c3f 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -104,6 +104,8 @@ var ( }, OutgoingChanSet: loopdb.ChannelSet{999}, } + + testRestrictions = NewRestrictions(1, 10000) ) // newTestConfig creates a default test config. @@ -121,7 +123,7 @@ func newTestConfig() (*Config, *test.LndMockServices) { LoopOutRestrictions: func(_ context.Context) (*Restrictions, error) { - return NewRestrictions(1, 10000), nil + return testRestrictions, nil }, Lnd: &lnd.LndServices, Clock: clock.NewTestClock(testTime), @@ -167,7 +169,7 @@ func TestParameters(t *testing.T) { chanID: originalRule, } - err := manager.SetParameters(expected) + err := manager.SetParameters(context.Background(), expected) require.NoError(t, err) // Check that changing the parameters we just set does not mutate @@ -182,10 +184,64 @@ func TestParameters(t *testing.T) { expected.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ lnwire.NewShortChanIDFromInt(0): NewThresholdRule(1, 2), } - err = manager.SetParameters(expected) + err = manager.SetParameters(context.Background(), expected) require.Equal(t, ErrZeroChannelID, err) } +// TestValidateRestrictions tests validating client restrictions against a set +// of server restrictions. +func TestValidateRestrictions(t *testing.T) { + tests := []struct { + name string + client *Restrictions + server *Restrictions + err error + }{ + { + name: "client invalid", + client: &Restrictions{ + Minimum: 100, + Maximum: 1, + }, + server: testRestrictions, + err: ErrMinimumExceedsMaximumAmt, + }, + { + name: "maximum exceeds server", + client: &Restrictions{ + Maximum: 2000, + }, + server: &Restrictions{ + Minimum: 1000, + Maximum: 1500, + }, + err: ErrMaxExceedsServer, + }, + { + name: "minimum less than server", + client: &Restrictions{ + Minimum: 500, + }, + server: &Restrictions{ + Minimum: 1000, + Maximum: 1500, + }, + err: ErrMinLessThanServer, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + err := validateRestrictions( + testCase.server, testCase.client, + ) + require.Equal(t, testCase.err, err) + }) + } +} + // TestRestrictedSuggestions tests getting of swap suggestions when we have // other in-flight swaps. We setup our manager with a set of channels and rules // that require a loop out swap, focusing on the filtering our of channels that @@ -376,7 +432,7 @@ func TestRestrictedSuggestions(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.expected, + testCase.expected, nil, ) }) } @@ -426,7 +482,7 @@ func TestSweepFeeLimit(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.swaps, + testCase.swaps, nil, ) }) } @@ -477,7 +533,7 @@ func TestSuggestSwaps(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.swaps, + testCase.swaps, nil, ) }) } @@ -547,7 +603,7 @@ func TestFeeLimits(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.expected, + testCase.expected, nil, ) }) } @@ -704,7 +760,7 @@ func TestFeeBudget(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.expectedSwaps, + testCase.expectedSwaps, nil, ) }) } @@ -797,7 +853,151 @@ func TestInFlightLimit(t *testing.T) { testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), - testCase.expectedSwaps, + testCase.expectedSwaps, nil, + ) + }) + } +} + +// TestSizeRestrictions tests the use of client-set size restrictions on swaps. +func TestSizeRestrictions(t *testing.T) { + var ( + serverRestrictions = Restrictions{ + Minimum: 6000, + Maximum: 10000, + } + + swap = loop.OutRequest{ + OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, + MaxPrepayRoutingFee: prepayFee, + MaxMinerFee: defaultMaximumMinerFee, + MaxSwapFee: testQuote.SwapFee, + MaxPrepayAmount: testQuote.PrepayAmount, + SweepConfTarget: loop.DefaultSweepConfTarget, + Initiator: autoloopSwapInitiator, + } + ) + + tests := []struct { + name string + + // clientRestrictions holds the restrictions that the client + // has configured. + clientRestrictions Restrictions + + // server holds the server's mocked responses to our terms + // endpoint. + serverRestrictions []Restrictions + + // expectedAmount is the amount that we expect for our swap. + expectedAmount btcutil.Amount + + // expectedError is the error we expect. + expectedError error + }{ + { + name: "minimum more than server, swap happens", + clientRestrictions: Restrictions{ + Minimum: 7000, + }, + serverRestrictions: []Restrictions{ + serverRestrictions, serverRestrictions, + }, + expectedAmount: 7500, + }, + { + name: "minimum more than server, no swap", + clientRestrictions: Restrictions{ + Minimum: 8000, + }, + serverRestrictions: []Restrictions{ + serverRestrictions, serverRestrictions, + }, + expectedAmount: 0, + }, + { + name: "maximum less than server, swap happens", + clientRestrictions: Restrictions{ + Maximum: 7000, + }, + serverRestrictions: []Restrictions{ + serverRestrictions, serverRestrictions, + }, + expectedAmount: 7000, + }, + { + // Originally, our client params are ok. But then the + // server increases its minimum, making the client + // params stale. + name: "client params stale over time", + clientRestrictions: Restrictions{ + Minimum: 6500, + Maximum: 9000, + }, + serverRestrictions: []Restrictions{ + serverRestrictions, + { + Minimum: 5000, + Maximum: 6000, + }, + }, + expectedAmount: 0, + expectedError: ErrMaxExceedsServer, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + cfg, lnd := newTestConfig() + + lnd.Channels = []lndclient.ChannelInfo{ + channel1, + } + + params := defaultParameters + params.ClientRestrictions = testCase.clientRestrictions + params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{ + chanID1: chanRule, + } + + // callCount tracks the number of calls we make to + // our restrictions endpoint. + var callCount int + + cfg.LoopOutRestrictions = func(_ context.Context) ( + *Restrictions, error) { + + restrictions := testCase.serverRestrictions[callCount] + callCount++ + + return &restrictions, nil + } + + // If we expect a swap (non-zero amount), we add a + // swap to our set of expected swaps, and update amount + // and fee accordingly. + var expectedSwaps []loop.OutRequest + if testCase.expectedAmount != 0 { + swap.Amount = testCase.expectedAmount + + swap.MaxSwapRoutingFee = ppmToSat( + testCase.expectedAmount, + defaultRoutingFeePPM, + ) + + expectedSwaps = append(expectedSwaps, swap) + } + + testSuggestSwaps( + t, newSuggestSwapsSetup(cfg, lnd, params), + expectedSwaps, testCase.expectedError, + ) + + require.Equal( + t, callCount, len(testCase.serverRestrictions), + "too many restrictions provided by mock", ) }) } @@ -827,7 +1027,7 @@ func newSuggestSwapsSetup(cfg *Config, lnd *test.LndMockServices, // use the default parameters and setup two channels (channel1 + channel2) with // chanRule set for each. func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup, - expected []loop.OutRequest) { + expected []loop.OutRequest, expectedErr error) { t.Parallel() @@ -857,10 +1057,10 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup, // them to use the rules set by the test. manager := NewManager(setup.cfg) - err := manager.SetParameters(setup.params) + err := manager.SetParameters(context.Background(), setup.params) require.NoError(t, err) actual, err := manager.SuggestSwaps(context.Background(), false) - require.NoError(t, err) + require.Equal(t, expectedErr, err) require.Equal(t, expected, actual) } diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 5536b55..72f9477 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -613,7 +613,7 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context, // SetLiquidityParams attempts to set our current liquidity manager's // parameters. -func (s *swapClientServer) SetLiquidityParams(_ context.Context, +func (s *swapClientServer) SetLiquidityParams(ctx context.Context, in *looprpc.SetLiquidityParamsRequest) (*looprpc.SetLiquidityParamsResponse, error) { @@ -666,7 +666,7 @@ func (s *swapClientServer) SetLiquidityParams(_ context.Context, } } - if err := s.liquidityMgr.SetParameters(params); err != nil { + if err := s.liquidityMgr.SetParameters(ctx, params); err != nil { return nil, err }