loop/instantout/reservation/manager_test.go

415 lines
12 KiB
Go
Raw Normal View History

2024-01-17 14:26:20 +01:00
package reservation
import (
"context"
"encoding/hex"
"sync/atomic"
2024-01-17 14:26:20 +01:00
"testing"
"time"
"github.com/btcsuite/btcd/wire"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/keychain"
2024-01-17 14:26:20 +01:00
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
var (
defaultReservationId = mustDecodeID("17cecc61ab4aafebdc0542dabdae0d0cb8907ec1c9c8ae387bc5a3309ca8b600")
)
func TestManager(t *testing.T) {
2026-03-05 12:18:05 +01:00
ctxb := t.Context()
2024-01-17 14:26:20 +01:00
testContext := newManagerTestContext(t)
initChan := make(chan struct{})
2024-01-17 14:26:20 +01:00
// Start the manager.
go func() {
err := testContext.manager.Run(ctxb, testContext.mockLnd.Height, initChan)
2024-01-17 14:26:20 +01:00
require.NoError(t, err)
}()
// We'll now wait for the manager to be initialized.
<-initChan
2024-01-17 14:26:20 +01:00
// Create a new reservation.
reservationFSM, err := testContext.manager.newReservation(
2024-01-17 14:26:20 +01:00
ctxb, uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) + defaultExpiry,
},
)
require.NoError(t, err)
// We'll expect the spendConfirmation to be sent to the server.
pkScript, err := reservationFSM.reservation.GetPkScript()
2024-01-17 14:26:20 +01:00
require.NoError(t, err)
confReg := <-testContext.mockLnd.RegisterConfChannel
require.Equal(t, confReg.PkScript, pkScript)
2024-01-17 14:26:20 +01:00
confTx := &wire.MsgTx{
TxOut: []*wire.TxOut{
{
Value: int64(defaultValue),
2024-01-17 14:26:20 +01:00
PkScript: pkScript,
},
},
}
// We'll now confirm the spend.
confReg.ConfChan <- &chainntnfs.TxConfirmation{
2024-01-17 14:26:20 +01:00
BlockHeight: uint32(testContext.mockLnd.Height),
Tx: confTx,
}
// We'll now expect the reservation to be confirmed.
err = reservationFSM.DefaultObserver.WaitForState(ctxb, 5*time.Second, Confirmed)
2024-01-17 14:26:20 +01:00
require.NoError(t, err)
// We'll now expect a spend registration.
var spendReg atomic.Pointer[test.SpendRegistration]
spendReg.Store(<-testContext.mockLnd.RegisterSpendChannel)
require.Equal(t, spendReg.Load().PkScript, pkScript)
go func() {
// We'll expect a second spend registration.
spendReg.Store(<-testContext.mockLnd.RegisterSpendChannel)
require.Equal(t, spendReg.Load().PkScript, pkScript)
}()
// We'll now try to lock the reservation.
err = testContext.manager.LockReservation(ctxb, defaultReservationId)
2024-01-17 14:26:20 +01:00
require.NoError(t, err)
// We'll try to lock the reservation again, which should fail.
err = testContext.manager.LockReservation(ctxb, defaultReservationId)
require.Error(t, err)
testContext.mockLnd.SpendChannel <- &chainntnfs.SpendDetail{
SpentOutPoint: spendReg.Load().Outpoint,
}
2024-01-17 14:26:20 +01:00
// We'll now expect the reservation to be expired.
err = reservationFSM.DefaultObserver.WaitForState(ctxb, 5*time.Second, Spent)
2024-01-17 14:26:20 +01:00
require.NoError(t, err)
testContext.manager.Lock()
_, ok := testContext.manager.activeReservations[defaultReservationId]
testContext.manager.Unlock()
require.False(t, ok)
2024-01-17 14:26:20 +01:00
}
// TestManagerContinuesAfterInvalidNotification verifies that a malformed
// server notification doesn't stop the reservation manager from processing
// later notifications.
func TestManagerContinuesAfterInvalidNotification(t *testing.T) {
testContext := newManagerTestContext(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
initChan := make(chan struct{})
errChan := make(chan error, 1)
go func() {
errChan <- testContext.manager.Run(
ctx, testContext.mockLnd.Height, initChan,
)
}()
<-initChan
// A malformed ID is rejected by newReservation. The manager should log
// the error and continue processing the stream.
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: []byte{1},
}
testContext.reservationNotificationChan <- &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
select {
case <-testContext.mockLnd.RegisterConfChannel:
case err := <-errChan:
require.NoError(t, err)
t.Fatal("reservation manager stopped after malformed notification")
case <-time.After(5 * time.Second):
t.Fatal("valid reservation notification was not processed")
}
cancel()
require.NoError(t, <-errChan)
}
// TestManagerRejectsDuplicateReservation verifies that a duplicate server
// notification cannot replace the active FSM for an existing reservation.
func TestManagerRejectsDuplicateReservation(t *testing.T) {
testContext := newManagerTestContext(t)
ctx := t.Context()
req := &swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
}
firstFSM, err := testContext.manager.newReservation(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.NoError(t, err)
secondFSM, err := testContext.manager.newReservation(
ctx, uint32(testContext.mockLnd.Height), req,
)
require.ErrorIs(t, err, ErrReservationAlreadyExists)
require.Nil(t, secondFSM)
require.Same(
t, firstFSM,
testContext.manager.activeReservations[defaultReservationId],
)
}
// TestManagerLimitsActiveReservations verifies that server notifications
// cannot grow the active FSM set without bound.
func TestManagerLimitsActiveReservations(t *testing.T) {
testContext := newManagerTestContext(t)
for i := range maxActiveReservations {
var id ID
id[0] = byte(i)
id[1] = byte(i >> 8)
testContext.manager.activeReservations[id] = NewFSM(
testContext.manager.cfg,
)
}
reservationFSM, err := testContext.manager.newReservation(
t.Context(), uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
},
)
require.ErrorIs(t, err, ErrTooManyActiveReservations)
require.Nil(t, reservationFSM)
require.Len(
t, testContext.manager.activeReservations,
maxActiveReservations,
)
}
// TestManagerKeepsReservationAfterWaitTimeout verifies that a caller-side
// wait timeout doesn't evict a reservation FSM that is still initializing.
func TestManagerKeepsReservationAfterWaitTimeout(t *testing.T) {
testContext := newManagerTestContext(t)
originalWaitTimeout := reservationStateWaitTimeout
originalPollDelay := reservationStatePollDelay
reservationStateWaitTimeout = 20 * time.Millisecond
reservationStatePollDelay = time.Millisecond
t.Cleanup(func() {
reservationStateWaitTimeout = originalWaitTimeout
reservationStatePollDelay = originalPollDelay
})
releaseOpen := make(chan struct{})
testContext.mockReservationClient.ExpectedCalls = nil
testContext.mockReservationClient.On(
"OpenReservation", mock.Anything, mock.Anything, mock.Anything,
).Run(func(mock.Arguments) {
<-releaseOpen
}).Return(
&swapserverrpc.ServerOpenReservationResponse{}, nil,
)
reservationFSM, err := testContext.manager.newReservation(
t.Context(), uint32(testContext.mockLnd.Height),
&swapserverrpc.ServerReservationNotification{
ReservationId: defaultReservationId[:],
Value: uint64(defaultValue),
ServerKey: defaultPubkeyBytes,
Expiry: uint32(testContext.mockLnd.Height) +
defaultExpiry,
},
)
require.Error(t, err)
require.Nil(t, reservationFSM)
testContext.manager.Lock()
activeFSM := testContext.manager.activeReservations[defaultReservationId]
testContext.manager.Unlock()
require.NotNil(t, activeFSM)
close(releaseOpen)
require.NoError(t, activeFSM.DefaultObserver.WaitForState(
t.Context(), 5*time.Second, WaitForConfirmation,
))
}
// TestManagerRecoversAllPersistedReservations verifies that the cap applied to
// new notifications doesn't prevent the manager from resuming obligations
// already recorded in the database. The terminal transitions also exercise
// concurrent observer-driven removal from the active map.
func TestManagerRecoversAllPersistedReservations(t *testing.T) {
reservations := make([]*Reservation, maxActiveReservations+1)
for i := range reservations {
reservations[i] = &Reservation{
ID: ID{
byte(i), byte(i >> 8), byte(i >> 16),
},
State: Init,
ProtocolVersion: ProtocolVersionServerInitiated,
}
}
manager := NewManager(&Config{
Store: &recoveryStore{reservations: reservations},
})
require.NoError(t, manager.RecoverReservations(t.Context()))
require.Eventually(t, func() bool {
manager.Lock()
defer manager.Unlock()
return len(manager.activeReservations) == 0
}, 5*time.Second, time.Millisecond)
}
// TestUnlockTerminalReservationIsIdempotent verifies that cleanup can safely
// race with terminal-state eviction without masking the original swap result.
func TestUnlockTerminalReservationIsIdempotent(t *testing.T) {
testContext := newManagerTestContext(t)
storedReservation := &Reservation{
ID: defaultReservationId,
State: Init,
ClientPubkey: defaultPubkey,
ServerPubkey: defaultPubkey,
Value: defaultValue,
Expiry: defaultExpiry,
ProtocolVersion: ProtocolVersionServerInitiated,
KeyLocator: keychain.KeyLocator{
Family: keychain.KeyFamily(KeyFamily),
Index: 1,
},
}
require.NoError(t, testContext.manager.cfg.Store.CreateReservation(
t.Context(), storedReservation,
))
storedReservation.State = TimedOut
require.NoError(t, testContext.manager.cfg.Store.UpdateReservation(
t.Context(), storedReservation,
))
require.NoError(t, testContext.manager.UnlockReservation(
t.Context(), defaultReservationId,
))
require.ErrorIs(t, testContext.manager.UnlockReservation(
t.Context(), ID{1},
), ErrReservationNotFound)
}
type recoveryStore struct {
Store
reservations []*Reservation
}
func (s *recoveryStore) ListReservations(context.Context) ([]*Reservation,
error) {
return s.reservations, nil
}
func (s *recoveryStore) UpdateReservation(context.Context,
*Reservation) error {
return nil
}
2024-01-17 14:26:20 +01:00
// 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
reservationNotificationChan chan *swapserverrpc.ServerReservationNotification
mockReservationClient *mockReservationClient
}
// newManagerTestContext creates a new test context for the reservation manager.
func newManagerTestContext(t *testing.T) *ManagerTestContext {
mockLnd := test.NewMockLnd()
lndContext := test.NewContext(t, mockLnd)
dbFixture := loopdb.NewTestDB(t)
store := NewSQLStore(loopdb.NewTypedStore[Querier](dbFixture))
2024-01-17 14:26:20 +01:00
mockReservationClient := new(mockReservationClient)
sendChan := make(chan *swapserverrpc.ServerReservationNotification)
mockReservationClient.On(
"OpenReservation", mock.Anything, mock.Anything, mock.Anything,
).Return(
&swapserverrpc.ServerOpenReservationResponse{}, nil,
)
mockNtfnManager := &mockNtfnManager{
sendChan: sendChan,
}
2024-01-17 14:26:20 +01:00
cfg := &Config{
Store: store,
Wallet: mockLnd.WalletKit,
ChainNotifier: mockLnd.ChainNotifier,
ReservationClient: mockReservationClient,
NotificationManager: mockNtfnManager,
2024-01-17 14:26:20 +01:00
}
manager := NewManager(cfg)
return &ManagerTestContext{
manager: manager,
context: lndContext,
mockLnd: mockLnd,
mockReservationClient: mockReservationClient,
reservationNotificationChan: sendChan,
}
}
type mockNtfnManager struct {
sendChan chan *swapserverrpc.ServerReservationNotification
2024-01-17 14:26:20 +01:00
}
func (m *mockNtfnManager) SubscribeReservations(
ctx context.Context,
) <-chan *swapserverrpc.ServerReservationNotification {
2024-01-17 14:26:20 +01:00
return m.sendChan
2024-01-17 14:26:20 +01:00
}
func mustDecodeID(id string) ID {
bytes, err := hex.DecodeString(id)
if err != nil {
panic(err)
}
var decoded ID
copy(decoded[:], bytes)
return decoded
}