mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Treat unconfirmed static-address deposits as swappable because their CSV timeout has not started yet. Keep confirmed deposits ahead of unconfirmed ones during automatic selection, then sort by value and remaining lifetime within each confirmation group. Share the expiry calculation with the dynamic-programming selector so unconfirmed deposits do not look like the earliest-expiring candidates.
446 lines
11 KiB
Go
446 lines
11 KiB
Go
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),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSelectNoChangeDepositsPrefersConfirmedTie verifies unconfirmed deposits
|
|
// are not treated as earlier-expiring than confirmed deposits. Their CSV timer
|
|
// has not started yet, so a same-value confirmed deposit should win the expiry
|
|
// tie-break.
|
|
func TestSelectNoChangeDepositsPrefersConfirmedTie(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
unconfirmed := makeDeposit(34, 0, 5_000, 0)
|
|
confirmed := makeDeposit(35, 0, 5_000, 200)
|
|
|
|
deposits, err := selectNoChangeDeposits(
|
|
5_000, 5_000, []*deposit.Deposit{
|
|
unconfirmed, confirmed,
|
|
}, 1_000, 100, nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t, []string{confirmed.OutPoint.String()},
|
|
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
|
|
}
|