mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Use the shared deposit-expiry helper when building autoloop DP candidates so unconfirmed deposits do not look like the earliest-expiring options. This keeps the no-change selector's expiry tie-break aligned with the generic loop-in deposit selection rules.
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
|
|
}
|