@@ -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.
9941054func (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.
12451393func (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