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.
This commit is contained in:
Boris Nagaev 2026-04-13 00:11:52 -05:00
parent 48a7bdc7d0
commit 94fc04a71a
No known key found for this signature in database
3 changed files with 612 additions and 9 deletions

View file

@ -0,0 +1,254 @@
package loopin
import (
"context"
"errors"
"slices"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightningnetwork/lnd/routing/route"
)
var (
// ErrNoAutoloopCandidate is returned when the static-address side
// cannot build a full-deposit, no-change loop-in candidate that fits
// the planner's requested amount bounds.
ErrNoAutoloopCandidate = errors.New("no autoloop candidate")
)
// PrepareAutoloopLoopIn builds a static-address loop-in request for autoloop
// without dispatching it. The returned request always uses full deposits,
// explicit outpoints, and an explicit selected amount, so the caller can
// account for the suggestion without depending on static-address internals.
func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context,
lastHop route.Vertex, minAmount, maxAmount btcutil.Amount, label,
initiator string, excludedOutpoints []string) (
*loop.StaticAddressLoopInRequest, int, bool, error) {
if minAmount <= 0 || maxAmount < minAmount {
return nil, 0, false, ErrNoAutoloopCandidate
}
allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState(
deposit.Deposited,
)
if err != nil {
return nil, 0, false, err
}
params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx)
if err != nil {
return nil, 0, false, err
}
excluded := make(map[string]struct{}, len(excludedOutpoints))
for _, outpoint := range excludedOutpoints {
excluded[outpoint] = struct{}{}
}
selectedDeposits, err := selectNoChangeDeposits(
maxAmount, minAmount, allDeposits, params.Expiry,
m.currentHeight.Load(), excluded,
)
if err != nil {
return nil, 0, false, err
}
selectedAmount := sumOfDeposits(selectedDeposits)
quote, err := m.cfg.QuoteGetter.GetLoopInQuote(
ctx, selectedAmount, m.cfg.NodePubkey, &lastHop, nil,
initiator, uint32(len(selectedDeposits)), false,
)
if err != nil {
return nil, 0, false, err
}
outpoints := make([]string, 0, len(selectedDeposits))
for _, selectedDeposit := range selectedDeposits {
outpoints = append(outpoints, selectedDeposit.OutPoint.String())
}
request := &loop.StaticAddressLoopInRequest{
DepositOutpoints: outpoints,
SelectedAmount: selectedAmount,
MaxSwapFee: quote.SwapFee,
LastHop: &lastHop,
Label: label,
Initiator: initiator,
Fast: false,
}
return request, len(selectedDeposits), false, nil
}
// selectNoChangeDeposits chooses the highest-value swappable deposit set whose
// full value stays within the requested range. The selector never creates
// change, so the returned set's total is the actual swap amount.
func selectNoChangeDeposits(maxAmount, minAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{}) ([]*deposit.Deposit, error) {
// Filter out deposits that cannot safely participate in a loop-in or
// were already allocated to a larger suggestion earlier in the same
// planning pass.
deposits := make([]*deposit.Deposit, 0, len(unfilteredDeposits))
for _, deposit := range unfilteredDeposits {
if _, ok := excludedOutpoints[deposit.OutPoint.String()]; ok {
continue
}
swappable := IsSwappable(
uint32(deposit.ConfirmationHeight), blockHeight,
csvExpiry,
)
if !swappable {
continue
}
if deposit.Value > maxAmount {
continue
}
deposits = append(deposits, deposit)
}
if len(deposits) == 0 {
return nil, ErrNoAutoloopCandidate
}
// Sort by value so the search finds large feasible totals early. The
// expiry tie-break keeps equal-value deposits deterministic and helps
// the later candidate comparison prefer sooner-expiring funds.
sort.SliceStable(deposits, func(i, j int) bool {
if deposits[i].Value == deposits[j].Value {
return deposits[i].ConfirmationHeight <
deposits[j].ConfirmationHeight
}
return deposits[i].Value > deposits[j].Value
})
// Precompute a suffix sum so branches that cannot possibly beat the
// current best total can be pruned before exploring the expensive part
// of the search tree.
suffixSums := make([]btcutil.Amount, len(deposits)+1)
for i := len(deposits) - 1; i >= 0; i-- {
suffixSums[i] = suffixSums[i+1] + deposits[i].Value
}
var (
bestSelection []int
bestTotal btcutil.Amount
)
// betterSelection applies the full-deposit ordering:
// 1. highest total not exceeding the target
// 2. fewer deposits
// 3. earlier-expiring deposits
betterSelection := func(candidate []int, total btcutil.Amount) bool {
switch {
case total > bestTotal:
return true
case total < bestTotal:
return false
case bestSelection == nil:
return true
case len(candidate) < len(bestSelection):
return true
case len(candidate) > len(bestSelection):
return false
}
// Use signed arithmetic here so an expired deposit cannot wrap
// the residual-life comparison if height updates race the
// earlier swappability filter.
left := make([]int64, len(candidate))
for i, index := range candidate {
left[i] = deposits[index].ConfirmationHeight +
int64(csvExpiry) - int64(blockHeight)
}
right := make([]int64, len(bestSelection))
for i, index := range bestSelection {
right[i] = deposits[index].ConfirmationHeight +
int64(csvExpiry) - int64(blockHeight)
}
slices.Sort(left)
slices.Sort(right)
for i := range left {
if left[i] == right[i] {
continue
}
return left[i] < right[i]
}
return false
}
// search explores include/exclude choices. The branch-and-bound checks
// are intentionally conservative: they only prune when no combination
// below the current node can beat the best known total or tie it with a
// smaller deposit count.
var search func(index int, total btcutil.Amount, selected []int)
search = func(index int, total btcutil.Amount, selected []int) {
if total > maxAmount {
return
}
if total >= minAmount && betterSelection(selected, total) {
bestTotal = total
bestSelection = append([]int(nil), selected...)
}
if index == len(deposits) {
return
}
maxReachable := total + suffixSums[index]
if maxReachable < bestTotal {
return
}
if maxReachable == bestTotal && bestSelection != nil &&
len(selected) >= len(bestSelection) {
return
}
// The include branch must not reuse selected's backing array.
// Otherwise a later append can leak into the exclude branch
// when the slice still has spare capacity.
selectedWithIndex := make([]int, len(selected)+1)
copy(selectedWithIndex, selected)
selectedWithIndex[len(selected)] = index
search(
index+1, total+deposits[index].Value,
selectedWithIndex,
)
search(index+1, total, selected)
}
search(0, 0, nil)
if len(bestSelection) == 0 {
return nil, ErrNoAutoloopCandidate
}
selectedDeposits := make([]*deposit.Deposit, 0, len(bestSelection))
for _, index := range bestSelection {
selectedDeposits = append(selectedDeposits, deposits[index])
}
return selectedDeposits, nil
}

View file

@ -0,0 +1,330 @@
package loopin
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/lightninglabs/loop/staticaddr/script"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
// TestSelectNoChangeDeposits verifies the full-deposit static-autoloop
// selector. The cases below target the filter paths, the branch-and-bound
// search, and every documented tie-breaker explicitly so coverage tracks the
// actual selection behavior instead of a handful of happy-path examples.
func TestSelectNoChangeDeposits(t *testing.T) {
depositSeven := makeDeposit(7, 0, 7_000, 200)
depositFour := makeDeposit(4, 0, 4_000, 210)
depositThreeA := makeDeposit(3, 0, 3_000, 220)
depositThreeB := makeDeposit(9, 0, 3_000, 221)
depositNine := makeDeposit(8, 0, 9_000, 205)
depositFourA := makeDeposit(5, 0, 4_000, 215)
depositFourB := makeDeposit(6, 0, 4_000, 216)
depositOneA := makeDeposit(10, 0, 1_000, 230)
depositOneB := makeDeposit(11, 0, 1_000, 231)
depositOneC := makeDeposit(21, 0, 1_000, 232)
depositFourC := makeDeposit(13, 0, 4_000, 200)
depositFourD := makeDeposit(14, 0, 4_000, 201)
depositFourE := makeDeposit(15, 0, 4_000, 220)
depositFourF := makeDeposit(16, 0, 4_000, 221)
depositFive := makeDeposit(17, 0, 5_000, 200)
depositUnsuitable := makeDeposit(18, 0, 6_000, 149)
depositOversized := makeDeposit(19, 0, 9_000, 220)
depositTwo := makeDeposit(20, 0, 2_000, 210)
testCases := []struct {
name string
maxAmount btcutil.Amount
minAmount btcutil.Amount
deposits []*deposit.Deposit
csvExpiry uint32
blockHeight uint32
excludedOutpoint map[string]struct{}
expected []*deposit.Deposit
expectedErr error
}{
{
name: "prefers exact deposit over smaller combo",
maxAmount: 7_000,
minAmount: 3_000,
deposits: []*deposit.Deposit{
depositSeven, depositFour, depositThreeA,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{depositSeven},
},
{
name: "excluded outpoint falls back to combo",
maxAmount: 7_000,
minAmount: 3_000,
deposits: []*deposit.Deposit{
depositSeven, depositFour, depositThreeA,
},
csvExpiry: 1_000,
blockHeight: 100,
excludedOutpoint: map[string]struct{}{
depositSeven.OutPoint.String(): {},
},
expected: []*deposit.Deposit{
depositFour, depositThreeA,
},
},
{
name: "same total prefers fewer deposits",
maxAmount: 6_000,
minAmount: 6_000,
deposits: []*deposit.Deposit{
depositFour, depositThreeA, depositThreeB,
depositOneA, depositOneB,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositThreeA, depositThreeB,
},
},
{
name: "same total rejects more deposits",
maxAmount: 2_000,
minAmount: 2_000,
deposits: []*deposit.Deposit{
depositTwo, depositOneA,
depositOneB, depositOneC,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{depositTwo},
},
{
name: "same total prefers earlier expiries",
maxAmount: 8_000,
minAmount: 8_000,
deposits: []*deposit.Deposit{
depositFourC, depositFourD,
depositFourE, depositFourF,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositFourC, depositFourD,
},
},
{
name: "identical residual lives keep stable pick",
maxAmount: 8_000,
minAmount: 8_000,
deposits: []*deposit.Deposit{
depositFour, depositThreeA,
depositFourA, depositThreeB,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositFour, depositFourA,
},
},
{
name: "filters unswappable and oversized deposits",
maxAmount: 7_000,
minAmount: 5_000,
deposits: []*deposit.Deposit{
depositFive, depositUnsuitable,
depositOversized,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{depositFive},
},
{
name: "returns no candidate when all are filtered",
maxAmount: 7_000,
minAmount: 5_000,
deposits: []*deposit.Deposit{
depositUnsuitable, depositOversized,
},
csvExpiry: 1_000,
blockHeight: 100,
expectedErr: ErrNoAutoloopCandidate,
},
{
name: "returns no candidate below minimum",
maxAmount: 10_000,
minAmount: 7_000,
deposits: []*deposit.Deposit{
depositFour, depositTwo,
},
csvExpiry: 1_000,
blockHeight: 100,
expectedErr: ErrNoAutoloopCandidate,
},
{
name: "zero minimum finds best positive total",
maxAmount: 7_000,
minAmount: 0,
deposits: []*deposit.Deposit{
depositFour, depositThreeA,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositFour, depositThreeA,
},
},
{
name: "deeper search isolates include and exclude",
maxAmount: 13_000,
minAmount: 10_000,
deposits: []*deposit.Deposit{
depositNine, depositSeven,
depositFourA, depositFourB,
depositThreeA,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositNine, depositFourA,
},
},
}
selectedOutpoints := func(deposits []*deposit.Deposit) []string {
result := make([]string, 0, len(deposits))
for _, selectedDeposit := range deposits {
result = append(
result, selectedDeposit.OutPoint.String(),
)
}
return result
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectedDeposits, err := selectNoChangeDeposits(
testCase.maxAmount, testCase.minAmount,
testCase.deposits, testCase.csvExpiry,
testCase.blockHeight, testCase.excludedOutpoint,
)
if testCase.expectedErr != nil {
require.ErrorIs(t, err, testCase.expectedErr)
require.Nil(t, selectedDeposits)
} else {
require.NoError(t, err)
require.Equal(
t, selectedOutpoints(testCase.expected),
selectedOutpoints(selectedDeposits),
)
}
})
}
}
// TestPrepareAutoloopLoopIn ensures the static manager returns an explicit
// full-deposit request and quotes it with the correct amount and deposit
// count.
func TestPrepareAutoloopLoopIn(t *testing.T) {
ctx := t.Context()
selectedDeposit := makeDeposit(1, 0, 9_000, 300)
quoteGetter := &mockQuoteGetter{
quote: &loop.LoopInQuote{
SwapFee: 123,
},
}
manager, err := NewManager(&Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
Expiry: 1_000,
},
},
DepositManager: &mockDepositManager{
activeDeposits: []*deposit.Deposit{selectedDeposit},
},
QuoteGetter: quoteGetter,
NodePubkey: route.Vertex{2},
}, 200)
require.NoError(t, err)
lastHop := route.Vertex{9}
request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn(
ctx, lastHop, 5_000, 10_000, "label", "autoloop", nil,
)
require.NoError(t, err)
require.Equal(
t, []string{selectedDeposit.OutPoint.String()},
request.DepositOutpoints,
)
require.Equal(t, selectedDeposit.Value, request.SelectedAmount)
require.Equal(t, btcutil.Amount(123), request.MaxSwapFee)
require.NotNil(t, request.LastHop)
require.Equal(t, lastHop, *request.LastHop)
require.Equal(t, "label", request.Label)
require.Equal(t, "autoloop", request.Initiator)
require.False(t, request.Fast)
require.Equal(t, 1, numDeposits)
require.False(t, hasChange)
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
require.NotNil(t, quoteGetter.lastHop)
require.Equal(t, lastHop, *quoteGetter.lastHop)
require.Equal(t, "autoloop", quoteGetter.initiator)
require.Equal(t, uint32(1), quoteGetter.numDeposits)
require.False(t, quoteGetter.fast)
}
// TestPrepareAutoloopLoopInExcludedOutpoints verifies that the manager passes
// excluded outpoints through the end-to-end preparation path before quoting
// the candidate.
func TestPrepareAutoloopLoopInExcludedOutpoints(t *testing.T) {
ctx := t.Context()
excludedDeposit := makeDeposit(1, 0, 9_000, 300)
selectedDeposit := makeDeposit(2, 0, 7_000, 301)
quoteGetter := &mockQuoteGetter{
quote: &loop.LoopInQuote{
SwapFee: 77,
},
}
manager, err := NewManager(&Config{
AddressManager: &mockAddressManager{
params: &script.Parameters{
Expiry: 1_000,
},
},
DepositManager: &mockDepositManager{
activeDeposits: []*deposit.Deposit{
excludedDeposit, selectedDeposit,
},
},
QuoteGetter: quoteGetter,
NodePubkey: route.Vertex{2},
}, 200)
require.NoError(t, err)
request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn(
ctx, route.Vertex{9}, 5_000, 10_000, "label", "autoloop",
[]string{excludedDeposit.OutPoint.String()},
)
require.NoError(t, err)
require.Equal(
t, []string{selectedDeposit.OutPoint.String()},
request.DepositOutpoints,
)
require.Equal(t, selectedDeposit.Value, request.SelectedAmount)
require.Equal(t, btcutil.Amount(77), request.MaxSwapFee)
require.Equal(t, 1, numDeposits)
require.False(t, hasChange)
require.Equal(t, selectedDeposit.Value, quoteGetter.amount)
}

View file

@ -222,6 +222,9 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) {
// mockDepositManager implements DepositManager for tests.
type mockDepositManager struct {
// activeDeposits is the set returned by GetActiveDepositsInState.
activeDeposits []*deposit.Deposit
// byOutpoint maps outpoint strings to deposits for direct lookups.
byOutpoint map[string]*deposit.Deposit
}
@ -277,36 +280,52 @@ func (m *mockDepositManager) DepositsForOutpoints(_ context.Context,
func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) (
[]*deposit.Deposit, error) {
return nil, nil
return m.activeDeposits, nil
}
// mockQuoteGetter returns either a configured quote or a configured error and
// records the quoted amount for assertions.
// mockQuoteGetter records the inputs to quote requests and returns a fixed
// loop-in quote.
type mockQuoteGetter struct {
// quote is the response returned from GetLoopInQuote.
quote *loop.LoopInQuote
// err is the optional error returned from GetLoopInQuote.
err error
// amount records the quoted amount.
amount btcutil.Amount
// lastHop records the quoted last hop.
lastHop *route.Vertex
// initiator records the quoted initiator string.
initiator string
// numDeposits records the quoted deposit count.
numDeposits uint32
// fast records the quoted fast flag.
fast bool
}
// GetLoopInQuote returns the configured quote result for tests.
// GetLoopInQuote returns the configured quote and records the request
// parameters for assertions.
func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context,
amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex,
_ [][]zpay32.HopHint, initiator string, numDeposits uint32,
fast bool) (*loop.LoopInQuote, error) {
m.amount = amt
_ = lastHop
_ = initiator
_ = numDeposits
_ = fast
m.lastHop = lastHop
m.initiator = initiator
m.numDeposits = numDeposits
m.fast = fast
if m.err != nil {
return nil, m.err
}
return &loop.LoopInQuote{}, nil
return m.quote, nil
}
// mockStore implements StaticAddressLoopInStore for tests.