diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index 3dd8cfbc..c6ac07fc 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -79,6 +79,11 @@ type StaticAddressLoopInStore interface { // GetLoopInByHash returns the loop-in swap with the given hash. GetLoopInByHash(ctx context.Context, swapHash lntypes.Hash) ( *StaticAddressLoopIn, error) + + // SwapHashesForDepositIDs returns a map of swap hashes to deposit IDs + // for the given deposit IDs. + SwapHashesForDepositIDs(ctx context.Context, + depositIDs []deposit.ID) (map[lntypes.Hash][]deposit.ID, error) } type QuoteGetter interface { diff --git a/staticaddr/loopin/manager.go b/staticaddr/loopin/manager.go index 13ef8be0..106585ba 100644 --- a/staticaddr/loopin/manager.go +++ b/staticaddr/loopin/manager.go @@ -19,6 +19,7 @@ import ( "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/labels" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" @@ -324,29 +325,9 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, // If the user selected an amount that is less than the total deposit // amount we'll check that the server sends us the correct change amount // back to our static address. - totalDepositAmount := loopIn.TotalDepositAmount() - changeAmt := totalDepositAmount - loopIn.SelectedAmount - if changeAmt > 0 && changeAmt < totalDepositAmount { - var foundChange bool - changePkScript := loopIn.AddressParams.PkScript - - for _, out := range sweepTx.TxOut { - if out.Value == int64(changeAmt) && - bytes.Equal(out.PkScript, changePkScript) { - - foundChange = true - break - } - } - - if !foundChange { - return fmt.Errorf("expected change output to our "+ - "static address, total_deposit_amount=%v, "+ - "selected_amount=%v, "+ - "expected_change_amount=%v ", - totalDepositAmount, loopIn.SelectedAmount, - changeAmt) - } + err = m.checkChange(ctx, sweepTx, loopIn.AddressParams) + if err != nil { + return err } // Check if all the deposits requested are part of the loop-in and @@ -465,6 +446,73 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context, return err } +// checkChange ensures that the server sends us the correct change amount +// back to our static address. An edge case arises if a batch contains two +// swaps with identical change outputs. The client needs to ensure that any +// swap referenced by the inputs has a respective change output in the batch. +func (m *Manager) checkChange(ctx context.Context, + sweepTx *wire.MsgTx, changeAddr *address.Parameters) error { + + prevOuts := make([]string, len(sweepTx.TxIn)) + for i, in := range sweepTx.TxIn { + prevOuts[i] = in.PreviousOutPoint.String() + } + + deposits, err := m.cfg.DepositManager.DepositsForOutpoints( + ctx, prevOuts, + ) + if err != nil { + return err + } + + depositIDs := make([]deposit.ID, len(deposits)) + for i, d := range deposits { + depositIDs[i] = d.ID + } + + swapHashes, err := m.cfg.Store.SwapHashesForDepositIDs(ctx, depositIDs) + if err != nil { + return err + } + + var expectedChange btcutil.Amount + for swapHash := range swapHashes { + loopIn, err := m.cfg.Store.GetLoopInByHash(ctx, swapHash) + if err != nil { + return err + } + + totalDepositAmount := loopIn.TotalDepositAmount() + changeAmt := totalDepositAmount - loopIn.SelectedAmount + if changeAmt > 0 && changeAmt < totalDepositAmount { + log.Debugf("expected change output to our "+ + "static address, total_deposit_amount=%v, "+ + "selected_amount=%v, "+ + "expected_change_amount=%v ", + totalDepositAmount, loopIn.SelectedAmount, + changeAmt) + + expectedChange += changeAmt + } + } + + if expectedChange == 0 { + return nil + } + + for _, out := range sweepTx.TxOut { + if out.Value == int64(expectedChange) && + bytes.Equal(out.PkScript, changeAddr.PkScript) { + + // We found the expected change output. + return nil + } + } + + return fmt.Errorf("couldn't find expected change of %v "+ + "satoshis sent to our static address", expectedChange) +} + // recover stars a loop-in state machine for each non-final loop-in to pick up // work where it was left off before the restart. func (m *Manager) recoverLoopIns(ctx context.Context) error { diff --git a/staticaddr/loopin/manager_test.go b/staticaddr/loopin/manager_test.go index 79c1a127..1e4bbf82 100644 --- a/staticaddr/loopin/manager_test.go +++ b/staticaddr/loopin/manager_test.go @@ -1,11 +1,16 @@ package loopin import ( + "context" "testing" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" ) @@ -133,3 +138,365 @@ func TestSelectDeposits(t *testing.T) { }) } } + +// mockDepositManager implements DepositManager for tests. +type mockDepositManager struct { + byOutpoint map[string]*deposit.Deposit +} + +func (m *mockDepositManager) GetAllDeposits(_ context.Context) ( + []*deposit.Deposit, error) { + + return nil, nil +} + +func (m *mockDepositManager) AllStringOutpointsActiveDeposits(_ []string, + _ fsm.StateType) ([]*deposit.Deposit, bool) { + + return nil, false +} + +func (m *mockDepositManager) TransitionDeposits(_ context.Context, + _ []*deposit.Deposit, _ fsm.EventType, _ fsm.StateType) error { + + return nil +} + +func (m *mockDepositManager) DepositsForOutpoints(_ context.Context, + outpoints []string) ([]*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 nil, nil +} + +// mockStore implements StaticAddressLoopInStore for tests. +type mockStore struct { + loopIns map[lntypes.Hash]*StaticAddressLoopIn + mapIDs map[lntypes.Hash][]deposit.ID +} + +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 nil, nil +} +func (s *mockStore) IsStored(_ context.Context, _ lntypes.Hash) (bool, error) { + return false, 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) *deposit.Deposit { + d := &deposit.Deposit{Value: value} + 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 := &address.Parameters{PkScript: []byte{0xaa, 0xbb}} + otherAddr := &address.Parameters{PkScript: []byte{0xcc, 0xdd}} + serverAddr := &address.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. + s1d1 := makeDeposit(1, 0, 1000) + s1d2 := makeDeposit(1, 1, 2000) + s2d1 := makeDeposit(2, 0, 1500) + s3d1 := makeDeposit(3, 0, 800) + s4d1 := makeDeposit(4, 0, 900) + + // 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 *address.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) + } + }) + } +}