From c77812471878482bcfb55e2920aa56b1dafacb95 Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 2 Mar 2021 14:42:02 +0200 Subject: [PATCH] liquidity: move fees behind interface --- liquidity/autoloop_test.go | 179 ++++++++++---------- liquidity/autoloop_testcontext_test.go | 11 +- liquidity/fees.go | 226 +++++++++++++++++++++++++ liquidity/interface.go | 24 +++ liquidity/liquidity.go | 207 ++++------------------ liquidity/liquidity_test.go | 13 +- loopd/swapclient_server.go | 50 +++--- 7 files changed, 420 insertions(+), 290 deletions(-) create mode 100644 liquidity/fees.go diff --git a/liquidity/autoloop_test.go b/liquidity/autoloop_test.go index c609615..0cb6480 100644 --- a/liquidity/autoloop_test.go +++ b/liquidity/autoloop_test.go @@ -70,32 +70,37 @@ func TestAutoLoopDisabled(t *testing.T) { func TestAutoLoopEnabled(t *testing.T) { defer test.Guard(t)() - channels := []lndclient.ChannelInfo{ - channel1, channel2, - } - - // Create a set of parameters with autoloop enabled. The autoloop budget - // is set to allow exactly 2 swaps at the prices that we set in our - // test quotes. - params := Parameters{ - Autoloop: true, - AutoFeeBudget: 40066, - AutoFeeStartDate: testTime, - MaxAutoInFlight: 2, - FailureBackOff: time.Hour, - SweepFeeRateLimit: 20000, - SweepConfTarget: 10, - MaximumPrepay: 20000, - MaximumSwapFeePPM: 1000, - MaximumRoutingFeePPM: 1000, - MaximumPrepayRoutingFeePPM: 1000, - MaximumMinerFee: 20000, - ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ - chanID1: chanRule, - chanID2: chanRule, - }, - } + var ( + channels = []lndclient.ChannelInfo{ + channel1, channel2, + } + swapFeePPM uint64 = 1000 + routeFeePPM uint64 = 1000 + prepayFeePPM uint64 = 1000 + prepayAmount = btcutil.Amount(20000) + maxMiner = btcutil.Amount(20000) + + // Create a set of parameters with autoloop enabled. The + // autoloop budget is set to allow exactly 2 swaps at the prices + // that we set in our test quotes. + params = Parameters{ + Autoloop: true, + AutoFeeBudget: 40066, + AutoFeeStartDate: testTime, + MaxAutoInFlight: 2, + FailureBackOff: time.Hour, + SweepConfTarget: 10, + FeeLimit: NewFeeCategoryLimit( + swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner, + prepayAmount, 20000, + ), + ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ + chanID1: chanRule, + chanID2: chanRule, + }, + } + ) c := newAutoloopTestCtx(t, params, channels, testRestrictions) c.start() @@ -104,18 +109,20 @@ func TestAutoLoopEnabled(t *testing.T) { var ( amt = chan1Rec.Amount - maxSwapFee = ppmToSat(amt, params.MaximumSwapFeePPM) + maxSwapFee = ppmToSat(amt, swapFeePPM) // Create a quote that is within our limits. We do not set miner // fee because this value is not actually set by the server. quote1 = &loop.LoopOutQuote{ SwapFee: maxSwapFee, - PrepayAmount: params.MaximumPrepay - 10, + PrepayAmount: prepayAmount - 10, + MinerFee: maxMiner - 10, } quote2 = &loop.LoopOutQuote{ SwapFee: maxSwapFee, - PrepayAmount: params.MaximumPrepay - 20, + PrepayAmount: prepayAmount - 20, + MinerFee: maxMiner - 10, } quoteRequest = &loop.LoopOutQuoteRequest{ @@ -134,18 +141,17 @@ func TestAutoLoopEnabled(t *testing.T) { }, } - maxRouteFee = ppmToSat(amt, params.MaximumRoutingFeePPM) + maxRouteFee = ppmToSat(amt, routeFeePPM) chan1Swap = &loop.OutRequest{ Amount: amt, MaxSwapRoutingFee: maxRouteFee, MaxPrepayRoutingFee: ppmToSat( - quote1.PrepayAmount, - params.MaximumPrepayRoutingFeePPM, + quote1.PrepayAmount, prepayFeePPM, ), MaxSwapFee: quote1.SwapFee, MaxPrepayAmount: quote1.PrepayAmount, - MaxMinerFee: params.MaximumMinerFee, + MaxMinerFee: maxMiner, SweepConfTarget: params.SweepConfTarget, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, Label: labels.AutoloopLabel(swap.TypeOut), @@ -156,12 +162,11 @@ func TestAutoLoopEnabled(t *testing.T) { Amount: amt, MaxSwapRoutingFee: maxRouteFee, MaxPrepayRoutingFee: ppmToSat( - quote2.PrepayAmount, - params.MaximumPrepayRoutingFeePPM, + quote2.PrepayAmount, routeFeePPM, ), MaxSwapFee: quote2.SwapFee, MaxPrepayAmount: quote2.PrepayAmount, - MaxMinerFee: params.MaximumMinerFee, + MaxMinerFee: maxMiner, SweepConfTarget: params.SweepConfTarget, OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()}, Label: labels.AutoloopLabel(swap.TypeOut), @@ -216,7 +221,7 @@ func TestAutoLoopEnabled(t *testing.T) { State: loopdb.StateSuccess, Cost: loopdb.SwapCost{ Server: quote1.SwapFee, - Onchain: params.MaximumMinerFee, + Onchain: maxMiner, Offchain: maxRouteFee + chan1Rec.MaxPrepayRoutingFee, }, @@ -273,42 +278,48 @@ func TestAutoLoopEnabled(t *testing.T) { func TestCompositeRules(t *testing.T) { defer test.Guard(t)() - // Setup our channels so that we have two channels with peer 2, and - // a single channel with peer 1. - channel3 := lndclient.ChannelInfo{ - ChannelID: chanID3.ToUint64(), - PubKeyBytes: peer2, - LocalBalance: 10000, - RemoteBalance: 0, - Capacity: 10000, - } + var ( + // Setup our channels so that we have two channels with peer 2, + // and a single channel with peer 1. + channel3 = lndclient.ChannelInfo{ + ChannelID: chanID3.ToUint64(), + PubKeyBytes: peer2, + LocalBalance: 10000, + RemoteBalance: 0, + Capacity: 10000, + } - channels := []lndclient.ChannelInfo{ - channel1, channel2, channel3, - } + channels = []lndclient.ChannelInfo{ + channel1, channel2, channel3, + } - // Create a set of parameters with autoloop enabled, set our budget to - // a value that will easily accommodate our two swaps. - params := Parameters{ - Autoloop: true, - AutoFeeBudget: 100000, - AutoFeeStartDate: testTime, - MaxAutoInFlight: 2, - FailureBackOff: time.Hour, - SweepFeeRateLimit: 20000, - SweepConfTarget: 10, - MaximumPrepay: 20000, - MaximumSwapFeePPM: 1000, - MaximumRoutingFeePPM: 1000, - MaximumPrepayRoutingFeePPM: 1000, - MaximumMinerFee: 20000, - ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ - chanID1: chanRule, - }, - PeerRules: map[route.Vertex]*ThresholdRule{ - peer2: chanRule, - }, - } + swapFeePPM uint64 = 1000 + routeFeePPM uint64 = 1000 + prepayFeePPM uint64 = 1000 + prepayAmount = btcutil.Amount(20000) + maxMiner = btcutil.Amount(20000) + + // Create a set of parameters with autoloop enabled, set our + // budget to a value that will easily accommodate our two swaps. + params = Parameters{ + FeeLimit: NewFeeCategoryLimit( + swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner, + prepayAmount, 20000, + ), + Autoloop: true, + AutoFeeBudget: 100000, + AutoFeeStartDate: testTime, + MaxAutoInFlight: 2, + FailureBackOff: time.Hour, + SweepConfTarget: 10, + ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{ + chanID1: chanRule, + }, + PeerRules: map[route.Vertex]*ThresholdRule{ + peer2: chanRule, + }, + } + ) c := newAutoloopTestCtx(t, params, channels, testRestrictions) c.start() @@ -320,11 +331,12 @@ func TestCompositeRules(t *testing.T) { // our budget, with an amount which would balance the peer /// across all of its channels. peerAmount = btcutil.Amount(15000) - maxPeerSwapFee = ppmToSat(peerAmount, params.MaximumSwapFeePPM) + maxPeerSwapFee = ppmToSat(peerAmount, swapFeePPM) peerSwapQuote = &loop.LoopOutQuote{ SwapFee: maxPeerSwapFee, - PrepayAmount: params.MaximumPrepay - 20, + PrepayAmount: prepayAmount - 20, + MinerFee: maxMiner - 10, } peerSwapQuoteRequest = &loop.LoopOutQuoteRequest{ @@ -332,20 +344,17 @@ func TestCompositeRules(t *testing.T) { SweepConfTarget: params.SweepConfTarget, } - maxPeerRouteFee = ppmToSat( - peerAmount, params.MaximumRoutingFeePPM, - ) + maxPeerRouteFee = ppmToSat(peerAmount, routeFeePPM) peerSwap = &loop.OutRequest{ Amount: peerAmount, MaxSwapRoutingFee: maxPeerRouteFee, MaxPrepayRoutingFee: ppmToSat( - peerSwapQuote.PrepayAmount, - params.MaximumPrepayRoutingFeePPM, + peerSwapQuote.PrepayAmount, routeFeePPM, ), MaxSwapFee: peerSwapQuote.SwapFee, MaxPrepayAmount: peerSwapQuote.PrepayAmount, - MaxMinerFee: params.MaximumMinerFee, + MaxMinerFee: maxMiner, SweepConfTarget: params.SweepConfTarget, OutgoingChanSet: loopdb.ChannelSet{ chanID2.ToUint64(), chanID3.ToUint64(), @@ -356,11 +365,12 @@ func TestCompositeRules(t *testing.T) { // Create a quote for our single channel swap that is within // our budget. chanAmount = chan1Rec.Amount - maxChanSwapFee = ppmToSat(chanAmount, params.MaximumSwapFeePPM) + maxChanSwapFee = ppmToSat(chanAmount, swapFeePPM) channelSwapQuote = &loop.LoopOutQuote{ SwapFee: maxChanSwapFee, - PrepayAmount: params.MaximumPrepay - 10, + PrepayAmount: prepayAmount - 10, + MinerFee: maxMiner - 10, } chanSwapQuoteRequest = &loop.LoopOutQuoteRequest{ @@ -368,20 +378,17 @@ func TestCompositeRules(t *testing.T) { SweepConfTarget: params.SweepConfTarget, } - maxChanRouteFee = ppmToSat( - chanAmount, params.MaximumRoutingFeePPM, - ) + maxChanRouteFee = ppmToSat(chanAmount, routeFeePPM) chanSwap = &loop.OutRequest{ Amount: chanAmount, MaxSwapRoutingFee: maxChanRouteFee, MaxPrepayRoutingFee: ppmToSat( - channelSwapQuote.PrepayAmount, - params.MaximumPrepayRoutingFeePPM, + channelSwapQuote.PrepayAmount, routeFeePPM, ), MaxSwapFee: channelSwapQuote.SwapFee, MaxPrepayAmount: channelSwapQuote.PrepayAmount, - MaxMinerFee: params.MaximumMinerFee, + MaxMinerFee: maxMiner, SweepConfTarget: params.SweepConfTarget, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, Label: labels.AutoloopLabel(swap.TypeOut), diff --git a/liquidity/autoloop_testcontext_test.go b/liquidity/autoloop_testcontext_test.go index ec40053..a7f9a77 100644 --- a/liquidity/autoloop_testcontext_test.go +++ b/liquidity/autoloop_testcontext_test.go @@ -65,10 +65,13 @@ func newAutoloopTestCtx(t *testing.T, parameters Parameters, // Create a mock lnd and set our expected fee rate for sweeps to our // sweep fee rate limit value. lnd := test.NewMockLnd() - lnd.SetFeeEstimate( - defaultParameters.SweepConfTarget, - defaultParameters.SweepFeeRateLimit, - ) + + categories, ok := parameters.FeeLimit.(*FeeCategoryLimit) + if ok { + lnd.SetFeeEstimate( + parameters.SweepConfTarget, categories.SweepFeeRateLimit, + ) + } testCtx := &autoloopTestCtx{ t: t, diff --git a/liquidity/fees.go b/liquidity/fees.go new file mode 100644 index 0000000..f5555f3 --- /dev/null +++ b/liquidity/fees.go @@ -0,0 +1,226 @@ +package liquidity + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +const ( + // defaultSwapFeePPM is the default limit we place on swap fees, + // expressed as parts per million of swap volume, 0.5%. + defaultSwapFeePPM = 5000 + + // defaultRoutingFeePPM is the default limit we place on routing fees + // for the swap invoice, expressed as parts per million of swap volume, + // 1%. + defaultRoutingFeePPM = 10000 + + // defaultRoutingFeePPM is the default limit we place on routing fees + // for the prepay invoice, expressed as parts per million of prepay + // volume, 0.5%. + defaultPrepayRoutingFeePPM = 5000 + + // defaultMaximumMinerFee is the default limit we place on miner fees + // 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. + defaultMaximumPrepay = 30000 + + // defaultSweepFeeRateLimit is the default limit we place on estimated + // sweep fees, (750 * 4 /1000 = 3 sat/vByte). + defaultSweepFeeRateLimit = chainfee.SatPerKWeight(750) +) + +var ( + // 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") + + // ErrInvalidSweepFeeRateLimit is returned if an invalid sweep fee limit + // is set. + ErrInvalidSweepFeeRateLimit = fmt.Errorf("sweep fee rate limit must "+ + "be > %v sat/vByte", + satPerKwToSatPerVByte(chainfee.AbsoluteFeePerKwFloor)) +) + +// Compile time assertion that FeeCategoryLimit implements FeeLimit. +var _ FeeLimit = (*FeeCategoryLimit)(nil) + +// FeeCategoryLimit is an implementation of the fee limit interface which sets +// a specific fee limit per fee category. +type FeeCategoryLimit struct { + // 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 uint64 + + // 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 uint64 + + // 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 uint64 + + // 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 + + // SweepFeeRateLimit is the limit that we place on our estimated sweep + // fee. A swap will not be suggested if estimated fee rate is above this + // value. + SweepFeeRateLimit chainfee.SatPerKWeight +} + +// NewFeeCategoryLimit created a new fee limit struct which sets individual +// fee limits per category. +func NewFeeCategoryLimit(swapFeePPM, routingFeePPM, prepayFeePPM uint64, + minerFee, prepay btcutil.Amount, + sweepLimit chainfee.SatPerKWeight) *FeeCategoryLimit { + + return &FeeCategoryLimit{ + MaximumPrepay: prepay, + MaximumSwapFeePPM: swapFeePPM, + MaximumRoutingFeePPM: routingFeePPM, + MaximumPrepayRoutingFeePPM: prepayFeePPM, + MaximumMinerFee: minerFee, + SweepFeeRateLimit: sweepLimit, + } +} + +func defaultFeeCategoryLimit() *FeeCategoryLimit { + return NewFeeCategoryLimit(defaultSwapFeePPM, defaultRoutingFeePPM, + defaultPrepayRoutingFeePPM, defaultMaximumMinerFee, + defaultMaximumPrepay, defaultSweepFeeRateLimit) +} + +// String returns the string representation of our fee category limits. +func (f *FeeCategoryLimit) String() string { + return fmt.Sprintf("fee categories: maximum prepay: %v, maximum "+ + "miner fee: %v, maximum swap fee ppm: %v, maximum "+ + "routing fee ppm: %v, maximum prepay routing fee ppm: %v,"+ + "sweep fee limit: %v", f.MaximumPrepay, f.MaximumMinerFee, + f.MaximumSwapFeePPM, f.MaximumRoutingFeePPM, + f.MaximumPrepayRoutingFeePPM, f.SweepFeeRateLimit, + ) +} + +func (f *FeeCategoryLimit) validate() error { + // Check that we have non-zero fee limits. + if f.MaximumSwapFeePPM == 0 { + return ErrZeroSwapFeePPM + } + + if f.MaximumRoutingFeePPM == 0 { + return ErrZeroRoutingPPM + } + + if f.MaximumPrepayRoutingFeePPM == 0 { + return ErrZeroPrepayPPM + } + + if f.MaximumPrepay == 0 { + return ErrZeroPrepay + } + + if f.MaximumMinerFee == 0 { + return ErrZeroMinerFee + } + + // Check that our sweep limit is above our minimum fee rate. We use + // absolute fee floor rather than kw floor because we will allow users + // to specify fee rate is sat/vByte and want to allow 1 sat/vByte. + if f.SweepFeeRateLimit < chainfee.AbsoluteFeePerKwFloor { + return ErrInvalidSweepFeeRateLimit + } + + return nil +} + +// mayLoopOut checks our estimated loop out sweep fee against our sweep limit. +func (f *FeeCategoryLimit) mayLoopOut(estimate chainfee.SatPerKWeight) error { + if estimate > f.SweepFeeRateLimit { + log.Debugf("Current fee estimate to sweep: %v sat/vByte "+ + "exceeds limit of: %v sat/vByte", + satPerKwToSatPerVByte(estimate), + satPerKwToSatPerVByte(f.SweepFeeRateLimit)) + + return newReasonError(ReasonSweepFees) + } + + return nil +} + +// loopOutLimits checks whether the quote provided is within our fee limits. +func (f *FeeCategoryLimit) loopOutLimits(amount btcutil.Amount, + quote *loop.LoopOutQuote) error { + + maxFee := ppmToSat(amount, f.MaximumSwapFeePPM) + + if quote.SwapFee > maxFee { + log.Debugf("quoted swap fee: %v > maximum swap fee: %v", + quote.SwapFee, maxFee) + + 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) + } + + if quote.PrepayAmount > f.MaximumPrepay { + log.Debugf("quoted prepay: %v > maximum prepay: %v", + quote.PrepayAmount, f.MaximumPrepay) + + return newReasonError(ReasonPrepay) + } + + 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, + quote *loop.LoopOutQuote) (btcutil.Amount, btcutil.Amount, + btcutil.Amount) { + + prepayMaxFee := ppmToSat( + quote.PrepayAmount, f.MaximumPrepayRoutingFeePPM, + ) + + routeMaxFee := ppmToSat(amount, f.MaximumRoutingFeePPM) + + return prepayMaxFee, routeMaxFee, f.MaximumMinerFee +} diff --git a/liquidity/interface.go b/liquidity/interface.go index a2386ed..4c695ad 100644 --- a/liquidity/interface.go +++ b/liquidity/interface.go @@ -3,9 +3,33 @@ package liquidity import ( "github.com/btcsuite/btcutil" "github.com/lightninglabs/loop" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" ) +// FeeLimit is an interface implemented by different strategies for limiting +// the fees we pay for autoloops. +type FeeLimit interface { + // String returns the string representation of fee limits. + String() string + + // validate returns an error if the values provided are invalid. + validate() error + + // mayLoopOut checks whether we may dispatch a loop out swap based on + // the current fee conditions. + mayLoopOut(estimate chainfee.SatPerKWeight) error + + // loopOutLimits checks whether the quote provided is within our fee + // limits for the swap amount. + loopOutLimits(amount btcutil.Amount, quote *loop.LoopOutQuote) error + + // loopOutFees return the maximum prepay and invoice routing fees for + // a swap amount and quote. + loopOutFees(amount btcutil.Amount, quote *loop.LoopOutQuote) ( + btcutil.Amount, btcutil.Amount, btcutil.Amount) +} + // swapSuggestion is an interface implemented by suggested swaps for our // different swap types. This interface is used to allow us to handle different // swap types with the same autoloop logic. diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index 278e9df..3046e6d 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -63,34 +63,6 @@ const ( // FeeBase is the base that we use to express fees. FeeBase = 1e6 - // defaultSwapFeePPM is the default limit we place on swap fees, - // expressed as parts per million of swap volume, 0.5%. - defaultSwapFeePPM = 5000 - - // defaultRoutingFeePPM is the default limit we place on routing fees - // for the swap invoice, expressed as parts per million of swap volume, - // 1%. - defaultRoutingFeePPM = 10000 - - // defaultRoutingFeePPM is the default limit we place on routing fees - // for the prepay invoice, expressed as parts per million of prepay - // volume, 0.5%. - defaultPrepayRoutingFeePPM = 5000 - - // defaultMaximumMinerFee is the default limit we place on miner fees - // 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. - defaultMaximumPrepay = 30000 - - // defaultSweepFeeRateLimit is the default limit we place on estimated - // sweep fees, (750 * 4 /1000 = 3 sat/vByte). - defaultSweepFeeRateLimit = chainfee.SatPerKWeight(750) - // defaultMaxInFlight is the default number of in-flight automatically // dispatched swaps we allow. Note that this does not enable automated // swaps itself (because we want non-zero values to be expressed in @@ -121,44 +93,18 @@ var ( // defaultParameters contains the default parameters that we start our // liquidity manger with. defaultParameters = Parameters{ - AutoFeeBudget: defaultBudget, - MaxAutoInFlight: defaultMaxInFlight, - ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), - PeerRules: make(map[route.Vertex]*ThresholdRule), - FailureBackOff: defaultFailureBackoff, - SweepFeeRateLimit: defaultSweepFeeRateLimit, - SweepConfTarget: loop.DefaultSweepConfTarget, - MaximumSwapFeePPM: defaultSwapFeePPM, - MaximumRoutingFeePPM: defaultRoutingFeePPM, - MaximumPrepayRoutingFeePPM: defaultPrepayRoutingFeePPM, - MaximumMinerFee: defaultMaximumMinerFee, - MaximumPrepay: defaultMaximumPrepay, + AutoFeeBudget: defaultBudget, + MaxAutoInFlight: defaultMaxInFlight, + ChannelRules: make(map[lnwire.ShortChannelID]*ThresholdRule), + PeerRules: make(map[route.Vertex]*ThresholdRule), + FailureBackOff: defaultFailureBackoff, + SweepConfTarget: loop.DefaultSweepConfTarget, + FeeLimit: defaultFeeCategoryLimit(), } // ErrZeroChannelID is returned if we get a rule for a 0 channel ID. ErrZeroChannelID = fmt.Errorf("zero channel ID not allowed") - // ErrInvalidSweepFeeRateLimit is returned if an invalid sweep fee limit - // is set. - 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") - // ErrNegativeBudget is returned if a negative swap budget is set. ErrNegativeBudget = errors.New("swap budget must be >= 0") @@ -254,41 +200,12 @@ type Parameters struct { // TODO(carla): add exponential backoff FailureBackOff time.Duration - // SweepFeeRateLimit is the limit that we place on our estimated sweep - // fee. A swap will not be suggested if estimated fee rate is above this - // value. - SweepFeeRateLimit chainfee.SatPerKWeight - // 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 - // 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 + // FeeLimit controls the fee limit we place on swaps. + FeeLimit FeeLimit // ClientRestrictions are the restrictions placed on swap size by the // client. @@ -324,15 +241,10 @@ func (p Parameters) String() string { } return fmt.Sprintf("rules: %v, failure backoff: %v, sweep "+ - "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, "+ - "minimum swap size=%v, maximum swap size=%v", - strings.Join(ruleList, ","), p.FailureBackOff, - p.SweepFeeRateLimit, p.SweepConfTarget, p.MaximumPrepay, - p.MaximumMinerFee, p.MaximumSwapFeePPM, - p.MaximumRoutingFeePPM, p.MaximumPrepayRoutingFeePPM, + "sweep 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.FeeLimit, p.AutoFeeBudget, p.AutoFeeStartDate, p.MaxAutoInFlight, p.ClientRestrictions.Minimum, p.ClientRestrictions.Maximum) } @@ -403,38 +315,14 @@ func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, } } - // Check that our sweep limit is above our minimum fee rate. We use - // absolute fee floor rather than kw floor because we will allow users - // to specify fee rate is sat/vByte and want to allow 1 sat/vByte. - if p.SweepFeeRateLimit < chainfee.AbsoluteFeePerKwFloor { - return ErrInvalidSweepFeeRateLimit - } - // 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) } - // 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 + if err := p.FeeLimit.validate(); err != nil { + return err } if p.AutoFeeBudget < 0 { @@ -733,14 +621,14 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( return nil, err } - if estimate > m.params.SweepFeeRateLimit { - log.Debugf("Current fee estimate to sweep within: %v blocks "+ - "%v sat/vByte exceeds limit of: %v sat/vByte", - m.params.SweepConfTarget, - satPerKwToSatPerVByte(estimate), - satPerKwToSatPerVByte(m.params.SweepFeeRateLimit)) + if err := m.params.FeeLimit.mayLoopOut(estimate); err != nil { + var reasonErr *reasonError + if errors.As(err, &reasonErr) { + return m.singleReasonSuggestion(reasonErr.reason), nil + + } - return m.singleReasonSuggestion(ReasonSweepFees), nil + return nil, err } // Get the current server side restrictions, combined with the client @@ -989,9 +877,8 @@ func (m *Manager) loopOutSwap(ctx context.Context, amount btcutil.Amount, // Check that the estimated fees for the suggested swap are // below the fee limits configured by the manager. - feeReason := m.checkFeeLimits(quote, amount) - if feeReason != ReasonNone { - return nil, newReasonError(feeReason) + if err := m.params.FeeLimit.loopOutLimits(amount, quote); err != nil { + return nil, err } outRequest, err := m.makeLoopOutRequest( @@ -1054,23 +941,24 @@ func (m *Manager) makeLoopOutRequest(ctx context.Context, amount btcutil.Amount, balance *balances, quote *loop.LoopOutQuote, autoloop bool) (loop.OutRequest, error) { - prepayMaxFee := ppmToSat( - quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM, + prepayMaxFee, routeMaxFee, minerFee := m.params.FeeLimit.loopOutFees( + amount, quote, ) - routeMaxFee := ppmToSat(amount, m.params.MaximumRoutingFeePPM) - var chanSet loopdb.ChannelSet for _, channel := range balance.channels { chanSet = append(chanSet, channel.ToUint64()) } + // Create a request with our calculated routing fees. We can use the + // swap fee, prepay amount and miner fee from the quote because we have + // already validated them. request := loop.OutRequest{ Amount: amount, OutgoingChanSet: chanSet, MaxPrepayRoutingFee: prepayMaxFee, MaxSwapRoutingFee: routeMaxFee, - MaxMinerFee: m.params.MaximumMinerFee, + MaxMinerFee: minerFee, MaxSwapFee: quote.SwapFee, MaxPrepayAmount: quote.PrepayAmount, SweepConfTarget: m.params.SweepConfTarget, @@ -1305,37 +1193,6 @@ func (s *swapTraffic) maySwap(peer route.Vertex, return 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) Reason { - - maxFee := ppmToSat(swapAmt, m.params.MaximumSwapFeePPM) - - if quote.SwapFee > maxFee { - log.Debugf("quoted swap fee: %v > maximum swap fee: %v", - quote.SwapFee, maxFee) - - return ReasonSwapFee - } - - if quote.MinerFee > m.params.MaximumMinerFee { - log.Debugf("quoted miner fee: %v > maximum miner "+ - "fee: %v", quote.MinerFee, m.params.MaximumMinerFee) - - return ReasonMinerFee - } - - if quote.PrepayAmount > m.params.MaximumPrepay { - log.Debugf("quoted prepay: %v > maximum prepay: %v", - quote.PrepayAmount, m.params.MaximumPrepay) - - return ReasonPrepay - } - - return ReasonNone -} - // satPerKwToSatPerVByte converts sat per kWeight to sat per vByte. func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 { return int64(satPerKw.FeePerKVByte() / 1000) @@ -1343,8 +1200,8 @@ func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 { // ppmToSat takes an amount and a measure of parts per million for the amount // and returns the amount that the ppm represents. -func ppmToSat(amount btcutil.Amount, ppm int) btcutil.Amount { - return btcutil.Amount(uint64(amount) * uint64(ppm) / FeeBase) +func ppmToSat(amount btcutil.Amount, ppm uint64) btcutil.Amount { + return btcutil.Amount(uint64(amount) * ppm / FeeBase) } func mSatToSatoshis(amount lnwire.MilliSatoshi) btcutil.Amount { diff --git a/liquidity/liquidity_test.go b/liquidity/liquidity_test.go index c014f71..9d9564c 100644 --- a/liquidity/liquidity_test.go +++ b/liquidity/liquidity_test.go @@ -125,8 +125,7 @@ func newTestConfig() (*Config, *test.LndMockServices) { // Set our fee estimate for the default number of confirmations to our // limit so that our fees will be ok by default. lnd.SetFeeEstimate( - defaultParameters.SweepConfTarget, - defaultParameters.SweepFeeRateLimit, + defaultParameters.SweepConfTarget, defaultSweepFeeRateLimit, ) return &Config{ @@ -978,8 +977,13 @@ func TestFeeBudget(t *testing.T) { } params.AutoFeeStartDate = testBudgetStart params.AutoFeeBudget = testCase.budget - params.MaximumMinerFee = testCase.maxMinerFee params.MaxAutoInFlight = 2 + params.FeeLimit = NewFeeCategoryLimit( + defaultSwapFeePPM, defaultRoutingFeePPM, + defaultPrepayRoutingFeePPM, + testCase.maxMinerFee, defaultMaximumPrepay, + defaultSweepFeeRateLimit, + ) // Set our custom max miner fee on each expected swap, // rather than having to create multiple vars for @@ -1129,8 +1133,7 @@ func TestSizeRestrictions(t *testing.T) { OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, MaxPrepayRoutingFee: prepayFee, MaxSwapRoutingFee: ppmToSat( - 7000, - defaultRoutingFeePPM, + 7000, defaultRoutingFeePPM, ), MaxMinerFee: defaultMaximumMinerFee, MaxSwapFee: testQuote.SwapFee, diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d0e1e32..d7ebbc2 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -572,22 +572,14 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context, cfg := s.liquidityMgr.GetParameters() - satPerByte := cfg.SweepFeeRateLimit.FeePerKVByte() / 1000 - totalRules := len(cfg.ChannelRules) + len(cfg.PeerRules) rpcCfg := &looprpc.LiquidityParameters{ - MaxMinerFeeSat: uint64(cfg.MaximumMinerFee), - MaxSwapFeePpm: uint64(cfg.MaximumSwapFeePPM), - MaxRoutingFeePpm: uint64(cfg.MaximumRoutingFeePPM), - MaxPrepayRoutingFeePpm: uint64(cfg.MaximumPrepayRoutingFeePPM), - MaxPrepaySat: uint64(cfg.MaximumPrepay), - SweepFeeRateSatPerVbyte: uint64(satPerByte), - SweepConfTarget: cfg.SweepConfTarget, - FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()), - Autoloop: cfg.Autoloop, - AutoloopBudgetSat: uint64(cfg.AutoFeeBudget), - AutoMaxInFlight: uint64(cfg.MaxAutoInFlight), + SweepConfTarget: cfg.SweepConfTarget, + FailureBackoffSec: uint64(cfg.FailureBackOff.Seconds()), + Autoloop: cfg.Autoloop, + AutoloopBudgetSat: uint64(cfg.AutoFeeBudget), + AutoMaxInFlight: uint64(cfg.MaxAutoInFlight), Rules: make( []*looprpc.LiquidityRule, 0, totalRules, ), @@ -595,6 +587,20 @@ func (s *swapClientServer) GetLiquidityParams(_ context.Context, MaxSwapAmount: uint64(cfg.ClientRestrictions.Maximum), } + feeCategories, ok := cfg.FeeLimit.(*liquidity.FeeCategoryLimit) + if !ok { + return nil, fmt.Errorf("unknown fee limit: %T", cfg.FeeLimit) + } + + satPerByte := feeCategories.SweepFeeRateLimit.FeePerKVByte() / 1000 + + rpcCfg.SweepFeeRateSatPerVbyte = uint64(satPerByte) + rpcCfg.MaxMinerFeeSat = uint64(feeCategories.MaximumMinerFee) + rpcCfg.MaxSwapFeePpm = feeCategories.MaximumSwapFeePPM + rpcCfg.MaxRoutingFeePpm = feeCategories.MaximumRoutingFeePPM + rpcCfg.MaxPrepayRoutingFeePpm = feeCategories.MaximumPrepayRoutingFeePPM + rpcCfg.MaxPrepaySat = uint64(feeCategories.MaximumPrepay) + // Zero golang time is different to a zero unix time, so we only set // our start date if it is non-zero. if !cfg.AutoFeeStartDate.IsZero() { @@ -639,14 +645,18 @@ func (s *swapClientServer) SetLiquidityParams(ctx context.Context, in.Parameters.SweepFeeRateSatPerVbyte * 1000, ) + feeLimit := liquidity.NewFeeCategoryLimit( + in.Parameters.MaxSwapFeePpm, + in.Parameters.MaxRoutingFeePpm, + in.Parameters.MaxPrepayRoutingFeePpm, + btcutil.Amount(in.Parameters.MaxMinerFeeSat), + btcutil.Amount(in.Parameters.MaxPrepaySat), + satPerVbyte.FeePerKWeight(), + ) + params := liquidity.Parameters{ - MaximumMinerFee: btcutil.Amount(in.Parameters.MaxMinerFeeSat), - MaximumSwapFeePPM: int(in.Parameters.MaxSwapFeePpm), - MaximumRoutingFeePPM: int(in.Parameters.MaxRoutingFeePpm), - MaximumPrepayRoutingFeePPM: int(in.Parameters.MaxPrepayRoutingFeePpm), - MaximumPrepay: btcutil.Amount(in.Parameters.MaxPrepaySat), - SweepFeeRateLimit: satPerVbyte.FeePerKWeight(), - SweepConfTarget: in.Parameters.SweepConfTarget, + FeeLimit: feeLimit, + SweepConfTarget: in.Parameters.SweepConfTarget, FailureBackOff: time.Duration(in.Parameters.FailureBackoffSec) * time.Second, Autoloop: in.Parameters.Autoloop,