loop/staticaddr/loopin/manager_test.go
Slyghtning 3fdd9e2250
staticaddr/loopin: recover deposits by current outpoint
Recovered loop-ins carry two outpoint views. DepositOutpoints is the
immutable swap input snapshot sent to the server and used to validate
sweep requests. Deposits comes from the store's swap_hash/deposit-id
join and reflects the current deposit rows.

The active-deposit lookup takes a detour through the reconstructed
deposit rows before asking the deposit manager for active deposits.
That keeps recovery from depending on the historical input snapshot.

A future replacement path can RBF a deposit from its original funding
outpoint to a replacement outpoint while the swap still needs to retain
the original input list. Looking up active deposits by DepositOutpoints
would then fail recovery even though the store still maps the correct
deposit IDs to the swap hash.

Keep list responses on the store reconstruction too, so they do not
re-resolve deposits through historical outpoints.
2026-07-08 09:01:17 +02:00

795 lines
21 KiB
Go

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: "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)
}
// 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)
}
// 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) 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
}
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 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,
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)
}
})
}
}