diff --git a/notifications/manager.go b/notifications/manager.go index 2ff6b087..ed408195 100644 --- a/notifications/manager.go +++ b/notifications/manager.go @@ -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. diff --git a/notifications/manager_test.go b/notifications/manager_test.go index 1768b004..b165c494 100644 --- a/notifications/manager_test.go +++ b/notifications/manager_test.go @@ -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. diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index 9be268d3..4b2d23e3 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -1,6 +1,7 @@ package loopin import ( + "bytes" "context" "crypto/rand" "errors" @@ -389,6 +390,114 @@ func (f *FSM) handleInvoiceUpdate(update lndclient.InvoiceUpdate) ( } } +// selectedDepositConfirmationHeights returns current confirmation heights for +// the original deposit outpoints selected by this loop-in. +func selectedDepositConfirmationHeights( + loopIn *StaticAddressLoopIn) map[string]int64 { + + confirmations := make(map[string]int64, len(loopIn.Deposits)) + outpoints := make(map[string]struct{}, len(loopIn.DepositOutpoints)) + for _, outpoint := range loopIn.DepositOutpoints { + outpoints[outpoint] = struct{}{} + } + + for _, d := range loopIn.Deposits { + if d == nil { + continue + } + + outpoint := d.OutPoint.String() + confirmationHeight := d.GetConfirmationHeight() + + if _, ok := outpoints[outpoint]; !ok { + continue + } + + confirmations[outpoint] = confirmationHeight + } + + return confirmations +} + +// refreshSelectedDeposits reloads the loop-in's selected deposits from the +// deposit manager/store so recovery does not rely on stale deposit snapshots. +func (f *FSM) refreshSelectedDeposits(ctx context.Context) error { + if f.cfg.DepositManager == nil || len(f.loopIn.DepositOutpoints) == 0 { + return nil + } + + const ignoreUnknownOutpoints = false + deposits, err := f.cfg.DepositManager.DepositsForOutpoints( + ctx, f.loopIn.DepositOutpoints, ignoreUnknownOutpoints, + ) + if err != nil { + return err + } + + if len(deposits) != len(f.loopIn.DepositOutpoints) { + return fmt.Errorf("expected %d selected deposits, got %d", + len(f.loopIn.DepositOutpoints), len(deposits)) + } + + f.loopIn.Deposits = deposits + + return nil +} + +// legacyMinConfsReached returns true once every original deposit is confirmed +// and the youngest original deposit has reached the legacy confirmation target. +func legacyMinConfsReached(outpoints []string, + confirmationHeights map[string]int64, currentHeight int32) bool { + + if currentHeight <= 0 || len(outpoints) == 0 { + return false + } + + youngestConfirmation := int64(0) + for _, outpoint := range outpoints { + confirmationHeight, ok := confirmationHeights[outpoint] + if !ok || confirmationHeight <= 0 { + return false + } + + if confirmationHeight > youngestConfirmation { + youngestConfirmation = confirmationHeight + } + } + + return int64(currentHeight) >= youngestConfirmation+deposit.MinConfs-1 +} + +// shouldStartLegacyConfirmationFallback reports whether the local MinConfs +// payment deadline fallback should be armed at the current block height. +// +// The primary path starts the deadline from a server risk-accepted notification. +// This fallback preserves the legacy client-side MinConfs behavior when no risk +// decision has been observed locally: once every original deposit reaches +// MinConfs, the client treats that as enough confirmation-risk clearance to +// start the payment window. The selected deposits are refreshed first so +// recovered swaps do not depend on stale in-memory deposit snapshots. +func (f *FSM) shouldStartLegacyConfirmationFallback(ctx context.Context, + currentHeight int32) bool { + + err := f.refreshSelectedDeposits(ctx) + if err != nil { + f.Warnf("unable to refresh selected deposits for legacy "+ + "confirmation fallback: %v", err) + + return false + } + + depositConfirmationHeights := selectedDepositConfirmationHeights( + f.loopIn, + ) + + return legacyMinConfsReached( + f.loopIn.DepositOutpoints, depositConfirmationHeights, + currentHeight, + ) +} + // originalDepositOutpointUnavailable checks the original selected deposit // outpoints against the chain backend's UTXO view. func (f *FSM) originalDepositOutpointUnavailable(ctx context.Context) ( @@ -653,7 +762,28 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } + var ( + riskAcceptedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification + riskRejectedChan <-chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification + cancelRiskNotificationSubscriptions = func() {} + ) + if f.cfg.NotificationManager != nil { + notificationCtx, cancel := context.WithCancel(ctx) + cancelRiskNotificationSubscriptions = cancel + riskAcceptedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskAccepted( + notificationCtx, f.loopIn.SwapHash, + ) + riskRejectedChan = f.cfg.NotificationManager. + SubscribeStaticLoopInRiskRejected( + notificationCtx, f.loopIn.SwapHash, + ) + } + defer cancelRiskNotificationSubscriptions() htlcConfirmed := false + depositsUnlocked := false invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) if err != nil { @@ -663,30 +793,34 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return f.HandleError(err) } - // Create the swap payment timeout timer. If it runs out we cancel the - // invoice, but keep monitoring the htlc confirmation. - // If the invoice was canceled, e.g. before a restart, we don't need to - // set a new deadline. - var deadlineChan <-chan time.Time - if invoice.State != invoices.ContractCanceled { - // If the invoice is still live we set the timeout to the - // remaining payment time. If too much time has elapsed, e.g. - // after a restart, we set the timeout to 0 to cancel the - // invoice and unlock the deposits immediately. - remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds() + // Create the swap payment timeout timer after the server confirms + // confirmation risk was accepted. If a server does not support risk + // notifications, fall back after the legacy deposit confirmation depth. + var ( + deadlineChan <-chan time.Time + deadlineTimer *time.Timer + deadlineStarted bool + ) + defer func() { + if deadlineTimer != nil { + deadlineTimer.Stop() + } + }() - // If the invoice isn't cancelled yet and the payment timeout - // elapsed, we set the timeout to 0 to cancel the invoice and - // unlock the deposits immediately. Otherwise, we start the - // timer with the remaining seconds to timeout. - timeout := time.Duration(0) * time.Second - if remainingTimeSeconds > 0 { - timeout = time.Duration(remainingTimeSeconds) * - time.Second + startPaymentDeadline := func(reason string) { + if deadlineStarted || invoice.State == invoices.ContractCanceled { + return } - deadlineChan = time.NewTimer(timeout).C - } else { + timeout := f.loopIn.PaymentTimeoutDuration() + + f.Infof("starting payment deadline after %s", reason) + deadlineTimer = time.NewTimer(timeout) + deadlineChan = deadlineTimer.C + deadlineStarted = true + } + + if invoice.State == invoices.ContractCanceled { // If the invoice was canceled previously we end our // subscription to invoice updates. cancelInvoiceSubscription() @@ -743,6 +877,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, } case <-deadlineChan: + deadlineChan = nil + // If the server didn't pay the invoice on time, we // cancel the invoice and keep monitoring the htlc tx // confirmation. We also need to unlock the deposits to @@ -753,9 +889,80 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if err != nil { f.Errorf("unable to unlock deposits after "+ "payment deadline: %v", err) + continue + } + depositsUnlocked = true + + case riskAccepted, ok := <-riskAcceptedChan: + if !ok { + riskAcceptedChan = nil + continue } + if !bytes.Equal( + riskAccepted.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + startPaymentDeadline("risk accepted notification") + + case riskRejected, ok := <-riskRejectedChan: + if !ok { + riskRejectedChan = nil + continue + } + + if !bytes.Equal( + riskRejected.SwapHash, f.loopIn.SwapHash[:], + ) { + + continue + } + + cancelInvoiceSubscription() + f.cancelSwapInvoice() + + return f.HandleError(errors.New( + "server rejected confirmation risk wait", + )) + case currentHeight := <-blockChan: + if !deadlineStarted && + invoice.State != invoices.ContractCanceled { + + err = f.refreshSelectedDeposits(ctx) + if err != nil { + f.Warnf("unable to refresh selected "+ + "deposits for legacy confirmation "+ + "fallback: %v", err) + } else { + depositConfirmationHeights := + selectedDepositConfirmationHeights( + f.loopIn, + ) + + if legacyMinConfsReached( + f.loopIn.DepositOutpoints, + depositConfirmationHeights, + currentHeight, + ) { + + // This fallback is a compatibility path for + // servers that do not send confirmation-risk + // notifications. Reaching legacy MinConfs is + // treated as synthetic risk acceptance, so the + // payment window starts here just as it would + // when a modern server sends an acceptance + // notification. + startPaymentDeadline( + "legacy confirmation fallback", + ) + } + } + } + // If the htlc is confirmed but blockChan fires before // htlcConfChan, we would wrongfully assume that the // htlc tx was not confirmed which would lead to @@ -781,13 +988,13 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, if !htlcConfirmed { f.Infof("swap timed out, htlc not confirmed") - // If the htlc hasn't confirmed but the timeout - // path opened up, and we didn't receive the - // swap payment, we consider the swap attempt to - // be failed. We cancelled the invoice, but - // don't need to unlock the deposits because - // that happened when the payment deadline was - // reached. + if !depositsUnlocked { + err = f.unlockDeposits(ctx) + if err != nil { + return f.HandleError(err) + } + } + return OnSwapTimedOut } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index d3d2b979..d8ecd9ad 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -270,6 +270,961 @@ func testValidateLoopInContract(_ int32, _ int32) error { return nil } +// TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted verifies that the +// payment timeout does not start until the server notifies us that confirmation +// risk was accepted. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineOnRiskAccepted(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{4, 5, 6} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{7}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc. + ServerStaticLoopInRiskAcceptedNotification, 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before risk acceptance: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + notificationMgr.riskAccepted <- &swapserverrpc.ServerStaticLoopInRiskAcceptedNotification{ + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after risk acceptance: %v", + hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.OnError, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected verifies that a server-side +// confirmation risk rejection is terminal for the client monitor action. +func TestMonitorInvoiceAndHtlcTxCancelsOnRiskRejected(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 6, 7} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskRejected: make( + chan *swapserverrpc. + ServerStaticLoopInRiskRejectedNotification, 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + notificationMgr.riskRejected <- &swapserverrpc.ServerStaticLoopInRiskRejectedNotification{ // nolint: lll + SwapHash: swapHash[:], + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes +// verifies that once the monitor state is reached, a missing original deposit +// outpoint does not cancel the invoice. After HTLC signatures are handed to the +// server, the outpoint can disappear because the server published the expected +// HTLC transaction. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelWhenOriginalOutpointVanishes( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{5, 7, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + txOutChecker := &testTxOutChecker{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: txOutChecker, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } + + require.Empty(t, txOutChecker.outpoints) + require.Empty(t, txOutChecker.includeMempool) +} + +// TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint +// verifies that the outpoint-vanished fallback is only active before payment +// has started. Once the invoice is accepted, the original deposit may disappear +// because the server has moved forward with the swap. +func TestMonitorInvoiceAndHtlcTxDoesNotCancelAcceptedInvoiceForMissingOutpoint( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{6, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{11}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractAccepted, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{}, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + TxOutChecker: &testTxOutChecker{}, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice should not have been canceled: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case <-resultChan: + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs verifies that the +// monitor action preserves the legacy payment deadline fallback when no risk +// decision has been observed locally. +func TestMonitorInvoiceAndHtlcTxStartsDeadlineAtLegacyMinConfs(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 9} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{8}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{depositRecord}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before deposit confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager +// verifies that the legacy payment deadline fallback still applies when the +// notification manager is configured but no risk decision has been observed. +func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackWithNotificationManager( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 10} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 0, + } + depositRecord := &deposit.Deposit{ + OutPoint: depositOutpoint, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{depositRecord}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + notificationMgr := &mockNotificationManager{ + riskAccepted: make( + chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification, + 1, + ), + } + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{depositRecord}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + NotificationManager: notificationMgr, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + depositRecord.Lock() + depositRecord.ConfirmationHeight = confirmationHeight + depositRecord.Unlock() + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before payment deadline: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight verifies that +// recovery can arm the legacy payment deadline without waiting for a later +// block notification. +func TestMonitorInvoiceAndHtlcTxStartsLegacyFallbackAtCurrentHeight( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 12} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{13}, + Index: 0, + } + staleDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 0, + } + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + freshDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: confirmationHeight, + } + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{staleDeposit}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: &silentBlockChainNotifier{ + ChainNotifierClient: mockLnd.ChainNotifier, + }, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{freshDeposit}, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled before payment deadline: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback verifies that a +// recovered monitor state does not rely on stale selected-deposit snapshots when +// deciding whether the legacy payment deadline fallback has opened. +func TestMonitorInvoiceAndHtlcTxRefreshesDepositsForLegacyFallback(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{7, 8, 11} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{12}, + Index: 0, + } + staleDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: 0, + } + confirmationHeight := int64(mockLnd.Height) - deposit.MinConfs + 1 + freshDeposit := &deposit.Deposit{ + OutPoint: depositOutpoint, + ConfirmationHeight: confirmationHeight, + } + type depositLookup struct { + outpoints []string + ignoreUnknown bool + } + depositLookups := make(chan depositLookup, 1) + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: 2_000, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now().Add(-time.Hour), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 1, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{staleDeposit}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: &noopDepositManager{ + deposits: []*deposit.Deposit{freshDeposit}, + depositsForOutpoints: func(outpoints []string, + ignoreUnknown bool) { + + select { + case depositLookups <- depositLookup{ + outpoints: append( + []string(nil), outpoints..., + ), + ignoreUnknown: ignoreUnknown, + }: + default: + } + }, + }, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled immediately after deposit "+ + "confirmation: %v", hash) + + case <-time.After(200 * time.Millisecond): + } + + select { + case lookup := <-depositLookups: + require.Equal(t, []string{ + depositOutpoint.String(), + }, lookup.outpoints) + require.False(t, lookup.ignoreUnknown) + + case <-ctx.Done(): + t.Fatalf("deposit refresh was not called: %v", ctx.Err()) + } + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + cancel() + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-time.After(time.Second): + t.Fatal("monitor action did not exit") + } +} + +// TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline verifies that +// deposits are unlocked even if the payment deadline never started before the +// HTLC timeout path opened. +func TestMonitorInvoiceAndHtlcTxUnlocksOnHtlcTimeoutWithoutDeadline( + t *testing.T) { + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + mockLnd := test.NewMockLnd() + + clientKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + serverKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + swapHash := lntypes.Hash{10, 11, 12} + depositOutpoint := wire.OutPoint{ + Hash: chainhash.Hash{10}, + Index: 0, + } + + loopIn := &StaticAddressLoopIn{ + SwapHash: swapHash, + HtlcCltvExpiry: mockLnd.Height, + InitiationHeight: uint32(mockLnd.Height), + InitiationTime: time.Now(), + ProtocolVersion: version.ProtocolVersion_V0, + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + PaymentTimeoutSeconds: 3_600, + DepositOutpoints: []string{ + depositOutpoint.String(), + }, + Deposits: []*deposit.Deposit{{ + OutPoint: depositOutpoint, + }}, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.SetInvoice(&lndclient.Invoice{ + Hash: swapHash, + State: invoices.ContractOpen, + }) + + depositMgr := &recordingDepositManager{} + cfg := &Config{ + AddressManager: &mockAddressManager{ + params: &script.Parameters{ + ClientPubkey: clientKey.PubKey(), + ServerPubkey: serverKey.PubKey(), + ProtocolVersion: version.ProtocolVersion_V0, + }, + }, + ChainNotifier: mockLnd.ChainNotifier, + DepositManager: depositMgr, + InvoicesClient: mockLnd.LndServices.Invoices, + LndClient: mockLnd.Client, + ChainParams: mockLnd.ChainParams, + } + + f, err := NewFSM(ctx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil) + }() + + waitForMonitorSubscriptions(t, ctx, mockLnd) + + require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1)) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + require.Equal(t, swapHash, hash) + + case <-ctx.Done(): + t.Fatalf("invoice was not canceled: %v", ctx.Err()) + } + + select { + case event := <-resultChan: + require.Equal(t, OnSwapTimedOut, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Equal(t, []fsm.EventType{fsm.OnError}, depositMgr.events) + require.Equal(t, []fsm.StateType{deposit.Deposited}, depositMgr.states) +} + +// waitForMonitorSubscriptions waits until invoice and HTLC watchers are active. +func waitForMonitorSubscriptions(t *testing.T, ctx context.Context, + mockLnd *test.LndMockServices) { + + t.Helper() + + select { + case <-mockLnd.SingleInvoiceSubcribeChannel: + case <-ctx.Done(): + t.Fatalf("invoice subscription not registered: %v", ctx.Err()) + } + + select { + case <-mockLnd.RegisterConfChannel: + case <-ctx.Done(): + t.Fatalf("htlc conf registration not received: %v", ctx.Err()) + } +} + // TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a // present txout does not trigger the RBF cancellation path. func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut(t *testing.T) { @@ -604,7 +1559,11 @@ func (m *mockAddressManager) GetStaticAddress(_ context.Context) ( } // noopDepositManager is a stub DepositManager used to satisfy FSM config. -type noopDepositManager struct{} +type noopDepositManager struct { + deposits []*deposit.Deposit + depositsForOutpoints func([]string, bool) + depositErr error +} // EnsureDepositsFresh implements DepositManager with a no-op. func (n *noopDepositManager) EnsureDepositsFresh(context.Context) error { @@ -633,10 +1592,14 @@ func (n *noopDepositManager) TransitionDeposits(context.Context, } // DepositsForOutpoints implements DepositManager with a no-op. -func (n *noopDepositManager) DepositsForOutpoints(context.Context, []string, - bool) ([]*deposit.Deposit, error) { +func (n *noopDepositManager) DepositsForOutpoints(_ context.Context, + outpoints []string, ignoreUnknown bool) ([]*deposit.Deposit, error) { - return nil, nil + if n.depositsForOutpoints != nil { + n.depositsForOutpoints(outpoints, ignoreUnknown) + } + + return n.deposits, n.depositErr } // GetActiveDepositsInState implements DepositManager with a no-op. @@ -657,6 +1620,9 @@ type recordingDepositManager struct { err error transitions []depositTransition + + events []fsm.EventType + states []fsm.StateType } // TransitionDeposits records the transition and returns the configured error. @@ -669,10 +1635,55 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context, event: event, state: state, }) + r.events = append(r.events, event) + r.states = append(r.states, state) return r.err } +// mockNotificationManager allows tests to push server notifications directly to +// monitor actions. +type mockNotificationManager struct { + riskAccepted chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + riskRejected chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification +} + +type silentBlockChainNotifier struct { + lndclient.ChainNotifierClient +} + +// RegisterBlockEpochNtfn implements ChainNotifierClient without delivering an +// initial block. Tests use it to assert current-height recovery behavior without +// relying on a block notification. +func (s *silentBlockChainNotifier) RegisterBlockEpochNtfn(context.Context) ( + chan int32, chan error, error) { + + return make(chan int32), make(chan error), nil +} + +// SubscribeStaticLoopInSweepRequests implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInSweepRequests( + context.Context) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification { + + return make(chan *swapserverrpc.ServerStaticLoopInSweepNotification) +} + +// SubscribeStaticLoopInRiskAccepted implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskAccepted( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification { + + return m.riskAccepted +} + +// SubscribeStaticLoopInRiskRejected implements NotificationManager. +func (m *mockNotificationManager) SubscribeStaticLoopInRiskRejected( + context.Context, lntypes.Hash, +) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification { + + return m.riskRejected +} + type testTxOutChecker struct { txOut *wire.TxOut err error diff --git a/staticaddr/loopin/interface.go b/staticaddr/loopin/interface.go index ec46b982..f2637885 100644 --- a/staticaddr/loopin/interface.go +++ b/staticaddr/loopin/interface.go @@ -125,4 +125,18 @@ type NotificationManager interface { // a sweep of a static loop in that has been finished. SubscribeStaticLoopInSweepRequests(ctx context.Context, ) <-chan *swapserverrpc.ServerStaticLoopInSweepNotification + + // SubscribeStaticLoopInRiskAccepted subscribes to static loop in risk + // accepted notifications. These are sent by the server after the selected + // deposits are accepted by confirmation risk tracking. + SubscribeStaticLoopInRiskAccepted( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskAcceptedNotification + + // SubscribeStaticLoopInRiskRejected subscribes to static loop in risk + // rejected notifications. These are sent by the server if it aborts the + // confirmation risk wait before payment. + SubscribeStaticLoopInRiskRejected( + ctx context.Context, swapHash lntypes.Hash, + ) <-chan *swapserverrpc.ServerStaticLoopInRiskRejectedNotification } diff --git a/staticaddr/loopin/loopin.go b/staticaddr/loopin/loopin.go index 8cf27e32..6c1860bd 100644 --- a/staticaddr/loopin/loopin.go +++ b/staticaddr/loopin/loopin.go @@ -467,12 +467,28 @@ func (l *StaticAddressLoopIn) TotalDepositAmount() btcutil.Amount { // RemainingPaymentTimeSeconds returns the remaining time in seconds until the // payment timeout is reached. The remaining time is calculated from the -// initiation time of the swap. If more than the swaps configured payment +// initiation time of the swap. If more than the swap's configured payment // timeout has passed, the remaining time will be negative. func (l *StaticAddressLoopIn) RemainingPaymentTimeSeconds() int64 { elapsedSinceInitiation := time.Since(l.InitiationTime).Seconds() - return int64(l.PaymentTimeoutSeconds) - int64(elapsedSinceInitiation) + return l.paymentTimeoutSeconds() - int64(elapsedSinceInitiation) +} + +// PaymentTimeoutDuration returns the configured payment timeout duration, +// falling back to the default if the swap predates the persisted timeout field. +func (l *StaticAddressLoopIn) PaymentTimeoutDuration() time.Duration { + return time.Duration(l.paymentTimeoutSeconds()) * time.Second +} + +// paymentTimeoutSeconds returns the configured timeout in seconds. +func (l *StaticAddressLoopIn) paymentTimeoutSeconds() int64 { + timeoutSeconds := int64(l.PaymentTimeoutSeconds) + if timeoutSeconds == 0 { + timeoutSeconds = int64(DefaultPaymentTimeoutSeconds) + } + + return timeoutSeconds } // Outpoints returns the wire outpoints of the deposits.