Skip to content

Commit f25e3fd

Browse files
committed
staticaddr: add autoloop loop-in prep
Add the static-address helper that prepares full-deposit autoloop loop-ins without dispatching them. The helper selects no-change deposit sets, records explicit outpoints, and quotes the exact selected amount before the planner tries to dispatch anything. The tests cover the full-deposit selector, the quoted request construction, and excluded outpoint handling so later liquidity work can rely on a stable preparation surface.
1 parent f3bf7b5 commit f25e3fd

3 files changed

Lines changed: 615 additions & 9 deletions

File tree

staticaddr/loopin/autoloop.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package loopin
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sort"
7+
8+
"github.com/btcsuite/btcd/btcutil"
9+
"github.com/lightninglabs/loop"
10+
"github.com/lightninglabs/loop/staticaddr/deposit"
11+
"github.com/lightningnetwork/lnd/routing/route"
12+
)
13+
14+
var (
15+
// ErrNoAutoloopCandidate is returned when the static-address side
16+
// cannot build a full-deposit, no-change loop-in candidate that fits
17+
// the planner's requested amount bounds.
18+
ErrNoAutoloopCandidate = errors.New("no autoloop candidate")
19+
)
20+
21+
// PrepareAutoloopLoopIn builds a static-address loop-in request for autoloop
22+
// without dispatching it. The returned request always uses full deposits,
23+
// explicit outpoints, and an explicit selected amount, so the caller can
24+
// account for the suggestion without depending on static-address internals.
25+
func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context,
26+
lastHop route.Vertex, minAmount, maxAmount btcutil.Amount, label,
27+
initiator string, excludedOutpoints []string) (
28+
*loop.StaticAddressLoopInRequest, int, bool, error) {
29+
30+
if minAmount <= 0 || maxAmount < minAmount {
31+
return nil, 0, false, ErrNoAutoloopCandidate
32+
}
33+
34+
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
35+
deposit.Deposited,
36+
)
37+
if err != nil {
38+
return nil, 0, false, err
39+
}
40+
41+
params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx)
42+
if err != nil {
43+
return nil, 0, false, err
44+
}
45+
46+
excluded := make(map[string]struct{}, len(excludedOutpoints))
47+
for _, outpoint := range excludedOutpoints {
48+
excluded[outpoint] = struct{}{}
49+
}
50+
51+
selectedDeposits, err := selectNoChangeDeposits(
52+
maxAmount, minAmount, allDeposits, params.Expiry,
53+
m.currentHeight.Load(), excluded,
54+
)
55+
if err != nil {
56+
return nil, 0, false, err
57+
}
58+
59+
selectedAmount := sumOfDeposits(selectedDeposits)
60+
quote, err := m.cfg.QuoteGetter.GetLoopInQuote(
61+
ctx, selectedAmount, m.cfg.NodePubkey, &lastHop, nil,
62+
initiator, uint32(len(selectedDeposits)), false,
63+
)
64+
if err != nil {
65+
return nil, 0, false, err
66+
}
67+
68+
outpoints := make([]string, 0, len(selectedDeposits))
69+
for _, selectedDeposit := range selectedDeposits {
70+
outpoints = append(outpoints, selectedDeposit.OutPoint.String())
71+
}
72+
73+
request := &loop.StaticAddressLoopInRequest{
74+
DepositOutpoints: outpoints,
75+
SelectedAmount: selectedAmount,
76+
MaxSwapFee: quote.SwapFee,
77+
LastHop: &lastHop,
78+
Label: label,
79+
Initiator: initiator,
80+
Fast: false,
81+
}
82+
83+
return request, len(selectedDeposits), false, nil
84+
}
85+
86+
// selectNoChangeDeposits chooses the highest-value swappable deposit set whose
87+
// full value stays within the requested range. The selector never creates
88+
// change, so the returned set's total is the actual swap amount.
89+
func selectNoChangeDeposits(maxAmount, minAmount btcutil.Amount,
90+
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
91+
excludedOutpoints map[string]struct{}) ([]*deposit.Deposit, error) {
92+
93+
// Filter out deposits that cannot safely participate in a loop-in or
94+
// were already allocated to a larger suggestion earlier in the same
95+
// planning pass.
96+
deposits := make([]*deposit.Deposit, 0, len(unfilteredDeposits))
97+
for _, deposit := range unfilteredDeposits {
98+
if _, ok := excludedOutpoints[deposit.OutPoint.String()]; ok {
99+
continue
100+
}
101+
102+
swappable := IsSwappable(
103+
uint32(deposit.ConfirmationHeight), blockHeight,
104+
csvExpiry,
105+
)
106+
if !swappable {
107+
continue
108+
}
109+
110+
if deposit.Value > maxAmount {
111+
continue
112+
}
113+
114+
deposits = append(deposits, deposit)
115+
}
116+
117+
if len(deposits) == 0 {
118+
return nil, ErrNoAutoloopCandidate
119+
}
120+
121+
// Sort by value so the search finds large feasible totals early. The
122+
// expiry tie-break keeps equal-value deposits deterministic and helps
123+
// the later candidate comparison prefer sooner-expiring funds.
124+
sort.SliceStable(deposits, func(i, j int) bool {
125+
if deposits[i].Value == deposits[j].Value {
126+
return deposits[i].ConfirmationHeight <
127+
deposits[j].ConfirmationHeight
128+
}
129+
130+
return deposits[i].Value > deposits[j].Value
131+
})
132+
133+
// Precompute a suffix sum so branches that cannot possibly beat the
134+
// current best total can be pruned before exploring the expensive part
135+
// of the search tree.
136+
suffixSums := make([]btcutil.Amount, len(deposits)+1)
137+
for i := len(deposits) - 1; i >= 0; i-- {
138+
suffixSums[i] = suffixSums[i+1] + deposits[i].Value
139+
}
140+
141+
var (
142+
bestSelection []int
143+
bestTotal btcutil.Amount
144+
)
145+
146+
// betterSelection applies the full-deposit ordering:
147+
// 1. highest total not exceeding the target
148+
// 2. fewer deposits
149+
// 3. earlier-expiring deposits
150+
betterSelection := func(candidate []int, total btcutil.Amount) bool {
151+
switch {
152+
case total > bestTotal:
153+
return true
154+
155+
case total < bestTotal:
156+
return false
157+
158+
case bestSelection == nil:
159+
return true
160+
161+
case len(candidate) < len(bestSelection):
162+
return true
163+
164+
case len(candidate) > len(bestSelection):
165+
return false
166+
}
167+
168+
// Use signed arithmetic here so an expired deposit cannot wrap
169+
// the residual-life comparison if height updates race the
170+
// earlier swappability filter.
171+
left := make([]int64, len(candidate))
172+
for i, index := range candidate {
173+
left[i] = int64(deposits[index].ConfirmationHeight) +
174+
int64(csvExpiry) - int64(blockHeight)
175+
}
176+
177+
right := make([]int64, len(bestSelection))
178+
for i, index := range bestSelection {
179+
right[i] = int64(deposits[index].ConfirmationHeight) +
180+
int64(csvExpiry) - int64(blockHeight)
181+
}
182+
183+
sort.Slice(left, func(i, j int) bool {
184+
return left[i] < left[j]
185+
})
186+
sort.Slice(right, func(i, j int) bool {
187+
return right[i] < right[j]
188+
})
189+
190+
for i := range left {
191+
if left[i] == right[i] {
192+
continue
193+
}
194+
195+
return left[i] < right[i]
196+
}
197+
198+
return false
199+
}
200+
201+
// search explores include/exclude choices. The branch-and-bound checks
202+
// are intentionally conservative: they only prune when no combination
203+
// below the current node can beat the best known total or tie it with a
204+
// smaller deposit count.
205+
var search func(index int, total btcutil.Amount, selected []int)
206+
search = func(index int, total btcutil.Amount, selected []int) {
207+
if total > maxAmount {
208+
return
209+
}
210+
211+
if total >= minAmount && betterSelection(selected, total) {
212+
bestTotal = total
213+
bestSelection = append([]int(nil), selected...)
214+
}
215+
216+
if index == len(deposits) {
217+
return
218+
}
219+
220+
maxReachable := total + suffixSums[index]
221+
if maxReachable < bestTotal {
222+
return
223+
}
224+
225+
if maxReachable == bestTotal && bestSelection != nil &&
226+
len(selected) >= len(bestSelection) {
227+
228+
return
229+
}
230+
231+
// The include branch must not reuse selected's backing array.
232+
// Otherwise a later append can leak into the exclude branch
233+
// when the slice still has spare capacity.
234+
selectedWithIndex := make([]int, len(selected)+1)
235+
copy(selectedWithIndex, selected)
236+
selectedWithIndex[len(selected)] = index
237+
238+
search(
239+
index+1, total+deposits[index].Value,
240+
selectedWithIndex,
241+
)
242+
search(index+1, total, selected)
243+
}
244+
245+
search(0, 0, nil)
246+
247+
if len(bestSelection) == 0 {
248+
return nil, ErrNoAutoloopCandidate
249+
}
250+
251+
selectedDeposits := make([]*deposit.Deposit, 0, len(bestSelection))
252+
for _, index := range bestSelection {
253+
selectedDeposits = append(selectedDeposits, deposits[index])
254+
}
255+
256+
return selectedDeposits, nil
257+
}

0 commit comments

Comments
 (0)