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.
This commit is contained in:
Slyghtning 2026-07-08 14:13:21 +02:00
parent 1abe617991
commit ac12d251f5
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
2 changed files with 175 additions and 0 deletions

View file

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

View file

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