diff --git a/staticaddr/loopin/autoloop.go b/staticaddr/loopin/autoloop.go new file mode 100644 index 00000000..851941e8 --- /dev/null +++ b/staticaddr/loopin/autoloop.go @@ -0,0 +1,254 @@ +package loopin + +import ( + "context" + "errors" + "slices" + "sort" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/routing/route" +) + +var ( + // ErrNoAutoloopCandidate is returned when the static-address side + // cannot build a full-deposit, no-change loop-in candidate that fits + // the planner's requested amount bounds. + ErrNoAutoloopCandidate = errors.New("no autoloop candidate") +) + +// PrepareAutoloopLoopIn builds a static-address loop-in request for autoloop +// without dispatching it. The returned request always uses full deposits, +// explicit outpoints, and an explicit selected amount, so the caller can +// account for the suggestion without depending on static-address internals. +func (m *Manager) PrepareAutoloopLoopIn(ctx context.Context, + lastHop route.Vertex, minAmount, maxAmount btcutil.Amount, label, + initiator string, excludedOutpoints []string) ( + *loop.StaticAddressLoopInRequest, int, bool, error) { + + if minAmount <= 0 || maxAmount < minAmount { + return nil, 0, false, ErrNoAutoloopCandidate + } + + allDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( + deposit.Deposited, + ) + if err != nil { + return nil, 0, false, err + } + + params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) + if err != nil { + return nil, 0, false, err + } + + excluded := make(map[string]struct{}, len(excludedOutpoints)) + for _, outpoint := range excludedOutpoints { + excluded[outpoint] = struct{}{} + } + + selectedDeposits, err := selectNoChangeDeposits( + maxAmount, minAmount, allDeposits, params.Expiry, + m.currentHeight.Load(), excluded, + ) + if err != nil { + return nil, 0, false, err + } + + selectedAmount := sumOfDeposits(selectedDeposits) + quote, err := m.cfg.QuoteGetter.GetLoopInQuote( + ctx, selectedAmount, m.cfg.NodePubkey, &lastHop, nil, + initiator, uint32(len(selectedDeposits)), false, + ) + if err != nil { + return nil, 0, false, err + } + + outpoints := make([]string, 0, len(selectedDeposits)) + for _, selectedDeposit := range selectedDeposits { + outpoints = append(outpoints, selectedDeposit.OutPoint.String()) + } + + request := &loop.StaticAddressLoopInRequest{ + DepositOutpoints: outpoints, + SelectedAmount: selectedAmount, + MaxSwapFee: quote.SwapFee, + LastHop: &lastHop, + Label: label, + Initiator: initiator, + Fast: false, + } + + return request, len(selectedDeposits), false, nil +} + +// selectNoChangeDeposits chooses the highest-value swappable deposit set whose +// full value stays within the requested range. The selector never creates +// change, so the returned set's total is the actual swap amount. +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 + ) + + // 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_test.go b/staticaddr/loopin/autoloop_test.go new file mode 100644 index 00000000..ca202def --- /dev/null +++ b/staticaddr/loopin/autoloop_test.go @@ -0,0 +1,330 @@ +package loopin + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/routing/route" + "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. +func TestSelectNoChangeDeposits(t *testing.T) { + depositSeven := makeDeposit(7, 0, 7_000, 200) + depositFour := makeDeposit(4, 0, 4_000, 210) + depositThreeA := makeDeposit(3, 0, 3_000, 220) + depositThreeB := makeDeposit(9, 0, 3_000, 221) + depositNine := makeDeposit(8, 0, 9_000, 205) + depositFourA := makeDeposit(5, 0, 4_000, 215) + depositFourB := makeDeposit(6, 0, 4_000, 216) + depositOneA := makeDeposit(10, 0, 1_000, 230) + depositOneB := makeDeposit(11, 0, 1_000, 231) + depositOneC := makeDeposit(21, 0, 1_000, 232) + depositFourC := makeDeposit(13, 0, 4_000, 200) + depositFourD := makeDeposit(14, 0, 4_000, 201) + depositFourE := makeDeposit(15, 0, 4_000, 220) + depositFourF := makeDeposit(16, 0, 4_000, 221) + depositFive := makeDeposit(17, 0, 5_000, 200) + depositUnsuitable := makeDeposit(18, 0, 6_000, 149) + depositOversized := makeDeposit(19, 0, 9_000, 220) + depositTwo := makeDeposit(20, 0, 2_000, 210) + + testCases := []struct { + name string + maxAmount btcutil.Amount + minAmount btcutil.Amount + deposits []*deposit.Deposit + csvExpiry uint32 + blockHeight uint32 + excludedOutpoint map[string]struct{} + expected []*deposit.Deposit + expectedErr error + }{ + { + name: "prefers exact deposit over smaller combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositSeven}, + }, + { + name: "excluded outpoint falls back to combo", + maxAmount: 7_000, + minAmount: 3_000, + deposits: []*deposit.Deposit{ + depositSeven, depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + excludedOutpoint: map[string]struct{}{ + depositSeven.OutPoint.String(): {}, + }, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "same total prefers fewer deposits", + maxAmount: 6_000, + minAmount: 6_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, depositThreeB, + depositOneA, depositOneB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositThreeA, depositThreeB, + }, + }, + { + name: "same total rejects more deposits", + maxAmount: 2_000, + minAmount: 2_000, + deposits: []*deposit.Deposit{ + depositTwo, depositOneA, + depositOneB, depositOneC, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositTwo}, + }, + { + name: "same total prefers earlier expiries", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFourC, depositFourD, + depositFourE, depositFourF, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFourC, depositFourD, + }, + }, + { + name: "identical residual lives keep stable pick", + maxAmount: 8_000, + minAmount: 8_000, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + depositFourA, depositThreeB, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositFourA, + }, + }, + { + name: "filters unswappable and oversized deposits", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositFive, depositUnsuitable, + depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{depositFive}, + }, + { + name: "returns no candidate when all are filtered", + maxAmount: 7_000, + minAmount: 5_000, + deposits: []*deposit.Deposit{ + depositUnsuitable, depositOversized, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "returns no candidate below minimum", + maxAmount: 10_000, + minAmount: 7_000, + deposits: []*deposit.Deposit{ + depositFour, depositTwo, + }, + csvExpiry: 1_000, + blockHeight: 100, + expectedErr: ErrNoAutoloopCandidate, + }, + { + name: "zero minimum finds best positive total", + maxAmount: 7_000, + minAmount: 0, + deposits: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositFour, depositThreeA, + }, + }, + { + name: "deeper search isolates include and exclude", + maxAmount: 13_000, + minAmount: 10_000, + deposits: []*deposit.Deposit{ + depositNine, depositSeven, + depositFourA, depositFourB, + depositThreeA, + }, + csvExpiry: 1_000, + blockHeight: 100, + expected: []*deposit.Deposit{ + depositNine, depositFourA, + }, + }, + } + + selectedOutpoints := func(deposits []*deposit.Deposit) []string { + result := make([]string, 0, len(deposits)) + for _, selectedDeposit := range deposits { + result = append( + result, selectedDeposit.OutPoint.String(), + ) + } + + return result + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + selectedDeposits, err := selectNoChangeDeposits( + testCase.maxAmount, testCase.minAmount, + testCase.deposits, testCase.csvExpiry, + testCase.blockHeight, testCase.excludedOutpoint, + ) + + if testCase.expectedErr != nil { + require.ErrorIs(t, err, testCase.expectedErr) + require.Nil(t, selectedDeposits) + } else { + require.NoError(t, err) + require.Equal( + t, selectedOutpoints(testCase.expected), + selectedOutpoints(selectedDeposits), + ) + } + }) + } +} + +// TestPrepareAutoloopLoopIn ensures the static manager returns an explicit +// full-deposit request and quotes it with the correct amount and deposit +// count. +func TestPrepareAutoloopLoopIn(t *testing.T) { + ctx := t.Context() + + selectedDeposit := makeDeposit(1, 0, 9_000, 300) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 123, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{selectedDeposit}, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + lastHop := route.Vertex{9} + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, lastHop, 5_000, 10_000, "label", "autoloop", nil, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(123), request.MaxSwapFee) + require.NotNil(t, request.LastHop) + require.Equal(t, lastHop, *request.LastHop) + require.Equal(t, "label", request.Label) + require.Equal(t, "autoloop", request.Initiator) + require.False(t, request.Fast) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) + require.NotNil(t, quoteGetter.lastHop) + require.Equal(t, lastHop, *quoteGetter.lastHop) + require.Equal(t, "autoloop", quoteGetter.initiator) + require.Equal(t, uint32(1), quoteGetter.numDeposits) + require.False(t, quoteGetter.fast) +} + +// TestPrepareAutoloopLoopInExcludedOutpoints verifies that the manager passes +// excluded outpoints through the end-to-end preparation path before quoting +// the candidate. +func TestPrepareAutoloopLoopInExcludedOutpoints(t *testing.T) { + ctx := t.Context() + + excludedDeposit := makeDeposit(1, 0, 9_000, 300) + + selectedDeposit := makeDeposit(2, 0, 7_000, 301) + + quoteGetter := &mockQuoteGetter{ + quote: &loop.LoopInQuote{ + SwapFee: 77, + }, + } + + manager, err := NewManager(&Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + Expiry: 1_000, + }, + }, + DepositManager: &mockDepositManager{ + activeDeposits: []*deposit.Deposit{ + excludedDeposit, selectedDeposit, + }, + }, + QuoteGetter: quoteGetter, + NodePubkey: route.Vertex{2}, + }, 200) + require.NoError(t, err) + + request, numDeposits, hasChange, err := manager.PrepareAutoloopLoopIn( + ctx, route.Vertex{9}, 5_000, 10_000, "label", "autoloop", + []string{excludedDeposit.OutPoint.String()}, + ) + require.NoError(t, err) + + require.Equal( + t, []string{selectedDeposit.OutPoint.String()}, + request.DepositOutpoints, + ) + require.Equal(t, selectedDeposit.Value, request.SelectedAmount) + require.Equal(t, btcutil.Amount(77), request.MaxSwapFee) + require.Equal(t, 1, numDeposits) + require.False(t, hasChange) + require.Equal(t, selectedDeposit.Value, quoteGetter.amount) +} diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 8df13296..8d9b11a9 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -222,6 +222,9 @@ func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { // mockDepositManager implements DepositManager for tests. type mockDepositManager struct { + // activeDeposits is the set returned by GetActiveDepositsInState. + activeDeposits []*deposit.Deposit + // byOutpoint maps outpoint strings to deposits for direct lookups. byOutpoint map[string]*deposit.Deposit } @@ -277,36 +280,52 @@ func (m *mockDepositManager) DepositsForOutpoints(_ context.Context, func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( []*deposit.Deposit, error) { - return nil, nil + return m.activeDeposits, nil } -// mockQuoteGetter returns either a configured quote or a configured error and -// records the quoted amount for assertions. +// mockQuoteGetter records the inputs to quote requests and returns a fixed +// loop-in quote. type mockQuoteGetter struct { + // quote is the response returned from GetLoopInQuote. + quote *loop.LoopInQuote + // err is the optional error returned from GetLoopInQuote. err error // amount records the quoted amount. amount btcutil.Amount + + // lastHop records the quoted last hop. + lastHop *route.Vertex + + // initiator records the quoted initiator string. + initiator string + + // numDeposits records the quoted deposit count. + numDeposits uint32 + + // fast records the quoted fast flag. + fast bool } -// GetLoopInQuote returns the configured quote result for tests. +// GetLoopInQuote returns the configured quote and records the request +// parameters for assertions. func (m *mockQuoteGetter) GetLoopInQuote(_ context.Context, amt btcutil.Amount, _ route.Vertex, lastHop *route.Vertex, _ [][]zpay32.HopHint, initiator string, numDeposits uint32, fast bool) (*loop.LoopInQuote, error) { m.amount = amt - _ = lastHop - _ = initiator - _ = numDeposits - _ = fast + m.lastHop = lastHop + m.initiator = initiator + m.numDeposits = numDeposits + m.fast = fast if m.err != nil { return nil, m.err } - return &loop.LoopInQuote{}, nil + return m.quote, nil } // mockStore implements StaticAddressLoopInStore for tests.