loop/notifications/manager_test.go

1303 lines
35 KiB
Go
Raw Permalink Normal View History

package notifications
import (
"context"
"errors"
"fmt"
"io"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/lightninglabs/aperture/l402"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
var (
testReservationId = []byte{0x01, 0x02}
testReservationId2 = []byte{0x03, 0x04}
)
// mockNotificationsClient implements the NotificationsClient interface for testing.
type mockNotificationsClient struct {
sync.Mutex
mockStream swapserverrpc.SwapServer_SubscribeNotificationsClient
subscribeErr error
attemptTimes []time.Time
timesCalled int
}
func (m *mockNotificationsClient) SubscribeNotifications(ctx context.Context,
in *swapserverrpc.SubscribeNotificationsRequest,
opts ...grpc.CallOption) (
swapserverrpc.SwapServer_SubscribeNotificationsClient, error) {
m.Lock()
defer m.Unlock()
m.timesCalled++
m.attemptTimes = append(m.attemptTimes, time.Now())
if m.subscribeErr != nil {
return nil, m.subscribeErr
}
return m.mockStream, nil
}
// mockSubscribeNotificationsClient simulates the server stream.
type mockSubscribeNotificationsClient struct {
grpc.ClientStream
recvChan chan *swapserverrpc.SubscribeNotificationsResponse
recvErrChan chan error
}
func (m *mockSubscribeNotificationsClient) Recv() (
*swapserverrpc.SubscribeNotificationsResponse, error) {
select {
case err := <-m.recvErrChan:
return nil, err
case notif, ok := <-m.recvChan:
if !ok {
return nil, io.EOF
}
return notif, nil
}
}
func (m *mockSubscribeNotificationsClient) Header() (metadata.MD, error) {
return nil, nil
}
func (m *mockSubscribeNotificationsClient) Trailer() metadata.MD {
return nil
}
func (m *mockSubscribeNotificationsClient) CloseSend() error {
return nil
}
func (m *mockSubscribeNotificationsClient) Context() context.Context {
return context.TODO()
}
2026-03-05 12:18:05 +01:00
func (m *mockSubscribeNotificationsClient) SendMsg(any) error {
return nil
}
2026-03-05 12:18:05 +01:00
func (m *mockSubscribeNotificationsClient) RecvMsg(any) error {
return nil
}
// TestManager_ReservationNotification tests that the Manager correctly
// forwards reservation notifications to subscribers.
func TestManager_ReservationNotification(t *testing.T) {
t.Parallel()
// Create a mock notification client
recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse, 1)
errChan := make(chan error, 1)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: errChan,
}
mockClient := &mockNotificationsClient{
mockStream: mockStream,
}
// Create a Manager with the mock client
mgr := NewManager(&Config{
Client: mockClient,
CurrentToken: func() (*l402.Token, error) {
// Simulate successful fetching of L402
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
// Subscribe to reservation notifications.
subCtx, subCancel := context.WithCancel(context.Background())
subChan := mgr.SubscribeReservations(subCtx)
// Run the manager.
2026-03-05 12:18:05 +01:00
ctx := t.Context()
go func() {
err := mgr.Run(ctx)
require.NoError(t, err)
}()
2026-02-25 11:04:57 +01:00
// Wait a bit to ensure manager is running and has subscribed to the
// server notifications stream.
require.Eventually(t, func() bool {
2026-02-25 11:04:57 +01:00
mockClient.Lock()
defer mockClient.Unlock()
return mockClient.timesCalled > 0
}, time.Second*5, 10*time.Millisecond)
2025-11-19 09:10:21 +01:00
require.Eventually(t, func() bool {
mockClient.Lock()
defer mockClient.Unlock()
return mockClient.timesCalled == 1
}, time.Second*5, 10*time.Millisecond)
// Send a test notification
testNotif := getTestNotification(testReservationId)
// Send the notification to the recvChan
recvChan <- testNotif
// Collect the notification in the callback
receivedNotification := <-subChan
// Now, check that the notification received in the callback matches the one sent
require.NotNil(t, receivedNotification)
require.Equal(t, testReservationId, receivedNotification.ReservationId)
// Cancel the subscription
subCancel()
// Send another test notification`
testNotif2 := getTestNotification(testReservationId2)
recvChan <- testNotif2
// Check that the subChan is eventually closed.
require.Eventually(t, func() bool {
select {
case _, ok := <-subChan:
return !ok
default:
return false
}
}, time.Second*5, 10*time.Millisecond)
}
func getTestNotification(resId []byte) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.SubscribeNotificationsResponse_ReservationNotification{
ReservationNotification: &swapserverrpc.ServerReservationNotification{
ReservationId: resId,
},
},
}
}
// unfinishedSwapNotification builds an unfinished swap notification.
func unfinishedSwapNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_UnfinishedSwap{
UnfinishedSwap: &swapserverrpc.
ServerUnfinishedSwapNotification{
SwapHash: swapHash[:],
},
},
}
}
// staticLoopInSweepNotification builds a static loop-in sweep notification.
func staticLoopInSweepNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInSweep{
StaticLoopInSweep: &swapserverrpc.
ServerStaticLoopInSweepNotification{
SwapHash: swapHash[:],
},
},
}
}
// staticLoopInRiskAcceptedNotification builds a risk accepted notification.
func staticLoopInRiskAcceptedNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
}
}
// staticLoopInRiskRejectedNotification builds a risk rejected notification.
func staticLoopInRiskRejectedNotification(
swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {
return &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
}
}
type staticLoopInRiskNotification interface {
GetSwapHash() []byte
}
// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout.
func assertStaticLoopInRiskNotificationSwapScoped[
T staticLoopInRiskNotification](t *testing.T,
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse, label string,
swapHashA, swapHashB lntypes.Hash) {
t.Helper()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChanA := subscribe(mgr, subCtx, swapHashA)
subChanB := subscribe(mgr, subCtx, swapHashB)
mgr.handleNotification(t.Context(), notification(swapHashA))
select {
case received := <-subChanA:
require.Equal(t, swapHashA[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatalf("did not receive first swap risk %s notification",
label)
}
select {
case received := <-subChanB:
t.Fatalf("second swap received wrong notification: %x",
received.GetSwapHash())
default:
}
mgr.handleNotification(t.Context(), notification(swapHashB))
select {
case received := <-subChanB:
require.Equal(t, swapHashB[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatalf("did not receive second swap risk %s notification",
label)
}
}
// TestManager_SlowReservationSubscriberDoesNotBlock tests that a reservation
// subscriber with a full notification channel does not block delivery to other
// subscribers. Reservation notifications are best-effort, so slow subscribers
// drop new notifications instead of queueing them.
func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
slowCtx, slowCancel := context.WithCancel(t.Context())
defer slowCancel()
slowChan := mgr.SubscribeReservations(slowCtx)
fastCtx, fastCancel := context.WithCancel(t.Context())
defer fastCancel()
fastChan := mgr.SubscribeReservations(fastCtx)
firstNotif := getTestNotification(testReservationId)
mgr.handleNotification(t.Context(), firstNotif)
received := <-fastChan
require.Equal(t, testReservationId, received.ReservationId)
secondNotif := getTestNotification(testReservationId2)
done := make(chan struct{})
go func() {
mgr.handleNotification(t.Context(), secondNotif)
close(done)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
select {
case received = <-fastChan:
require.Equal(t, testReservationId2, received.ReservationId)
case <-time.After(time.Second):
t.Fatal("fast subscriber did not receive notification")
}
require.Len(t, slowChan, 1)
select {
case received = <-slowChan:
require.Equal(t, testReservationId, received.ReservationId)
case <-time.After(time.Second):
t.Fatal("slow subscriber did not receive first notification")
}
select {
case received = <-slowChan:
t.Fatalf("slow subscriber received dropped notification %x",
received.ReservationId)
case <-time.After(50 * time.Millisecond):
}
}
// TestManager_UnfinishedSwapNotificationWaitsForSubscriber verifies that
// unfinished swap recovery notifications are not dropped when the local
// subscriber is briefly behind.
func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) {
t.Parallel()
assertQueuedSwapHashNotifications(
t,
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
ServerUnfinishedSwapNotification {
return mgr.SubscribeUnfinishedSwaps(ctx)
},
unfinishedSwapNotification,
func(ntfn *swapserverrpc.ServerUnfinishedSwapNotification) []byte {
return ntfn.SwapHash
},
lntypes.Hash{0x02, 0x03}, lntypes.Hash{0x04, 0x05},
"did not receive first unfinished swap notification",
"second unfinished swap notification was dropped",
)
}
// TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber verifies
// that a full static-loop-in sweep subscriber channel does not block the global
// notification receive loop.
func TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber(
t *testing.T) {
t.Parallel()
assertQueuedSwapHashNotifications(
t,
func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
ServerStaticLoopInSweepNotification {
return mgr.SubscribeStaticLoopInSweepRequests(ctx)
},
staticLoopInSweepNotification,
func(ntfn *swapserverrpc.ServerStaticLoopInSweepNotification) []byte {
return ntfn.SwapHash
},
lntypes.Hash{0x12, 0x13}, lntypes.Hash{0x14, 0x15},
"did not receive first sweep notification",
"second sweep notification was not queued",
)
}
// TestManager_QueuedNotificationChannelClosesOnCancel verifies that queued
// subscribers own their channel shutdown even when delivery is blocked.
func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
subChan := mgr.SubscribeUnfinishedSwaps(subCtx)
swapHashA := lntypes.Hash{0x21, 0x22}
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashA))
require.Eventually(t, func() bool {
return len(subChan) == 1
}, time.Second, 10*time.Millisecond)
swapHashB := lntypes.Hash{0x23, 0x24}
done := make(chan struct{})
go func() {
mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashB))
close(done)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
subCancel()
select {
case received, ok := <-subChan:
require.True(t, ok)
require.Equal(t, swapHashA[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("first unfinished swap notification was not delivered")
}
require.Eventually(t, func() bool {
select {
case _, ok := <-subChan:
return !ok
default:
return false
}
}, time.Second, 10*time.Millisecond)
}
// TestNotificationQueueDropsAtCapacity checks the queue's explicit drop policy
// once a subscriber reaches its configured backlog limit.
func TestNotificationQueueDropsAtCapacity(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
recvChan := make(chan int, 1)
enqueue := newNotificationQueue(ctx, recvChan, 0)
enqueue(1)
select {
case ntfn := <-recvChan:
t.Fatalf("received dropped notification %d", ntfn)
case <-time.After(50 * time.Millisecond):
}
}
// assertQueuedSwapHashNotifications checks queued delivery for swap hashes.
func assertQueuedSwapHashNotifications[T any](t *testing.T,
subscribe func(*Manager, context.Context) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse,
swapHash func(T) []byte, swapHashA, swapHashB lntypes.Hash,
firstFailureMsg, secondFailureMsg string) {
t.Helper()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := subscribe(mgr, subCtx)
mgr.handleNotification(t.Context(), notification(swapHashA))
done := make(chan struct{})
go func() {
mgr.handleNotification(t.Context(), notification(swapHashB))
close(done)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
select {
case received := <-subChan:
require.Equal(t, swapHashA[:], swapHash(received))
case <-time.After(time.Second):
t.Fatal(firstFailureMsg)
}
select {
case received := <-subChan:
require.Equal(t, swapHashB[:], swapHash(received))
case <-time.After(time.Second):
t.Fatal(secondFailureMsg)
}
}
// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
// forwards static loop in risk accepted notifications to subscribers.
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
swapHash := lntypes.Hash{0x04, 0x05}
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
},
)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not receive risk accepted notification")
}
}
// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are
// handed to the durable callback before they are treated as delivered.
func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) {
t.Parallel()
type persistedDecision struct {
swapHash lntypes.Hash
accepted bool
}
persisted := make(chan persistedDecision, 2)
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
swapHash lntypes.Hash, accepted bool) error {
persisted <- persistedDecision{
swapHash: swapHash,
accepted: accepted,
}
return nil
},
})
acceptedHash := lntypes.Hash{0x16, 0x17}
rejectedHash := lntypes.Hash{0x18, 0x19}
mgr.handleNotification(
t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash),
)
mgr.handleNotification(
t.Context(), staticLoopInRiskRejectedNotification(rejectedHash),
)
select {
case decision := <-persisted:
require.Equal(t, acceptedHash, decision.swapHash)
require.True(t, decision.accepted)
case <-time.After(time.Second):
t.Fatal("accepted risk decision was not persisted")
}
select {
case decision := <-persisted:
require.Equal(t, rejectedHash, decision.swapHash)
require.False(t, decision.accepted)
case <-time.After(time.Second):
t.Fatal("rejected risk decision was not persisted")
}
}
// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an
// early risk notification is still cached if the swap row does not exist yet.
func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) {
t.Parallel()
swapHash := lntypes.Hash{0x1a, 0x1b}
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
_ lntypes.Hash, _ bool) error {
return errors.New("swap not stored yet")
},
})
mgr.handleNotification(
t.Context(), staticLoopInRiskAcceptedNotification(swapHash),
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk notification after persist failure")
}
}
// TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel verifies that
// a non-persisted risk decision remains replayable if the subscriber is canceled
// before the FSM has a chance to process it.
func TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t *testing.T) {
t.Parallel()
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t,
(*Manager).SubscribeStaticLoopInRiskAccepted,
staticLoopInRiskAcceptedNotification,
)
assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
t,
(*Manager).SubscribeStaticLoopInRiskRejected,
staticLoopInRiskRejectedNotification,
)
}
func assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel[
T staticLoopInRiskNotification](t *testing.T,
subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
notification func(lntypes.Hash) *swapserverrpc.
SubscribeNotificationsResponse) {
t.Helper()
swapHash := lntypes.Hash{0x2a, 0x2b}
mgr := NewManager(&Config{
PersistStaticLoopInRiskDecision: func(_ context.Context,
_ lntypes.Hash, _ bool) error {
return errors.New("swap not stored yet")
},
})
subCtx, subCancel := context.WithCancel(t.Context())
subChan := subscribe(mgr, subCtx, swapHash)
mgr.handleNotification(t.Context(), notification(swapHash))
require.Eventually(t, func() bool {
return len(subChan) == 1
}, time.Second, 10*time.Millisecond)
subCancel()
select {
case <-subChan:
case <-time.After(time.Second):
t.Fatal("risk decision notification was not delivered before " +
"cancel")
}
select {
case _, ok := <-subChan:
require.False(t, ok)
case <-time.After(time.Second):
t.Fatal("risk decision subscription did not close after cancel")
}
replayCtx, replayCancel := context.WithCancel(t.Context())
defer replayCancel()
replayChan := subscribe(mgr, replayCtx, swapHash)
select {
case received := <-replayChan:
require.Equal(t, swapHash[:], received.GetSwapHash())
case <-time.After(time.Second):
t.Fatal("cached risk decision was lost after subscriber " +
"cancellation")
}
}
// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) {
t.Parallel()
assertStaticLoopInRiskNotificationSwapScoped(
t, func(m *Manager, ctx context.Context,
swapHash lntypes.Hash) <-chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification {
return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash)
}, staticLoopInRiskAcceptedNotification, "accepted",
lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07},
)
}
// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
// replays a risk accepted notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
swapHash := lntypes.Hash{0x06, 0x07}
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
StaticLoopInRiskAccepted: &swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification{
SwapHash: swapHash[:],
},
},
},
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk accepted notification")
}
}
// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager
// forwards static loop in risk rejected notifications to subscribers.
func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
swapHash := lntypes.Hash{0x08, 0x09}
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
},
)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not receive risk rejected notification")
}
}
// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) {
t.Parallel()
assertStaticLoopInRiskNotificationSwapScoped(
t, func(m *Manager, ctx context.Context,
swapHash lntypes.Hash) <-chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification {
return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash)
}, staticLoopInRiskRejectedNotification, "rejected",
lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b},
)
}
// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager
// replays a risk rejected notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) {
t.Parallel()
mgr := NewManager(&Config{})
swapHash := lntypes.Hash{0x0a, 0x0b}
mgr.handleNotification(
t.Context(),
&swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.
SubscribeNotificationsResponse_StaticLoopInRiskRejected{
StaticLoopInRiskRejected: &swapserverrpc.
ServerStaticLoopInRiskRejectedNotification{
SwapHash: swapHash[:],
},
},
},
)
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)
select {
case received := <-subChan:
require.Equal(t, swapHash[:], received.SwapHash)
case <-time.After(time.Second):
t.Fatal("did not replay risk rejected notification")
}
}
// TestManager_Backoff verifies that repeated failures in
// subscribeNotifications cause the Manager to space out subscription attempts
// via a predictable incremental backoff.
func TestManager_Backoff(t *testing.T) {
t.Parallel()
// We'll tolerate a bit of jitter in the timing checks.
const tolerance = 300 * time.Millisecond
recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
recvErrChan := make(chan error)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: recvErrChan,
}
// Create a new mock client that will fail to subscribe.
mockClient := &mockNotificationsClient{
mockStream: mockStream,
subscribeErr: errors.New("failing on purpose"),
}
// Manager with a successful CurrentToken so that it always tries
// to subscribe.
mgr := NewManager(&Config{
Client: mockClient,
CurrentToken: func() (*l402.Token, error) {
// Simulate successful fetching of L402
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
// Run the manager in a background goroutine.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
2026-03-05 12:18:05 +01:00
wg.Go(func() {
// We ignore the returned error because the Manager returns
// nil on context cancel.
_ = mgr.Run(ctx)
2026-03-05 12:18:05 +01:00
})
// Wait long enough to see at least 3 subscription attempts using
// the Manager's default pattern.
// We'll wait ~5 seconds total so we capture at least 3 attempts:
// - Attempt #1: immediate
// - Attempt #2: ~1 second
// - Attempt #3: ~3 seconds after that etc.
time.Sleep(5 * time.Second)
// Cancel the context to stop the manager.
cancel()
wg.Wait()
// Check how many attempts we made.
require.GreaterOrEqual(t, len(mockClient.attemptTimes), 3,
"expected at least 3 attempts within 5 seconds",
)
expectedDelay := time.Second
for i := 1; i < len(mockClient.attemptTimes); i++ {
// The expected delay for the i-th gap (comparing attempt i to
// attempt i-1) is i seconds (because the manager increments
// the backoff by 1 second each time).
actualDelay := mockClient.attemptTimes[i].Sub(
mockClient.attemptTimes[i-1],
)
require.InDeltaf(
t, expectedDelay, actualDelay, float64(tolerance),
"Attempt %d -> Attempt %d delay should be ~%v, got %v",
i, i+1, expectedDelay, actualDelay,
)
expectedDelay += time.Second
}
}
// TestManager_MinAliveConnTime verifies that the Manager enforces the minimum
// alive connection time before considering a subscription successful.
func TestManager_MinAliveConnTime(t *testing.T) {
t.Parallel()
// Tolerance to allow for scheduling jitter.
const tolerance = 300 * time.Millisecond
// Set a small MinAliveConnTime so the test doesn't run too long.
// Once a subscription stays alive longer than 2s, the manager resets
// its backoff to 10s on the next loop iteration.
const minAlive = 1 * time.Second
// We'll provide a channel for incoming notifications
// and another for forcing errors to close the subscription.
recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
recvErrChan := make(chan error)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: recvErrChan,
}
// No immediate error from SubscribeNotifications, so it "succeeds".
// We trigger subscription closure by sending an error to recvErrChan.
mockClient := &mockNotificationsClient{
mockStream: mockStream,
// subscribeErr stays nil => success on each call.
}
// Create a Manager that uses our mock client and enforces
// MinAliveConnTime=2s.
mgr := NewManager(&Config{
Client: mockClient,
MinAliveConnTime: minAlive,
CurrentToken: func() (*l402.Token, error) {
// Simulate successful fetching of L402
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
2026-03-05 12:18:05 +01:00
wg.Go(func() {
_ = mgr.Run(ctx)
2026-03-05 12:18:05 +01:00
})
// Let the subscription stay alive for 2s, which is >1s (minAlive).
// Then force an error to end the subscription. The manager sees
// it stayed connected ~2s and resets its backoff to 10s.
go func() {
time.Sleep(2 * time.Second)
recvErrChan <- errors.New("mock subscription closed")
}()
// Wait enough time (~13s) to see:
// - First subscription (2s)
// - Manager resets to 10s
// - Second subscription attempt starts ~10s later.
time.Sleep(13 * time.Second)
// Signal EOF so the subscription stops.
close(recvChan)
// Stop the manager and wait for cleanup.
cancel()
wg.Wait()
// Expect at least 2 attempts in attemptTimes:
// 1) The one that stayed alive for 2s,
// 2) The next attempt ~10s after that.
require.GreaterOrEqual(
t, len(mockClient.attemptTimes), 2,
"expected at least 2 attempts with a successful subscription",
)
require.InDeltaf(
t, 12*time.Second,
mockClient.attemptTimes[1].Sub(mockClient.attemptTimes[0]),
float64(tolerance),
"Second attempt should occur ~2s after the first",
)
}
// TestManager_Backoff_Pending_Token verifies that the Manager backs off when
// the token is pending.
func TestManager_Backoff_Pending_Token(t *testing.T) {
t.Parallel()
// We'll tolerate a bit of jitter in the timing checks.
const tolerance = 300 * time.Millisecond
recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
recvErrChan := make(chan error)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: recvErrChan,
}
// Create a new mock client that will fail to subscribe.
mockClient := &mockNotificationsClient{
mockStream: mockStream,
// subscribeErr stays nil => would succeed on each call.
}
var tokenCalls []time.Time
// Manager with a successful CurrentToken so that it always tries
// to subscribe.
mgr := NewManager(&Config{
Client: mockClient,
CurrentToken: func() (*l402.Token, error) {
tokenCalls = append(tokenCalls, time.Now())
if len(tokenCalls) < 3 {
// Simulate a pending token.
return &l402.Token{}, nil
}
// Simulate successful fetching of L402
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
// Run the manager in a background goroutine.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
2026-03-05 12:18:05 +01:00
wg.Go(func() {
// We ignore the returned error because the Manager returns
// nil on context cancel.
_ = mgr.Run(ctx)
2026-03-05 12:18:05 +01:00
})
// Wait long enough to see at least 3 token calls, so we can see that
// we'll indeed backoff when the token is pending.
time.Sleep(5 * time.Second)
// Signal EOF so the subscription stops.
close(recvChan)
// Cancel the context to stop the manager.
cancel()
wg.Wait()
// Expect exactly 3 token calls.
2025-03-06 14:26:11 +01:00
require.Len(t, tokenCalls, 3)
require.InDeltaf(
t, 3*time.Second, tokenCalls[2].Sub(tokenCalls[0]),
float64(tolerance),
"Expected to backoff for at ~3 seconds due to pending token",
)
}
// TestManager_HtlcConfirmedNotification tests that the Manager correctly
// forwards htlc confirmed notifications to subscribers via the end-to-end
// subscription path.
func TestManager_HtlcConfirmedNotification(t *testing.T) {
t.Parallel()
// Create a mock notification client.
recvChan := make(
chan *swapserverrpc.SubscribeNotificationsResponse, 1,
)
errChan := make(chan error, 1)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: errChan,
}
mockClient := &mockNotificationsClient{
mockStream: mockStream,
}
mgr := NewManager(&Config{
Client: mockClient,
CurrentToken: func() (*l402.Token, error) {
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
// Subscribe to htlc confirmed notifications.
ctx := t.Context()
subCtx, subCancel := context.WithCancel(ctx)
defer subCancel()
subChan := mgr.SubscribeHtlcConfirmed(subCtx)
// Run the manager.
go func() {
_ = mgr.Run(ctx)
}()
// Wait for the manager to subscribe to the server stream.
require.Eventually(t, func() bool {
mockClient.Lock()
defer mockClient.Unlock()
return mockClient.timesCalled > 0
}, time.Second*5, 10*time.Millisecond)
// Send an htlc confirmed notification via the mock stream.
testSwapHash := []byte("test_hash_32_bytes_long_padding!")
testNtfn := &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed{ // nolint: lll
HtlcConfirmed: &swapserverrpc.ServerHtlcConfirmedNotification{ // nolint: lll
SwapHash: testSwapHash,
HtlcOutpoint: "abc123:0",
HtlcAddress: "tb1qexamplehtlcaddress",
SatPerVbyte: 25,
},
},
}
recvChan <- testNtfn
// Verify the subscriber receives it.
select {
case received := <-subChan:
require.NotNil(t, received)
require.Equal(t, testSwapHash, received.SwapHash)
require.Equal(t, "abc123:0", received.HtlcOutpoint)
require.Equal(t, "tb1qexamplehtlcaddress", received.HtlcAddress)
require.Equal(t, uint32(25), received.SatPerVbyte)
case <-time.After(5 * time.Second):
t.Fatal("did not receive htlc confirmed notification")
}
// Cancel the subscription and verify the channel closes.
subCancel()
require.Eventually(t, func() bool {
select {
case _, ok := <-subChan:
return !ok
default:
return false
}
}, time.Second*5, 10*time.Millisecond)
}
// TestManager_HtlcConfirmedNonBlocking tests that a slow htlc confirmed
// subscriber does not block the notification pipeline.
func TestManager_HtlcConfirmedNonBlocking(t *testing.T) {
t.Parallel()
synctest.Test(t, func(t *testing.T) {
recvChan := make(
chan *swapserverrpc.SubscribeNotificationsResponse, 1,
)
errChan := make(chan error, 1)
mockStream := &mockSubscribeNotificationsClient{
recvChan: recvChan,
recvErrChan: errChan,
}
mockClient := &mockNotificationsClient{
mockStream: mockStream,
}
mgr := NewManager(&Config{
Client: mockClient,
CurrentToken: func() (*l402.Token, error) {
return &l402.Token{
Preimage: lntypes.Preimage{1, 2, 3},
}, nil
},
})
// Subscribe but never read from the channel.
subCtx, subCancel := context.WithCancel(t.Context())
defer subCancel()
_ = mgr.SubscribeHtlcConfirmed(subCtx)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() {
_ = mgr.Run(ctx)
}()
// Wait for the manager to connect and block on stream Recv().
synctest.Wait()
mockClient.Lock()
require.Greater(t, mockClient.timesCalled, 0)
mockClient.Unlock()
// Also send a reservation notification to prove the pipeline
// is not blocked — it should still be dispatched once the
// timed htlc fanout drop has elapsed.
resChan := mgr.SubscribeReservations(subCtx)
// Send two notifications rapidly. The subscriber channel has
// buffer 1, so the first should be delivered and the second
// should be dropped after waiting for the timeout.
for i := range 2 {
ntfn := &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed{ // nolint: lll
HtlcConfirmed: &swapserverrpc.ServerHtlcConfirmedNotification{ // nolint: lll
SwapHash: fmt.Appendf(nil, "hash_%d_padding_to_32bytes!!", i), // nolint: lll
HtlcOutpoint: "abc:0",
HtlcAddress: "tb1qexamplehtlcaddress",
SatPerVbyte: 25,
},
},
}
recvChan <- ntfn
}
resNtfn := &swapserverrpc.SubscribeNotificationsResponse{
Notification: &swapserverrpc.SubscribeNotificationsResponse_ReservationNotification{ // nolint: lll
ReservationNotification: &swapserverrpc.ServerReservationNotification{ // nolint: lll
ReservationId: []byte("res1"),
},
},
}
recvChan <- resNtfn
select {
case res := <-resChan:
require.Equal(t, []byte("res1"), res.ReservationId)
case <-time.After(5 * htlcConfirmedSubscriberSendTimeout):
t.Fatal("reservation notification blocked by slow " +
"htlc confirmed subscriber")
}
// Stop the manager and ensure all goroutines in the test exit.
cancel()
close(recvChan)
synctest.Wait()
})
}