From 814af6aadf3ed69d4212707bf4fd73346c5157a9 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Thu, 2 Jul 2026 07:49:23 +0200 Subject: [PATCH] staticaddr/loopin: keep htlc monitor resumable After the client gives the server HTLC signatures, shutdown must not drive the monitor state through the generic error path. That path cancels the invoice and attempts to unlock deposits even though the server can still publish the HTLC. Return NoOp for monitor-state cancellation races and cover shutdown with a regression test that asserts no invoice cancellation or deposit unlock occurs. --- staticaddr/loopin/actions.go | 43 +++++++++++++-- staticaddr/loopin/actions_test.go | 91 +++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/staticaddr/loopin/actions.go b/staticaddr/loopin/actions.go index a30a60c3..9851e8bf 100644 --- a/staticaddr/loopin/actions.go +++ b/staticaddr/loopin/actions.go @@ -565,6 +565,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, subscribeCtx, f.loopIn.SwapHash, ) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to subscribe to swap "+ "invoice: %w", err) @@ -592,6 +596,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfChan, htlcErrConfChan, err := registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to monitor htlc tx confirmation: %w", err) @@ -602,15 +610,23 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, registerBlocks := f.cfg.ChainNotifier.RegisterBlockEpochNtfn blockChan, blockChanErr, err := registerBlocks(ctx) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to subscribe to new blocks: %w", err) return f.HandleError(err) } - htlcConfirmed := false - + // Look up the current invoice state after registering subscriptions so + // recovery can resume the payment deadline from the latest known state. invoice, err := f.cfg.LndClient.LookupInvoice(ctx, f.loopIn.SwapHash) if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to look up invoice by swap hash: %w", err) @@ -625,8 +641,8 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, 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. + // after a restart, we cancel the invoice immediately and keep + // monitoring the HTLC until it can no longer confirm. remainingTimeSeconds := f.loopIn.RemainingPaymentTimeSeconds() // If the invoice isn't cancelled yet and the payment timeout @@ -658,6 +674,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.cancelSwapInvoice() } + htlcConfirmed := false for { select { case <-htlcConfChan: @@ -666,6 +683,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfirmed = true case err = <-htlcErrConfChan: + if ctx.Err() != nil { + return fsm.NoOp + } + f.Errorf("htlc tx conf chan error, re-registering: "+ "%v", err) @@ -676,6 +697,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, // Re-register for htlc confirmation. htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to re-register for "+ "htlc tx confirmation: %w", err) @@ -690,6 +715,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, htlcConfChan, htlcErrConfChan, err = registerHtlcConf() if err != nil { + if ctx.Err() != nil { + return fsm.NoOp + } + err = fmt.Errorf("unable to monitor htlc tx "+ "confirmation: %v", err) @@ -761,6 +790,10 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, return OnSweepHtlcTimeout case err = <-blockChanErr: + if ctx.Err() != nil { + return fsm.NoOp + } + f.Errorf("block subscription error: %v", err) return f.HandleError(err) @@ -784,7 +817,7 @@ func (f *FSM) MonitorInvoiceAndHtlcTxAction(ctx context.Context, f.Errorf("invoice subscription error: %v", err) case <-ctx.Done(): - return f.HandleError(ctx.Err()) + return fsm.NoOp } } } diff --git a/staticaddr/loopin/actions_test.go b/staticaddr/loopin/actions_test.go index 6fb61459..e2e24c51 100644 --- a/staticaddr/loopin/actions_test.go +++ b/staticaddr/loopin/actions_test.go @@ -207,6 +207,97 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) { } } +// TestMonitorInvoiceAndHtlcTxNoOpOnShutdown ensures that a shutdown while the +// client is monitoring an HTLC-signed loop-in keeps the swap resumable instead +// of entering the generic unlock path. +func TestMonitorInvoiceAndHtlcTxNoOpOnShutdown(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + runCtx, stop := context.WithCancel(ctx) + + 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} + 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, + } + loopIn.SetState(MonitorInvoiceAndHtlcTx) + + mockLnd.Invoices[swapHash] = &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(runCtx, loopIn, cfg, false) + require.NoError(t, err) + + resultChan := make(chan fsm.EventType, 1) + go func() { + resultChan <- f.MonitorInvoiceAndHtlcTxAction(runCtx, nil) + }() + + 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()) + } + + stop() + + select { + case event := <-resultChan: + require.Equal(t, fsm.NoOp, event) + + case <-ctx.Done(): + t.Fatalf("monitor action did not exit: %v", ctx.Err()) + } + + require.Nil(t, f.LastActionError) + require.Empty(t, depositMgr.transitions) + + select { + case hash := <-mockLnd.FailInvoiceChannel: + t.Fatalf("invoice canceled on shutdown: %v", hash) + + default: + } +} + // TestSweepHtlcTimeoutActionNoOpOnShutdown ensures that a shutdown during // timeout sweep publication keeps the FSM in the same state so it can resume // after restart.