mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Move static loop-in label validation to the rpc boundary and remove the same check from the internal manager path. This keeps external requests aligned with the existing swap rpc surface while allowing internal autoloop callers to keep using reserved labels for automated swaps. The tests cover both sides of that contract: rpc requests still reject reserved labels, and the manager path accepts them.
630 lines
16 KiB
Go
630 lines
16 KiB
Go
package loopin
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"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/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()
|
|
|
|
selectedDeposit := makeDeposit(1, 0, 9_000)
|
|
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)
|
|
}
|
|
|
|
// mockDepositManager implements DepositManager for tests.
|
|
type mockDepositManager struct {
|
|
// 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 {
|
|
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 nil, nil
|
|
}
|
|
|
|
// mockQuoteGetter returns either a configured quote or a configured error and
|
|
// records the quoted amount for assertions.
|
|
type mockQuoteGetter struct {
|
|
// err is the optional error returned from GetLoopInQuote.
|
|
err error
|
|
|
|
// amount records the quoted amount.
|
|
amount btcutil.Amount
|
|
}
|
|
|
|
// GetLoopInQuote returns the configured quote result for tests.
|
|
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
|
|
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
|
|
return &loop.LoopInQuote{}, 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 := &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.
|
|
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 *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)
|
|
}
|
|
})
|
|
}
|
|
}
|