From 3b29ef36291de4b779ef4f4daf91101f6fb0a76b Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 29 Apr 2026 09:49:52 +0200 Subject: [PATCH] staticaddr/deposit: replay startup block to recovered deposits The deposit manager consumes one block epoch before recovered deposit FSMs are started. That left already-expired recovered deposits idle until another block arrived. Remember the startup height and deliver it to active deposit FSMs after recovery and reconciliation have finished. Move the block notification fanout into a helper so startup replay and normal block handling use the same path. Add coverage that starts the manager with a recovered deposit at its expiry height and verifies the expiry sweep is signed and published immediately. --- staticaddr/deposit/manager.go | 59 ++++++++++++++++++++---------- staticaddr/deposit/manager_test.go | 55 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 22f97d23..5a5b8fc4 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -128,9 +128,11 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { return err } + var startupHeight uint32 select { case height := <-newBlockChan: - m.currentHeight.Store(uint32(height)) + startupHeight = uint32(height) + m.currentHeight.Store(startupHeight) case err = <-newBlockErrChan: return err @@ -154,6 +156,13 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { log.Errorf("unable to reconcile deposits: %v", err) } + // The startup height was consumed before recovered deposit FSMs existed. + // Replay it so already-expired recovered deposits can act immediately. + err = m.notifyActiveDeposits(ctx, startupHeight) + if err != nil { + return err + } + // Start the deposit notifier. m.pollDeposits(ctx) @@ -171,24 +180,9 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { log.Errorf("unable to reconcile deposits: %v", err) } - // Inform all active deposits about a new block arrival. - m.mu.Lock() - activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) - for _, fsm := range m.activeDeposits { - activeDeposits = append(activeDeposits, fsm) - } - m.mu.Unlock() - - for _, fsm := range activeDeposits { - select { - case fsm.blockNtfnChan <- uint32(height): - - case <-fsm.quitChan: - continue - - case <-ctx.Done(): - return ctx.Err() - } + err = m.notifyActiveDeposits(ctx, uint32(height)) + if err != nil { + return err } case outpoint := <-m.finalizedDepositChan: @@ -205,6 +199,33 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { } } +// notifyActiveDeposits informs all active deposit FSMs about a new block +// height. +func (m *Manager) notifyActiveDeposits(ctx context.Context, + height uint32) error { + + m.mu.Lock() + activeDeposits := make([]*FSM, 0, len(m.activeDeposits)) + for _, fsm := range m.activeDeposits { + activeDeposits = append(activeDeposits, fsm) + } + m.mu.Unlock() + + for _, fsm := range activeDeposits { + select { + case fsm.blockNtfnChan <- height: + + case <-fsm.quitChan: + continue + + case <-ctx.Done(): + return ctx.Err() + } + } + + return nil +} + // recoverDeposits recovers static address parameters, previous deposits and // state machines from the database and starts the deposit notifier. func (m *Manager) recoverDeposits(ctx context.Context) error { diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 8999fc77..1d70e3f4 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -346,6 +346,61 @@ func TestManager(t *testing.T) { } } +// TestManagerReplaysStartupBlockToRecoveredDeposits verifies that the initial +// block epoch consumed during startup is delivered to recovered deposit FSMs. +func TestManagerReplaysStartupBlockToRecoveredDeposits(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + const defaultTimeout = 30 * time.Second + + testContext := newManagerTestContext(t) + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + // Send only the startup block at the recovered deposit's expiry height. + testContext.blockChan <- int32( + defaultDepositConfirmations + defaultExpiry, + ) + + select { + case <-initChan: + + case err := <-runErrChan: + require.NoError(t, err, "manager failed to start") + + case <-time.After(defaultTimeout): + t.Fatal("manager timed out starting") + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive sign request") + } + + select { + case <-testContext.mockLnd.TxPublishChannel: + + case <-time.After(defaultTimeout): + t.Fatal("did not receive published expiry tx") + } + + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(defaultTimeout): + t.Fatal("manager did not stop") + } +} + // ManagerTestContext is a helper struct that contains all the necessary // components to test the reservation manager. type ManagerTestContext struct {