diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 4c35df7..af4475c 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -13,10 +13,28 @@ // - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep // transaction to confirm within our configured number of confirmations // that we will suggest swaps for. +// - Maximum Swap Fee PPM: the maximum server fee, expressed as parts per +// million of the full swap amount +// - Maximum Routing Fee PPM: the maximum off-chain routing fees for the swap +// invoice, expressed as parts per million of the swap amount. +// - Maximum Prepay Routing Fee PPM: the maximum off-chain routing fees for the +// swap prepayment, expressed as parts per million of the prepay amount. +// - Maximum Prepay: the maximum now-show fee, expressed in satoshis. This +// amount is only payable in the case where the swap server broadcasts a htlc +// and the client fails to sweep the preimage. +// - Maximum miner fee: the maximum miner fee we are willing to pay to sweep the +// on chain htlc. Note that the client will use current fee estimates to +// sweep, so this value acts more as a sanity check in the case of a large fee +// spike. +// +// The maximum fee per-swap is calculated as follows: +// (swap amount * serverPPM/1e6) + miner fee + (swap amount * routingPPM/1e6) +// + (prepay amount * prepayPPM/1e6). package liquidity import ( "context" + "errors" "fmt" "strings" "sync" @@ -55,8 +73,10 @@ const ( defaultPrepayRoutingFeePPM = 5000 // defaultMaximumMinerFee is the default limit we place on miner fees - // per swap. - defaultMaximumMinerFee = 15000 + // per swap. We apply a multiplier to this default fee to guard against + // the case where we have broadcast the preimage, then fees spike and + // we need to sweep the preimage. + defaultMaximumMinerFee = 15000 * 100 // defaultMaximumPrepay is the default limit we place on prepay // invoices. @@ -71,10 +91,15 @@ var ( // defaultParameters contains the default parameters that we start our // liquidity manger with. defaultParameters = Parameters{ - ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), - FailureBackOff: defaultFailureBackoff, - SweepFeeRateLimit: defaultSweepFeeRateLimit, - SweepConfTarget: loop.DefaultSweepConfTarget, + ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), + FailureBackOff: defaultFailureBackoff, + SweepFeeRateLimit: defaultSweepFeeRateLimit, + SweepConfTarget: loop.DefaultSweepConfTarget, + MaximumSwapFeePPM: defaultSwapFeePPM, + MaximumRoutingFeePPM: defaultRoutingFeePPM, + MaximumPrepayRoutingFeePPM: defaultPrepayRoutingFeePPM, + MaximumMinerFee: defaultMaximumMinerFee, + MaximumPrepay: defaultMaximumPrepay, } // ErrZeroChannelID is returned if we get a rule for a 0 channel ID. @@ -85,6 +110,21 @@ var ( ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+ "be > %v sat/vByte", satPerKwToSatPerVByte(chainfee.AbsoluteFeePerKwFloor)) + + // ErrZeroMinerFee is returned if a zero maximum miner fee is set. + ErrZeroMinerFee = errors.New("maximum miner fee must be non-zero") + + // ErrZeroSwapFeePPM is returned if a zero server fee ppm is set. + ErrZeroSwapFeePPM = errors.New("swap fee PPM must be non-zero") + + // ErrZeroRoutingPPM is returned if a zero routing fee ppm is set. + ErrZeroRoutingPPM = errors.New("routing fee PPM must be non-zero") + + // ErrZeroPrepayPPM is returned if a zero prepay routing fee ppm is set. + ErrZeroPrepayPPM = errors.New("prepay routing fee PPM must be non-zero") + + // ErrZeroPrepay is returned if a zero maximum prepay is set. + ErrZeroPrepay = errors.New("maximum prepay must be non-zero") ) // Config contains the external functionality required to run the @@ -103,6 +143,11 @@ type Config struct { // ListLoopIn returns all of the loop in swaps stored on disk. ListLoopIn func() ([]*loopdb.LoopIn, error) + // LoopOutQuote gets swap fee, estimated miner fee and prepay amount for + // a loop out swap. + LoopOutQuote func(ctx context.Context, + request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) + // Clock allows easy mocking of time in unit tests. Clock clock.Clock @@ -129,6 +174,33 @@ type Parameters struct { // transaction in. This value affects the on chain fees we will pay. SweepConfTarget int32 + // MaximumPrepay is the maximum prepay amount we are willing to pay per + // swap. + MaximumPrepay btcutil.Amount + + // MaximumSwapFeePPM is the maximum server fee we are willing to pay per + // swap expressed as parts per million of the swap volume. + MaximumSwapFeePPM int + + // MaximumRoutingFeePPM is the maximum off-chain routing fee we + // are willing to pay for off chain invoice routing fees per swap, + // expressed as parts per million of the swap amount. + MaximumRoutingFeePPM int + + // MaximumPrepayRoutingFeePPM is the maximum off-chain routing fee we + // are willing to pay for off chain prepay routing fees per swap, + // expressed as parts per million of the prepay amount. + MaximumPrepayRoutingFeePPM int + + // MaximumMinerFee is the maximum on chain fee that we cap our miner + // fee at in case where we need to claim on chain because we have + // revealed the preimage, but fees have spiked. We will not initiate a + // swap if we estimate that the sweep cost will be above our sweep + // fee limit, and we use fee estimates at time of sweep to set our fees, + // so this is just a sane cap covering the special case where we need to + // sweep during a fee spike. + MaximumMinerFee btcutil.Amount + // ChannelRules maps a short channel ID to a rule that describes how we // would like liquidity to be managed. ChannelRules map[lnwire.ShortChannelID]*ThresholdRule @@ -145,9 +217,13 @@ func (p Parameters) String() string { } return fmt.Sprintf("channel rules: %v, failure backoff: %v, sweep "+ - "fee rate limit: %v, sweep conf target: %v", + "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", strings.Join(channelRules, ","), p.FailureBackOff, - p.SweepFeeRateLimit, p.SweepConfTarget, + p.SweepFeeRateLimit, p.SweepConfTarget, p.MaximumPrepay, + p.MaximumMinerFee, p.MaximumSwapFeePPM, + p.MaximumRoutingFeePPM, p.MaximumPrepayRoutingFeePPM, ) } @@ -178,6 +254,27 @@ func (p Parameters) validate(minConfs int32) error { minConfs) } + // Check that we have non-zero fee limits. + if p.MaximumSwapFeePPM == 0 { + return ErrZeroSwapFeePPM + } + + if p.MaximumRoutingFeePPM == 0 { + return ErrZeroRoutingPPM + } + + if p.MaximumPrepayRoutingFeePPM == 0 { + return ErrZeroPrepayPPM + } + + if p.MaximumPrepay == 0 { + return ErrZeroPrepay + } + + if p.MaximumMinerFee == 0 { + return ErrZeroMinerFee + } + return nil } @@ -317,25 +414,62 @@ func (m *Manager) SuggestSwaps(ctx context.Context) ( suggestion := rule.suggestSwap(balance, outRestrictions) // We can have nil suggestions in the case where no action is - // required, so only add non-nil suggestions. - if suggestion != nil { - outRequest := m.makeLoopOutRequest(suggestion) - suggestions = append(suggestions, outRequest) + // required, so we skip over them. + if suggestion == nil { + continue } + + // Get a quote for a swap of this amount. + quote, err := m.cfg.LoopOutQuote( + ctx, &loop.LoopOutQuoteRequest{ + Amount: suggestion.Amount, + SweepConfTarget: m.params.SweepConfTarget, + SwapPublicationDeadline: m.cfg.Clock.Now(), + }, + ) + if err != nil { + return nil, err + } + + log.Debugf("quote for suggestion: %v, swap fee: %v, "+ + "miner fee: %v, prepay: %v", suggestion, quote.SwapFee, + quote.MinerFee, quote.PrepayAmount) + + // Check that the estimated fees for the suggested swap are + // below the fee limits configured by the manager. + err = m.checkFeeLimits(quote, suggestion.Amount) + if err != nil { + log.Infof("suggestion: %v expected fees too high: %v", + suggestion, err) + + continue + } + + outRequest := m.makeLoopOutRequest(suggestion, quote) + suggestions = append(suggestions, outRequest) } return suggestions, nil } -// makeLoopOutRequest creates a loop out request from a suggestion, setting fee -// limits defined by our default fee values. -func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.OutRequest { +// 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 +// amounts we expect to route. The estimation we use elsewhere is the repo is +// route-independent, which is a very poor estimation so we don't bother with +// checking against this inaccurate constant. We use the exact prepay amount +// and swap fee given to us by the server, but use our maximum miner fee anyway +// to give us some leeway when performing the swap. +func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation, + quote *loop.LoopOutQuote) loop.OutRequest { + prepayMaxFee := ppmToSat( - defaultMaximumPrepay, defaultPrepayRoutingFeePPM, + quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM, ) - routeMaxFee := ppmToSat(suggestion.Amount, defaultRoutingFeePPM) - maxSwapFee := ppmToSat(suggestion.Amount, defaultSwapFeePPM) + routeMaxFee := ppmToSat( + suggestion.Amount, m.params.MaximumRoutingFeePPM, + ) return loop.OutRequest{ Amount: suggestion.Amount, @@ -344,9 +478,9 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation) loop.Out }, MaxPrepayRoutingFee: prepayMaxFee, MaxSwapRoutingFee: routeMaxFee, - MaxMinerFee: defaultMaximumMinerFee, - MaxSwapFee: maxSwapFee, - MaxPrepayAmount: defaultMaximumPrepay, + MaxMinerFee: m.params.MaximumMinerFee, + MaxSwapFee: quote.SwapFee, + MaxPrepayAmount: quote.PrepayAmount, SweepConfTarget: m.params.SweepConfTarget, } } @@ -484,6 +618,31 @@ func (m *Manager) getEligibleChannels(ctx context.Context, return eligible, nil } +// checkFeeLimits takes a set of fees for a swap and checks whether they exceed +// our swap limits. +func (m *Manager) checkFeeLimits(quote *loop.LoopOutQuote, + swapAmt btcutil.Amount) error { + + maxFee := ppmToSat(swapAmt, m.params.MaximumSwapFeePPM) + + if quote.SwapFee > maxFee { + return fmt.Errorf("quoted swap fee: %v > maximum swap fee: %v", + quote.SwapFee, maxFee) + } + + if quote.MinerFee > m.params.MaximumMinerFee { + return fmt.Errorf("quoted miner fee: %v > maximum miner "+ + "fee: %v", quote.MinerFee, m.params.MaximumMinerFee) + } + + if quote.PrepayAmount > m.params.MaximumPrepay { + return fmt.Errorf("quoted prepay: %v > maximum prepay: %v", + quote.PrepayAmount, m.params.MaximumPrepay) + } + + return nil +} + // satPerKwToSatPerVByte converts sat per kWeight to sat per vByte. func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 { return int64(satPerKw.FeePerKVByte() / 1000) diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index fe8e074..a38dd1b 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcutil" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/loopdb" @@ -44,11 +45,16 @@ var ( // chanRule is a rule that produces chan1Rec. chanRule = NewThresholdRule(50, 0) + testQuote = &loop.LoopOutQuote{ + SwapFee: btcutil.Amount(1), + PrepayAmount: btcutil.Amount(500), + MinerFee: btcutil.Amount(50), + } + prepayFee = ppmToSat( - defaultMaximumPrepay, defaultPrepayRoutingFeePPM, + testQuote.PrepayAmount, defaultPrepayRoutingFeePPM, ) routingFee = ppmToSat(7500, defaultRoutingFeePPM) - swapFee = ppmToSat(7500, defaultSwapFeePPM) // chan1Rec is the suggested swap for channel 1 when we use chanRule. chan1Rec = loop.OutRequest{ @@ -57,8 +63,8 @@ var ( MaxPrepayRoutingFee: prepayFee, MaxSwapRoutingFee: routingFee, MaxMinerFee: defaultMaximumMinerFee, - MaxSwapFee: swapFee, - MaxPrepayAmount: defaultMaximumPrepay, + MaxSwapFee: testQuote.SwapFee, + MaxPrepayAmount: testQuote.PrepayAmount, SweepConfTarget: loop.DefaultSweepConfTarget, } @@ -69,8 +75,8 @@ var ( MaxPrepayRoutingFee: prepayFee, MaxSwapRoutingFee: routingFee, MaxMinerFee: defaultMaximumMinerFee, - MaxSwapFee: swapFee, - MaxPrepayAmount: defaultMaximumPrepay, + MaxPrepayAmount: testQuote.PrepayAmount, + MaxSwapFee: testQuote.SwapFee, SweepConfTarget: loop.DefaultSweepConfTarget, } @@ -110,6 +116,12 @@ func newTestConfig() (*Config, *test.LndMockServices) { ListLoopIn: func() ([]*loopdb.LoopIn, error) { return nil, nil }, + LoopOutQuote: func(_ context.Context, + _ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, + error) { + + return testQuote, nil + }, }, lnd } @@ -448,6 +460,73 @@ func TestSuggestSwaps(t *testing.T) { } } +// TestFeeLimits tests limiting of swap suggestions by fees. +func TestFeeLimits(t *testing.T) { + tests := []struct { + name string + quote *loop.LoopOutQuote + expected []loop.OutRequest + }{ + { + name: "fees ok", + quote: testQuote, + expected: []loop.OutRequest{ + chan1Rec, + }, + }, + { + name: "insufficient prepay", + quote: &loop.LoopOutQuote{ + SwapFee: 1, + PrepayAmount: defaultMaximumPrepay + 1, + MinerFee: 50, + }, + }, + { + name: "insufficient miner fee", + quote: &loop.LoopOutQuote{ + SwapFee: 1, + PrepayAmount: 100, + MinerFee: defaultMaximumMinerFee + 1, + }, + }, + { + // Swap fee limited to 0.5% of 7500 = 37,5. + name: "insufficient swap fee", + quote: &loop.LoopOutQuote{ + SwapFee: 38, + PrepayAmount: 100, + MinerFee: 500, + }, + }, + } + + for _, testCase := range tests { + testCase := testCase + + t.Run(testCase.name, func(t *testing.T) { + cfg, lnd := newTestConfig() + cfg.LoopOutQuote = func(context.Context, + *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, + error) { + + return testCase.quote, nil + } + + channels := []lndclient.ChannelInfo{ + channel1, + } + rules := map[lnwire.ShortChannelID]*ThresholdRule{ + chanID1: chanRule, + } + + testSuggestSwaps( + t, cfg, lnd, channels, rules, testCase.expected, + ) + }) + } +} + // testSuggestSwaps tests getting swap suggestions. func testSuggestSwaps(t *testing.T, cfg *Config, lnd *test.LndMockServices, channels []lndclient.ChannelInfo, diff --git a/liquidity/suggestions.go b/liquidity/suggestions.go index 681d105..3f2bfc0 100644 --- a/liquidity/suggestions.go +++ b/liquidity/suggestions.go @@ -1,6 +1,8 @@ package liquidity import ( + "fmt" + "github.com/btcsuite/btcutil" "github.com/lightningnetwork/lnd/lnwire" ) @@ -15,6 +17,12 @@ type LoopOutRecommendation struct { Channel lnwire.ShortChannelID } +// String returns a string representation of a loop out recommendation. +func (l *LoopOutRecommendation) String() string { + return fmt.Sprintf("loop out: %v over %v", l.Amount, + l.Channel.ToUint64()) +} + // newLoopOutRecommendation creates a new loop out swap. func newLoopOutRecommendation(amount btcutil.Amount, channelID lnwire.ShortChannelID) *LoopOutRecommendation { diff --git a/loopd/utils.go b/loopd/utils.go index 509bd3d..b70cab2 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -49,6 +49,7 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager { }, Lnd: client.LndServices, Clock: clock.NewDefaultClock(), + LoopOutQuote: client.LoopOutQuote, ListLoopOut: client.Store.FetchLoopOutSwaps, ListLoopIn: client.Store.FetchLoopInSwaps, MinimumConfirmations: minConfTarget,