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.
This commit is contained in:
Boris Nagaev 2026-05-14 01:20:54 -05:00
parent 616b0534a6
commit 8d6df2bf20
No known key found for this signature in database
4 changed files with 1008 additions and 165 deletions

View file

@ -3,8 +3,6 @@ package loopin
import (
"context"
"errors"
"slices"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop"
@ -91,164 +89,8 @@ 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
return selectNoChangeDepositsWithMemoryBudget(
maxAmount, minAmount, unfilteredDeposits, csvExpiry,
blockHeight, excludedOutpoints, autoloopDPMaxMemoryBytes,
)
// 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,530 @@
package loopin
import (
"errors"
"math/bits"
"sort"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/staticaddr/deposit"
)
const (
// autoloopDPMaxMemoryBytes caps the selector's working set. The
// selector compresses the sum space when needed so one planning tick
// cannot consume unbounded memory just because a node has many static
// deposits.
autoloopDPMaxMemoryBytes = 128 * 1024 * 1024
// autoloopDPStateOverheadBytes approximates the per-bucket cost outside
// of the bitset itself. The exact sum and count slices account for the
// logical state, and the extra slack keeps the sizing conservative so
// the selector stays below the intended memory budget in practice.
autoloopDPStateOverheadBytes = 16
)
var (
// errAutoloopDPMemoryBudgetTooSmall is returned when even the coarsest
// two-bucket table would exceed the configured memory budget. This is a
// structural limitation of the bounded-memory representation, not a
// liquidity constraint, so callers should not collapse it into
// ErrNoAutoloopCandidate.
errAutoloopDPMemoryBudgetTooSmall = errors.New(
"autoloop dp memory budget too small",
)
)
// autoloopCandidateDeposit carries the precomputed metadata the DP needs to
// make deterministic comparisons.
type autoloopCandidateDeposit struct {
// deposit is the original static-address deposit that may be selected.
deposit *deposit.Deposit
// residualLife is the remaining lifetime of the deposit in blocks once
// the current height is taken into account.
residualLife int64
// outpoint is cached so deterministic ordering does not keep rebuilding
// the string form during sort comparisons.
outpoint string
}
// autoloopDPTable stores one representative subset for each compressed sum
// bucket. Each representative carries its full bitset so later updates can
// compare candidates without relying on mutable predecessor buckets.
//
// This is intentionally heavier than a predecessor-only table. A simpler
// parent-pointer representation would be smaller per bucket, but it becomes
// incorrect once a source bucket is overwritten by a later update because
// already-derived states would silently change their parent chains.
//
// When the selector compresses the sum space, several exact sums can share one
// bucket. The build phase keeps only one representative for that bucket and
// prefers the larger exact sum before the final band scan considers expiry.
// That makes the compressed path approximate: an earlier-expiring smaller-sum
// subset can be hidden by a larger-sum subset in the same bucket. The default
// 128 MiB budget keeps realistic production inputs at step = 1, so this trade-
// off only matters when tests or future callers intentionally lower the memory
// budget.
type autoloopDPTable struct {
// wordsPerState is the number of 64-bit words needed to represent one
// deposit-selection bitset.
wordsPerState int
// exactSums stores the real satoshi sum of the representative subset in
// each bucket. The selector never trusts the compressed bucket weight
// for range checks because the DP is allowed to scale the sum space.
exactSums []btcutil.Amount
// counts stores the number of selected deposits for each bucket. A
// value of -1 marks an unreachable bucket.
counts []int32
// selections stores one flattened bitset per bucket. The bitset is the
// self-contained reconstruction data for the representative subset.
selections []uint64
}
// selectNoChangeDepositsWithMemoryBudget runs the bounded-memory selector with
// an explicit budget. Tests use this entry point to force the scaling path and
// to exercise hard budget failures deterministically.
func selectNoChangeDepositsWithMemoryBudget(maxAmount, minAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{},
maxMemoryBytes int) ([]*deposit.Deposit, error) {
eligibleDeposits, eligibleTotal := filterAutoloopCandidateDeposits(
maxAmount, unfilteredDeposits, csvExpiry, blockHeight,
excludedOutpoints,
)
if len(eligibleDeposits) == 0 || eligibleTotal < minAmount {
return nil, ErrNoAutoloopCandidate
}
step, bucketCount, err := autoloopDPSizing(
maxAmount, len(eligibleDeposits), maxMemoryBytes,
)
if err != nil {
return nil, err
}
table := newAutoloopDPTable(bucketCount, len(eligibleDeposits))
// The empty subset is the base state for all later transitions.
table.counts[0] = 0
for depositIndex, candidateDeposit := range eligibleDeposits {
weight := autoloopDPBucketWeight(
candidateDeposit.deposit.Value, step,
)
// Descending updates preserve the 0-1 constraint: every deposit
// is either present once in a candidate or not at all.
start := bucketCount - weight - 1
for sourceBucket := start; sourceBucket >= 0; sourceBucket-- {
if !table.isReachable(sourceBucket) {
continue
}
destBucket := sourceBucket + weight
candidateSum := table.exactSums[sourceBucket] +
candidateDeposit.deposit.Value
if candidateSum > maxAmount {
continue
}
beats := table.candidateBeatsState(
sourceBucket, destBucket, depositIndex,
candidateSum, eligibleDeposits,
)
if !beats {
continue
}
table.copyStateFromSource(
destBucket, sourceBucket, depositIndex,
candidateSum,
)
}
}
bestTotal := btcutil.Amount(-1)
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < minAmount || exactSum > maxAmount {
continue
}
if exactSum > bestTotal {
bestTotal = exactSum
}
}
if bestTotal < 0 {
return nil, ErrNoAutoloopCandidate
}
// The band lets expiry management influence the final choice, but only
// after the selector first learns the best liquidity amount that the
// compressed DP table can achieve. The slack gives back up to 25 percent
// of the gain above minAmount, not 25 percent of bestTotal itself.
slack := (bestTotal - minAmount) / 4
bandFloor := bestTotal - slack
bestBucket := -1
for bucket := 1; bucket < bucketCount; bucket++ {
if !table.isReachable(bucket) {
continue
}
exactSum := table.exactSums[bucket]
if exactSum < bandFloor || exactSum > bestTotal {
continue
}
if bestBucket == -1 {
bestBucket = bucket
continue
}
beats := table.stateBeatsStateWithinBand(
bucket, bestBucket, eligibleDeposits,
)
if beats {
bestBucket = bucket
}
}
if bestBucket == -1 {
return nil, ErrNoAutoloopCandidate
}
selectedIndices := table.selectedIndices(bestBucket)
selectedDeposits := make([]*deposit.Deposit, 0, len(selectedIndices))
for _, index := range selectedIndices {
selectedDeposits = append(
selectedDeposits, eligibleDeposits[index].deposit,
)
}
return selectedDeposits, nil
}
// filterAutoloopCandidateDeposits removes deposits that can never participate
// in a full-deposit static autoloop suggestion and sorts the remainder in the
// order used by the expiry comparisons.
func filterAutoloopCandidateDeposits(maxAmount btcutil.Amount,
unfilteredDeposits []*deposit.Deposit, csvExpiry, blockHeight uint32,
excludedOutpoints map[string]struct{}) (
[]autoloopCandidateDeposit, btcutil.Amount) {
eligibleDeposits := make(
[]autoloopCandidateDeposit, 0, len(unfilteredDeposits),
)
var eligibleTotal btcutil.Amount
for _, candidateDeposit := range unfilteredDeposits {
outpoint := candidateDeposit.OutPoint.String()
if _, ok := excludedOutpoints[outpoint]; ok {
continue
}
swappable := IsSwappable(
uint32(candidateDeposit.ConfirmationHeight),
blockHeight, csvExpiry,
)
if !swappable {
continue
}
if candidateDeposit.Value > maxAmount {
continue
}
residualLife := candidateDeposit.ConfirmationHeight +
int64(csvExpiry) - int64(blockHeight)
eligibleDeposits = append(
eligibleDeposits, autoloopCandidateDeposit{
deposit: candidateDeposit,
residualLife: residualLife,
outpoint: outpoint,
},
)
eligibleTotal += candidateDeposit.Value
}
// The DP compares reconstructed residual-life sequences directly.
// Sorting deposits by residual life first keeps those later comparisons
// exact and deterministic without inventing a scalar "urgency score".
sort.Slice(eligibleDeposits, func(i, j int) bool {
left := eligibleDeposits[i]
right := eligibleDeposits[j]
switch {
case left.residualLife != right.residualLife:
return left.residualLife < right.residualLife
case left.deposit.Value != right.deposit.Value:
return left.deposit.Value > right.deposit.Value
}
return left.outpoint < right.outpoint
})
return eligibleDeposits, eligibleTotal
}
// autoloopDPSizing chooses the smallest bucket step that keeps the compressed
// table within the configured memory budget.
func autoloopDPSizing(maxAmount btcutil.Amount, depositCount,
maxMemoryBytes int) (btcutil.Amount, int, error) {
wordsPerState := autoloopDPWordsPerState(depositCount)
stateBytes := wordsPerState*8 + autoloopDPStateOverheadBytes
maxBuckets := maxMemoryBytes / stateBytes
// The selector needs at least bucket zero plus one positive bucket.
if maxBuckets < 2 {
return 0, 0, errAutoloopDPMemoryBudgetTooSmall
}
step := ceilAmountDiv(maxAmount, btcutil.Amount(maxBuckets-1))
step = max(step, 1)
bucketCount := int(ceilAmountDiv(maxAmount, step)) + 1
return step, bucketCount, nil
}
// autoloopDPWordsPerState returns the number of 64-bit words needed to encode
// one subset bitset for the current deposit count.
func autoloopDPWordsPerState(depositCount int) int {
return (depositCount + 63) / 64
}
// autoloopDPBucketWeight compresses a deposit value into one DP bucket weight.
//
// The table rounds down, but never below one bucket. Rounding up each deposit
// would accidentally reject some valid exact sums once several per-item
// round-up errors accumulate. The exact-sum guard remains the real safety
// boundary: compressed weights only decide which representative states are kept
// in memory, never whether a candidate is allowed to exceed maxAmount.
func autoloopDPBucketWeight(value, step btcutil.Amount) int {
weight := int(value / step)
if weight == 0 {
return 1
}
return weight
}
// ceilAmountDiv performs positive ceiling division for amount sizing.
func ceilAmountDiv(numerator, denominator btcutil.Amount) btcutil.Amount {
if numerator <= 0 {
return 0
}
return (numerator + denominator - 1) / denominator
}
// newAutoloopDPTable allocates the bounded-memory DP table.
func newAutoloopDPTable(bucketCount, depositCount int) *autoloopDPTable {
wordsPerState := autoloopDPWordsPerState(depositCount)
counts := make([]int32, bucketCount)
for i := range counts {
counts[i] = -1
}
return &autoloopDPTable{
wordsPerState: wordsPerState,
exactSums: make([]btcutil.Amount, bucketCount),
counts: counts,
selections: make([]uint64, bucketCount*wordsPerState),
}
}
// isReachable reports whether a bucket currently has a representative subset.
func (t *autoloopDPTable) isReachable(bucket int) bool {
return t.counts[bucket] >= 0
}
// stateWords returns the flattened bitset slice for one bucket.
func (t *autoloopDPTable) stateWords(bucket int) []uint64 {
start := bucket * t.wordsPerState
end := start + t.wordsPerState
return t.selections[start:end]
}
// copyStateFromSource writes a winning candidate into the destination bucket.
func (t *autoloopDPTable) copyStateFromSource(destBucket, sourceBucket,
depositIndex int, exactSum btcutil.Amount) {
destWords := t.stateWords(destBucket)
sourceWords := t.stateWords(sourceBucket)
copy(destWords, sourceWords)
wordIndex := depositIndex / 64
bitIndex := uint(depositIndex % 64)
destWords[wordIndex] |= uint64(1) << bitIndex
t.exactSums[destBucket] = exactSum
t.counts[destBucket] = t.counts[sourceBucket] + 1
}
// candidateBeatsState reports whether the candidate obtained by extending the
// source bucket with one deposit should replace the destination bucket.
func (t *autoloopDPTable) candidateBeatsState(sourceBucket, destBucket,
depositIndex int, candidateSum btcutil.Amount,
deposits []autoloopCandidateDeposit) bool {
if !t.isReachable(destBucket) {
return true
}
existingSum := t.exactSums[destBucket]
if candidateSum != existingSum {
return candidateSum > existingSum
}
candidateCount := int(t.counts[sourceBucket]) + 1
existingCount := int(t.counts[destBucket])
if candidateCount != existingCount {
return candidateCount < existingCount
}
// Only exact-sum and exact-count ties fall through to expiry
// comparison. That keeps the expensive residual-life reconstruction
// off the hot path.
return t.candidateEarlierThanState(
sourceBucket, destBucket, depositIndex, deposits,
)
}
// candidateEarlierThanState compares the candidate residual-life sequence to
// the existing destination sequence.
func (t *autoloopDPTable) candidateEarlierThanState(sourceBucket, destBucket,
depositIndex int, deposits []autoloopCandidateDeposit) bool {
candidateResidualLives := t.candidateResidualLives(
sourceBucket, depositIndex, deposits,
)
existingResidualLives := t.stateResidualLives(destBucket, deposits)
return compareResidualLifeSequences(
candidateResidualLives, existingResidualLives,
) < 0
}
// stateBeatsStateWithinBand applies the final band-local ordering:
// 1. earlier-expiring deposits.
// 2. larger exact total.
// 3. fewer deposits.
func (t *autoloopDPTable) stateBeatsStateWithinBand(leftBucket, rightBucket int,
deposits []autoloopCandidateDeposit) bool {
leftResidualLives := t.stateResidualLives(leftBucket, deposits)
rightResidualLives := t.stateResidualLives(rightBucket, deposits)
cmp := compareResidualLifeSequences(
leftResidualLives, rightResidualLives,
)
switch cmp {
case -1:
return true
case 1:
return false
}
leftSum := t.exactSums[leftBucket]
rightSum := t.exactSums[rightBucket]
if leftSum != rightSum {
return leftSum > rightSum
}
return t.counts[leftBucket] < t.counts[rightBucket]
}
// selectedIndices reconstructs the selected deposit indices for a bucket in
// sorted order. The caller must pass a reachable bucket.
func (t *autoloopDPTable) selectedIndices(bucket int) []int {
count := int(t.counts[bucket])
if count < 0 {
panic("selectedIndices called on unreachable bucket")
}
selectedIndices := make([]int, 0, count)
for wordIndex, word := range t.stateWords(bucket) {
for word != 0 {
bitIndex := bits.TrailingZeros64(word)
selectedIndices = append(
selectedIndices, wordIndex*64+bitIndex,
)
word &^= uint64(1) << uint(bitIndex)
}
}
return selectedIndices
}
// candidateResidualLives reconstructs the candidate residual-life sequence.
// The current deposit index is always larger than every index already present
// in the source bucket, so appending preserves the sorted order.
func (t *autoloopDPTable) candidateResidualLives(sourceBucket, depositIndex int,
deposits []autoloopCandidateDeposit) []int64 {
residualLives := t.stateResidualLives(sourceBucket, deposits)
residualLives = append(
residualLives, deposits[depositIndex].residualLife,
)
return residualLives
}
// stateResidualLives reconstructs the sorted residual-life sequence for a
// bucket.
func (t *autoloopDPTable) stateResidualLives(bucket int,
deposits []autoloopCandidateDeposit) []int64 {
selectedIndices := t.selectedIndices(bucket)
residualLives := make([]int64, 0, len(selectedIndices))
for _, index := range selectedIndices {
residualLives = append(
residualLives, deposits[index].residualLife,
)
}
return residualLives
}
// compareResidualLifeSequences compares two sorted residual-life sequences.
//
// A smaller residual-life value means the deposit expires sooner and is thus
// more urgent to consume. The comparison intentionally stops at the shorter
// length: if one sequence is a strict prefix of the other, expiry alone does
// not provide a principled winner and the caller falls back to amount and
// deposit-count tie-breakers instead of inventing an arbitrary preference for
// the longer or shorter set.
func compareResidualLifeSequences(left, right []int64) int {
limit := min(len(left), len(right))
for i := range limit {
switch {
case left[i] < right[i]:
return -1
case left[i] > right[i]:
return 1
}
}
return 0
}

View file

@ -0,0 +1,424 @@
package loopin
import (
"testing"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/loop/staticaddr/deposit"
"github.com/stretchr/testify/require"
)
// TestSelectNoChangeDepositsWithMemoryBudget covers the dp-specific behavior
// that the default helper does not expose directly: forced scaling and hard
// budget failures.
func TestSelectNoChangeDepositsWithMemoryBudget(t *testing.T) {
t.Parallel()
depositSeven := makeDeposit(31, 0, 7_000, 240)
depositFour := makeDeposit(32, 0, 4_000, 241)
depositThree := makeDeposit(33, 0, 3_000, 242)
testCases := []struct {
name string
maxAmount btcutil.Amount
minAmount btcutil.Amount
deposits []*deposit.Deposit
maxMemory int
expected []*deposit.Deposit
expectedError error
}{
{
name: "a tight but sufficient budget forces scaled " +
"buckets and still finds the only valid subset",
maxAmount: 10_000,
minAmount: 9_000,
deposits: []*deposit.Deposit{
depositSeven, depositFour, depositThree,
},
maxMemory: 240,
expected: []*deposit.Deposit{
depositSeven, depositThree,
},
},
{
name: "a budget smaller than two state buckets fails " +
"explicitly instead of pretending no " +
"candidate exists",
maxAmount: 10_000,
minAmount: 9_000,
deposits: []*deposit.Deposit{
depositSeven, depositThree,
},
maxMemory: 23,
expectedError: errAutoloopDPMemoryBudgetTooSmall,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
deposits, err := selectNoChangeDepositsWithMemoryBudget(
testCase.maxAmount, testCase.minAmount,
testCase.deposits, 1_000, 100, nil,
testCase.maxMemory,
)
if testCase.expectedError != nil {
require.ErrorIs(t, err, testCase.expectedError)
require.Nil(t, deposits)
return
}
require.NoError(t, err)
require.Equal(
t, depositOutpoints(testCase.expected),
depositOutpoints(deposits),
)
})
}
}
// TestAutoloopDPSizing verifies the bucket sizing math. These cases are easier
// to understand directly than by inferring the step from a larger selector
// behavior test.
func TestAutoloopDPSizing(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
maxAmount btcutil.Amount
depositCount int
maxMemoryBytes int
expectedStep btcutil.Amount
expectedBuckets int
expectedWords int
expectedError error
}{
{
name: "when the table fits exactly the step remains " +
"one",
maxAmount: 100,
depositCount: 1,
maxMemoryBytes: 24 * 200,
expectedStep: 1,
expectedBuckets: 101,
expectedWords: 1,
},
{
name: "when memory is tight the step increases just " +
"enough to stay inside budget",
maxAmount: 100,
depositCount: 64,
maxMemoryBytes: 24 * 11,
expectedStep: 10,
expectedBuckets: 11,
expectedWords: 1,
},
{
name: "when the budget cannot hold bucket zero and " +
"one positive bucket, sizing fails",
maxAmount: 100,
depositCount: 64,
maxMemoryBytes: 23,
expectedError: errAutoloopDPMemoryBudgetTooSmall,
expectedWords: 1,
},
{
name: "a non-positive max amount still rounds the " +
"step up to one after ceiling division " +
"returns zero",
maxAmount: 0,
depositCount: 64,
maxMemoryBytes: 24 * 10,
expectedStep: 1,
expectedBuckets: 1,
expectedWords: 1,
},
{
name: "when the deposit count exceeds one word the " +
"state size rounds up to the next word",
maxAmount: 100,
depositCount: 65,
maxMemoryBytes: 32 * 50,
expectedStep: 3,
expectedBuckets: 35,
expectedWords: 2,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
require.Equal(
t, testCase.expectedWords,
autoloopDPWordsPerState(testCase.depositCount),
)
step, bucketCount, err := autoloopDPSizing(
testCase.maxAmount, testCase.depositCount,
testCase.maxMemoryBytes,
)
if testCase.expectedError != nil {
require.ErrorIs(t, err, testCase.expectedError)
require.Zero(t, step)
require.Zero(t, bucketCount)
return
}
require.NoError(t, err)
require.Equal(t, testCase.expectedStep, step)
require.Equal(t, testCase.expectedBuckets, bucketCount)
})
}
}
// TestAutoloopDPBucketWeight verifies the compressed bucket mapping directly.
// The selector relies on this helper to avoid the round-up bug where several
// individually rounded deposits can make a valid exact sum unreachable.
func TestAutoloopDPBucketWeight(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
value btcutil.Amount
step btcutil.Amount
expected int
}{
{
name: "values larger than the step are truncated " +
"into the matching floor bucket",
value: 10,
step: 3,
expected: 3,
},
{
name: "values smaller than the step still consume " +
"one bucket so they remain selectable",
value: 2,
step: 5,
expected: 1,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
require.Equal(
t, testCase.expected,
autoloopDPBucketWeight(
testCase.value, testCase.step,
),
)
})
}
}
// TestCompareResidualLifeSequences isolates the expiry-order helper. This is
// the selector's "what expires sooner?" rule, so the cases state explicitly
// why the helper should consider one sequence earlier, later, or tied.
func TestCompareResidualLifeSequences(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
left []int64
right []int64
expected int
}{
{
name: "the left sequence is earlier when the first " +
"differing deposit expires sooner",
left: []int64{100, 150},
right: []int64{100, 200},
expected: -1,
},
{
name: "the right sequence is earlier when its first " +
"differing deposit expires sooner",
left: []int64{150, 250},
right: []int64{150, 200},
expected: 1,
},
{
name: "a strict prefix is treated as an expiry tie " +
"so later amount and count rules can decide",
left: []int64{100},
right: []int64{100, 200},
expected: 0,
},
{
name: "the same strict-prefix rule applies " +
"regardless of which side is longer",
left: []int64{100, 200},
right: []int64{100},
expected: 0,
},
{
name: "identical residual-life sequences compare as " +
"equal",
left: []int64{100, 200},
right: []int64{100, 200},
expected: 0,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
result := compareResidualLifeSequences(
testCase.left, testCase.right,
)
require.Equal(t, testCase.expected, result)
})
}
}
// TestFilterAutoloopCandidateDeposits covers the low-level filter and sort
// helper so selector tests do not have to infer ordering rules indirectly.
func TestFilterAutoloopCandidateDeposits(t *testing.T) {
t.Parallel()
earlierSameValue := makeDeposit(41, 0, 5_000, 200)
laterSameValueA := makeDeposit(42, 0, 5_000, 210)
laterSameValueB := makeDeposit(43, 0, 5_000, 210)
oversized := makeDeposit(44, 0, 9_000, 220)
unswappable := makeDeposit(45, 0, 4_000, 149)
selectedDeposits, total := filterAutoloopCandidateDeposits(
7_000,
[]*deposit.Deposit{
laterSameValueB, oversized, earlierSameValue,
unswappable, laterSameValueA,
},
1_000, 100, nil,
)
require.Equal(t, btcutil.Amount(15_000), total)
require.Equal(
t,
[]string{
earlierSameValue.OutPoint.String(),
laterSameValueA.OutPoint.String(),
laterSameValueB.OutPoint.String(),
},
candidateOutpoints(selectedDeposits),
)
}
// TestAutoloopDPComparators isolates helper branches that are awkward to hit
// predictably through the full selector alone.
func TestAutoloopDPComparators(t *testing.T) {
t.Parallel()
makeCandidateDeposits := func(
deposits ...*deposit.Deposit) []autoloopCandidateDeposit {
candidates := make(
[]autoloopCandidateDeposit, 0, len(deposits),
)
for _, deposit := range deposits {
candidate := autoloopCandidateDeposit{
deposit: deposit,
residualLife: deposit.ConfirmationHeight,
outpoint: deposit.OutPoint.String(),
}
candidates = append(candidates, candidate)
}
return candidates
}
t.Run("candidateBeatsState prefers a larger exact sum in the same "+
"bucket",
func(t *testing.T) {
t.Parallel()
small := makeDeposit(51, 0, 4_000, 300)
medium := makeDeposit(52, 0, 5_000, 301)
later := makeDeposit(53, 0, 1_000, 302)
candidates := makeCandidateDeposits(
small, medium, later,
)
table := newAutoloopDPTable(3, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, small.Value)
table.copyStateFromSource(2, 0, 1, medium.Value)
require.True(t, table.candidateBeatsState(
2, 1, 2, medium.Value+later.Value, candidates,
))
},
)
t.Run("stateBeatsStateWithinBand falls back to sum when expiry ties",
func(t *testing.T) {
t.Parallel()
six := makeDeposit(54, 0, 6_000, 300)
five := makeDeposit(55, 0, 5_000, 300)
candidates := makeCandidateDeposits(six, five)
table := newAutoloopDPTable(3, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, six.Value)
table.copyStateFromSource(2, 0, 1, five.Value)
require.True(t, table.stateBeatsStateWithinBand(
1, 2, candidates,
))
},
)
t.Run("stateBeatsStateWithinBand falls back to fewer deposits "+
"when expiry and sum tie",
func(t *testing.T) {
t.Parallel()
six := makeDeposit(56, 0, 6_000, 300)
five := makeDeposit(57, 0, 5_000, 300)
one := makeDeposit(58, 0, 1_000, 300)
candidates := makeCandidateDeposits(six, five, one)
table := newAutoloopDPTable(4, len(candidates))
table.counts[0] = 0
table.copyStateFromSource(1, 0, 0, six.Value)
table.copyStateFromSource(2, 0, 1, five.Value)
table.copyStateFromSource(3, 2, 2, five.Value+one.Value)
require.True(t, table.stateBeatsStateWithinBand(
1, 3, candidates,
))
},
)
}
// depositOutpoints turns a deposit set into a stable, readable assertion
// surface for the selector tests.
func depositOutpoints(deposits []*deposit.Deposit) []string {
outpoints := make([]string, 0, len(deposits))
for _, selectedDeposit := range deposits {
outpoints = append(outpoints, selectedDeposit.OutPoint.String())
}
return outpoints
}
// candidateOutpoints exposes the filtered candidate order in a readable form.
func candidateOutpoints(
deposits []autoloopCandidateDeposit) []string {
outpoints := make([]string, 0, len(deposits))
for _, candidateDeposit := range deposits {
outpoints = append(outpoints, candidateDeposit.outpoint)
}
return outpoints
}

View file

@ -11,10 +11,15 @@ import (
"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.
// 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)
@ -34,6 +39,7 @@ func TestSelectNoChangeDeposits(t *testing.T) {
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
@ -189,6 +195,47 @@ func TestSelectNoChangeDeposits(t *testing.T) {
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 {