/
liquidity.go
1551 lines (1275 loc) · 45.5 KB
/
liquidity.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Package liquidity is responsible for monitoring our node's liquidity. It
// allows setting of a liquidity rule which describes the desired liquidity
// balance on a per-channel basis.
//
// Swap suggestions are limited to channels that are not currently being used
// for a pending swap. If we are currently processing an unrestricted swap (ie,
// a loop out with no outgoing channel targets set or a loop in with no last
// hop set), we will not suggest any swaps because these swaps will shift the
// balances of our channels in ways we can't predict.
//
// Fee restrictions are placed on swap suggestions to ensure that we only
// suggest swaps that fit the configured fee preferences.
// - Sweep Fee Rate Limit: the maximum sat/vByte fee estimate for our sweep
// transaction to confirm within our configured number of confirmations
// that we will suggest swaps for.
// - Maximum Swap Fee PPM: the maximum server fee, expressed as parts per
// million of the full swap amount
// - Maximum Routing Fee PPM: the maximum off-chain routing fees for the swap
// invoice, expressed as parts per million of the swap amount.
// - Maximum Prepay Routing Fee PPM: the maximum off-chain routing fees for the
// swap prepayment, expressed as parts per million of the prepay amount.
// - Maximum Prepay: the maximum now-show fee, expressed in satoshis. This
// amount is only payable in the case where the swap server broadcasts a htlc
// and the client fails to sweep the preimage.
// - Maximum miner fee: the maximum miner fee we are willing to pay to sweep the
// on chain htlc. Note that the client will use current fee estimates to
// sweep, so this value acts more as a sanity check in the case of a large fee
// spike.
//
// The maximum fee per-swap is calculated as follows:
// (swap amount * serverPPM/1e6) + miner fee + (swap amount * routingPPM/1e6)
// + (prepay amount * prepayPPM/1e6).
package liquidity
import (
"context"
"errors"
"fmt"
"math"
"sort"
"sync"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/funding"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/ticker"
"google.golang.org/protobuf/proto"
clientrpc "github.com/lightninglabs/loop/looprpc"
)
const (
// defaultFailureBackoff is the default amount of time we backoff if
// a channel is part of a temporarily failed swap.
defaultFailureBackoff = time.Hour * 24
// defaultAmountBackoff is the default backoff we apply to the amount
// of a loop out swap that failed the off-chain payments.
defaultAmountBackoff = float64(0.25)
// defaultAmountBackoffRetry is the default number of times we will
// perform an amount backoff to a loop out swap before we give up.
defaultAmountBackoffRetry = 5
// defaultSwapWaitTimeout is the default maximum amount of time we
// wait for a swap to reach a terminal state.
defaultSwapWaitTimeout = time.Hour * 24
// defaultPaymentCheckInterval is the default time that passes between
// checks for loop out payments status.
defaultPaymentCheckInterval = time.Second * 2
// defaultConfTarget is the default sweep target we use for loop outs.
// We get our inbound liquidity quickly using preimage push, so we can
// use a long conf target without worrying about ux impact.
defaultConfTarget = 100
// FeeBase is the base that we use to express fees.
FeeBase = 1e6
// 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
// suggestions as a dry-run).
defaultMaxInFlight = 1
// DefaultAutoloopTicker is the default amount of time between automated
// swap checks.
DefaultAutoloopTicker = time.Minute * 20
// autoloopSwapInitiator is the value we send in the initiator field of
// a swap request when issuing an automatic swap.
autoloopSwapInitiator = "autoloop"
// We use a static fee rate to estimate our sweep fee, because we
// can't realistically estimate what our fee estimate will be by the
// time we reach timeout. We set this to a high estimate so that we can
// account for worst-case fees, (1250 * 4 / 1000) = 50 sat/byte.
defaultLoopInSweepFee = chainfee.SatPerKWeight(1250)
)
var (
// defaultHtlcConfTarget is the default confirmation target we use for
// loop in swap htlcs, set to the same default at the client.
defaultHtlcConfTarget = loop.DefaultHtlcConfTarget
// defaultBudget is the default autoloop budget we set. This budget will
// only be used for automatically dispatched swaps if autoloop is
// explicitly enabled, so we are happy to set a non-zero value here. The
// amount chosen simply uses the current defaults to provide budget for
// a single swap. We don't have a swap amount so we just use our max
// funding amount.
defaultBudget = ppmToSat(funding.MaxBtcFundingAmount, defaultFeePPM)
// defaultBudgetRefreshPeriod is the default amount of time we wait for
// the autoloop budget to be refreshed.
defaultBudgetRefreshPeriod = time.Hour * 24 * 7
// ErrZeroChannelID is returned if we get a rule for a 0 channel ID.
ErrZeroChannelID = fmt.Errorf("zero channel ID not allowed")
// ErrNegativeBudget is returned if a negative swap budget is set.
ErrNegativeBudget = errors.New("swap budget must be >= 0")
// ErrZeroInFlight is returned is a zero in flight swaps value is set.
ErrZeroInFlight = errors.New("max in flight swaps must be >=0")
// ErrMinimumExceedsMaximumAmt is returned when the minimum configured
// swap amount is more than the maximum.
ErrMinimumExceedsMaximumAmt = errors.New("minimum swap amount " +
"exceeds maximum")
// ErrMaxExceedsServer is returned if the maximum swap amount set is
// more than the server offers.
ErrMaxExceedsServer = errors.New("maximum swap amount is more than " +
"server maximum")
// ErrMinLessThanServer is returned if the minimum swap amount set is
// less than the server minimum.
ErrMinLessThanServer = errors.New("minimum swap amount is less than " +
"server minimum")
// ErrNoRules is returned when no rules are set for swap suggestions.
ErrNoRules = errors.New("no rules set for autoloop")
// ErrExclusiveRules is returned when a set of rules that may not be
// set together are specified.
ErrExclusiveRules = errors.New("channel and peer rules must be " +
"exclusive")
// ErrAmbiguousDestAddr is returned when a destination address and
// a extended public key account is set.
ErrAmbiguousDestAddr = errors.New("ambiguous destination address")
// ErrAccountAndAddrType indicates if an account is set but the
// account address type is not or vice versa.
ErrAccountAndAddrType = errors.New("account and address type have " +
"to be both either set or unset")
)
// Config contains the external functionality required to run the
// liquidity manager.
type Config struct {
// AutoloopTicker determines how often we should check whether we want
// to dispatch an automated swap. We use a force ticker so that we can
// trigger autoloop in itests.
AutoloopTicker *ticker.Force
// Restrictions returns the restrictions that the server applies to
// swaps.
Restrictions func(ctx context.Context, swapType swap.Type,
initiator string) (*Restrictions, error)
// Lnd provides us with access to lnd's rpc servers.
Lnd *lndclient.LndServices
// ListLoopOut returns all of the loop our swaps stored on disk.
ListLoopOut func(context.Context) ([]*loopdb.LoopOut, error)
// GetLoopOut returns a single loop out swap based on the provided swap
// hash.
GetLoopOut func(ctx context.Context, hash lntypes.Hash) (*loopdb.LoopOut, error)
// ListLoopIn returns all of the loop in swaps stored on disk.
ListLoopIn func(ctx context.Context) ([]*loopdb.LoopIn, error)
// LoopOutQuote gets swap fee, estimated miner fee and prepay amount for
// a loop out swap.
LoopOutQuote func(ctx context.Context,
request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error)
// LoopInQuote provides a quote for a loop in swap.
LoopInQuote func(ctx context.Context,
request *loop.LoopInQuoteRequest) (*loop.LoopInQuote, error)
// LoopOut dispatches a loop out.
LoopOut func(ctx context.Context, request *loop.OutRequest) (
*loop.LoopOutSwapInfo, error)
// LoopIn dispatches a loop in swap.
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,
initiator string) (*loop.LoopInTerms, error)
// LoopOutTerms returns the terms for a loop out swap.
LoopOutTerms func(ctx context.Context,
initiator string) (*loop.LoopOutTerms, error)
// Clock allows easy mocking of time in unit tests.
Clock clock.Clock
// MinimumConfirmations is the minimum number of confirmations we allow
// setting for sweep target.
MinimumConfirmations int32
// PutLiquidityParams writes the serialized `Parameters` into db.
//
// NOTE: the params are encoded using `proto.Marshal` over an RPC
// request.
PutLiquidityParams func(ctx context.Context, params []byte) error
// FetchLiquidityParams reads the serialized `Parameters` from db.
//
// NOTE: the params are decoded using `proto.Unmarshal` over a
// serialized RPC request.
FetchLiquidityParams func(ctx context.Context) ([]byte, error)
}
// Manager contains a set of desired liquidity rules for our channel
// balances.
type Manager struct {
// cfg contains the external functionality we require to determine our
// current liquidity balance.
cfg *Config
// params is the set of parameters we are currently using. These may be
// updated at runtime.
params Parameters
// 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.
// We run this loop even if automated swaps are not currently enabled rather
// than managing starting and stopping the ticker as our parameters are updated.
func (m *Manager) Run(ctx context.Context) error {
m.cfg.AutoloopTicker.Resume()
defer m.cfg.AutoloopTicker.Stop()
// Before we start the main loop, load the params from db.
req, err := m.loadParams(ctx)
if err != nil {
return err
}
// Set the params if there's one.
if req != nil {
if err := m.SetParameters(ctx, req); err != nil {
return err
}
}
for {
select {
case <-m.cfg.AutoloopTicker.Ticks():
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:
default:
log.Errorf("autoloop failed: %v", err)
}
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// NewManager creates a liquidity manager which has no rules set.
func NewManager(cfg *Config) *Manager {
return &Manager{
cfg: cfg,
params: defaultParameters,
}
}
// GetParameters returns a copy of our current parameters.
func (m *Manager) GetParameters() Parameters {
m.paramsLock.Lock()
defer m.paramsLock.Unlock()
return cloneParameters(m.params)
}
// SetParameters takes an RPC request and calls the internal method to set
// parameters for the manager.
func (m *Manager) SetParameters(ctx context.Context,
req *clientrpc.LiquidityParameters) error {
params, err := RpcToParameters(req)
if err != nil {
return err
}
if err := m.setParameters(ctx, *params); err != nil {
return err
}
// Save the params on disk.
//
// NOTE: alternatively we can save the bytes in memory and persist them
// on disk during shutdown to save us some IO cost from hitting the db.
// Since setting params is NOT a frequent action, it's should put
// little pressure on our db. Only when performance becomes an issue,
// we can then apply the alternative.
return m.saveParams(ctx, req)
}
// SetParameters updates our current set of parameters if the new parameters
// provided are valid.
func (m *Manager) setParameters(ctx context.Context,
params Parameters) error {
restrictions, err := m.cfg.Restrictions(
ctx, swap.TypeOut, getInitiator(m.params),
)
if err != nil {
return err
}
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
if err != nil {
return err
}
err = params.validate(
m.cfg.MinimumConfirmations, channels, restrictions,
)
if err != nil {
return err
}
m.paramsLock.Lock()
defer m.paramsLock.Unlock()
m.params = cloneParameters(params)
return nil
}
// saveParams marshals an RPC request and saves it to db.
func (m *Manager) saveParams(ctx context.Context, req proto.Message) error {
// Marshal the params.
paramsBytes, err := proto.Marshal(req)
if err != nil {
return err
}
// Save the params on disk.
if err := m.cfg.PutLiquidityParams(ctx, paramsBytes); err != nil {
return fmt.Errorf("failed to save params: %v", err)
}
return nil
}
// loadParams unmarshals a serialized RPC request from db and returns the RPC
// request.
func (m *Manager) loadParams(ctx context.Context) (
*clientrpc.LiquidityParameters, error) {
paramsBytes, err := m.cfg.FetchLiquidityParams(ctx)
if err != nil {
return nil, fmt.Errorf("failed to read params: %v", err)
}
// Return early if there's nothing saved.
if paramsBytes == nil {
return nil, nil
}
// Unmarshal the params.
req := &clientrpc.LiquidityParameters{}
err = proto.Unmarshal(paramsBytes, req)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal params: %v", err)
}
return req, nil
}
// autoloop gets a set of suggested swaps and dispatches them automatically if
// we have automated looping enabled.
func (m *Manager) autoloop(ctx context.Context) error {
// First check if we should refresh our budget before calculating any
// swaps for autoloop.
m.refreshAutoloopBudget(ctx)
suggestion, err := m.SuggestSwaps(ctx)
if err != nil {
return err
}
for _, swap := range suggestion.OutSwaps {
// If we don't actually have dispatch of swaps enabled, log
// suggestions.
if !m.params.Autoloop {
log.Debugf("recommended autoloop out: %v sats over "+
"%v", swap.Amount, swap.OutgoingChanSet)
continue
}
// Create a copy of our range var so that we can reference it.
swap := swap
// Check if the parameter for custom address is defined for loop
// outs.
if m.params.DestAddr != nil {
swap.DestAddr = m.params.DestAddr
swap.IsExternalAddr = true
}
go m.dispatchStickyLoopOut(
ctx, swap, defaultAmountBackoffRetry,
defaultAmountBackoff,
)
}
for _, in := range suggestion.InSwaps {
// If we don't actually have dispatch of swaps enabled, log
// suggestions.
if !m.params.Autoloop {
log.Debugf("recommended autoloop in: %v sats over "+
"%v", in.Amount, in.LastHop)
continue
}
in := in
loopIn, err := m.cfg.LoopIn(ctx, &in)
if err != nil {
return err
}
log.Infof("loop in automatically dispatched: hash: %v, "+
"address: p2wsh(%v), p2tr(%v)", loopIn.SwapHash,
loopIn.HtlcAddressP2WSH, loopIn.HtlcAddressP2TR)
}
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 {
case m.cfg.AutoloopTicker.Force <- m.cfg.Clock.Now():
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// 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(ctx)
if err != nil {
return err
}
loopIn, err := m.cfg.ListLoopIn(ctx)
if err != nil {
return err
}
// Get a summary of our existing swaps so that we can check our autoloop
// budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
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, getInitiator(m.params),
)
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 = 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,
)
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
}
// If no fee is set, override our current parameters in order to use the
// default percent limit of easy-autoloop.
easyParams := m.params
switch feeLimit := easyParams.FeeLimit.(type) {
case *FeePortion:
if feeLimit.PartsPerMillion == 0 {
easyParams.FeeLimit = &FeePortion{
PartsPerMillion: defaultFeePPM,
}
}
default:
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, easyParams,
)
if err != nil {
return err
}
var swp loop.OutRequest
if t, ok := suggestion.(*loopOutSwapSuggestion); ok {
swp = t.OutRequest
} else {
return fmt.Errorf("unexpected swap suggestion type: %T", t)
}
// Dispatch a sticky loop out.
go m.dispatchStickyLoopOut(
ctx, swp, defaultAmountBackoffRetry, defaultAmountBackoff,
)
return nil
}
// Suggestions provides a set of suggested swaps, and the set of channels that
// were excluded from consideration.
type Suggestions struct {
// OutSwaps is the set of loop out swaps that we suggest executing.
OutSwaps []loop.OutRequest
// InSwaps is the set of loop in swaps that we suggest executing.
InSwaps []loop.LoopInRequest
// DisqualifiedChans maps the set of channels that we do not recommend
// swaps on to the reason that we did not recommend a swap.
DisqualifiedChans map[lnwire.ShortChannelID]Reason
// Disqualified peers maps the set of peers that we do not recommend
// swaps for to the reason that they were excluded.
DisqualifiedPeers map[route.Vertex]Reason
}
func newSuggestions() *Suggestions {
return &Suggestions{
DisqualifiedChans: make(map[lnwire.ShortChannelID]Reason),
DisqualifiedPeers: make(map[route.Vertex]Reason),
}
}
func (s *Suggestions) addSwap(swap swapSuggestion) error {
switch t := swap.(type) {
case *loopOutSwapSuggestion:
s.OutSwaps = append(s.OutSwaps, t.OutRequest)
case *loopInSwapSuggestion:
s.InSwaps = append(s.InSwaps, t.LoopInRequest)
default:
return fmt.Errorf("unexpected swap type: %T", swap)
}
return nil
}
// singleReasonSuggestion is a helper function which returns a set of
// suggestions where all of our rules are disqualified due to a reason that
// applies to all of them (such as being out of budget).
func (m *Manager) singleReasonSuggestion(reason Reason) *Suggestions {
resp := newSuggestions()
for id := range m.params.ChannelRules {
resp.DisqualifiedChans[id] = reason
}
for peer := range m.params.PeerRules {
resp.DisqualifiedPeers[peer] = reason
}
return resp
}
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
// balance for the set of rules configured for the manager, failing if there are
// no rules set. It takes an autoloop boolean that indicates whether the
// suggestions are being used for our internal autolooper. This boolean is used
// to determine the information we add to our swap suggestion and whether we
// return any suggestions.
func (m *Manager) SuggestSwaps(ctx context.Context) (
*Suggestions, error) {
m.paramsLock.Lock()
defer m.paramsLock.Unlock()
// If we have no rules set, exit early to avoid unnecessary calls to
// lnd and the server.
if !m.params.haveRules() {
return nil, ErrNoRules
}
// Get restrictions placed on swaps by the server.
outRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeOut)
if err != nil {
return nil, err
}
inRestrictions, err := m.getSwapRestrictions(ctx, swap.TypeIn)
if err != nil {
return nil, err
}
// List our current set of swaps so that we can determine which channels
// are already being utilized by swaps. Note that these calls may race
// with manual initiation of swaps.
loopOut, err := m.cfg.ListLoopOut(ctx)
if err != nil {
return nil, err
}
loopIn, err := m.cfg.ListLoopIn(ctx)
if err != nil {
return nil, err
}
// Get a summary of our existing swaps so that we can check our autoloop
// budget.
summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn)
err = m.checkSummaryBudget(summary)
if err != nil {
return m.singleReasonSuggestion(ReasonBudgetElapsed), nil
}
allowedSwaps, err := m.checkSummaryInflight(summary)
if err != nil {
return m.singleReasonSuggestion(ReasonInFlight), nil
}
channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false)
if err != nil {
return nil, err
}
// Collect a map of channel IDs to peer pubkeys, and a set of per-peer
// balances which we will use for peer-level liquidity rules.
channelPeers := make(map[uint64]route.Vertex)
peerChannels := make(map[route.Vertex]*balances)
for _, channel := range channels {
channelPeers[channel.ChannelID] = channel.PubKeyBytes
bal, ok := peerChannels[channel.PubKeyBytes]
if !ok {
bal = &balances{}
}
chanID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
bal.channels = append(bal.channels, chanID)
bal.capacity += channel.Capacity
bal.incoming += channel.RemoteBalance
bal.outgoing += channel.LocalBalance
bal.pubkey = channel.PubKeyBytes
peerChannels[channel.PubKeyBytes] = bal
}
// Get a summary of the channels and peers that are not eligible due
// to ongoing swaps.
traffic := m.currentSwapTraffic(loopOut, loopIn)
var (
suggestions []swapSuggestion
resp = newSuggestions()
)
for peer, balances := range peerChannels {
rule, haveRule := m.params.PeerRules[peer]
if !haveRule {
continue
}
suggestion, err := m.suggestSwap(
ctx, traffic, balances, rule, outRestrictions,
inRestrictions,
)
var reasonErr *reasonError
if errors.As(err, &reasonErr) {
resp.DisqualifiedPeers[peer] = reasonErr.reason
continue
}
if err != nil {
return nil, err
}
suggestions = append(suggestions, suggestion)
}
for _, channel := range channels {
balance := newBalances(channel)
channelID := lnwire.NewShortChanIDFromInt(channel.ChannelID)
rule, ok := m.params.ChannelRules[channelID]
if !ok {
continue
}
suggestion, err := m.suggestSwap(
ctx, traffic, balance, rule, outRestrictions,
inRestrictions,
)
var reasonErr *reasonError
if errors.As(err, &reasonErr) {
resp.DisqualifiedChans[channelID] = reasonErr.reason
continue
}
if err != nil {
return nil, err
}
suggestions = append(suggestions, suggestion)
}
// If we have no swaps to execute after we have applied all of our
// limits, just return our set of disqualified swaps.
if len(suggestions) == 0 {
return resp, nil
}
// Sort suggestions by amount in descending order.
sort.SliceStable(suggestions, func(i, j int) bool {
return suggestions[i].amount() > suggestions[j].amount()
})
// Run through our suggested swaps in descending order of amount and
// return all of the swaps which will fit within our remaining budget.
available := m.params.AutoFeeBudget - summary.totalFees()
// setReason is a helper that adds a swap's channels to our disqualified
// list with the reason provided.
setReason := func(reason Reason, swap swapSuggestion) {
for _, peer := range swap.peers(channelPeers) {
_, ok := m.params.PeerRules[peer]
if !ok {
continue
}
resp.DisqualifiedPeers[peer] = reason
}
for _, channel := range swap.channels() {
_, ok := m.params.ChannelRules[channel]
if !ok {
continue
}
resp.DisqualifiedChans[channel] = reason
}
}
for _, swap := range suggestions {
swap := swap
// If we do not have enough funds available, or we hit our
// in flight limit, we record this value for the rest of the
// swaps.
var reason Reason
switch {
case available == 0:
reason = ReasonBudgetInsufficient
case len(resp.OutSwaps) == allowedSwaps:
reason = ReasonInFlight
}
if reason != ReasonNone {
setReason(reason, swap)
continue
}
fees := swap.fees()
// If the maximum fee we expect our swap to use is less than the
// amount we have available, we add it to our set of swaps that
// fall within the budget and decrement our available amount.
if fees <= available {
available -= fees
if err := resp.addSwap(swap); err != nil {
return nil, err
}
} else {
refreshTime := m.params.AutoFeeRefreshPeriod -
time.Since(m.params.AutoloopBudgetLastRefresh)
log.Infof("Swap fee exceeds budget, remaining budget: "+
"%v, swap fee %v, next budget refresh: %v",
available, fees, refreshTime)
setReason(ReasonBudgetInsufficient, swap)
}
}
return resp, nil
}
// suggestSwap checks whether we can currently perform a swap, and creates a
// swap request for the rule provided.
func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
balance *balances, rule *SwapRule, outRestrictions *Restrictions,
inRestrictions *Restrictions) (swapSuggestion, error) {
var (
builder swapBuilder
restrictions *Restrictions
)
// Get an appropriate builder and set of restrictions based on our swap
// type.
switch rule.Type {
case swap.TypeOut:
builder = newLoopOutBuilder(m.cfg)
restrictions = outRestrictions
case swap.TypeIn:
builder = newLoopInBuilder(m.cfg)
restrictions = inRestrictions
default:
return nil, fmt.Errorf("unsupported swap type: %v", rule.Type)
}
// Before we get any swap suggestions, we check what the current fee
// estimate is to sweep within our target number of confirmations. If
// This fee exceeds the fee limit we have set, we will not suggest any
// swaps at present.
if err := builder.maySwap(ctx, m.params); err != nil {
return nil, err
}
// First, check whether this peer/channel combination is already in use
// for our swap.
err := builder.inUse(traffic, balance.pubkey, balance.channels)
if err != nil {
return nil, err
}
// Next, get the amount that we need to swap for this entity, skipping
// over it if no change in liquidity is required.
amount := rule.swapAmount(balance, restrictions, rule.Type)
if amount == 0 {
return nil, newReasonError(ReasonLiquidityOk)
}
return builder.buildSwap(
ctx, balance.pubkey, balance.channels, amount, m.params,
)
}
// getSwapRestrictions queries the server for its latest swap size restrictions,
// validates client restrictions (if present) against these values and merges
// the client's custom requirements with the server's limits to produce a single
// set of limitations for our swap.
func (m *Manager) getSwapRestrictions(ctx context.Context, swapType swap.Type) (