notifications: fan out static loop-in risk decisions

Add cached per-swap notification fanout for static loop-in
confirmation-risk acceptance and rejection notifications so loop-in
FSMs can subscribe by swap hash and receive decisions that arrived
before subscription.
This commit is contained in:
Slyghtning 2026-07-08 13:56:11 +02:00
parent ef78c85e88
commit da96842264
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 439 additions and 0 deletions

View file

@ -26,6 +26,14 @@ const (
// static loop in sweep requests.
NotificationTypeStaticLoopInSweepRequest
// NotificationTypeStaticLoopInRiskAccepted is the notification type for
// static loop in confirmation risk acceptance.
NotificationTypeStaticLoopInRiskAccepted
// NotificationTypeStaticLoopInRiskRejected is the notification type for
// static loop in confirmation risk rejection.
NotificationTypeStaticLoopInRiskRejected
// NotificationTypeUnfinishedSwap is the notification type for unfinished
// swap notifications.
NotificationTypeUnfinishedSwap
@ -92,6 +100,12 @@ type Manager struct {
hasL402 bool
subscribers map[NotificationType][]subscriber
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification
}
// NewManager creates a new notification manager.
@ -107,6 +121,14 @@ func NewManager(cfg *Config) *Manager {
return &Manager{
cfg: cfg,
subscribers: make(map[NotificationType][]subscriber),
staticLoopInRiskAccepted: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification,
),
staticLoopInRiskRejected: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification,
),
}
}
@ -114,6 +136,7 @@ type subscriber struct {
subCtx context.Context
recvChan any
enqueue func(any)
swapHash *lntypes.Hash
}
// newNotificationQueue creates a per-subscriber FIFO delivery function.
@ -271,6 +294,80 @@ func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
return notifChan
}
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
notifChan := make(
chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[NotificationTypeStaticLoopInRiskAccepted] = append(
m.subscribers[NotificationTypeStaticLoopInRiskAccepted], sub,
)
if ntfn, ok := m.staticLoopInRiskAccepted[swapHash]; ok {
notifChan <- ntfn
delete(m.staticLoopInRiskAccepted, swapHash)
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
m.Lock()
delete(m.staticLoopInRiskAccepted, swapHash)
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
notifChan := make(
chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[NotificationTypeStaticLoopInRiskRejected] = append(
m.subscribers[NotificationTypeStaticLoopInRiskRejected], sub,
)
if ntfn, ok := m.staticLoopInRiskRejected[swapHash]; ok {
notifChan <- ntfn
delete(m.staticLoopInRiskRejected, swapHash)
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeStaticLoopInRiskRejected, sub)
m.Lock()
delete(m.staticLoopInRiskRejected, swapHash)
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
@ -476,6 +573,94 @@ func (m *Manager) handleNotification(ntfn *swapserverrpc.
queueNotification(sub, recvChan, staticLoopInSweepRequestNtfn)
}
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskAccepted: // nolint: lll
// We'll forward the static loop in risk accepted notification to the
// subscriber for the matching swap.
riskAcceptedNtfn := ntfn.GetStaticLoopInRiskAccepted()
m.Lock()
defer m.Unlock()
var (
swapHash lntypes.Hash
hasSwapHash bool
)
if riskAcceptedNtfn != nil {
hash, err := lntypes.MakeHash(riskAcceptedNtfn.SwapHash)
if err != nil {
log.Warnf("Received invalid static loop in risk "+
"accepted notification: %v", err)
} else {
swapHash = hash
hasSwapHash = true
m.staticLoopInRiskAccepted[hash] =
riskAcceptedNtfn
delete(m.staticLoopInRiskRejected, hash)
}
}
for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskAccepted] { // nolint: lll
if !hasSwapHash || sub.swapHash == nil ||
*sub.swapHash != swapHash {
continue
}
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification)
select {
case recvChan <- riskAcceptedNtfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping static loop in risk " +
"accepted notification for slow subscriber")
}
}
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInRiskRejected: // nolint: lll
// We'll forward the static loop in risk rejected notification to the
// subscriber for the matching swap.
riskRejectedNtfn := ntfn.GetStaticLoopInRiskRejected()
m.Lock()
defer m.Unlock()
var (
swapHash lntypes.Hash
hasSwapHash bool
)
if riskRejectedNtfn != nil {
hash, err := lntypes.MakeHash(riskRejectedNtfn.SwapHash)
if err != nil {
log.Warnf("Received invalid static loop in risk "+
"rejected notification: %v", err)
} else {
swapHash = hash
hasSwapHash = true
m.staticLoopInRiskRejected[hash] =
riskRejectedNtfn
delete(m.staticLoopInRiskAccepted, hash)
}
}
for _, sub := range m.subscribers[NotificationTypeStaticLoopInRiskRejected] { // nolint: lll
if !hasSwapHash || sub.swapHash == nil ||
*sub.swapHash != swapHash {
continue
}
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification)
select {
case recvChan <- riskRejectedNtfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping static loop in risk " +
"rejected notification for slow subscriber")
}
}
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
// We'll forward the unfinished swap notification to all
// subscribers.

View file

@ -220,6 +220,88 @@ func staticLoopInSweepNotification(
}
}
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(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(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
@ -460,6 +542,178 @@ func assertQueuedSwapHashNotifications[T any](t *testing.T,
}
}
// 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(
&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_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(
&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(
&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(
&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.