@@ -36,14 +36,17 @@ import (
3636 "context"
3737 "errors"
3838 "fmt"
39+ "sort"
3940 "strings"
4041 "sync"
4142 "time"
4243
4344 "github.com/btcsuite/btcutil"
4445 "github.com/lightninglabs/lndclient"
4546 "github.com/lightninglabs/loop"
47+ "github.com/lightninglabs/loop/labels"
4648 "github.com/lightninglabs/loop/loopdb"
49+ "github.com/lightningnetwork/lnd"
4750 "github.com/lightningnetwork/lnd/clock"
4851 "github.com/lightningnetwork/lnd/lnwallet/chainfee"
4952 "github.com/lightningnetwork/lnd/lnwire"
@@ -88,9 +91,21 @@ const (
8891)
8992
9093var (
94+ // defaultBudget is the default autoloop budget we set. This budget will
95+ // only be used for automatically dispatched swaps if autoloop is
96+ // explicitly enabled, so we are happy to set a non-zero value here. The
97+ // amount chosen simply uses the current defaults to provide budget for
98+ // a single swap. We don't have a swap amount to calculate our maximum
99+ // routing fee, so we use 0.16 BTC for now.
100+ defaultBudget = defaultMaximumMinerFee +
101+ ppmToSat (lnd .MaxBtcFundingAmount , defaultSwapFeePPM ) +
102+ ppmToSat (defaultMaximumPrepay , defaultPrepayRoutingFeePPM ) +
103+ ppmToSat (lnd .MaxBtcFundingAmount , defaultRoutingFeePPM )
104+
91105 // defaultParameters contains the default parameters that we start our
92106 // liquidity manger with.
93107 defaultParameters = Parameters {
108+ AutoFeeBudget : defaultBudget ,
94109 ChannelRules : make (map [lnwire.ShortChannelID ]* ThresholdRule ),
95110 FailureBackOff : defaultFailureBackoff ,
96111 SweepFeeRateLimit : defaultSweepFeeRateLimit ,
@@ -125,6 +140,9 @@ var (
125140
126141 // ErrZeroPrepay is returned if a zero maximum prepay is set.
127142 ErrZeroPrepay = errors .New ("maximum prepay must be non-zero" )
143+
144+ // ErrNegativeBudget is returned if a negative swap budget is set.
145+ ErrNegativeBudget = errors .New ("swap budget must be >= 0" )
128146)
129147
130148// Config contains the external functionality required to run the
@@ -159,6 +177,16 @@ type Config struct {
159177// Parameters is a set of parameters provided by the user which guide
160178// how we assess liquidity.
161179type Parameters struct {
180+ // AutoFeeBudget is the total amount we allow to be spent on
181+ // automatically dispatched swaps. Once this budget has been used, we
182+ // will stop dispatching swaps until the budget is increased or the
183+ // start date is moved.
184+ AutoFeeBudget btcutil.Amount
185+
186+ // AutoFeeStartDate is the date from which we will include automatically
187+ // dispatched swaps in our current budget, inclusive.
188+ AutoFeeStartDate time.Time
189+
162190 // FailureBackOff is the amount of time that we require passes after a
163191 // channel has been part of a failed loop out swap before we suggest
164192 // using it again.
@@ -219,12 +247,13 @@ func (p Parameters) String() string {
219247 return fmt .Sprintf ("channel rules: %v, failure backoff: %v, sweep " +
220248 "fee rate limit: %v, sweep conf target: %v, maximum prepay: " +
221249 "%v, maximum miner fee: %v, maximum swap fee ppm: %v, maximum " +
222- "routing fee ppm: %v, maximum prepay routing fee ppm: %v" ,
250+ "routing fee ppm: %v, maximum prepay routing fee ppm: %v, " +
251+ "auto budget: %v, budget start: %v" ,
223252 strings .Join (channelRules , "," ), p .FailureBackOff ,
224253 p .SweepFeeRateLimit , p .SweepConfTarget , p .MaximumPrepay ,
225254 p .MaximumMinerFee , p .MaximumSwapFeePPM ,
226255 p .MaximumRoutingFeePPM , p .MaximumPrepayRoutingFeePPM ,
227- )
256+ p . AutoFeeBudget , p . AutoFeeStartDate )
228257}
229258
230259// validate checks whether a set of parameters is valid. It takes the minimum
@@ -275,6 +304,10 @@ func (p Parameters) validate(minConfs int32) error {
275304 return ErrZeroMinerFee
276305 }
277306
307+ if p .AutoFeeBudget < 0 {
308+ return ErrNegativeBudget
309+ }
310+
278311 return nil
279312}
280313
@@ -356,6 +389,16 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
356389 return nil , nil
357390 }
358391
392+ // If our start date is in the future, we interpret this as meaning that
393+ // we should start using our budget at this date. This means that we
394+ // have no budget for the present, so we just return.
395+ if m .params .AutoFeeStartDate .After (m .cfg .Clock .Now ()) {
396+ log .Debugf ("autoloop fee budget start time: %v is in " +
397+ "the future" , m .params .AutoFeeStartDate )
398+
399+ return nil , nil
400+ }
401+
359402 // Before we get any swap suggestions, we check what the current fee
360403 // estimate is to sweep within our target number of confirmations. If
361404 // This fee exceeds the fee limit we have set, we will not suggest any
@@ -396,6 +439,23 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
396439 return nil , err
397440 }
398441
442+ // Get a summary of our existing swaps so that we can check our autoloop
443+ // budget.
444+ summary , err := m .checkExistingAutoLoops (ctx , loopOut )
445+ if err != nil {
446+ return nil , err
447+ }
448+
449+ if summary .totalFees () >= m .params .AutoFeeBudget {
450+ log .Debugf ("autoloop fee budget: %v exhausted, %v spent on " +
451+ "completed swaps, %v reserved for ongoing swaps " +
452+ "(upper limit)" ,
453+ m .params .AutoFeeBudget , summary .spentFees ,
454+ summary .pendingFees )
455+
456+ return nil , nil
457+ }
458+
399459 eligible , err := m .getEligibleChannels (ctx , loopOut , loopIn )
400460 if err != nil {
401461 return nil , err
@@ -449,7 +509,45 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
449509 suggestions = append (suggestions , outRequest )
450510 }
451511
452- return suggestions , nil
512+ // If we have no suggestions after we have applied all of our limits,
513+ // just return.
514+ if len (suggestions ) == 0 {
515+ return nil , nil
516+ }
517+
518+ // Sort suggestions by amount in descending order.
519+ sort .SliceStable (suggestions , func (i , j int ) bool {
520+ return suggestions [i ].Amount > suggestions [j ].Amount
521+ })
522+
523+ // Run through our suggested swaps in descending order of amount and
524+ // return all of the swaps which will fit within our remaining budget.
525+ var (
526+ available = m .params .AutoFeeBudget - summary .totalFees ()
527+ inBudget []loop.OutRequest
528+ )
529+
530+ for _ , swap := range suggestions {
531+ fees := worstCaseOutFees (
532+ swap .MaxPrepayRoutingFee , swap .MaxSwapRoutingFee ,
533+ swap .MaxSwapFee , swap .MaxMinerFee , swap .MaxPrepayAmount ,
534+ )
535+
536+ // If the maximum fee we expect our swap to use is less than the
537+ // amount we have available, we add it to our set of swaps that
538+ // fall within the budget and decrement our available amount.
539+ if fees <= available {
540+ available -= fees
541+ inBudget = append (inBudget , swap )
542+ }
543+
544+ // If we're out of budget, exit early.
545+ if available == 0 {
546+ break
547+ }
548+ }
549+
550+ return inBudget , nil
453551}
454552
455553// makeLoopOutRequest creates a loop out request from a suggestion. Since we
@@ -485,6 +583,87 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
485583 }
486584}
487585
586+ // worstCaseOutFees calculates the largest possible fees for a loop out swap,
587+ // comparing the fees for a successful swap to the cost when the client pays
588+ // the prepay because they failed to sweep the on chain htlc. This is unlikely,
589+ // because we expect clients to be online to sweep, but we want to account for
590+ // every outcome so we include it.
591+ func worstCaseOutFees (prepayRouting , swapRouting , swapFee , minerFee ,
592+ prepayAmount btcutil.Amount ) btcutil.Amount {
593+
594+ var (
595+ successFees = prepayRouting + minerFee + swapFee + swapRouting
596+ noShowFees = prepayRouting + prepayAmount
597+ )
598+
599+ if noShowFees > successFees {
600+ return noShowFees
601+ }
602+
603+ return successFees
604+ }
605+
606+ // existingAutoLoopSummary provides a summary of the existing autoloops which
607+ // were dispatched during our current budget period.
608+ type existingAutoLoopSummary struct {
609+ // spentFees is the amount we have spent on completed swaps.
610+ spentFees btcutil.Amount
611+
612+ // pendingFees is the worst-case amount of fees we could spend on in
613+ // flight autoloops.
614+ pendingFees btcutil.Amount
615+ }
616+
617+ // totalFees returns the total amount of fees that automatically dispatched
618+ // swaps may consume.
619+ func (e * existingAutoLoopSummary ) totalFees () btcutil.Amount {
620+ return e .spentFees + e .pendingFees
621+ }
622+
623+ // checkExistingAutoLoops calculates the total amount that has been spent by
624+ // automatically dispatched swaps that have completed, and the worst-case fee
625+ // total for our set of ongoing, automatically dispatched swaps.
626+ func (m * Manager ) checkExistingAutoLoops (ctx context.Context ,
627+ loopOuts []* loopdb.LoopOut ) (* existingAutoLoopSummary , error ) {
628+
629+ var summary existingAutoLoopSummary
630+
631+ for _ , out := range loopOuts {
632+ if out .Contract .Label != labels .AutoOutLabel () {
633+ continue
634+ }
635+
636+ // If we have a pending swap, we are uncertain of the fees that
637+ // it will end up paying. We use the worst-case estimate based
638+ // on the maximum values we set for each fee category. This will
639+ // likely over-estimate our fees (because we probably won't
640+ // spend our maximum miner amount). If a swap is not pending,
641+ // it has succeeded or failed so we just record our actual fees
642+ // for the swap provided that the swap completed after our
643+ // budget start date.
644+ if out .State ().State .Type () == loopdb .StateTypePending {
645+ prepay , err := m .cfg .Lnd .Client .DecodePaymentRequest (
646+ ctx , out .Contract .PrepayInvoice ,
647+ )
648+ if err != nil {
649+ return nil , err
650+ }
651+
652+ summary .pendingFees += worstCaseOutFees (
653+ out .Contract .MaxPrepayRoutingFee ,
654+ out .Contract .MaxSwapRoutingFee ,
655+ out .Contract .MaxSwapFee ,
656+ out .Contract .MaxMinerFee ,
657+ mSatToSatoshis (prepay .Value ),
658+ )
659+ } else if ! out .LastUpdateTime ().Before (m .params .AutoFeeStartDate ) {
660+ summary .spentFees += out .State ().Cost .Total ()
661+ }
662+ }
663+
664+ return & summary , nil
665+ }
666+
488667// getEligibleChannels takes lists of our existing loop out and in swaps, and
489668// gets a list of channels that are not currently being utilized for a swap.
490669// If an unrestricted swap is ongoing, we return an empty set of channels
@@ -653,3 +832,7 @@ func satPerKwToSatPerVByte(satPerKw chainfee.SatPerKWeight) int64 {
653832func ppmToSat (amount btcutil.Amount , ppm int ) btcutil.Amount {
654833 return btcutil .Amount (uint64 (amount ) * uint64 (ppm ) / FeeBase )
655834}
835+
836+ func mSatToSatoshis (amount lnwire.MilliSatoshi ) btcutil.Amount {
837+ return btcutil .Amount (amount / 1000 )
838+ }
0 commit comments