package loopin import ( "bytes" "context" "errors" "testing" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) type testCase struct { name string deposits []*deposit.Deposit targetValue btcutil.Amount csvExpiry uint32 blockHeight uint32 expected []*deposit.Deposit expectedErr string } // TestSelectDeposits tests the selectDeposits function, which selects // deposits that can cover a target value while respecting the dust limit. func TestSelectDeposits(t *testing.T) { d1, d2, d3, d4 := &deposit.Deposit{ Value: 1_000_000, ConfirmationHeight: 5_000, }, &deposit.Deposit{ Value: 2_000_000, ConfirmationHeight: 5_001, }, &deposit.Deposit{ Value: 3_000_000, ConfirmationHeight: 5_002, }, &deposit.Deposit{ Value: 3_000_000, ConfirmationHeight: 5_003, } d1.Hash = chainhash.Hash{1} d1.Index = 0 d2.Hash = chainhash.Hash{2} d2.Index = 0 d3.Hash = chainhash.Hash{3} d3.Index = 0 d4.Hash = chainhash.Hash{4} d4.Index = 0 testCases := []testCase{ { name: "single deposit exact target", deposits: []*deposit.Deposit{d1}, targetValue: 1_000_000, expected: []*deposit.Deposit{d1}, expectedErr: "", }, { name: "prefer larger deposit when both cover", deposits: []*deposit.Deposit{d1, d2}, targetValue: 1_000_000, expected: []*deposit.Deposit{d2}, expectedErr: "", }, { name: "prefer largest among three when one is enough", deposits: []*deposit.Deposit{d1, d2, d3}, targetValue: 1_000_000, expected: []*deposit.Deposit{d3}, expectedErr: "", }, { name: "prefer confirmed deposit over larger unconfirmed one", deposits: []*deposit.Deposit{ { Value: 2_000_000, ConfirmationHeight: 0, }, { Value: 1_500_000, ConfirmationHeight: 5_004, }, }, targetValue: 1_000_000, expected: []*deposit.Deposit{ { Value: 1_500_000, ConfirmationHeight: 5_004, }, }, expectedErr: "", }, { name: "single deposit insufficient by 1", deposits: []*deposit.Deposit{d1}, targetValue: 1_000_001, expected: []*deposit.Deposit{}, expectedErr: "not enough deposits to cover", }, { name: "target leaves exact dust limit change", deposits: []*deposit.Deposit{d1}, targetValue: 1_000_000 - dustLimit, expected: []*deposit.Deposit{d1}, expectedErr: "", }, { name: "target leaves dust change (just over)", deposits: []*deposit.Deposit{d1}, targetValue: 1_000_000 - dustLimit + 1, expected: []*deposit.Deposit{}, expectedErr: "not enough deposits to cover", }, { name: "all deposits exactly match target", deposits: []*deposit.Deposit{d1, d2, d3}, targetValue: d1.Value + d2.Value + d3.Value, expected: []*deposit.Deposit{d1, d2, d3}, expectedErr: "", }, { name: "sum minus dust limit is allowed (change == dust)", deposits: []*deposit.Deposit{d1, d2, d3}, targetValue: d1.Value + d2.Value + d3.Value - dustLimit, expected: []*deposit.Deposit{d1, d2, d3}, expectedErr: "", }, { name: "sum minus dust limit plus 1 is not allowed (dust change)", deposits: []*deposit.Deposit{d1, d2, d3}, targetValue: d1.Value + d2.Value + d3.Value - dustLimit + 1, expected: []*deposit.Deposit{}, expectedErr: "not enough deposits to cover", }, { name: "tie by value, prefer earlier expiry", deposits: []*deposit.Deposit{d3, d4}, targetValue: d4.Value - dustLimit, // d3/d4 have the // same value but different expiration. expected: []*deposit.Deposit{d3}, expectedErr: "", }, { name: "prefilter filters deposits close to expiry", deposits: func() []*deposit.Deposit { // dClose expires before // htlcExpiry+DepositHtlcDelta and must be // filtered out. dOK expires exactly at the // threshold and must be eligible. dClose := &deposit.Deposit{ Value: 3_000_000, ConfirmationHeight: 3000, } dClose.Hash = chainhash.Hash{5} dClose.Index = 0 dOK := &deposit.Deposit{ Value: 2_000_000, ConfirmationHeight: 3050, } dOK.Hash = chainhash.Hash{6} dOK.Index = 0 return []*deposit.Deposit{dClose, dOK} }(), targetValue: 1_000_000, csvExpiry: 1000, blockHeight: 3000, expected: func() []*deposit.Deposit { // Only dOK should be considered. // dClose is filtered. dOK := &deposit.Deposit{ Value: 2_000_000, ConfirmationHeight: 3050, } dOK.Hash = chainhash.Hash{6} dOK.Index = 0 return []*deposit.Deposit{dOK} }(), expectedErr: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { selectedDeposits, err := SelectDeposits( tc.targetValue, tc.deposits, tc.csvExpiry, tc.blockHeight, ) if tc.expectedErr == "" { require.NoError(t, err) } else { require.ErrorContains(t, err, tc.expectedErr) } require.ElementsMatch(t, tc.expected, selectedDeposits) }) } } // TestInitiateLoopInAllowsReservedAutoloopLabel verifies that the internal // loop-in manager path does not reject reserved autoloop labels. The RPC // boundary owns that validation, while internal autoloop dispatch must be able // to reuse the reserved labels directly. func TestInitiateLoopInAllowsReservedAutoloopLabel(t *testing.T) { ctx := t.Context() const confirmationHeight = 0 selectedDeposit := makeDeposit(1, 0, 9_000, confirmationHeight) selectedOutpoint := selectedDeposit.OutPoint.String() quoteErr := errors.New("quote failed") quoteGetter := &mockQuoteGetter{ err: quoteErr, } manager, err := NewManager(&Config{ DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{ selectedOutpoint: selectedDeposit, }, }, QuoteGetter: quoteGetter, NodePubkey: route.Vertex{2}, }, 200) require.NoError(t, err) _, err = manager.initiateLoopIn(ctx, &loop.StaticAddressLoopInRequest{ DepositOutpoints: []string{selectedOutpoint}, SelectedAmount: selectedDeposit.Value, MaxSwapFee: 1_000, Label: labels.AutoloopLabel(swap.TypeIn), Initiator: "autoloop", }) require.ErrorIs(t, err, quoteErr) require.NotContains(t, err.Error(), labels.ErrReservedPrefix.Error()) require.Equal(t, selectedDeposit.Value, quoteGetter.amount) } // TestUpdateLoopInSendsUpdateAfterSuccessfulStoreUpdate protects the // notification contract: after a successful database update, the manager must // publish the stored loop-in state to listeners. func TestUpdateLoopInSendsUpdateAfterSuccessfulStoreUpdate(t *testing.T) { ctx := t.Context() swapHash := lntypes.Hash{1, 2, 3} updates := make(chan *StaticAddressLoopIn, 1) loopIn := &StaticAddressLoopIn{SwapHash: swapHash} loopIn.SetState(SignHtlcTx) loopInFsm := &FSM{ cfg: &Config{ Store: &mockStore{stored: true}, SendUpdate: func(_ context.Context, updated *StaticAddressLoopIn) error { updates <- updated return nil }, }, loopIn: loopIn, } loopInFsm.updateLoopIn(ctx, fsm.Notification{ PreviousState: SignHtlcTx, NextState: MonitorInvoiceAndHtlcTx, }) select { case updated := <-updates: require.Equal(t, swapHash, updated.SwapHash) require.Equal(t, MonitorInvoiceAndHtlcTx, updated.GetState()) case <-ctx.Done(): t.Fatal(ctx.Err()) } } // TestHandleLoopInSweepReqRejectsInvalidServerNonce ensures that a malformed // MuSig2 nonce returned by the server is rejected before it reaches the signer. func TestHandleLoopInSweepReqRejectsInvalidServerNonce(t *testing.T) { ctx := t.Context() changeAddr := &script.Parameters{ PkScript: []byte{0xaa, 0xbb}, } const confirmationHeight = 0 dep := makeDeposit(7, 0, 10_000, confirmationHeight) depOutpoint := outpointString(dep) swapHash := lntypes.Hash{9} loopIn := &StaticAddressLoopIn{ SwapHash: swapHash, DepositOutpoints: []string{depOutpoint}, SelectedAmount: dep.Value, } loopIn.SetState(Succeeded) sweepTx := makeSweepTx( []wire.OutPoint{dep.OutPoint}, []*wire.TxOut{{ Value: int64(dep.Value), PkScript: []byte{0xcc, 0xdd}, }}, ) sweepPacket, err := psbt.NewFromUnsignedTx(sweepTx) require.NoError(t, err) var psbtBuf bytes.Buffer require.NoError(t, sweepPacket.Serialize(&psbtBuf)) mgr := &Manager{ cfg: &Config{ AddressManager: &mockAddressManager{ params: changeAddr, }, DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{ depOutpoint: dep, }, }, Store: &mockStore{ loopIns: map[lntypes.Hash]*StaticAddressLoopIn{ swapHash: loopIn, }, mapIDs: map[lntypes.Hash][]deposit.ID{ swapHash: {dep.ID}, }, }, }, } req := &swapserverrpc.ServerStaticLoopInSweepNotification{ SweepTxPsbt: psbtBuf.Bytes(), SwapHash: swapHash[:], DepositToNonces: map[string][]byte{ depOutpoint: make([]byte, musig2.PubNonceSize-1), }, PrevoutInfo: []*swapserverrpc.PrevoutInfo{{ Value: uint64(dep.Value), PkScript: changeAddr.PkScript, TxidBytes: dep.Hash[:], OutputIndex: dep.Index, }}, } err = mgr.handleLoopInSweepReq(ctx, req) require.ErrorContains(t, err, "invalid server nonce") require.ErrorContains(t, err, depOutpoint) } // TestActiveDepositsForLoopInUsesCurrentDepositOutpoints verifies that // recovery checks the current deposit outpoints reconstructed by the store // rather than the original outpoint snapshot persisted on the swap. func TestActiveDepositsForLoopInUsesCurrentDepositOutpoints(t *testing.T) { oldOutpoint := wire.OutPoint{ Hash: chainhash.Hash{0xaa}, Index: 0, } currentDeposit := makeDeposit(0xbb, 1, 10_000, 42) manager := &Manager{ cfg: &Config{ DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{ currentDeposit.OutPoint.String(): currentDeposit, }, }, }, } deposits, allActive := manager.activeDepositsForLoopIn( &StaticAddressLoopIn{ DepositOutpoints: []string{oldOutpoint.String()}, Deposits: []*deposit.Deposit{currentDeposit}, }, ) require.True(t, allActive) require.Equal(t, []*deposit.Deposit{currentDeposit}, deposits) } // TestGetAllSwapsPreservesStoreDeposits verifies that list responses keep the // store's swap_hash/deposit-id reconstruction even when DepositOutpoints is an // original input snapshot and the deposit's current outpoint has changed. func TestGetAllSwapsPreservesStoreDeposits(t *testing.T) { oldOutpoint := wire.OutPoint{ Hash: chainhash.Hash{0xcc}, Index: 0, } currentDeposit := makeDeposit(0xdd, 1, 10_000, 42) swap := &StaticAddressLoopIn{ DepositOutpoints: []string{oldOutpoint.String()}, Deposits: []*deposit.Deposit{currentDeposit}, } manager := &Manager{ cfg: &Config{ Store: &mockStore{ swaps: []*StaticAddressLoopIn{swap}, }, }, } swaps, err := manager.GetAllSwaps(t.Context()) require.NoError(t, err) require.Len(t, swaps, 1) require.Equal(t, []string{oldOutpoint.String()}, swaps[0].DepositOutpoints) require.Equal(t, []*deposit.Deposit{currentDeposit}, swaps[0].Deposits) } // TestIsSwappableUnconfirmed checks that an unconfirmed deposit is considered // swappable because its CSV timeout has not started yet. func TestIsSwappableUnconfirmed(t *testing.T) { require.True(t, IsSwappable(0, 5000, 1000)) } // 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 } func (m *mockDepositManager) EnsureDepositsFresh(context.Context) error { return nil } func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( []*deposit.Deposit, error) { return nil, nil } func (m *mockDepositManager) AllStringOutpointsActiveDeposits(outpoints []string, state fsm.StateType) ([]*deposit.Deposit, bool) { if state != deposit.Deposited && state != fsm.EmptyState { return nil, false } if m.byOutpoint == nil { return nil, false } res := make([]*deposit.Deposit, 0, len(outpoints)) for _, outpoint := range outpoints { selectedDeposit, ok := m.byOutpoint[outpoint] if !ok { return nil, false } res = append(res, selectedDeposit) } return res, true } func (m *mockDepositManager) TransitionDeposits(_ context.Context, _ []*deposit.Deposit, _ fsm.EventType, _ fsm.StateType) error { return nil } func (m *mockDepositManager) DepositsForOutpoints(_ context.Context, outpoints []string, ignoreUnknown bool) ([]*deposit.Deposit, error) { res := make([]*deposit.Deposit, 0, len(outpoints)) for _, op := range outpoints { if d, ok := m.byOutpoint[op]; ok { res = append(res, d) } } return res, nil } func (m *mockDepositManager) GetActiveDepositsInState(_ fsm.StateType) ( []*deposit.Deposit, error) { return m.activeDeposits, nil } // 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 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 m.lastHop = lastHop m.initiator = initiator m.numDeposits = numDeposits m.fast = fast if m.err != nil { return nil, m.err } return m.quote, nil } // mockStore implements StaticAddressLoopInStore for tests. type mockStore struct { swaps []*StaticAddressLoopIn loopIns map[lntypes.Hash]*StaticAddressLoopIn mapIDs map[lntypes.Hash][]deposit.ID stored bool } func (s *mockStore) CreateLoopIn(_ context.Context, _ *StaticAddressLoopIn) error { return nil } func (s *mockStore) UpdateLoopIn(_ context.Context, _ *StaticAddressLoopIn) error { return nil } func (s *mockStore) GetStaticAddressLoopInSwapsByStates(_ context.Context, _ []fsm.StateType) ([]*StaticAddressLoopIn, error) { return s.swaps, nil } func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { return s.stored, nil } // RecordStaticAddressRiskDecision implements Store for manager tests. func (s *mockStore) RecordStaticAddressRiskDecision(context.Context, lntypes.Hash, ConfirmationRiskDecision) error { return nil } func (s *mockStore) GetLoopInByHash(_ context.Context, swapHash lntypes.Hash) (*StaticAddressLoopIn, error) { li, ok := s.loopIns[swapHash] if !ok { return nil, nil } return li, nil } func (s *mockStore) SwapHashesForDepositIDs(_ context.Context, depositIDs []deposit.ID) (map[lntypes.Hash][]deposit.ID, error) { // Filter the prepared mapping to only include hashes that reference // any of the provided deposit IDs. idSet := make(map[deposit.ID]struct{}, len(depositIDs)) for _, id := range depositIDs { idSet[id] = struct{}{} } res := make(map[lntypes.Hash][]deposit.ID) for h, ids := range s.mapIDs { for _, id := range ids { if _, ok := idSet[id]; ok { res[h] = ids break } } } return res, nil } // helper to create a deposit with specific outpoint and value. func makeDeposit(h byte, index uint32, value btcutil.Amount, confirmationHeight int64) *deposit.Deposit { d := &deposit.Deposit{ Value: value, ConfirmationHeight: confirmationHeight, } d.Hash = chainhash.Hash{h} d.Index = index var id deposit.ID id[0] = h d.ID = id return d } // helper to outpoint string as used by txin.PreviousOutPoint.String(). func outpointString(d *deposit.Deposit) string { return wire.OutPoint{Hash: d.Hash, Index: d.Index}.String() } // build a sweep tx with given inputs and outputs. func makeSweepTx(inputs []wire.OutPoint, outputs []*wire.TxOut) *wire.MsgTx { tx := wire.NewMsgTx(2) for _, in := range inputs { tx.AddTxIn(&wire.TxIn{PreviousOutPoint: in}) } for _, out := range outputs { tx.AddTxOut(out) } return tx } // TestCheckChange exercises all relevant scenarios for checkChange. func TestCheckChange(t *testing.T) { ctx := context.Background() // Prepare a common change address and an alternate address. changeAddr := &script.Parameters{PkScript: []byte{0xaa, 0xbb}} otherAddr := &script.Parameters{PkScript: []byte{0xcc, 0xdd}} serverAddr := &script.Parameters{PkScript: []byte{0xee, 0xff}} // Prepare swaps (loop-ins) with varying deposit totals and selections. // Helper to make a swap with deposits and selected amount. makeSwap := func(h byte, deposits []*deposit.Deposit, selected btcutil.Amount) (lntypes.Hash, *StaticAddressLoopIn) { var hash lntypes.Hash hash[0] = h li := &StaticAddressLoopIn{ Deposits: deposits, SelectedAmount: selected, AddressParams: changeAddr, } return hash, li } // Deposits belonging to different swaps. const confirmationHeight = 0 s1d1 := makeDeposit(1, 0, 1000, confirmationHeight) s1d2 := makeDeposit(1, 1, 2000, confirmationHeight) s2d1 := makeDeposit(2, 0, 1500, confirmationHeight) s3d1 := makeDeposit(3, 0, 800, confirmationHeight) s4d1 := makeDeposit(4, 0, 900, confirmationHeight) // Swaps: // A: total 3000, selected 3000 => no change. hA, liA := makeSwap(10, []*deposit.Deposit{s1d1, s1d2}, 3000) // B: total 1500, selected 1000 => change 500. hB, liB := makeSwap(11, []*deposit.Deposit{s2d1}, 1000) // C: total 800, selected 400 => change 400. hC, liC := makeSwap(12, []*deposit.Deposit{s3d1}, 400) // D: total 900, selected 500 => change 400. hD, liD := makeSwap(13, []*deposit.Deposit{s4d1}, 500) // Mapping deposits -> swaps (by deposit IDs). mapIDs := map[lntypes.Hash][]deposit.ID{ hA: {s1d1.ID, s1d2.ID}, hB: {s2d1.ID}, hC: {s3d1.ID}, hD: {s4d1.ID}, } loopIns := map[lntypes.Hash]*StaticAddressLoopIn{ hA: liA, hB: liB, hC: liC, hD: liD, } // Common manager with mocked dependencies; will change inputs per test. mgr := &Manager{ cfg: &Config{ DepositManager: &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{}, }, Store: &mockStore{ loopIns: loopIns, mapIDs: mapIDs, }, }, } type testCase struct { name string inDeps []*deposit.Deposit // deposits referenced by tx inputs outputs []*wire.TxOut // outputs in sweep tx addr *script.Parameters expectErr bool expectedErrMsg string } cases := []testCase{ { name: "no change expected (selected == total)", inDeps: []*deposit.Deposit{s1d1, s1d2}, // No change output required. outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, }, addr: changeAddr, }, { name: "single swap change present", inDeps: []*deposit.Deposit{s2d1}, // B -> change 500 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 500, PkScript: changeAddr.PkScript, }, }, addr: changeAddr, }, { name: "multiple swaps different change amounts", inDeps: []*deposit.Deposit{s2d1, s3d1}, // B(500)+C(400)=900 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 900, PkScript: changeAddr.PkScript, }, }, addr: changeAddr, }, { name: "two swaps with identical change values sum correctly", inDeps: []*deposit.Deposit{s3d1, s4d1}, // C(400)+D(400)=800 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 800, PkScript: changeAddr.PkScript, }, }, addr: changeAddr, }, { name: "missing change output results in error", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{}, addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, { name: "wrong address for change output", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 500, PkScript: otherAddr.PkScript, }, }, addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, { name: "wrong amount for change output", inDeps: []*deposit.Deposit{s2d1}, // expect 500 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 400, PkScript: changeAddr.PkScript, }, }, addr: changeAddr, expectErr: true, expectedErrMsg: "couldn't find expected change", }, { name: "mixed swaps some with change some without", inDeps: []*deposit.Deposit{s1d1, s1d2, s3d1}, // A(0)+C(400)=400 outputs: []*wire.TxOut{ { Value: 1337, PkScript: serverAddr.PkScript, }, { Value: 400, PkScript: changeAddr.PkScript, }, { Value: 1000, PkScript: otherAddr.PkScript, }, }, addr: changeAddr, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { // Prepare inputs mapping for deposit manager. mdm := &mockDepositManager{ byOutpoint: map[string]*deposit.Deposit{}, } inputs := make([]wire.OutPoint, 0, len(tc.inDeps)) for _, d := range tc.inDeps { mdm.byOutpoint[outpointString(d)] = d inputs = append( inputs, wire.OutPoint{ Hash: d.Hash, Index: d.Index, }, ) } mgr.cfg.DepositManager = mdm tx := makeSweepTx(inputs, tc.outputs) err := mgr.checkChange(ctx, tx, tc.addr) if tc.expectErr { require.Error(t, err) if tc.expectedErrMsg != "" { require.ErrorContains( t, err, tc.expectedErrMsg, ) } } else { require.NoError(t, err) } }) } }