From 8d6df2bf20db90e05a99613a486350d8e75137bf Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 14 May 2026 01:20:54 -0500 Subject: [PATCH] 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. --- staticaddr/loopin/autoloop.go | 164 +------- staticaddr/loopin/autoloop_dp.go | 530 ++++++++++++++++++++++++++ staticaddr/loopin/autoloop_dp_test.go | 424 +++++++++++++++++++++ staticaddr/loopin/autoloop_test.go | 55 ++- 4 files changed, 1008 insertions(+), 165 deletions(-) create mode 100644 staticaddr/loopin/autoloop_dp.go create mode 100644 staticaddr/loopin/autoloop_dp_test.go diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go index 851941e8..337d73e1 100644 --- a/staticaddr/loopin/autoloop.go +++ b/staticaddr/loopin/autoloop.go @@ -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 } diff --git a/staticaddr/loopin/autoloop_dp.go b/staticaddr/loopin/autoloop_dp.go new file mode 100644 index 00000000..c605c43e --- /dev/null +++ b/staticaddr/loopin/autoloop_dp.go @@ -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 +} diff --git a/staticaddr/loopin/autoloop_dp_test.go b/staticaddr/loopin/autoloop_dp_test.go new file mode 100644 index 00000000..c1253670 --- /dev/null +++ b/staticaddr/loopin/autoloop_dp_test.go @@ -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 +} diff --git a/staticaddr/loopin/autoloop_test.go b/staticaddr/loopin/autoloop_test.go index ca202def..5d4a7882 100644 --- a/staticaddr/loopin/autoloop_test.go +++ b/staticaddr/loopin/autoloop_test.go @@ -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 {