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.
This commit is contained in:
Slyghtning 2026-04-29 09:49:52 +02:00
parent 3df71f9a19
commit 3b29ef3629
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 95 additions and 19 deletions

View file

@ -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 {

View file

@ -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 {