diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 0b3f0d4d..3a9850b2 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -37,7 +37,6 @@ import ( "errors" "fmt" "sort" - "strings" "sync" "time" @@ -102,19 +101,6 @@ var ( // funding amount. defaultBudget = ppmToSat(funding.MaxBtcFundingAmount, defaultFeePPM) - // defaultParameters contains the default parameters that we start our - // liquidity manger with. - defaultParameters = Parameters{ - AutoFeeBudget: defaultBudget, - MaxAutoInFlight: defaultMaxInFlight, - ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule), - PeerRules: make(map[route.Vertex]*SwapRule), - FailureBackOff: defaultFailureBackoff, - SweepConfTarget: defaultConfTarget, - HtlcConfTarget: defaultHtlcConfTarget, - FeeLimit: defaultFeePortion(), - } - // ErrZeroChannelID is returned if we get a rule for a 0 channel ID. ErrZeroChannelID = fmt.Errorf("zero channel ID not allowed") @@ -195,222 +181,6 @@ type Config struct { MinimumConfirmations int32 } -// Parameters is a set of parameters provided by the user which guide -// how we assess liquidity. -type Parameters struct { - // Autoloop enables automatic dispatch of swaps. - Autoloop bool - - // AutoFeeBudget is the total amount we allow to be spent on - // automatically dispatched swaps. Once this budget has been used, we - // will stop dispatching swaps until the budget is increased or the - // start date is moved. - AutoFeeBudget btcutil.Amount - - // AutoFeeStartDate is the date from which we will include automatically - // dispatched swaps in our current budget, inclusive. - AutoFeeStartDate time.Time - - // MaxAutoInFlight is the maximum number of in-flight automatically - // dispatched swaps we allow. - MaxAutoInFlight int - - // FailureBackOff is the amount of time that we require passes after a - // channel has been part of a failed loop out swap before we suggest - // using it again. - // TODO(carla): add exponential backoff - FailureBackOff time.Duration - - // SweepConfTarget is the number of blocks we aim to confirm our sweep - // transaction in. This value affects the on chain fees we will pay. - SweepConfTarget int32 - - // HtlcConfTarget is the confirmation target that we use for publishing - // loop in swap htlcs on chain. - HtlcConfTarget int32 - - // FeeLimit controls the fee limit we place on swaps. - FeeLimit FeeLimit - - // 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. These rules and PeerRules are - // exclusively set to prevent overlap between peer and channel rules. - 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]*SwapRule -} - -// String returns the string representation of our parameters. -func (p Parameters) String() string { - ruleList := make([]string, 0, len(p.ChannelRules)+len(p.PeerRules)) - - for channel, rule := range p.ChannelRules { - ruleList = append( - ruleList, fmt.Sprintf("Channel: %v: %v", channel, rule), - ) - } - - for peer, rule := range p.PeerRules { - ruleList = append( - ruleList, fmt.Sprintf("Peer: %v: %v", peer, rule), - ) - } - - return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ - "sweep conf target: %v, htlc conf target: %v,fees: %v, "+ - "auto budget: %v, budget start: %v, max auto in flight: %v, "+ - "minimum swap size=%v, maximum swap size=%v", - strings.Join(ruleList, ","), p.FailureBackOff, - p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit, - p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, - p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) -} - -// haveRules returns a boolean indicating whether we have any rules configured. -func (p Parameters) haveRules() bool { - if len(p.ChannelRules) != 0 { - return true - } - - if len(p.PeerRules) != 0 { - return true - } - - return false -} - -// validate checks whether a set of parameters is valid. Our set of currently -// open channels are required to check that there is no overlap between the -// rules set on a per-peer level, and those set for specific channels. We can't -// allow both, because then we're trying to cater for two separate liquidity -// goals on the same channel. Since we use short channel ID, we don't need to -// worry about pending channels (users would need to work very hard to get the -// short channel ID for a pending channel). Likewise, we don't care about closed -// channels, since there is no action that may occur on them, and we want to -// allow peer-level rules to be set once a channel which had a specific rule -// has been closed. It takes the minimum confirmations we allow for sweep -// confirmation target as a parameter. -// TODO(carla): prune channels that have been closed from rules. -func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, - server *Restrictions) error { - - // First, we check that the rules on a per peer and per channel do not - // overlap, since this could lead to contractions. - for _, channel := range openChans { - // If we don't have a rule for the peer, there's no way we have - // an overlap between this peer and the channel. - _, ok := p.PeerRules[channel.PubKeyBytes] - if !ok { - continue - } - - shortID := lnwire.NewShortChanIDFromInt(channel.ChannelID) - _, ok = p.ChannelRules[shortID] - if ok { - log.Debugf("Rules for peer: %v and its channel: %v "+ - "can't both be set", channel.PubKeyBytes, shortID) - - return ErrExclusiveRules - } - } - - for channel, rule := range p.ChannelRules { - if channel.ToUint64() == 0 { - return ErrZeroChannelID - } - - if rule.Type == swap.TypeIn { - return errors.New("channel level rules not supported for " + - "loop in swaps, only peer-level rules allowed") - } - - if err := rule.validate(); err != nil { - return fmt.Errorf("channel: %v has invalid rule: %v", - channel.ToUint64(), err) - } - } - - for peer, rule := range p.PeerRules { - if err := rule.validate(); err != nil { - return fmt.Errorf("peer: %v has invalid rule: %v", - peer, err) - } - } - - // Check that our confirmation target is above our required minimum. - if p.SweepConfTarget < minConfs { - return fmt.Errorf("confirmation target must be at least: %v", - minConfs) - } - - if p.HtlcConfTarget < 1 { - return fmt.Errorf("htlc confirmation target must be > 0") - } - - if err := p.FeeLimit.validate(); err != nil { - return err - } - - if p.AutoFeeBudget < 0 { - return ErrNegativeBudget - } - - if p.MaxAutoInFlight <= 0 { - 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 -} - // Manager contains a set of desired liquidity rules for our channel // balances. type Manager struct { @@ -494,34 +264,6 @@ func (m *Manager) SetParameters(ctx context.Context, params Parameters) error { return nil } -// cloneParameters creates a deep clone of a parameters struct so that callers -// cannot mutate our parameters. Although our parameters struct itself is not -// a reference, we still need to clone the contents of maps. -func cloneParameters(params Parameters) Parameters { - paramCopy := params - paramCopy.ChannelRules = make( - map[lnwire.ShortChannelID]*SwapRule, - len(params.ChannelRules), - ) - - for channel, rule := range params.ChannelRules { - ruleCopy := *rule - paramCopy.ChannelRules[channel] = &ruleCopy - } - - paramCopy.PeerRules = make( - map[route.Vertex]*SwapRule, - len(params.PeerRules), - ) - - for peer, rule := range params.PeerRules { - ruleCopy := *rule - paramCopy.PeerRules[peer] = &ruleCopy - } - - return paramCopy -} - // autoloop gets a set of suggested swaps and dispatches them automatically if // we have automated looping enabled. func (m *Manager) autoloop(ctx context.Context) error { diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index c1fc41d3..f6732898 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -246,60 +246,6 @@ func TestParameters(t *testing.T) { 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 diff --git a/liquidity/parameters.go b/liquidity/parameters.go new file mode 100644 index 00000000..ff6355b0 --- /dev/null +++ b/liquidity/parameters.go @@ -0,0 +1,273 @@ +package liquidity + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" +) + +var ( + // defaultParameters contains the default parameters that we start our + // liquidity manger with. + defaultParameters = Parameters{ + AutoFeeBudget: defaultBudget, + MaxAutoInFlight: defaultMaxInFlight, + ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule), + PeerRules: make(map[route.Vertex]*SwapRule), + FailureBackOff: defaultFailureBackoff, + SweepConfTarget: defaultConfTarget, + HtlcConfTarget: defaultHtlcConfTarget, + FeeLimit: defaultFeePortion(), + } +) + +// Parameters is a set of parameters provided by the user which guide +// how we assess liquidity. +type Parameters struct { + // Autoloop enables automatic dispatch of swaps. + Autoloop bool + + // AutoFeeBudget is the total amount we allow to be spent on + // automatically dispatched swaps. Once this budget has been used, we + // will stop dispatching swaps until the budget is increased or the + // start date is moved. + AutoFeeBudget btcutil.Amount + + // AutoFeeStartDate is the date from which we will include automatically + // dispatched swaps in our current budget, inclusive. + AutoFeeStartDate time.Time + + // MaxAutoInFlight is the maximum number of in-flight automatically + // dispatched swaps we allow. + MaxAutoInFlight int + + // FailureBackOff is the amount of time that we require passes after a + // channel has been part of a failed loop out swap before we suggest + // using it again. + // TODO(carla): add exponential backoff + FailureBackOff time.Duration + + // SweepConfTarget is the number of blocks we aim to confirm our sweep + // transaction in. This value affects the on chain fees we will pay. + SweepConfTarget int32 + + // HtlcConfTarget is the confirmation target that we use for publishing + // loop in swap htlcs on chain. + HtlcConfTarget int32 + + // FeeLimit controls the fee limit we place on swaps. + FeeLimit FeeLimit + + // 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. These rules and PeerRules are + // exclusively set to prevent overlap between peer and channel rules. + 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]*SwapRule +} + +// String returns the string representation of our parameters. +func (p Parameters) String() string { + ruleList := make([]string, 0, len(p.ChannelRules)+len(p.PeerRules)) + + for channel, rule := range p.ChannelRules { + ruleList = append( + ruleList, fmt.Sprintf("Channel: %v: %v", channel, rule), + ) + } + + for peer, rule := range p.PeerRules { + ruleList = append( + ruleList, fmt.Sprintf("Peer: %v: %v", peer, rule), + ) + } + + return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ + "sweep conf target: %v, htlc conf target: %v,fees: %v, "+ + "auto budget: %v, budget start: %v, max auto in flight: %v, "+ + "minimum swap size=%v, maximum swap size=%v", + strings.Join(ruleList, ","), p.FailureBackOff, + p.SweepConfTarget, p.HtlcConfTarget, p.FeeLimit, + p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, + p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) +} + +// haveRules returns a boolean indicating whether we have any rules configured. +func (p Parameters) haveRules() bool { + if len(p.ChannelRules) != 0 { + return true + } + + if len(p.PeerRules) != 0 { + return true + } + + return false +} + +// validate checks whether a set of parameters is valid. Our set of currently +// open channels are required to check that there is no overlap between the +// rules set on a per-peer level, and those set for specific channels. We can't +// allow both, because then we're trying to cater for two separate liquidity +// goals on the same channel. Since we use short channel ID, we don't need to +// worry about pending channels (users would need to work very hard to get the +// short channel ID for a pending channel). Likewise, we don't care about closed +// channels, since there is no action that may occur on them, and we want to +// allow peer-level rules to be set once a channel which had a specific rule +// has been closed. It takes the minimum confirmations we allow for sweep +// confirmation target as a parameter. +// TODO(carla): prune channels that have been closed from rules. +func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, + server *Restrictions) error { + + // First, we check that the rules on a per peer and per channel do not + // overlap, since this could lead to contractions. + for _, channel := range openChans { + // If we don't have a rule for the peer, there's no way we have + // an overlap between this peer and the channel. + _, ok := p.PeerRules[channel.PubKeyBytes] + if !ok { + continue + } + + shortID := lnwire.NewShortChanIDFromInt(channel.ChannelID) + _, ok = p.ChannelRules[shortID] + if ok { + log.Debugf("Rules for peer: %v and its channel: %v "+ + "can't both be set", channel.PubKeyBytes, shortID) + + return ErrExclusiveRules + } + } + + for channel, rule := range p.ChannelRules { + if channel.ToUint64() == 0 { + return ErrZeroChannelID + } + + if rule.Type == swap.TypeIn { + return errors.New("channel level rules not supported for " + + "loop in swaps, only peer-level rules allowed") + } + + if err := rule.validate(); err != nil { + return fmt.Errorf("channel: %v has invalid rule: %v", + channel.ToUint64(), err) + } + } + + for peer, rule := range p.PeerRules { + if err := rule.validate(); err != nil { + return fmt.Errorf("peer: %v has invalid rule: %v", + peer, err) + } + } + + // Check that our confirmation target is above our required minimum. + if p.SweepConfTarget < minConfs { + return fmt.Errorf("confirmation target must be at least: %v", + minConfs) + } + + if p.HtlcConfTarget < 1 { + return fmt.Errorf("htlc confirmation target must be > 0") + } + + if err := p.FeeLimit.validate(); err != nil { + return err + } + + if p.AutoFeeBudget < 0 { + return ErrNegativeBudget + } + + if p.MaxAutoInFlight <= 0 { + 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 +} + +// cloneParameters creates a deep clone of a parameters struct so that callers +// cannot mutate our parameters. Although our parameters struct itself is not +// a reference, we still need to clone the contents of maps. +func cloneParameters(params Parameters) Parameters { + paramCopy := params + paramCopy.ChannelRules = make( + map[lnwire.ShortChannelID]*SwapRule, + len(params.ChannelRules), + ) + + for channel, rule := range params.ChannelRules { + ruleCopy := *rule + paramCopy.ChannelRules[channel] = &ruleCopy + } + + paramCopy.PeerRules = make( + map[route.Vertex]*SwapRule, + len(params.PeerRules), + ) + + for peer, rule := range params.PeerRules { + ruleCopy := *rule + paramCopy.PeerRules[peer] = &ruleCopy + } + + return paramCopy +} diff --git a/liquidity/parameters_test.go b/liquidity/parameters_test.go new file mode 100644 index 00000000..46faa3b8 --- /dev/null +++ b/liquidity/parameters_test.go @@ -0,0 +1,61 @@ +package liquidity + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// 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) + }) + } +}