mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
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:
parent
616b0534a6
commit
8d6df2bf20
4 changed files with 1008 additions and 165 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
530
staticaddr/loopin/autoloop_dp.go
Normal file
530
staticaddr/loopin/autoloop_dp.go
Normal 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
|
||||
}
|
||||
424
staticaddr/loopin/autoloop_dp_test.go
Normal file
424
staticaddr/loopin/autoloop_dp_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue