From ac12d251f501c2c00e8d69231aa7d93711febbd1 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 8 Jul 2026 14:13:21 +0200 Subject: [PATCH] staticaddr/deposit: replay startup block after recovery The first block epoch is consumed before recovered deposit FSMs exist. Replay that startup height after recovery so already-expired deposits can run expiry handling immediately after restart. --- staticaddr/deposit/manager.go | 8 ++ staticaddr/deposit/manager_test.go | 167 +++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 4d892f7e..6c37af8e 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -136,6 +136,14 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { err = m.reconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) + } else { + // The startup height was consumed before recovered deposit FSMs + // existed. Replay it so already-expired recovered deposits can act + // immediately, but only after their wallet view is fresh. + err = m.notifyActiveDeposits(ctx, startupHeight) + if err != nil { + return err + } } // Start the deposit notifier. diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 880e286c..78663f9a 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -3,6 +3,7 @@ package deposit import ( "context" "encoding/hex" + "errors" "testing" "time" @@ -141,6 +142,22 @@ func (m *mockAddressManager) ListUnspent(ctx context.Context, args.Error(1) } +// listUnspentOverride delegates all address-manager methods except +// ListUnspent to another implementation. +type listUnspentOverride struct { + AddressManager + + listUnspent func(context.Context, int32, int32) ([]*lnwallet.Utxo, + error) +} + +// ListUnspent calls the override's ListUnspent implementation. +func (l *listUnspentOverride) ListUnspent(ctx context.Context, + minConfs, maxConfs int32) ([]*lnwallet.Utxo, error) { + + return l.listUnspent(ctx, minConfs, maxConfs) +} + func (m *mockAddressManager) GetTaprootAddress(clientPubkey, serverPubkey *btcec.PublicKey, expiry int64) (*btcutil.AddressTaproot, error) { @@ -314,6 +331,156 @@ 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") + } +} + +// TestManagerSkipsExpiryNotificationOnReconcileFailure verifies that deposit +// FSMs cannot make an expiry decision from stale confirmation data when wallet +// reconciliation fails at startup or while processing a later block. +func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { + testCases := []struct { + name string + startupHeight int32 + blockHeight int32 + }{ + { + name: "startup", + startupHeight: int32( + defaultDepositConfirmations + defaultExpiry, + ), + }, + { + name: "block", + startupHeight: int32(defaultDepositConfirmations), + blockHeight: int32( + defaultDepositConfirmations + defaultExpiry, + ), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + testContext := newManagerTestContext(t) + baseAddressManager := testContext.mockAddressManager + var listUnspentCalls int + testContext.manager.cfg.AddressManager = + &listUnspentOverride{ + AddressManager: baseAddressManager, + listUnspent: func(ctx context.Context, + minConfs, maxConfs int32) ( + []*lnwallet.Utxo, error) { + + listUnspentCalls++ + if testCase.blockHeight != 0 && + listUnspentCalls == 1 { + + return baseAddressManager.ListUnspent( + ctx, minConfs, maxConfs, + ) + } + + return nil, errors.New( + "injected reconciliation failure", + ) + }, + } + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + testContext.blockChan <- testCase.startupHeight + + select { + case <-initChan: + + case err := <-runErrChan: + require.NoError(t, err, "manager failed to start") + + case <-time.After(time.Second): + t.Fatal("manager timed out starting") + } + + if testCase.blockHeight != 0 { + testContext.blockChan <- testCase.blockHeight + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + t.Fatal("expiry sweep signed with stale deposit data") + + case <-time.After(200 * time.Millisecond): + } + + cancel() + select { + case err := <-runErrChan: + require.ErrorIs(t, err, context.Canceled) + + case <-time.After(time.Second): + 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 {