diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index e2b9766..4d371e3 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -36,6 +36,7 @@ import ( "context" "errors" "fmt" + "math" "sort" "sync" "time" @@ -201,6 +202,12 @@ type Config struct { LoopIn func(ctx context.Context, request *loop.LoopInRequest) (*loop.LoopInSwapInfo, error) + // LoopInTerms returns the terms for a loop in swap. + LoopInTerms func(ctx context.Context) (*loop.LoopInTerms, error) + + // LoopOutTerms returns the terms for a loop out swap. + LoopOutTerms func(ctx context.Context) (*loop.LoopOutTerms, error) + // Clock allows easy mocking of time in unit tests. Clock clock.Clock @@ -234,6 +241,15 @@ type Manager struct { // paramsLock is a lock for our current set of parameters. paramsLock sync.Mutex + + // activeStickyLoops is a counter that helps us keep track of currently + // active sticky loops. We use this to ensure we don't dispatch more + // than the max configured loops at a time. + activeStickyLoops int + + // activeStickyLock is a lock to ensure atomic access to the + // activeStickyLoops counter. + activeStickyLock sync.Mutex } // Run periodically checks whether we should automatically dispatch a loop out. @@ -259,15 +275,24 @@ func (m *Manager) Run(ctx context.Context) error { for { select { case <-m.cfg.AutoloopTicker.Ticks(): - err := m.autoloop(ctx) - switch err { - case ErrNoRules: - log.Debugf("No rules configured for autoloop") + if m.params.EasyAutoloop { + err := m.easyAutoLoop(ctx) + if err != nil { + log.Errorf("easy autoloop failed: %v", + err) + } + } else { + err := m.autoloop(ctx) + switch err { + case ErrNoRules: + log.Debugf("no rules configured for " + + "autoloop") - case nil: + case nil: - default: - log.Errorf("autoloop failed: %v", err) + default: + log.Errorf("autoloop failed: %v", err) + } } case <-ctx.Done(): @@ -446,6 +471,29 @@ func (m *Manager) autoloop(ctx context.Context) error { return nil } +// easyAutoLoop is the main entry point for the easy auto loop functionality. +// This function will try to dispatch a swap in order to meet the easy autoloop +// requirements. For easyAutoloop to work there needs to be an +// EasyAutoloopTarget defined in the parameters. Easy autoloop also uses the +// configured max inflight swaps and budget rules defined in the parameters. +func (m *Manager) easyAutoLoop(ctx context.Context) error { + if !m.params.Autoloop { + return nil + } + + // First check if we should refresh our budget before calculating any + // swaps for autoloop. + m.refreshAutoloopBudget(ctx) + + // Dispatch the best easy autoloop swap. + err := m.dispatchBestEasyAutoloopSwap(ctx) + if err != nil { + return err + } + + return nil +} + // ForceAutoLoop force-ticks our auto-out ticker. func (m *Manager) ForceAutoLoop(ctx context.Context) error { select { @@ -457,6 +505,135 @@ func (m *Manager) ForceAutoLoop(ctx context.Context) error { } } +// dispatchBestEasyAutoloopSwap tries to dispatch a swap to bring the total +// local balance back to the target. +func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error { + // Retrieve existing swaps. + loopOut, err := m.cfg.ListLoopOut() + if err != nil { + return err + } + + loopIn, err := m.cfg.ListLoopIn() + if err != nil { + return err + } + + // Get a summary of our existing swaps so that we can check our autoloop + // budget. + summary, err := m.checkExistingAutoLoops(ctx, loopOut, loopIn) + if err != nil { + return err + } + + err = m.checkSummaryBudget(summary) + if err != nil { + return err + } + + _, err = m.checkSummaryInflight(summary) + if err != nil { + return err + } + + // Get all channels in order to calculate current total local balance. + channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false) + if err != nil { + return err + } + + localTotal := btcutil.Amount(0) + for _, channel := range channels { + localTotal += channel.LocalBalance + } + + // Since we're only autolooping-out we need to check if we are below + // the target, meaning that we already meet the requirements. + if localTotal <= m.params.EasyAutoloopTarget { + log.Debugf("total local balance %v below target %v", + localTotal, m.params.EasyAutoloopTarget) + return nil + } + + restrictions, err := m.cfg.Restrictions(ctx, swap.TypeOut) + if err != nil { + return err + } + + // Calculate the amount that we want to loop out. If it exceeds the max + // allowed clamp it to max. + amount := localTotal - m.params.EasyAutoloopTarget + if amount > restrictions.Maximum { + amount = btcutil.Amount(restrictions.Maximum) + } + + // If the amount we want to loop out is less than the minimum we can't + // proceed with a swap, so we return early. + if amount < restrictions.Minimum { + log.Debugf("easy autoloop: swap amount is below minimum swap "+ + "size, minimum=%v, need to swap %v", + restrictions.Minimum, amount) + return nil + } + + log.Debugf("easy autoloop: local_total=%v, target=%v, "+ + "attempting to loop out %v", localTotal, + m.params.EasyAutoloopTarget, amount) + + // Start building that swap. + builder := newLoopOutBuilder(m.cfg) + + channel := m.pickEasyAutoloopChannel( + channels, restrictions, loopOut, loopIn, amount, + ) + if channel == nil { + return fmt.Errorf("no eligible channel for easy autoloop") + } + + log.Debugf("easy autoloop: picked channel %v with local balance %v", + channel.ChannelID, channel.LocalBalance) + + swapAmt, err := btcutil.NewAmount( + math.Min(channel.LocalBalance.ToBTC(), amount.ToBTC()), + ) + if err != nil { + return err + } + + // Override our current parameters in order to use the const percent + // limit of easy-autoloop. + easyParams := m.params + easyParams.FeeLimit = &FeePortion{ + PartsPerMillion: defaultFeePPM, + } + + // Set the swap outgoing channel to the chosen channel. + outgoing := []lnwire.ShortChannelID{ + lnwire.NewShortChanIDFromInt(channel.ChannelID), + } + + suggestion, err := builder.buildSwap( + ctx, channel.PubKeyBytes, outgoing, swapAmt, true, easyParams, + ) + if err != nil { + return err + } + + swap := loop.OutRequest{} + if t, ok := suggestion.(*loopOutSwapSuggestion); ok { + swap = t.OutRequest + } else { + return fmt.Errorf("unexpected swap suggestion type: %T", t) + } + + // Dispatch a sticky loop out. + go m.dispatchStickyLoopOut( + ctx, swap, defaultAmountBackoffRetry, defaultAmountBackoff, + ) + + return nil +} + // Suggestions provides a set of suggested swaps, and the set of channels that // were excluded from consideration. type Suggestions struct { @@ -563,23 +740,13 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) ( return nil, err } - if summary.totalFees() >= m.params.AutoFeeBudget { - log.Debugf("autoloop fee budget: %v exhausted, %v spent on "+ - "completed swaps, %v reserved for ongoing swaps "+ - "(upper limit)", - m.params.AutoFeeBudget, summary.spentFees, - summary.pendingFees) - + err = m.checkSummaryBudget(summary) + if err != nil { return m.singleReasonSuggestion(ReasonBudgetElapsed), nil } - // If we have already reached our total allowed number of in flight - // swaps, we do not suggest any more at the moment. - allowedSwaps := m.params.MaxAutoInFlight - summary.inFlightCount - if allowedSwaps <= 0 { - log.Debugf("%v autoloops allowed, %v in flight", - m.params.MaxAutoInFlight, summary.inFlightCount) - + allowedSwaps, err := m.checkSummaryInflight(summary) + if err != nil { return m.singleReasonSuggestion(ReasonInFlight), nil } @@ -1058,12 +1225,31 @@ func (m *Manager) refreshAutoloopBudget(ctx context.Context) { func (m *Manager) dispatchStickyLoopOut(ctx context.Context, out loop.OutRequest, retryCount uint16, amountBackoff float64) { + // Check our sticky loop counter to decide whether we should continue + // executing this loop. + m.activeStickyLock.Lock() + if m.activeStickyLoops >= m.params.MaxAutoInFlight { + m.activeStickyLock.Unlock() + return + } + + m.activeStickyLoops += 1 + m.activeStickyLock.Unlock() + + // No matter the outcome, decrease the counter upon exiting sticky loop. + defer func() { + m.activeStickyLock.Lock() + m.activeStickyLoops -= 1 + m.activeStickyLock.Unlock() + }() + for i := 0; i < int(retryCount); i++ { // Dispatch the swap. swap, err := m.cfg.LoopOut(ctx, &out) if err != nil { - log.Errorf("unable to dispatch loop out, hash: %v, "+ - "err: %v", swap.SwapHash, err) + log.Errorf("unable to dispatch loop out, amt: %v, "+ + "err: %v", out.Amount, err) + return } log.Infof("loop out automatically dispatched: hash: %v, "+ @@ -1190,6 +1376,97 @@ func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash, updateChan <- nil } +// pickEasyAutoloopChannel picks a channel to be used for an easy autoloop swap. +// This function prioritizes channels with high local balance but also consults +// previous failures and ongoing swaps to avoid temporary channel failures or +// swap conflicts. +func (m *Manager) pickEasyAutoloopChannel(channels []lndclient.ChannelInfo, + restrictions *Restrictions, loopOut []*loopdb.LoopOut, + loopIn []*loopdb.LoopIn, amount btcutil.Amount) *lndclient.ChannelInfo { + + traffic := m.currentSwapTraffic(loopOut, loopIn) + + // Sort the candidate channels based on descending local balance. We + // want to prioritize picking a channel with the highest possible local + // balance. + sort.Slice(channels, func(i, j int) bool { + return channels[i].LocalBalance > channels[j].LocalBalance + }) + + // Check each channel, since channels are already sorted we return the + // first channel that passes all checks. + for _, channel := range channels { + shortChanID := lnwire.NewShortChanIDFromInt(channel.ChannelID) + + if !channel.Active { + log.Debugf("Channel %v cannot be used for easy "+ + "autoloop: inactive", channel.ChannelID) + continue + } + + lastFail, recentFail := traffic.failedLoopOut[shortChanID] + if recentFail { + log.Debugf("Channel %v cannot be used for easy "+ + "autoloop: last failed swap was at %v", + channel.ChannelID, lastFail) + continue + } + + if traffic.ongoingLoopOut[shortChanID] { + log.Debugf("Channel %v cannot be used for easy "+ + "autoloop: ongoing swap", channel.ChannelID) + continue + } + + if channel.LocalBalance < restrictions.Minimum { + log.Debugf("Channel %v cannot be used for easy "+ + "autoloop: insufficient local balance %v,"+ + "minimum is %v, skipping remaining channels", + channel.ChannelID, channel.LocalBalance, + restrictions.Minimum) + return nil + } + + return &channel + } + + return nil +} + +func (m *Manager) numActiveStickyLoops() int { + m.activeStickyLock.Lock() + defer m.activeStickyLock.Unlock() + + return m.activeStickyLoops + +} + +func (m *Manager) checkSummaryBudget(summary *existingAutoLoopSummary) error { + if summary.totalFees() >= m.params.AutoFeeBudget { + return fmt.Errorf("autoloop fee budget: %v exhausted, %v spent on "+ + "completed swaps, %v reserved for ongoing swaps "+ + "(upper limit)", + m.params.AutoFeeBudget, summary.spentFees, + summary.pendingFees) + + } + + return nil +} + +func (m *Manager) checkSummaryInflight( + summary *existingAutoLoopSummary) (int, error) { + // If we have already reached our total allowed number of in flight + // swaps we return early. + allowedSwaps := m.params.MaxAutoInFlight - summary.inFlightCount + if allowedSwaps <= 0 { + return 0, fmt.Errorf("%v autoloops allowed, %v in flight", + m.params.MaxAutoInFlight, summary.inFlightCount) + } + + return allowedSwaps, nil +} + // swapTraffic contains a summary of our current and previously failed swaps. type swapTraffic struct { ongoingLoopOut map[lnwire.ShortChannelID]bool diff --git a/liquidity/parameters.go b/liquidity/parameters.go index a1823d0..34f8ae1 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -98,6 +98,14 @@ type Parameters struct { // CustomPaymentCheckInterval is an optional custom interval to use when // checking an autoloop loop out payments' payment status. CustomPaymentCheckInterval time.Duration + + // EasyAutoloop is a boolean that indicates whether we should use the + // easy autoloop feature. + EasyAutoloop bool + + // EasyAutoloopTarget is the target amount of liquidity that we want to + // maintain in our channels. + EasyAutoloopTarget btcutil.Amount } // String returns the string representation of our parameters. @@ -397,7 +405,9 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, Minimum: btcutil.Amount(req.MinSwapAmount), Maximum: btcutil.Amount(req.MaxSwapAmount), }, - HtlcConfTarget: req.HtlcConfTarget, + HtlcConfTarget: req.HtlcConfTarget, + EasyAutoloop: req.EasyAutoloop, + EasyAutoloopTarget: btcutil.Amount(req.EasyAutoloopLocalTargetSat), } if req.AutoloopBudgetRefreshPeriodSec != 0 { @@ -493,9 +503,15 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, Rules: make( []*clientrpc.LiquidityRule, 0, totalRules, ), - MinSwapAmount: uint64(cfg.ClientRestrictions.Minimum), - MaxSwapAmount: uint64(cfg.ClientRestrictions.Maximum), - HtlcConfTarget: cfg.HtlcConfTarget, + MinSwapAmount: uint64( + cfg.ClientRestrictions.Minimum, + ), + MaxSwapAmount: uint64( + cfg.ClientRestrictions.Maximum, + ), + HtlcConfTarget: cfg.HtlcConfTarget, + EasyAutoloop: cfg.EasyAutoloop, + EasyAutoloopLocalTargetSat: uint64(cfg.EasyAutoloopTarget), } switch f := cfg.FeeLimit.(type) { diff --git a/loopd/utils.go b/loopd/utils.go index 67fdefc..cb0c267 100644 --- a/loopd/utils.go +++ b/loopd/utils.go @@ -72,6 +72,8 @@ func getLiquidityManager(client *loop.Client) *liquidity.Manager { ListLoopOut: client.Store.FetchLoopOutSwaps, GetLoopOut: client.Store.FetchLoopOutSwap, ListLoopIn: client.Store.FetchLoopInSwaps, + LoopInTerms: client.LoopInTerms, + LoopOutTerms: client.LoopOutTerms, MinimumConfirmations: minConfTarget, PutLiquidityParams: client.Store.PutLiquidityParams, FetchLiquidityParams: client.Store.FetchLiquidityParams,