mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
The first block epoch is consumed before recovered deposit FSMs exist. Replay that startup height after recovery so already-expired deposits can run expiry handling immediately after restart.
623 lines
16 KiB
Go
623 lines
16 KiB
Go
package deposit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop/staticaddr/script"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightninglabs/loop/swapserverrpc"
|
|
"github.com/lightninglabs/loop/test"
|
|
"github.com/lightningnetwork/lnd/chainntnfs"
|
|
"github.com/lightningnetwork/lnd/input"
|
|
"github.com/lightningnetwork/lnd/lnrpc/chainrpc"
|
|
"github.com/lightningnetwork/lnd/lnwallet"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
var (
|
|
defaultServerPubkeyBytes, _ = hex.DecodeString("021c97a90a411ff2b10dc2a8e32de2f29d2fa49d41bfbb52bd416e460db0747d0d")
|
|
|
|
defaultServerPubkey, _ = btcec.ParsePubKey(defaultServerPubkeyBytes)
|
|
|
|
defaultExpiry = uint32(100)
|
|
|
|
defaultDepositConfirmations = uint32(3)
|
|
)
|
|
|
|
type mockStaticAddressClient struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockStaticAddressClient) ServerStaticAddressLoopIn(ctx context.Context,
|
|
in *swapserverrpc.ServerStaticAddressLoopInRequest,
|
|
opts ...grpc.CallOption) (
|
|
*swapserverrpc.ServerStaticAddressLoopInResponse, error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.ServerStaticAddressLoopInResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockStaticAddressClient) PushStaticAddressSweeplessSigs(ctx context.Context,
|
|
in *swapserverrpc.PushStaticAddressSweeplessSigsRequest,
|
|
opts ...grpc.CallOption) (
|
|
*swapserverrpc.PushStaticAddressSweeplessSigsResponse, error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.PushStaticAddressSweeplessSigsResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockStaticAddressClient) PushStaticAddressHtlcSigs(ctx context.Context,
|
|
in *swapserverrpc.PushStaticAddressHtlcSigsRequest,
|
|
opts ...grpc.CallOption) (
|
|
*swapserverrpc.PushStaticAddressHtlcSigsResponse, error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.PushStaticAddressHtlcSigsResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
// ServerWithdrawDeposits implements the deprecated RPC required by the
|
|
// generated client interface. Production code uses ServerPsbtWithdrawDeposits.
|
|
//
|
|
//nolint:staticcheck
|
|
func (m *mockStaticAddressClient) ServerWithdrawDeposits(ctx context.Context,
|
|
in *swapserverrpc.ServerWithdrawRequest,
|
|
opts ...grpc.CallOption) (*swapserverrpc.ServerWithdrawResponse,
|
|
error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.ServerWithdrawResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockStaticAddressClient) ServerPsbtWithdrawDeposits(ctx context.Context,
|
|
in *swapserverrpc.ServerPsbtWithdrawRequest,
|
|
opts ...grpc.CallOption) (*swapserverrpc.ServerPsbtWithdrawResponse,
|
|
error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.ServerPsbtWithdrawResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockStaticAddressClient) ServerNewAddress(ctx context.Context,
|
|
in *swapserverrpc.ServerNewAddressRequest, opts ...grpc.CallOption) (
|
|
*swapserverrpc.ServerNewAddressResponse, error) {
|
|
|
|
args := m.Called(ctx, in, opts)
|
|
|
|
return args.Get(0).(*swapserverrpc.ServerNewAddressResponse),
|
|
args.Error(1)
|
|
}
|
|
|
|
type mockAddressManager struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *mockAddressManager) GetStaticAddressParameters(ctx context.Context) (
|
|
*script.Parameters, error) {
|
|
|
|
args := m.Called(ctx)
|
|
|
|
return args.Get(0).(*script.Parameters),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockAddressManager) GetStaticAddress(ctx context.Context) (
|
|
*script.StaticAddress, error) {
|
|
|
|
args := m.Called(ctx)
|
|
|
|
return args.Get(0).(*script.StaticAddress),
|
|
args.Error(1)
|
|
}
|
|
|
|
func (m *mockAddressManager) ListUnspent(ctx context.Context,
|
|
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
|
|
|
|
args := m.Called(ctx, minConfs, maxConfs)
|
|
if listUnspent, ok := args.Get(0).(func() []*lnwallet.Utxo); ok {
|
|
return listUnspent(), args.Error(1)
|
|
}
|
|
|
|
return args.Get(0).([]*lnwallet.Utxo),
|
|
args.Error(1)
|
|
}
|
|
|
|
// listUnspentOverride delegates all address-manager methods except
|
|
// ListUnspent to another implementation.
|
|
type listUnspentOverride struct {
|
|
AddressManager
|
|
|
|
listUnspent func(context.Context, int32, int32) ([]*lnwallet.Utxo,
|
|
error)
|
|
}
|
|
|
|
// ListUnspent calls the override's ListUnspent implementation.
|
|
func (l *listUnspentOverride) ListUnspent(ctx context.Context,
|
|
minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) {
|
|
|
|
return l.listUnspent(ctx, minConfs, maxConfs)
|
|
}
|
|
|
|
func (m *mockAddressManager) GetTaprootAddress(clientPubkey,
|
|
serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot,
|
|
error) {
|
|
|
|
args := m.Called(clientPubkey, serverPubkey, expiry)
|
|
|
|
return args.Get(0).(*btcutil.AddressTaproot),
|
|
args.Error(1)
|
|
}
|
|
|
|
type mockStore struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (s *mockStore) CreateDeposit(ctx context.Context, deposit *Deposit) error {
|
|
args := s.Called(ctx, deposit)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (s *mockStore) UpdateDeposit(ctx context.Context, deposit *Deposit) error {
|
|
args := s.Called(ctx, deposit)
|
|
return args.Error(0)
|
|
}
|
|
|
|
func (s *mockStore) GetDeposit(ctx context.Context, depositID ID) (*Deposit,
|
|
error) {
|
|
|
|
args := s.Called(ctx, depositID)
|
|
return args.Get(0).(*Deposit), args.Error(1)
|
|
}
|
|
|
|
func (s *mockStore) DepositForOutpoint(ctx context.Context,
|
|
outpoint string) (*Deposit, error) {
|
|
|
|
args := s.Called(ctx, outpoint)
|
|
return args.Get(0).(*Deposit), args.Error(1)
|
|
}
|
|
|
|
func (s *mockStore) AllDeposits(ctx context.Context) ([]*Deposit, error) {
|
|
args := s.Called(ctx)
|
|
return args.Get(0).([]*Deposit), args.Error(1)
|
|
}
|
|
|
|
type MockChainNotifier struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (m *MockChainNotifier) RawClientWithMacAuth(
|
|
ctx context.Context) (context.Context, time.Duration,
|
|
chainrpc.ChainNotifierClient) {
|
|
|
|
return ctx, 0, nil
|
|
}
|
|
|
|
func (m *MockChainNotifier) RegisterConfirmationsNtfn(ctx context.Context,
|
|
txid *chainhash.Hash, pkScript []byte, numConfs, heightHint int32,
|
|
_ ...lndclient.NotifierOption) (chan *chainntnfs.TxConfirmation,
|
|
chan error, error) {
|
|
|
|
args := m.Called(ctx, txid, pkScript, numConfs, heightHint)
|
|
return args.Get(0).(chan *chainntnfs.TxConfirmation),
|
|
args.Get(1).(chan error), args.Error(2)
|
|
}
|
|
|
|
func (m *MockChainNotifier) RegisterBlockEpochNtfn(ctx context.Context) (
|
|
chan int32, chan error, error) {
|
|
|
|
args := m.Called(ctx)
|
|
return args.Get(0).(chan int32), args.Get(1).(chan error), args.Error(2)
|
|
}
|
|
|
|
func (m *MockChainNotifier) RegisterSpendNtfn(ctx context.Context,
|
|
outpoint *wire.OutPoint, pkScript []byte, heightHint int32,
|
|
_ ...lndclient.NotifierOption) (chan *chainntnfs.SpendDetail,
|
|
chan error, error) {
|
|
|
|
args := m.Called(ctx, pkScript, heightHint)
|
|
return args.Get(0).(chan *chainntnfs.SpendDetail),
|
|
args.Get(1).(chan error), args.Error(2)
|
|
}
|
|
|
|
// TestManager checks that the manager processes the right channel notifications
|
|
// while a deposit is expiring.
|
|
func TestManager(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
const defaultTimeout = 30 * time.Second
|
|
|
|
// Create the test context with required mocks.
|
|
testContext := newManagerTestContext(t)
|
|
|
|
// Start the deposit manager.
|
|
initChan := make(chan struct{})
|
|
runErrChan := make(chan error, 1)
|
|
go func() {
|
|
runErrChan <- testContext.manager.Run(ctx, initChan)
|
|
}()
|
|
|
|
// Send an initial block so the manager can proceed past its startup
|
|
// block wait.
|
|
testContext.blockChan <- int32(defaultDepositConfirmations)
|
|
|
|
// Ensure that the manager has been initialized.
|
|
select {
|
|
case <-initChan:
|
|
case err := <-runErrChan:
|
|
require.NoError(t, err, "manager failed to start")
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("manager timed out starting")
|
|
}
|
|
|
|
// Notify about the last block before the expiry.
|
|
testContext.blockChan <- int32(
|
|
defaultDepositConfirmations + defaultExpiry - 1,
|
|
)
|
|
|
|
// Ensure that the deposit state machine didn't sign for the expiry tx.
|
|
select {
|
|
case <-testContext.mockLnd.SignOutputRawChannel:
|
|
t.Fatal("received unexpected sign request")
|
|
|
|
case <-time.After(defaultTimeout):
|
|
}
|
|
|
|
// Mine the expiry tx height.
|
|
testContext.blockChan <- int32(
|
|
defaultDepositConfirmations + defaultExpiry,
|
|
)
|
|
|
|
// Ensure that the deposit state machine signed the expiry tx.
|
|
select {
|
|
case <-testContext.mockLnd.SignOutputRawChannel:
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive sign request")
|
|
}
|
|
|
|
// Ensure that the signed expiry transaction is published.
|
|
var expiryTx *wire.MsgTx
|
|
select {
|
|
case expiryTx = <-testContext.mockLnd.TxPublishChannel:
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive published expiry tx")
|
|
}
|
|
|
|
// Ensure that the deposit is waiting for a confirmation notification.
|
|
testContext.confChan <- &chainntnfs.TxConfirmation{
|
|
BlockHeight: defaultDepositConfirmations + defaultExpiry + 3,
|
|
Tx: expiryTx,
|
|
}
|
|
|
|
// Ensure that the manager observed the finalization and removed the
|
|
// deposit from its active set.
|
|
require.Eventually(t, func() bool {
|
|
testContext.manager.mu.Lock()
|
|
defer testContext.manager.mu.Unlock()
|
|
|
|
return len(testContext.manager.activeDeposits) == 0
|
|
}, defaultTimeout, 10*time.Millisecond)
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-runErrChan:
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("manager did not stop")
|
|
}
|
|
}
|
|
|
|
// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial
|
|
// block epoch consumed during startup is delivered to recovered deposit FSMs.
|
|
func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
const defaultTimeout = 30 * time.Second
|
|
|
|
testContext := newManagerTestContext(t)
|
|
|
|
initChan := make(chan struct{})
|
|
runErrChan := make(chan error, 1)
|
|
go func() {
|
|
runErrChan <- testContext.manager.Run(ctx, initChan)
|
|
}()
|
|
|
|
// Send only the startup block at the recovered deposit's expiry height.
|
|
testContext.blockChan <- int32(
|
|
defaultDepositConfirmations + defaultExpiry,
|
|
)
|
|
|
|
select {
|
|
case <-initChan:
|
|
|
|
case err := <-runErrChan:
|
|
require.NoError(t, err, "manager failed to start")
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("manager timed out starting")
|
|
}
|
|
|
|
select {
|
|
case <-testContext.mockLnd.SignOutputRawChannel:
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive sign request")
|
|
}
|
|
|
|
select {
|
|
case <-testContext.mockLnd.TxPublishChannel:
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("did not receive published expiry tx")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-runErrChan:
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
|
|
case <-time.After(defaultTimeout):
|
|
t.Fatal("manager did not stop")
|
|
}
|
|
}
|
|
|
|
// TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit
|
|
// FSMs cannot make an expiry decision from stale confirmation data when wallet
|
|
// reconciliation fails at startup or while processing a later block.
|
|
func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
startupHeight int32
|
|
blockHeight int32
|
|
}{
|
|
{
|
|
name: "startup",
|
|
startupHeight: int32(
|
|
defaultDepositConfirmations + defaultExpiry,
|
|
),
|
|
},
|
|
{
|
|
name: "block",
|
|
startupHeight: int32(defaultDepositConfirmations),
|
|
blockHeight: int32(
|
|
defaultDepositConfirmations + defaultExpiry,
|
|
),
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
testContext := newManagerTestContext(t)
|
|
baseAddressManager := testContext.mockAddressManager
|
|
var listUnspentCalls int
|
|
testContext.manager.cfg.AddressManager =
|
|
&listUnspentOverride{
|
|
AddressManager: baseAddressManager,
|
|
listUnspent: func(ctx context.Context,
|
|
minConfs, maxConfs int32) (
|
|
[]*lnwallet.Utxo, error) {
|
|
|
|
listUnspentCalls++
|
|
if testCase.blockHeight != 0 &&
|
|
listUnspentCalls == 1 {
|
|
|
|
return baseAddressManager.ListUnspent(
|
|
ctx, minConfs, maxConfs,
|
|
)
|
|
}
|
|
|
|
return nil, errors.New(
|
|
"injected reconciliation failure",
|
|
)
|
|
},
|
|
}
|
|
|
|
initChan := make(chan struct{})
|
|
runErrChan := make(chan error, 1)
|
|
go func() {
|
|
runErrChan <- testContext.manager.Run(ctx, initChan)
|
|
}()
|
|
|
|
testContext.blockChan <- testCase.startupHeight
|
|
|
|
select {
|
|
case <-initChan:
|
|
|
|
case err := <-runErrChan:
|
|
require.NoError(t, err, "manager failed to start")
|
|
|
|
case <-time.After(time.Second):
|
|
t.Fatal("manager timed out starting")
|
|
}
|
|
|
|
if testCase.blockHeight != 0 {
|
|
testContext.blockChan <- testCase.blockHeight
|
|
}
|
|
|
|
select {
|
|
case <-testContext.mockLnd.SignOutputRawChannel:
|
|
t.Fatal("expiry sweep signed with stale deposit data")
|
|
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-runErrChan:
|
|
require.ErrorIs(t, err, context.Canceled)
|
|
|
|
case <-time.After(time.Second):
|
|
t.Fatal("manager did not stop")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ManagerTestContext is a helper struct that contains all the necessary
|
|
// components to test the reservation manager.
|
|
type ManagerTestContext struct {
|
|
manager *Manager
|
|
context test.Context
|
|
mockLnd *test.LndMockServices
|
|
mockStaticAddressClient *mockStaticAddressClient
|
|
mockAddressManager *mockAddressManager
|
|
confChan chan *chainntnfs.TxConfirmation
|
|
confErrChan chan error
|
|
blockChan chan int32
|
|
blockErrChan chan error
|
|
}
|
|
|
|
// newManagerTestContext creates a new test context for the reservation manager.
|
|
func newManagerTestContext(t *testing.T) *ManagerTestContext {
|
|
mockLnd := test.NewMockLnd()
|
|
lndContext := test.NewContext(t, mockLnd)
|
|
|
|
mockStaticAddressClient := new(mockStaticAddressClient)
|
|
mockAddressManager := new(mockAddressManager)
|
|
mockStore := new(mockStore)
|
|
mockChainNotifier := new(MockChainNotifier)
|
|
confChan := make(chan *chainntnfs.TxConfirmation)
|
|
confErrChan := make(chan error)
|
|
blockChan := make(chan int32)
|
|
blockErrChan := make(chan error)
|
|
|
|
ID, err := GetRandomDepositID()
|
|
utxo := &lnwallet.Utxo{
|
|
AddressType: lnwallet.TaprootPubkey,
|
|
Value: btcutil.Amount(100000),
|
|
Confirmations: int64(defaultDepositConfirmations),
|
|
PkScript: []byte("pkscript"),
|
|
OutPoint: wire.OutPoint{
|
|
Hash: chainhash.Hash{},
|
|
Index: 0xffffffff,
|
|
},
|
|
}
|
|
require.NoError(t, err)
|
|
storedDeposits := []*Deposit{
|
|
{
|
|
ID: ID,
|
|
state: Deposited,
|
|
OutPoint: utxo.OutPoint,
|
|
Value: utxo.Value,
|
|
ConfirmationHeight: 3,
|
|
TimeOutSweepPkScript: []byte{0x42, 0x21, 0x69},
|
|
},
|
|
}
|
|
|
|
mockStore.On(
|
|
"AllDeposits", mock.Anything,
|
|
).Return(storedDeposits, nil)
|
|
|
|
mockStore.On(
|
|
"UpdateDeposit", mock.Anything, mock.Anything,
|
|
).Return(nil)
|
|
|
|
var manager *Manager
|
|
mockAddressManager.On(
|
|
"GetStaticAddressParameters", mock.Anything,
|
|
).Return(&script.Parameters{
|
|
Expiry: defaultExpiry,
|
|
}, nil)
|
|
|
|
mockAddressManager.On(
|
|
"ListUnspent", mock.Anything, mock.Anything, mock.Anything,
|
|
).Return(func() []*lnwallet.Utxo {
|
|
currentUtxo := *utxo
|
|
currentHeight := manager.currentHeight.Load()
|
|
if currentHeight < defaultDepositConfirmations {
|
|
currentUtxo.Confirmations = 0
|
|
} else {
|
|
currentUtxo.Confirmations = int64(
|
|
currentHeight - defaultDepositConfirmations + 1,
|
|
)
|
|
}
|
|
|
|
return []*lnwallet.Utxo{¤tUtxo}
|
|
}, nil)
|
|
|
|
// Define the expected return values for the mocks.
|
|
mockChainNotifier.On(
|
|
"RegisterConfirmationsNtfn", mock.Anything, mock.Anything,
|
|
mock.Anything, mock.Anything, mock.Anything,
|
|
).Return(confChan, confErrChan, nil)
|
|
|
|
mockChainNotifier.On("RegisterBlockEpochNtfn", mock.Anything).Return(
|
|
blockChan, blockErrChan, nil,
|
|
)
|
|
|
|
cfg := &ManagerConfig{
|
|
AddressManager: mockAddressManager,
|
|
Store: mockStore,
|
|
WalletKit: mockLnd.WalletKit,
|
|
ChainNotifier: mockChainNotifier,
|
|
Signer: mockLnd.Signer,
|
|
}
|
|
|
|
manager = NewManager(cfg)
|
|
|
|
testContext := &ManagerTestContext{
|
|
manager: manager,
|
|
context: lndContext,
|
|
mockLnd: mockLnd,
|
|
mockStaticAddressClient: mockStaticAddressClient,
|
|
mockAddressManager: mockAddressManager,
|
|
confChan: confChan,
|
|
confErrChan: confErrChan,
|
|
blockChan: blockChan,
|
|
blockErrChan: blockErrChan,
|
|
}
|
|
|
|
staticAddress := generateStaticAddress(
|
|
context.Background(), testContext,
|
|
)
|
|
mockAddressManager.On(
|
|
"GetStaticAddress", mock.Anything,
|
|
).Return(staticAddress, nil)
|
|
|
|
return testContext
|
|
}
|
|
|
|
func generateStaticAddress(ctx context.Context,
|
|
t *ManagerTestContext) *script.StaticAddress {
|
|
|
|
keyDescriptor, err := t.mockLnd.WalletKit.DeriveNextKey(
|
|
ctx, swap.StaticAddressKeyFamily,
|
|
)
|
|
require.NoError(t.context.T, err)
|
|
|
|
staticAddress, err := script.NewStaticAddress(
|
|
input.MuSig2Version100RC2, int64(defaultExpiry),
|
|
keyDescriptor.PubKey, defaultServerPubkey,
|
|
)
|
|
require.NoError(t.context.T, err)
|
|
|
|
return staticAddress
|
|
}
|