Skip to content

Commit fad9f40

Browse files
committed
liquidity+loopd: add easy autoloop
Adds the easy autoloop function which executes a budget update and the best easy-autoloop swap. The easy-autoloop function re-uses functions used in the normal autoloop that relate to on-going swaps and traffic summary.
1 parent cd9f6f1 commit fad9f40

File tree

3 files changed

+322
-27
lines changed

3 files changed

+322
-27
lines changed

liquidity/liquidity.go

Lines changed: 300 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
450498
func (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.
462639
type 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) {
10581225
func (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.
11941471
type swapTraffic struct {
11951472
ongoingLoopOut map[lnwire.ShortChannelID]bool

0 commit comments

Comments
 (0)