mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Store server confirmation-risk decisions with static loop-in swaps and recover accepted payment-deadline timers after restart. Wire notification persistence through loopd so recovered swaps do not lose pending risk state. Deduplicate notification fanout cache entries by swap hash.
773 lines
20 KiB
Go
773 lines
20 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: "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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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 {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|