loop/notifications/manager.go

779 lines
21 KiB
Go
Raw Normal View History

package notifications
import (
"context"
"sync"
"time"
"github.com/lightninglabs/aperture/l402"
"github.com/lightninglabs/loop/swapserverrpc"
"github.com/lightningnetwork/lnd/lntypes"
"google.golang.org/grpc"
)
// NotificationType is the type of notification that the manager can handle.
type NotificationType int
const (
// NotificationTypeUnknown is the default notification type.
NotificationTypeUnknown NotificationType = iota
// NotificationTypeReservation is the notification type for reservation
// notifications.
NotificationTypeReservation
// NotificationTypeStaticLoopInSweepRequest is the notification type for
// static loop in sweep requests.
NotificationTypeStaticLoopInSweepRequest
2025-10-30 17:55:09 +01:00
// 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
2025-10-30 17:55:09 +01:00
// NotificationTypeUnfinishedSwap is the notification type for unfinished
// swap notifications.
NotificationTypeUnfinishedSwap
// NotificationTypeHtlcConfirmed is the notification type for HTLC
// confirmed notifications.
NotificationTypeHtlcConfirmed
)
const (
// defaultMinAliveConnTime is the default minimum time that the
// connection to the server needs to be alive before we consider it a
// successful connection.
defaultMinAliveConnTime = time.Minute
2025-10-30 17:56:14 +01:00
// htlcConfirmedSubscriberSendTimeout is how long we wait for a busy
// htlc-confirmed subscriber before dropping the notification.
htlcConfirmedSubscriberSendTimeout = 200 * time.Millisecond
// defaultMaxQueuedNotifications is the default number of notifications
// we queue per subscriber before dropping new notifications.
defaultMaxQueuedNotifications = 1024
2025-10-30 17:56:14 +01:00
// current_version is the current version of the notification listener.
current_version = swapserverrpc.SubscribeNotificationsRequest_V1
)
// Client is the interface that the notification manager needs to implement in
// order to be able to subscribe to notifications.
type Client interface {
// SubscribeNotifications subscribes to the notifications from the server.
SubscribeNotifications(ctx context.Context,
in *swapserverrpc.SubscribeNotificationsRequest,
opts ...grpc.CallOption) (
swapserverrpc.SwapServer_SubscribeNotificationsClient, error)
}
// Config contains all the services that the notification manager needs to
// operate.
type Config struct {
// Client is the client used to communicate with the swap server.
Client Client
// CurrentToken returns the token that is currently contained in the
// store or an l402.ErrNoToken error if there is none.
CurrentToken func() (*l402.Token, error)
// MinAliveConnTime is the minimum time that the connection to the
// server needs to be alive before we consider it a successful.
MinAliveConnTime time.Duration
// MaxQueuedNotifications is the maximum number of notifications that
// can wait in each subscriber's delivery queue.
MaxQueuedNotifications int
// PersistStaticLoopInRiskDecision durably records static loop-in
// confirmation-risk decisions. If this fails, the notification is still
// cached and forwarded so a later subscriber can process it after the swap
// row exists.
PersistStaticLoopInRiskDecision func(context.Context, lntypes.Hash,
bool) error
}
// Manager is a manager for notifications that the swap server sends to the
// client.
type Manager struct {
sync.Mutex
cfg *Config
hasL402 bool
// subscribers holds active notification subscribers by notification
// type. It is guarded by the Manager mutex.
subscribers map[NotificationType][]subscriber
// staticLoopInRiskAccepted caches accepted risk decisions by swap hash
// so a later matching subscriber can receive a previously delivered
// server decision.
staticLoopInRiskAccepted map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification
// staticLoopInRiskRejected caches rejected risk decisions by swap hash
// so a later matching subscriber can receive a previously delivered
// server decision.
staticLoopInRiskRejected map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification
// staticLoopInRiskPersisted records whether the cached risk decision for
// a swap hash was durably persisted. Unpersisted decisions remain cached
// after subscriber cancellation so they can be replayed.
staticLoopInRiskPersisted map[lntypes.Hash]bool
}
// NewManager creates a new notification manager.
func NewManager(cfg *Config) *Manager {
// Set the default minimum alive connection time if it's not set.
if cfg.MinAliveConnTime == 0 {
cfg.MinAliveConnTime = defaultMinAliveConnTime
}
if cfg.MaxQueuedNotifications <= 0 {
cfg.MaxQueuedNotifications = defaultMaxQueuedNotifications
}
return &Manager{
cfg: cfg,
subscribers: make(map[NotificationType][]subscriber),
staticLoopInRiskAccepted: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification,
),
staticLoopInRiskRejected: make(
map[lntypes.Hash]*swapserverrpc.
ServerStaticLoopInRiskRejectedNotification,
),
staticLoopInRiskPersisted: make(map[lntypes.Hash]bool),
}
}
type subscriber struct {
subCtx context.Context
2026-03-05 12:18:05 +01:00
recvChan any
swapHash *lntypes.Hash
enqueue func(any)
}
// newNotificationQueue creates a per-subscriber FIFO delivery function.
func newNotificationQueue[T any](ctx context.Context,
recvChan chan T, maxPending int) func(any) {
type queue struct {
sync.Mutex
pending []T
notify chan struct{}
closed bool
}
q := &queue{
notify: make(chan struct{}, 1),
}
closeQueue := func() {
q.Lock()
q.closed = true
q.pending = nil
q.Unlock()
}
go func() {
defer close(recvChan)
defer closeQueue()
for {
select {
case <-ctx.Done():
return
default:
}
q.Lock()
if len(q.pending) == 0 {
q.Unlock()
select {
case <-q.notify:
continue
case <-ctx.Done():
return
}
}
ntfn := q.pending[0]
var zero T
q.pending[0] = zero
q.pending = q.pending[1:]
q.Unlock()
select {
case recvChan <- ntfn:
case <-ctx.Done():
return
}
}
}()
return func(ntfn any) {
typedNtfn, ok := ntfn.(T)
if !ok {
log.Warnf("unexpected notification type %T", ntfn)
return
}
q.Lock()
if q.closed {
q.Unlock()
return
}
if len(q.pending) >= maxPending {
q.Unlock()
log.Warnf("dropping notification for slow subscriber: "+
"queue depth %d reached", maxPending)
return
}
q.pending = append(q.pending, typedNtfn)
q.Unlock()
select {
case q.notify <- struct{}{}:
default:
}
}
}
// queueNotification queues or synchronously sends a must-deliver notification.
func queueNotification[T any](sub subscriber, recvChan chan T, ntfn T) {
if sub.enqueue != nil {
sub.enqueue(ntfn)
return
}
log.Warnf("subscriber has no notification queue, falling back to " +
"blocking send")
select {
case recvChan <- ntfn:
case <-sub.subCtx.Done():
}
}
// dropNotification sends a best-effort notification to a subscriber.
func dropNotification[T any](sub subscriber, recvChan chan T, ntfn T,
description string) {
select {
case recvChan <- ntfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping %s notification for slow subscriber",
description)
}
}
// SubscribeReservations subscribes to the reservation notifications.
func (m *Manager) SubscribeReservations(ctx context.Context,
) <-chan *swapserverrpc.ServerReservationNotification {
notifChan := make(chan *swapserverrpc.ServerReservationNotification, 1)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
}
m.addSubscriber(NotificationTypeReservation, sub)
2025-10-30 17:55:09 +01:00
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeReservation, sub)
close(notifChan)
2025-10-30 17:55:09 +01:00
})
return notifChan
}
// SubscribeStaticLoopInSweepRequests subscribes to the static loop in sweep
// requests.
func (m *Manager) SubscribeStaticLoopInSweepRequests(ctx context.Context,
) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification {
notifChan := make(
chan *swapserverrpc.ServerStaticLoopInSweepNotification, 1,
)
2025-10-30 17:55:09 +01:00
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
enqueue: newNotificationQueue(
ctx, notifChan, m.cfg.MaxQueuedNotifications,
),
}
m.addSubscriber(NotificationTypeStaticLoopInSweepRequest, sub)
2025-10-30 17:55:09 +01:00
context.AfterFunc(ctx, func() {
m.removeSubscriber(
2025-10-30 17:55:09 +01:00
NotificationTypeStaticLoopInSweepRequest,
sub,
)
2025-10-30 17:55:09 +01:00
})
return notifChan
}
func subscribeStaticLoopInRiskDecision[T any](m *Manager, ctx context.Context,
swapHash lntypes.Hash, notifType NotificationType,
notifications map[lntypes.Hash]T) <-chan T {
notifChan := make(chan T, 1)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
swapHash: &swapHash,
}
m.Lock()
m.subscribers[notifType] = append(m.subscribers[notifType], sub)
if ntfn, ok := notifications[swapHash]; ok {
notifChan <- ntfn
if m.staticLoopInRiskPersisted[swapHash] {
delete(notifications, swapHash)
delete(m.staticLoopInRiskPersisted, swapHash)
}
}
m.Unlock()
context.AfterFunc(ctx, func() {
m.removeSubscriber(notifType, sub)
m.Lock()
if _, ok := notifications[swapHash]; ok &&
m.staticLoopInRiskPersisted[swapHash] {
delete(notifications, swapHash)
delete(m.staticLoopInRiskPersisted, swapHash)
}
m.Unlock()
close(notifChan)
})
return notifChan
}
// SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk accepted
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskAccepted(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification {
return subscribeStaticLoopInRiskDecision(
m, ctx, swapHash, NotificationTypeStaticLoopInRiskAccepted,
m.staticLoopInRiskAccepted,
)
}
// SubscribeStaticLoopInRiskRejected subscribes to static loop in risk rejected
// notifications.
func (m *Manager) SubscribeStaticLoopInRiskRejected(ctx context.Context,
swapHash lntypes.Hash,
) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification {
return subscribeStaticLoopInRiskDecision(
m, ctx, swapHash, NotificationTypeStaticLoopInRiskRejected,
m.staticLoopInRiskRejected,
)
}
2025-10-30 17:55:09 +01:00
// SubscribeUnfinishedSwaps subscribes to the unfinished swap notifications.
func (m *Manager) SubscribeUnfinishedSwaps(ctx context.Context,
) <-chan *swapserverrpc.ServerUnfinishedSwapNotification {
notifChan := make(
chan *swapserverrpc.ServerUnfinishedSwapNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
enqueue: newNotificationQueue(
ctx, notifChan, m.cfg.MaxQueuedNotifications,
),
2025-10-30 17:55:09 +01:00
}
m.addSubscriber(NotificationTypeUnfinishedSwap, sub)
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeUnfinishedSwap, sub)
})
return notifChan
}
// SubscribeHtlcConfirmed subscribes to the HTLC confirmed notifications.
func (m *Manager) SubscribeHtlcConfirmed(ctx context.Context,
) <-chan *swapserverrpc.ServerHtlcConfirmedNotification {
notifChan := make(
chan *swapserverrpc.ServerHtlcConfirmedNotification, 1,
)
sub := subscriber{
subCtx: ctx,
recvChan: notifChan,
}
m.addSubscriber(NotificationTypeHtlcConfirmed, sub)
context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeHtlcConfirmed, sub)
close(notifChan)
})
return notifChan
}
// Run starts the notification manager. It will keep on running until the
// context is canceled. It will subscribe to notifications and forward them to
// the subscribers. On a first successful connection to the server, it will
// close the readyChan to signal that the manager is ready.
func (m *Manager) Run(ctx context.Context) error {
// Initially we want to immediately try to connect to the server.
var (
waitTime time.Duration
backoff time.Duration
attempts int
timer = time.NewTimer(0)
)
// Start the notification runloop.
for {
// Increase the wait time for the next iteration.
backoff = waitTime + time.Duration(attempts)*time.Second
waitTime = 0
// Reset the timer with the new backoff time.
timer.Reset(backoff)
// Return if the context has been canceled.
select {
case <-ctx.Done():
return nil
case <-timer.C:
}
// In order to create a valid l402 we first are going to call
// the FetchL402 method. As a client might not have outbound
// capacity yet, we'll retry until we get a valid response.
if !m.hasL402 {
token, err := m.cfg.CurrentToken()
if err != nil {
// We only log the error if it's not the case
// that we don't have a token yet to avoid
// spamming the logs.
if err != l402.ErrNoToken {
log.Errorf("Error getting L402 from "+
"the store: %v", err)
}
// Use a default of 1 second wait time to avoid
// hogging the CPU.
waitTime = time.Second
continue
}
// If the preimage is empty, we don't have a valid L402
// yet so we'll continue to retry with the incremental
// backoff.
emptyPreimage := lntypes.Preimage{}
if token.Preimage == emptyPreimage {
attempts++
continue
}
attempts = 0
m.hasL402 = true
}
connectAttempted := time.Now()
err := m.subscribeNotifications(ctx)
if err != nil {
log.Errorf("Error subscribing to notifications: %v",
err)
}
connectionAliveTime := time.Since(connectAttempted)
// Note that we may be able to connet to the stream but not
// able to use it if the client is unable to pay for their
// L402. In this case the subscription will fail on the first
// read immediately after connecting. We'll therefore only
// consider the connection successful if we were able to use
// the stream for at least the minimum alive connection time
// (which defaults to 1 minute).
if connectionAliveTime > m.cfg.MinAliveConnTime {
// Reset the backoff to 10 seconds and the connect
// attempts to zero if we were really connected for a
// considerable amount of time (1 minute).
waitTime = time.Second * 10
attempts = 0
} else {
// We either failed to connect or the stream
// disconnected immediately, so we just increase the
// backoff.
attempts++
}
}
}
// subscribeNotifications subscribes to the notifications from the server.
func (m *Manager) subscribeNotifications(ctx context.Context) error {
callCtx, cancel := context.WithCancel(ctx)
defer cancel()
notifStream, err := m.cfg.Client.SubscribeNotifications(
2025-10-30 17:56:14 +01:00
callCtx, &swapserverrpc.SubscribeNotificationsRequest{
Version: current_version,
},
)
if err != nil {
return err
}
log.Debugf("Successfully subscribed to server notifications")
for {
notification, err := notifStream.Recv()
if err == nil && notification != nil {
log.Tracef("Received notification: %v", notification)
m.handleNotification(ctx, notification)
continue
}
log.Errorf("Error receiving notification: %v", err)
return err
}
}
// staticLoopInRiskDecisionName returns the log label for a risk decision.
func staticLoopInRiskDecisionName(accepted bool) string {
if accepted {
return "accepted"
}
return "rejected"
}
// handleStaticLoopInRiskDecision persists, caches, and forwards a risk
// decision notification to the matching subscriber.
func (m *Manager) handleStaticLoopInRiskDecision(ctx context.Context,
swapHashBytes []byte, accepted bool, notifType NotificationType,
cacheDecision func(lntypes.Hash, bool),
notifySubscriber func(subscriber)) {
decision := staticLoopInRiskDecisionName(accepted)
persisted := m.cfg.PersistStaticLoopInRiskDecision == nil
var (
swapHash lntypes.Hash
hasSwapHash bool
)
if swapHashBytes != nil {
hash, err := lntypes.MakeHash(swapHashBytes)
if err != nil {
log.Warnf("Received invalid static loop in risk "+
"%s notification: %v", decision, err)
} else {
swapHash = hash
hasSwapHash = true
}
}
if hasSwapHash && m.cfg.PersistStaticLoopInRiskDecision != nil {
err := m.cfg.PersistStaticLoopInRiskDecision(
ctx, swapHash, accepted,
)
if err != nil {
log.Errorf("Unable to persist static loop in risk "+
"%s notification: %v", decision, err)
} else {
persisted = true
}
}
m.Lock()
defer m.Unlock()
if hasSwapHash {
cacheDecision(swapHash, persisted)
}
for _, sub := range m.subscribers[notifType] {
if !hasSwapHash || sub.swapHash == nil ||
*sub.swapHash != swapHash {
continue
}
notifySubscriber(sub)
}
}
// handleNotification handles an incoming notification from the server,
// forwarding it to the appropriate subscribers.
func (m *Manager) handleNotification(ctx context.Context, ntfn *swapserverrpc.
SubscribeNotificationsResponse) {
switch ntfn.Notification.(type) {
case *swapserverrpc.SubscribeNotificationsResponse_ReservationNotification: // nolint: lll
// We'll forward the reservation notification to all subscribers.
reservationNtfn := ntfn.GetReservationNotification()
m.Lock()
defer m.Unlock()
for _, sub := range m.subscribers[NotificationTypeReservation] {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerReservationNotification)
select {
case recvChan <- reservationNtfn:
case <-sub.subCtx.Done():
default:
log.Debugf("Dropping reservation " +
"notification for slow subscriber")
}
}
case *swapserverrpc.SubscribeNotificationsResponse_StaticLoopInSweep: // nolint: lll
// We'll forward the static loop in sweep request to all
// subscribers.
staticLoopInSweepRequestNtfn := ntfn.GetStaticLoopInSweep()
m.Lock()
defer m.Unlock()
for _, sub := range m.subscribers[NotificationTypeStaticLoopInSweepRequest] { // nolint: lll
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInSweepNotification)
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()
var swapHashBytes []byte
if riskAcceptedNtfn != nil {
swapHashBytes = riskAcceptedNtfn.SwapHash
}
m.handleStaticLoopInRiskDecision(
ctx, swapHashBytes, true,
NotificationTypeStaticLoopInRiskAccepted,
func(swapHash lntypes.Hash, persisted bool) {
m.staticLoopInRiskAccepted[swapHash] =
riskAcceptedNtfn
m.staticLoopInRiskPersisted[swapHash] = persisted
delete(m.staticLoopInRiskRejected, swapHash)
},
func(sub subscriber) {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskAcceptedNotification)
dropNotification(
sub, recvChan, riskAcceptedNtfn,
"static loop in risk accepted",
)
},
)
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()
var swapHashBytes []byte
if riskRejectedNtfn != nil {
swapHashBytes = riskRejectedNtfn.SwapHash
}
m.handleStaticLoopInRiskDecision(
ctx, swapHashBytes, false,
NotificationTypeStaticLoopInRiskRejected,
func(swapHash lntypes.Hash, persisted bool) {
m.staticLoopInRiskRejected[swapHash] =
riskRejectedNtfn
m.staticLoopInRiskPersisted[swapHash] = persisted
delete(m.staticLoopInRiskAccepted, swapHash)
},
func(sub subscriber) {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerStaticLoopInRiskRejectedNotification)
dropNotification(
sub, recvChan, riskRejectedNtfn,
"static loop in risk rejected",
)
},
)
2025-10-30 17:55:09 +01:00
case *swapserverrpc.SubscribeNotificationsResponse_UnfinishedSwap: // nolint: lll
// We'll forward the unfinished swap notification to all
// subscribers.
unfinishedSwapNtfn := ntfn.GetUnfinishedSwap()
m.Lock()
defer m.Unlock()
for _, sub := range m.subscribers[NotificationTypeUnfinishedSwap] {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerUnfinishedSwapNotification)
queueNotification(sub, recvChan, unfinishedSwapNtfn)
2025-10-30 17:55:09 +01:00
}
case *swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed:
// We'll forward the htlc confirmed notification to all
// subscribers. We wait briefly for a slow subscriber and
// then drop to avoid stalling the notification pipeline.
htlcConfirmedNtfn := ntfn.GetHtlcConfirmed()
m.Lock()
defer m.Unlock()
subscribers := m.subscribers[NotificationTypeHtlcConfirmed]
for _, sub := range subscribers {
recvChan := sub.recvChan.(chan *swapserverrpc.
ServerHtlcConfirmedNotification)
select {
case recvChan <- htlcConfirmedNtfn:
case <-time.After(htlcConfirmedSubscriberSendTimeout):
log.Infof("Dropping htlc confirmed " +
"notification, subscriber busy")
}
}
default:
log.Debugf("Received unknown notification type: %v", ntfn)
}
}
// addSubscriber adds a subscriber to the manager.
func (m *Manager) addSubscriber(notifType NotificationType, sub subscriber) {
m.Lock()
defer m.Unlock()
m.subscribers[notifType] = append(m.subscribers[notifType], sub)
}
// removeSubscriber removes a subscriber from the manager.
func (m *Manager) removeSubscriber(notifType NotificationType, sub subscriber) {
m.Lock()
defer m.Unlock()
subs := m.subscribers[notifType]
newSubs := make([]subscriber, 0, len(subs))
for _, s := range subs {
if s.recvChan != sub.recvChan {
newSubs = append(newSubs, s)
}
}
m.subscribers[notifType] = newSubs
}