Skip to content

Commit f377f0b

Browse files
committed
liquidity: add static autoloop planner
Wire static-address-backed loop-ins into the existing autoloop planner and dispatch path. Loop-in rules can now be converted into static candidates, prepared after global sorting, filtered with static fee limits, and dispatched through the static manager. This also fixes MaxAutoInFlight enforcement across all suggested swap types and adds planner tests for missing static candidates and mixed in-flight filtering.
1 parent 4cb0f59 commit f377f0b

4 files changed

Lines changed: 617 additions & 21 deletions

File tree

liquidity/liquidity.go

Lines changed: 186 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,19 @@ type Config struct {
213213
LoopIn func(ctx context.Context,
214214
request *loop.LoopInRequest) (*loop.LoopInSwapInfo, error)
215215

216+
// PrepareStaticLoopIn builds a static-address-backed loop-in request
217+
// for autoloop without dispatching it. The excluded outpoints set lets
218+
// the planner avoid reusing deposits across multiple suggestions from
219+
// the same pass.
220+
PrepareStaticLoopIn func(ctx context.Context, peer route.Vertex,
221+
minAmount, amount btcutil.Amount, label, initiator string,
222+
excludedOutpoints []string) (*PreparedStaticLoopIn, error)
223+
224+
// StaticLoopIn dispatches a prepared static-address-backed loop-in.
225+
StaticLoopIn func(ctx context.Context,
226+
request *loop.StaticAddressLoopInRequest) (
227+
*StaticLoopInDispatchResult, error)
228+
216229
// LoopInTerms returns the terms for a loop in swap.
217230
LoopInTerms func(ctx context.Context,
218231
initiator string) (*loop.LoopInTerms, error)
@@ -502,6 +515,30 @@ func (m *Manager) autoloop(ctx context.Context) error {
502515
loopIn.HtlcAddressP2WSH, loopIn.HtlcAddressP2TR)
503516
}
504517

518+
for _, in := range suggestion.StaticInSwaps {
519+
// Static loop-ins follow the same dry-run semantics as the legacy
520+
// autoloop suggestions. We only dispatch them when autoloop is
521+
// actually enabled.
522+
if !m.params.Autoloop {
523+
log.Debugf("recommended static autoloop in: %v sats "+
524+
"over %v", in.SelectedAmount, in.DepositOutpoints)
525+
526+
continue
527+
}
528+
529+
if m.cfg.StaticLoopIn == nil {
530+
return errors.New("static loop in dispatcher unavailable")
531+
}
532+
533+
loopIn, err := m.cfg.StaticLoopIn(ctx, &in)
534+
if err != nil {
535+
return err
536+
}
537+
538+
log.Infof("static loop in automatically dispatched: hash: %v",
539+
loopIn.SwapHash)
540+
}
541+
505542
return nil
506543
}
507544

@@ -662,6 +699,7 @@ func (m *Manager) dispatchBestEasyAutoloopSwap(ctx context.Context) error {
662699
if err != nil {
663700
return err
664701
}
702+
665703
if channel == nil {
666704
return fmt.Errorf("no eligible channel for easy autoloop")
667705
}
@@ -865,6 +903,7 @@ func (m *Manager) dispatchBestAssetEasyAutoloopSwap(ctx context.Context,
865903
if err != nil {
866904
return err
867905
}
906+
868907
if channel == nil {
869908
return fmt.Errorf("no eligible channel for easy autoloop")
870909
}
@@ -961,13 +1000,35 @@ func (s *Suggestions) addSwap(swap swapSuggestion) error {
9611000
case *loopInSwapSuggestion:
9621001
s.InSwaps = append(s.InSwaps, t.LoopInRequest)
9631002

1003+
case *staticLoopInSwapSuggestion:
1004+
s.StaticInSwaps = append(s.StaticInSwaps, t.request)
1005+
9641006
default:
9651007
return fmt.Errorf("unexpected swap type: %T", swap)
9661008
}
9671009

9681010
return nil
9691011
}
9701012

1013+
// count returns the total number of accepted suggestions regardless of swap
1014+
// type.
1015+
func (s *Suggestions) count() int {
1016+
return len(s.OutSwaps) + len(s.InSwaps) + len(s.StaticInSwaps)
1017+
}
1018+
1019+
// suggestionCandidate is the shared view used while ordering suggestions
1020+
// before final budget and in-flight filtering.
1021+
type suggestionCandidate interface {
1022+
// amount returns the requested swap amount.
1023+
amount() btcutil.Amount
1024+
1025+
// channels returns the channels implicated by the candidate.
1026+
channels() []lnwire.ShortChannelID
1027+
1028+
// peers returns the peers implicated by the candidate.
1029+
peers(knownChans map[uint64]route.Vertex) []route.Vertex
1030+
}
1031+
9711032
// singleReasonSuggestion is a helper function which returns a set of
9721033
// suggestions where all of our rules are disqualified due to a reason that
9731034
// applies to all of them (such as being out of budget).
@@ -985,12 +1046,11 @@ func (m *Manager) singleReasonSuggestion(reason Reason) *Suggestions {
9851046
return resp
9861047
}
9871048

988-
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
989-
// balance for the set of rules configured for the manager, failing if there are
990-
// no rules set. It takes an autoloop boolean that indicates whether the
991-
// suggestions are being used for our internal autolooper. This boolean is used
992-
// to determine the information we add to our swap suggestion and whether we
993-
// return any suggestions.
1049+
// SuggestSwaps returns a set of swap suggestions based on our current
1050+
// liquidity balance for the rules configured on the manager. The planner
1051+
// fails when no rules are set and otherwise returns both suggested swaps and
1052+
// structured disqualification reasons for rules that could not be satisfied in
1053+
// the current pass.
9941054
func (m *Manager) SuggestSwaps(ctx context.Context) (
9951055
*Suggestions, error) {
9961056

@@ -1086,8 +1146,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
10861146
)
10871147

10881148
var (
1089-
suggestions []swapSuggestion
1090-
resp = newSuggestions()
1149+
candidates []suggestionCandidate
1150+
resp = newSuggestions()
10911151
)
10921152

10931153
for peer, balances := range peerChannels {
@@ -1110,7 +1170,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11101170
return nil, err
11111171
}
11121172

1113-
suggestions = append(suggestions, suggestion)
1173+
candidates = append(candidates, suggestion)
11141174
}
11151175

11161176
for _, channel := range channels {
@@ -1145,18 +1205,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11451205
return nil, err
11461206
}
11471207

1148-
suggestions = append(suggestions, suggestion)
1208+
candidates = append(candidates, suggestion)
11491209
}
11501210

11511211
// If we have no swaps to execute after we have applied all of our
11521212
// limits, just return our set of disqualified swaps.
1153-
if len(suggestions) == 0 {
1213+
if len(candidates) == 0 {
11541214
return resp, nil
11551215
}
11561216

11571217
// Sort suggestions by amount in descending order.
1158-
sort.SliceStable(suggestions, func(i, j int) bool {
1159-
return suggestions[i].amount() > suggestions[j].amount()
1218+
sort.SliceStable(candidates, func(i, j int) bool {
1219+
return candidates[i].amount() > candidates[j].amount()
11601220
})
11611221

11621222
// Run through our suggested swaps in descending order of amount and
@@ -1165,8 +1225,8 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11651225

11661226
// setReason is a helper that adds a swap's channels to our disqualified
11671227
// list with the reason provided.
1168-
setReason := func(reason Reason, swap swapSuggestion) {
1169-
for _, peer := range swap.peers(channelPeers) {
1228+
setReason := func(reason Reason, candidate suggestionCandidate) {
1229+
for _, peer := range candidate.peers(channelPeers) {
11701230
_, ok := m.params.PeerRules[peer]
11711231
if !ok {
11721232
continue
@@ -1175,7 +1235,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11751235
resp.DisqualifiedPeers[peer] = reason
11761236
}
11771237

1178-
for _, channel := range swap.channels() {
1238+
for _, channel := range candidate.channels() {
11791239
_, ok := m.params.ChannelRules[channel]
11801240
if !ok {
11811241
continue
@@ -1185,7 +1245,40 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11851245
}
11861246
}
11871247

1188-
for _, swap := range suggestions {
1248+
var excludedOutpoints []string
1249+
for _, candidate := range candidates {
1250+
var swap swapSuggestion
1251+
1252+
switch t := candidate.(type) {
1253+
case swapSuggestion:
1254+
swap = t
1255+
1256+
case *staticLoopInCandidate:
1257+
swap, excludedOutpoints, err = m.prepareStaticLoopInSuggestion(
1258+
ctx, t, excludedOutpoints,
1259+
)
1260+
switch {
1261+
case errors.Is(err, ErrNoStaticLoopInCandidate):
1262+
setReason(ReasonStaticLoopInNoCandidate, candidate)
1263+
continue
1264+
1265+
case err == nil:
1266+
1267+
default:
1268+
var reasonErr *reasonError
1269+
if errors.As(err, &reasonErr) {
1270+
setReason(reasonErr.reason, candidate)
1271+
continue
1272+
}
1273+
1274+
return nil, err
1275+
}
1276+
1277+
default:
1278+
return nil, fmt.Errorf("unexpected candidate type: %T",
1279+
candidate)
1280+
}
1281+
11891282
// If we do not have enough funds available, or we hit our
11901283
// in flight limit, we record this value for the rest of the
11911284
// swaps.
@@ -1194,12 +1287,12 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
11941287
case available == 0:
11951288
reason = ReasonBudgetInsufficient
11961289

1197-
case len(resp.OutSwaps) == allowedSwaps:
1290+
case resp.count() == allowedSwaps:
11981291
reason = ReasonInFlight
11991292
}
12001293

12011294
if reason != ReasonNone {
1202-
setReason(reason, swap)
1295+
setReason(reason, candidate)
12031296
continue
12041297
}
12051298

@@ -1221,7 +1314,7 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
12211314
log.Infof("Swap fee exceeds budget, remaining budget: "+
12221315
"%v, swap fee %v, next budget refresh: %v",
12231316
available, fees, refreshTime)
1224-
setReason(ReasonBudgetInsufficient, swap)
1317+
setReason(ReasonBudgetInsufficient, candidate)
12251318
}
12261319
}
12271320

@@ -1240,11 +1333,66 @@ func (m *Manager) loadStaticLoopIns(ctx context.Context) (
12401333
return m.cfg.ListStaticLoopIn(ctx)
12411334
}
12421335

1336+
// prepareStaticLoopInSuggestion turns a peer-level static loop-in candidate
1337+
// into a concrete swap suggestion. The helper only runs after all candidates
1338+
// have been sorted so it can carry a mutable excluded-deposit set through the
1339+
// whole planner pass.
1340+
func (m *Manager) prepareStaticLoopInSuggestion(ctx context.Context,
1341+
candidate *staticLoopInCandidate,
1342+
excludedOutpoints []string) (swapSuggestion, []string, error) {
1343+
1344+
if m.cfg.PrepareStaticLoopIn == nil {
1345+
return nil, excludedOutpoints, errors.New(
1346+
"static loop in preparer unavailable",
1347+
)
1348+
}
1349+
1350+
label := ""
1351+
if m.params.Autoloop {
1352+
label = labels.AutoloopLabel(swap.TypeIn)
1353+
if m.params.EasyAutoloop {
1354+
label = labels.EasyAutoloopLabel(swap.TypeIn)
1355+
}
1356+
}
1357+
1358+
prepared, err := m.cfg.PrepareStaticLoopIn(
1359+
ctx, candidate.peer, candidate.minAmount, candidate.amountHint,
1360+
label,
1361+
getInitiator(m.params), excludedOutpoints,
1362+
)
1363+
if err != nil {
1364+
return nil, excludedOutpoints, err
1365+
}
1366+
1367+
// Static loop-ins have a different timeout-risk profile than
1368+
// wallet-funded loop-ins, so use the dedicated static fee model before
1369+
// the candidate can compete for budget and in-flight slots.
1370+
err = staticLoopInFeeLimit(
1371+
m.params.FeeLimit, prepared.Request.SelectedAmount,
1372+
prepared.Request.MaxSwapFee, prepared.NumDeposits,
1373+
prepared.HasChange,
1374+
)
1375+
if err != nil {
1376+
return nil, excludedOutpoints, err
1377+
}
1378+
1379+
nextExcluded := append(
1380+
append([]string(nil), excludedOutpoints...),
1381+
prepared.Request.DepositOutpoints...,
1382+
)
1383+
1384+
return &staticLoopInSwapSuggestion{
1385+
request: prepared.Request,
1386+
numDeposits: prepared.NumDeposits,
1387+
hasChange: prepared.HasChange,
1388+
}, nextExcluded, nil
1389+
}
1390+
12431391
// suggestSwap checks whether we can currently perform a swap, and creates a
12441392
// swap request for the rule provided.
12451393
func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
12461394
balance *balances, rule *SwapRule, outRestrictions *Restrictions,
1247-
inRestrictions *Restrictions) (swapSuggestion, error) {
1395+
inRestrictions *Restrictions) (suggestionCandidate, error) {
12481396

12491397
var (
12501398
builder swapBuilder
@@ -1288,6 +1436,23 @@ func (m *Manager) suggestSwap(ctx context.Context, traffic *swapTraffic,
12881436
return nil, newReasonError(ReasonLiquidityOk)
12891437
}
12901438

1439+
// Static loop-ins are prepared later, once the planner has a sorted
1440+
// view of all loop-in candidates. That later step needs a mutable set
1441+
// of excluded deposits so that two suggestions in the same pass cannot
1442+
// consume the same static funds.
1443+
if rule.Type == swap.TypeIn &&
1444+
m.params.LoopInSource == LoopInSourceStaticAddress {
1445+
1446+
return &staticLoopInCandidate{
1447+
peer: balance.pubkey,
1448+
minAmount: restrictions.Minimum,
1449+
amountHint: amount,
1450+
channelSet: append(
1451+
[]lnwire.ShortChannelID(nil), balance.channels...,
1452+
),
1453+
}, nil
1454+
}
1455+
12911456
return builder.buildSwap(
12921457
ctx, balance.pubkey, balance.channels, amount, m.params,
12931458
)

0 commit comments

Comments
 (0)