loop/staticaddr/loopin/autoloop_test.go
Boris Nagaev 8d6df2bf20
staticaddr: use dp autoloop selector
Replace the recursive full-deposit autoloop selector with a bounded-memory
DP implementation in staticaddr/loopin/autoloop_dp.go. The new selector
keeps the existing no-change semantics, first finds the best reachable
total, then applies the 25 percent band rule so earlier-expiring deposits
can win inside that near-optimal range.

The DP table is capped at 128 MiB and keeps exact satoshi sums alongside
compressed bucket weights, so planning stays memory-bounded without
allowing oversized candidates. The compressed weighting now rounds down
with a minimum of one bucket, which avoids rejecting valid sums after
multiple per-deposit rounding steps while leaving the exact-sum check
as the real safety boundary.
2026-05-22 02:20:36 -05:00

377 lines
10 KiB
Go

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 exercises the bounded-memory selector end to end.
// The full decision rule:
//
// 1. build only full-deposit, no-change candidates
// 2. find the best reachable total in the requested range
// 3. allow a band that gives back up to 25 percent of the gain above
// minAmount
// 4. inside that band, prefer earlier-expiring deposits
// 5. fall back to larger total, then fewer deposits
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)
depositTen := makeDeposit(23, 0, 10_000, 500)
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,
},
},
{
// A slightly smaller total can win when it stays inside
// the band that gives back only part of the gain above
// minAmount.
name: "smaller earlier-expiring combo can win " +
"inside band",
maxAmount: 10_000,
minAmount: 6_000,
deposits: []*deposit.Deposit{
depositTen, depositFive, depositFourC,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{
depositFive, depositFourC,
},
},
{
name: "smaller earlier candidate below band does not " +
"beat best total",
maxAmount: 9_000,
minAmount: 8_000,
deposits: []*deposit.Deposit{
depositNine, depositSeven,
},
csvExpiry: 1_000,
blockHeight: 100,
expected: []*deposit.Deposit{depositNine},
},
{
name: "returns no candidate when enough value exists " +
"but no subset fits range",
maxAmount: 6_000,
minAmount: 5_000,
deposits: []*deposit.Deposit{
depositFourC, depositFourD, depositFourE,
},
csvExpiry: 1_000,
blockHeight: 100,
expectedErr: ErrNoAutoloopCandidate,
},
}
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)
}