@@ -36,6 +36,7 @@ import (
3636 "context"
3737 "errors"
3838 "fmt"
39+ "math"
3940 "sort"
4041 "sync"
4142 "time"
@@ -201,6 +202,12 @@ type Config struct {
201202 LoopIn func (ctx context.Context ,
202203 request * loop.LoopInRequest ) (* loop.LoopInSwapInfo , error )
203204
205+ // LoopInTerms returns the terms for a loop in swap.
206+ LoopInTerms func (ctx context.Context ) (* loop.LoopInTerms , error )
207+
208+ // LoopOutTerms returns the terms for a loop out swap.
209+ LoopOutTerms func (ctx context.Context ) (* loop.LoopOutTerms , error )
210+
204211 // Clock allows easy mocking of time in unit tests.
205212 Clock clock.Clock
206213
@@ -234,6 +241,15 @@ type Manager struct {
234241
235242 // paramsLock is a lock for our current set of parameters.
236243 paramsLock sync.Mutex
244+
245+ // activeStickyLoops is a counter that helps us keep track of currently
246+ // active sticky loops. We use this to ensure we don't dispatch more
247+ // than the max configured loops at a time.
248+ activeStickyLoops int
249+
250+ // activeStickyLock is a lock to ensure atomic access to the
251+ // activeStickyLoops counter.
252+ activeStickyLock sync.Mutex
237253}
238254
239255// Run periodically checks whether we should automatically dispatch a loop out.
@@ -259,15 +275,24 @@ func (m *Manager) Run(ctx context.Context) error {
259275 for {
260276 select {
261277 case <- m .cfg .AutoloopTicker .Ticks ():
262- err := m .autoloop (ctx )
263- switch err {
264- case ErrNoRules :
265- log .Debugf ("No rules configured for autoloop" )
278+ if m .params .EasyAutoloop {
279+ err := m .easyAutoLoop (ctx )
280+ if err != nil {
281+ log .Errorf ("easy autoloop failed: %v" ,
282+ err )
283+ }
284+ } else {
285+ err := m .autoloop (ctx )
286+ switch err {
287+ case ErrNoRules :
288+ log .Debugf ("no rules configured for " +
289+ "autoloop" )
266290
267- case nil :
291+ case nil :
268292
269- default :
270- log .Errorf ("autoloop failed: %v" , err )
293+ default :
294+ log .Errorf ("autoloop failed: %v" , err )
295+ }
271296 }
272297
273298 case <- ctx .Done ():
@@ -446,6 +471,29 @@ func (m *Manager) autoloop(ctx context.Context) error {
446471 return nil
447472}
448473
474+ // easyAutoLoop is the main entry point for the easy auto loop functionality.
475+ // This function will try to dispatch a swap in order to meet the easy autoloop
476+ // requirements. For easyAutoloop to work there needs to be an
477+ // EasyAutoloopTarget defined in the parameters. Easy autoloop also uses the
478+ // configured max inflight swaps and budget rules defined in the parameters.
479+ func (m * Manager ) easyAutoLoop (ctx context.Context ) error {
480+ if ! m .params .Autoloop {
481+ return nil
482+ }
483+
484+ // First check if we should refresh our budget before calculating any
485+ // swaps for autoloop.
486+ m .refreshAutoloopBudget (ctx )
487+
488+ // Dispatch the best easy autoloop swap.
489+ err := m .dispatchBestEasyAutoloopSwap (ctx )
490+ if err != nil {
491+ return err
492+ }
493+
494+ return nil
495+ }
496+
449497// ForceAutoLoop force-ticks our auto-out ticker.
450498func (m * Manager ) ForceAutoLoop (ctx context.Context ) error {
451499 select {
@@ -457,6 +505,135 @@ func (m *Manager) ForceAutoLoop(ctx context.Context) error {
457505 }
458506}
459507
508+ // dispatchBestEasyAutoloopSwap tries to dispatch a swap to bring the total
509+ // local balance back to the target.
510+ func (m * Manager ) dispatchBestEasyAutoloopSwap (ctx context.Context ) error {
511+ // Retrieve existing swaps.
512+ loopOut , err := m .cfg .ListLoopOut ()
513+ if err != nil {
514+ return err
515+ }
516+
517+ loopIn , err := m .cfg .ListLoopIn ()
518+ if err != nil {
519+ return err
520+ }
521+
522+ // Get a summary of our existing swaps so that we can check our autoloop
523+ // budget.
524+ summary , err := m .checkExistingAutoLoops (ctx , loopOut , loopIn )
525+ if err != nil {
526+ return err
527+ }
528+
529+ err = m .checkSummaryBudget (summary )
530+ if err != nil {
531+ return err
532+ }
533+
534+ _ , err = m .checkSummaryInflight (summary )
535+ if err != nil {
536+ return err
537+ }
538+
539+ // Get all channels in order to calculate current total local balance.
540+ channels , err := m .cfg .Lnd .Client .ListChannels (ctx , false , false )
541+ if err != nil {
542+ return err
543+ }
544+
545+ localTotal := btcutil .Amount (0 )
546+ for _ , channel := range channels {
547+ localTotal += channel .LocalBalance
548+ }
549+
550+ // Since we're only autolooping-out we need to check if we are below
551+ // the target, meaning that we already meet the requirements.
552+ if localTotal <= m .params .EasyAutoloopTarget {
553+ log .Debugf ("total local balance %v below target %v" ,
554+ localTotal , m .params .EasyAutoloopTarget )
555+ return nil
556+ }
557+
558+ restrictions , err := m .cfg .Restrictions (ctx , swap .TypeOut )
559+ if err != nil {
560+ return err
561+ }
562+
563+ // Calculate the amount that we want to loop out. If it exceeds the max
564+ // allowed clamp it to max.
565+ amount := localTotal - m .params .EasyAutoloopTarget
566+ if amount > restrictions .Maximum {
567+ amount = btcutil .Amount (restrictions .Maximum )
568+ }
569+
570+ // If the amount we want to loop out is less than the minimum we can't
571+ // proceed with a swap, so we return early.
572+ if amount < restrictions .Minimum {
573+ log .Debugf ("easy autoloop: swap amount is below minimum swap " +
574+ "size, minimum=%v, need to swap %v" ,
575+ restrictions .Minimum , amount )
576+ return nil
577+ }
578+
579+ log .Debugf ("easy autoloop: local_total=%v, target=%v, " +
580+ "attempting to loop out %v" , localTotal ,
581+ m .params .EasyAutoloopTarget , amount )
582+
583+ // Start building that swap.
584+ builder := newLoopOutBuilder (m .cfg )
585+
586+ channel := m .pickEasyAutoloopChannel (
587+ channels , restrictions , loopOut , loopIn , amount ,
588+ )
589+ if channel == nil {
590+ return fmt .Errorf ("no eligible channel for easy autoloop" )
591+ }
592+
593+ log .Debugf ("easy autoloop: picked channel %v with local balance %v" ,
594+ channel .ChannelID , channel .LocalBalance )
595+
596+ swapAmt , err := btcutil .NewAmount (
597+ math .Min (channel .LocalBalance .ToBTC (), amount .ToBTC ()),
598+ )
599+ if err != nil {
600+ return err
601+ }
602+
603+ // Override our current parameters in order to use the const percent
604+ // limit of easy-autoloop.
605+ easyParams := m .params
606+ easyParams .FeeLimit = & FeePortion {
607+ PartsPerMillion : defaultFeePPM ,
608+ }
609+
610+ // Set the swap outgoing channel to the chosen channel.
611+ outgoing := []lnwire.ShortChannelID {
612+ lnwire .NewShortChanIDFromInt (channel .ChannelID ),
613+ }
614+
615+ suggestion , err := builder .buildSwap (
616+ ctx , channel .PubKeyBytes , outgoing , swapAmt , true , easyParams ,
617+ )
618+ if err != nil {
619+ return err
620+ }
621+
622+ swap := loop.OutRequest {}
623+ if t , ok := suggestion .(* loopOutSwapSuggestion ); ok {
624+ swap = t .OutRequest
625+ } else {
626+ return fmt .Errorf ("unexpected swap suggestion type: %T" , t )
627+ }
628+
629+ // Dispatch a sticky loop out.
630+ go m .dispatchStickyLoopOut (
631+ ctx , swap , defaultAmountBackoffRetry , defaultAmountBackoff ,
632+ )
633+
634+ return nil
635+ }
636+
460637// Suggestions provides a set of suggested swaps, and the set of channels that
461638// were excluded from consideration.
462639type Suggestions struct {
@@ -563,23 +740,13 @@ func (m *Manager) SuggestSwaps(ctx context.Context, autoloop bool) (
563740 return nil , err
564741 }
565742
566- if summary .totalFees () >= m .params .AutoFeeBudget {
567- log .Debugf ("autoloop fee budget: %v exhausted, %v spent on " +
568- "completed swaps, %v reserved for ongoing swaps " +
569- "(upper limit)" ,
570- m .params .AutoFeeBudget , summary .spentFees ,
571- summary .pendingFees )
572-
743+ err = m .checkSummaryBudget (summary )
744+ if err != nil {
573745 return m .singleReasonSuggestion (ReasonBudgetElapsed ), nil
574746 }
575747
576- // If we have already reached our total allowed number of in flight
577- // swaps, we do not suggest any more at the moment.
578- allowedSwaps := m .params .MaxAutoInFlight - summary .inFlightCount
579- if allowedSwaps <= 0 {
580- log .Debugf ("%v autoloops allowed, %v in flight" ,
581- m .params .MaxAutoInFlight , summary .inFlightCount )
582-
748+ allowedSwaps , err := m .checkSummaryInflight (summary )
749+ if err != nil {
583750 return m .singleReasonSuggestion (ReasonInFlight ), nil
584751 }
585752
@@ -1058,12 +1225,31 @@ func (m *Manager) refreshAutoloopBudget(ctx context.Context) {
10581225func (m * Manager ) dispatchStickyLoopOut (ctx context.Context ,
10591226 out loop.OutRequest , retryCount uint16 , amountBackoff float64 ) {
10601227
1228+ // Check our sticky loop counter to decide whether we should continue
1229+ // executing this loop.
1230+ m .activeStickyLock .Lock ()
1231+ if m .activeStickyLoops >= m .params .MaxAutoInFlight {
1232+ m .activeStickyLock .Unlock ()
1233+ return
1234+ }
1235+
1236+ m .activeStickyLoops += 1
1237+ m .activeStickyLock .Unlock ()
1238+
1239+ // No matter the outcome, decrease the counter upon exiting sticky loop.
1240+ defer func () {
1241+ m .activeStickyLock .Lock ()
1242+ m .activeStickyLoops -= 1
1243+ m .activeStickyLock .Unlock ()
1244+ }()
1245+
10611246 for i := 0 ; i < int (retryCount ); i ++ {
10621247 // Dispatch the swap.
10631248 swap , err := m .cfg .LoopOut (ctx , & out )
10641249 if err != nil {
1065- log .Errorf ("unable to dispatch loop out, hash: %v, " +
1066- "err: %v" , swap .SwapHash , err )
1250+ log .Errorf ("unable to dispatch loop out, amt: %v, " +
1251+ "err: %v" , out .Amount , err )
1252+ return
10671253 }
10681254
10691255 log .Infof ("loop out automatically dispatched: hash: %v, " +
@@ -1190,6 +1376,97 @@ func (m *Manager) waitForSwapPayment(ctx context.Context, swapHash lntypes.Hash,
11901376 updateChan <- nil
11911377}
11921378
1379+ // pickEasyAutoloopChannel picks a channel to be used for an easy autoloop swap.
1380+ // This function prioritizes channels with high local balance but also consults
1381+ // previous failures and ongoing swaps to avoid temporary channel failures or
1382+ // swap conflicts.
1383+ func (m * Manager ) pickEasyAutoloopChannel (channels []lndclient.ChannelInfo ,
1384+ restrictions * Restrictions , loopOut []* loopdb.LoopOut ,
1385+ loopIn []* loopdb.LoopIn , amount btcutil.Amount ) * lndclient.ChannelInfo {
1386+
1387+ traffic := m .currentSwapTraffic (loopOut , loopIn )
1388+
1389+ // Sort the candidate channels based on descending local balance. We
1390+ // want to prioritize picking a channel with the highest possible local
1391+ // balance.
1392+ sort .Slice (channels , func (i , j int ) bool {
1393+ return channels [i ].LocalBalance > channels [j ].LocalBalance
1394+ })
1395+
1396+ // Check each channel, since channels are already sorted we return the
1397+ // first channel that passes all checks.
1398+ for _ , channel := range channels {
1399+ shortChanID := lnwire .NewShortChanIDFromInt (channel .ChannelID )
1400+
1401+ if ! channel .Active {
1402+ log .Debugf ("Channel %v cannot be used for easy " +
1403+ "autoloop: inactive" , channel .ChannelID )
1404+ continue
1405+ }
1406+
1407+ lastFail , recentFail := traffic .failedLoopOut [shortChanID ]
1408+ if recentFail {
1409+ log .Debugf ("Channel %v cannot be used for easy " +
1410+ "autoloop: last failed swap was at %v" ,
1411+ channel .ChannelID , lastFail )
1412+ continue
1413+ }
1414+
1415+ if traffic .ongoingLoopOut [shortChanID ] {
1416+ log .Debugf ("Channel %v cannot be used for easy " +
1417+ "autoloop: ongoing swap" , channel .ChannelID )
1418+ continue
1419+ }
1420+
1421+ if channel .LocalBalance < restrictions .Minimum {
1422+ log .Debugf ("Channel %v cannot be used for easy " +
1423+ "autoloop: insufficient local balance %v," +
1424+ "minimum is %v, skipping remaining channels" ,
1425+ channel .ChannelID , channel .LocalBalance ,
1426+ restrictions .Minimum )
1427+ return nil
1428+ }
1429+
1430+ return & channel
1431+ }
1432+
1433+ return nil
1434+ }
1435+
1436+ func (m * Manager ) numActiveStickyLoops () int {
1437+ m .activeStickyLock .Lock ()
1438+ defer m .activeStickyLock .Unlock ()
1439+
1440+ return m .activeStickyLoops
1441+
1442+ }
1443+
1444+ func (m * Manager ) checkSummaryBudget (summary * existingAutoLoopSummary ) error {
1445+ if summary .totalFees () >= m .params .AutoFeeBudget {
1446+ return fmt .Errorf ("autoloop fee budget: %v exhausted, %v spent on " +
1447+ "completed swaps, %v reserved for ongoing swaps " +
1448+ "(upper limit)" ,
1449+ m .params .AutoFeeBudget , summary .spentFees ,
1450+ summary .pendingFees )
1451+
1452+ }
1453+
1454+ return nil
1455+ }
1456+
1457+ func (m * Manager ) checkSummaryInflight (
1458+ summary * existingAutoLoopSummary ) (int , error ) {
1459+ // If we have already reached our total allowed number of in flight
1460+ // swaps we return early.
1461+ allowedSwaps := m .params .MaxAutoInFlight - summary .inFlightCount
1462+ if allowedSwaps <= 0 {
1463+ return 0 , fmt .Errorf ("%v autoloops allowed, %v in flight" ,
1464+ m .params .MaxAutoInFlight , summary .inFlightCount )
1465+ }
1466+
1467+ return allowedSwaps , nil
1468+ }
1469+
11931470// swapTraffic contains a summary of our current and previously failed swaps.
11941471type swapTraffic struct {
11951472 ongoingLoopOut map [lnwire.ShortChannelID ]bool
0 commit comments