From b0d0e4c656a5af3f519b103b512d3e4cde1230ae Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 31 Aug 2026 12:07:36 +0200 Subject: [PATCH] staticaddr/deposit: preserve confirmation heights Bracket wallet UTXO queries with matching chain-synced lnd tips before converting confirmation counts into absolute heights. Defer expiry and spending decisions while the wallet is catching up, and cap queued expiry notifications at the reconciled tip. --- docs/release-notes/release-notes-next.md | 4 + loopd/daemon.go | 11 +- loopd/swapclient_server_staticaddr_test.go | 18 +- staticaddr/deposit/interface.go | 7 + staticaddr/deposit/manager.go | 167 ++++++++++--- staticaddr/deposit/manager_height_test.go | 81 +++++++ staticaddr/deposit/manager_reconcile_test.go | 237 ++++++++++++++++--- staticaddr/deposit/manager_test.go | 85 +++++++ 8 files changed, 539 insertions(+), 71 deletions(-) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 896af8058..6d4ca7cdb 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -16,6 +16,10 @@ #### Bug Fixes +* Static Address deposit reconciliation now preserves authoritative + first-confirmation heights while lnd is catching up, preventing premature + expiry decisions from mismatched wallet and block-notification heights. + * `loopd --version` inside the official Docker images now reports the commit it was built from instead of an empty string. diff --git a/loopd/daemon.go b/loopd/daemon.go index 319709d84..2db880272 100644 --- a/loopd/daemon.go +++ b/loopd/daemon.go @@ -654,11 +654,12 @@ func (d *Daemon) initialize(withMacaroonService bool) error { // Static address deposit manager setup. depositStore := deposit.NewSqlStore(baseDb) depoCfg := &deposit.ManagerConfig{ - AddressManager: staticAddressManager, - Store: depositStore, - WalletKit: d.lnd.WalletKit, - ChainNotifier: d.lnd.ChainNotifier, - Signer: d.lnd.Signer, + LightningClient: d.lnd.Client, + AddressManager: staticAddressManager, + Store: depositStore, + WalletKit: d.lnd.WalletKit, + ChainNotifier: d.lnd.ChainNotifier, + Signer: d.lnd.Signer, } depositManager = deposit.NewManager(depoCfg) diff --git a/loopd/swapclient_server_staticaddr_test.go b/loopd/swapclient_server_staticaddr_test.go index bb4cc01ca..8b1dd311b 100644 --- a/loopd/swapclient_server_staticaddr_test.go +++ b/loopd/swapclient_server_staticaddr_test.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" @@ -18,6 +19,20 @@ import ( "github.com/stretchr/testify/require" ) +type staticAddrTestLightningClient struct { + lndclient.LightningClient +} + +func (c *staticAddrTestLightningClient) GetInfo(context.Context) ( + *lndclient.Info, error) { + + return &lndclient.Info{ + BlockHeight: 1, + BestBlockHash: chainhash.Hash{1}, + SyncedToChain: true, + }, nil +} + type staticAddrDepositStore struct { allDeposits []*deposit.Deposit byOutpoint map[string]*deposit.Deposit @@ -99,7 +114,8 @@ func newTestDepositManager( } return deposit.NewManager(&deposit.ManagerConfig{ - AddressManager: &staticAddrTestAddressManager{}, + LightningClient: &staticAddrTestLightningClient{}, + AddressManager: &staticAddrTestAddressManager{}, Store: &staticAddrDepositStore{ allDeposits: deposits, byOutpoint: byOutpoint, diff --git a/staticaddr/deposit/interface.go b/staticaddr/deposit/interface.go index 8606c7e60..3bdc0e617 100644 --- a/staticaddr/deposit/interface.go +++ b/staticaddr/deposit/interface.go @@ -5,10 +5,17 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightningnetwork/lnd/lnwallet" ) +// LightningClient exposes the lnd chain information required to reconcile +// wallet confirmation counts against an authoritative height. +type LightningClient interface { + GetInfo(ctx context.Context) (*lndclient.Info, error) +} + const ( IdLength = 32 ) diff --git a/staticaddr/deposit/manager.go b/staticaddr/deposit/manager.go index 61fc8e769..2ffe0d136 100644 --- a/staticaddr/deposit/manager.go +++ b/staticaddr/deposit/manager.go @@ -35,8 +35,20 @@ const ( PollInterval = 10 * time.Second ) +var ( + // ErrConfirmationSnapshotUnavailable is returned to spending paths when + // lnd's wallet view cannot be tied to a stable, chain-synced tip. + ErrConfirmationSnapshotUnavailable = errors.New( + "authoritative deposit confirmation snapshot unavailable", + ) +) + // ManagerConfig holds the configuration for the address manager. type ManagerConfig struct { + // LightningClient is used to obtain a chain-synced wallet height for + // confirmation-height reconciliation. + LightningClient LightningClient + // AddressManager is the address manager that is used to fetch static // address parameters. AddressManager AddressManager @@ -133,14 +145,19 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { // Reconcile immediately on startup so deposits are available // before the first ticker fires. - err = m.reconcileDeposits(ctx) + confirmationTipHeight, err := m.reconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) - } else { + } else if confirmationTipHeight != 0 { // 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) + // immediately, but only after their wallet view and confirmation + // heights are fresh. + err = m.notifyActiveDeposits( + ctx, expiryNotificationHeight( + startupHeight, confirmationTipHeight, + ), + ) if err != nil { return err } @@ -158,13 +175,21 @@ func (m *Manager) Run(ctx context.Context, initChan chan struct{}) error { case height := <-newBlockChan: m.currentHeight.Store(uint32(height)) - err := m.reconcileDeposits(ctx) + confirmationTipHeight, err := + m.reconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile deposits: %v", err) continue } + if confirmationTipHeight == 0 { + continue + } - err = m.notifyActiveDeposits(ctx, uint32(height)) + err = m.notifyActiveDeposits( + ctx, expiryNotificationHeight( + uint32(height), confirmationTipHeight, + ), + ) if err != nil { return err } @@ -271,7 +296,7 @@ func (m *Manager) pollDeposits(ctx context.Context) { for { select { case <-ticker.C: - err := m.reconcileDeposits(ctx) + _, err := m.reconcileDeposits(ctx) if err != nil { log.Errorf("unable to reconcile "+ "deposits: %v", err) @@ -290,69 +315,140 @@ func (m *Manager) pollDeposits(ctx context.Context) { // an unconfirmed funding transaction is replaced, a confirmed deposit is // reorged out, or the output was spent outside the active manager path. func (m *Manager) EnsureDepositsFresh(ctx context.Context) error { - return m.reconcileDeposits(ctx) + confirmationTipHeight, err := m.reconcileDeposits(ctx) + if err != nil { + return err + } + knownHeight := m.currentHeight.Load() + if confirmationTipHeight == 0 || + confirmationTipHeight < knownHeight { + + return ErrConfirmationSnapshotUnavailable + } + + return nil } // reconcileDeposits fetches all spends to our static addresses from our lnd // wallet and matches it against the deposits in our memory that we've seen so // far. It picks the newly identified deposits and starts a state machine per -// deposit to track its progress. -func (m *Manager) reconcileDeposits(ctx context.Context) error { +// deposit to track its progress. The returned height is the stable, +// chain-synced lnd tip that bracketed the wallet query. A zero height means +// confirmed deposit heights and expiry decisions must be deferred. +func (m *Manager) reconcileDeposits(ctx context.Context) (uint32, error) { m.reconcileMu.Lock() defer m.reconcileMu.Unlock() log.Tracef("Reconciling new deposits...") + if m.cfg.LightningClient == nil { + return 0, errors.New("lightning client unavailable") + } + + before, err := m.cfg.LightningClient.GetInfo(ctx) + if err != nil { + return 0, fmt.Errorf("unable to get lnd info before listing "+ + "deposits: %w", err) + } + utxos, err := m.cfg.AddressManager.ListUnspent( ctx, 0, MaxConfs, ) if err != nil { - return fmt.Errorf("unable to list new deposits: %w", err) + return 0, fmt.Errorf("unable to list new deposits: %w", err) + } + + after, err := m.cfg.LightningClient.GetInfo(ctx) + if err != nil { + return 0, fmt.Errorf("unable to get lnd info after listing "+ + "deposits: %w", err) + } + + confirmationTipHeight := stableConfirmationTip(before, after) + if confirmationTipHeight == 0 { + log.Debugf("Deferring confirmed deposit heights while lnd's " + + "wallet tip is changing or unsynced") } - currentHeight := m.currentHeight.Load() - err = m.updateDepositConfirmations(ctx, utxos, currentHeight) + err = m.updateDepositConfirmations( + ctx, utxos, confirmationTipHeight, + ) if err != nil { - return fmt.Errorf("unable to update deposit "+ + return 0, fmt.Errorf("unable to update deposit "+ "confirmations: %w", err) } err = m.syncActiveDeposits(ctx, utxos) if err != nil { - return fmt.Errorf("unable to sync active deposits: %w", err) + return 0, fmt.Errorf("unable to sync active deposits: %w", err) } newDeposits := m.filterNewDeposits(utxos) if len(newDeposits) == 0 { log.Tracef("No new deposits...") - return nil + return confirmationTipHeight, nil } for _, utxo := range newDeposits { - deposit, err := m.createNewDeposit(ctx, utxo, currentHeight) + // A confirmed deposit must not be represented as unconfirmed just + // because its absolute confirmation height is not yet known. Defer + // retaining it until lnd provides an authoritative snapshot. Actual + // mempool deposits continue to be retained immediately. + if utxo.Confirmations > 0 && confirmationTipHeight == 0 { + continue + } + + deposit, err := m.createNewDeposit( + ctx, utxo, confirmationTipHeight, + ) if err != nil { - return fmt.Errorf("unable to retain new deposit: %w", + return 0, fmt.Errorf("unable to retain new deposit: %w", err) } log.Debugf("Received deposit: %v", deposit) err = m.startDepositFsm(ctx, deposit) if err != nil { - return fmt.Errorf("unable to start new deposit FSM: %w", + return 0, fmt.Errorf("unable to start new deposit FSM: %w", err) } } - return nil + return confirmationTipHeight, nil +} + +// stableConfirmationTip returns the lnd height that brackets a wallet query +// only when both observations describe the same chain-synced tip. This keeps a +// queued block epoch from being combined with a newer wallet confirmation +// count while lnd is catching up. +func stableConfirmationTip(before, after *lndclient.Info) uint32 { + if before == nil || after == nil || !before.SyncedToChain || + !after.SyncedToChain || before.BlockHeight == 0 || + before.BlockHeight != after.BlockHeight || + before.BestBlockHash != after.BestBlockHash { + + return 0 + } + + return after.BlockHeight +} + +// expiryNotificationHeight caps a queued block epoch at the stable lnd tip +// that bracketed deposit reconciliation. This prevents a stale pre-reorg epoch +// from advancing expiry beyond lnd's current authoritative chain view. +func expiryNotificationHeight(blockEpochHeight, + confirmationTipHeight uint32) uint32 { + + return min(blockEpochHeight, confirmationTipHeight) } // createNewDeposit transforms the wallet utxo into a deposit struct and stores // it in our database and manager memory. func (m *Manager) createNewDeposit(ctx context.Context, - utxo *lnwallet.Utxo, currentHeight uint32) (*Deposit, error) { + utxo *lnwallet.Utxo, confirmationTipHeight uint32) (*Deposit, error) { confirmationHeight, err := confirmationHeightForUtxo( - currentHeight, utxo, + confirmationTipHeight, utxo, ) if err != nil { return nil, err @@ -398,23 +494,24 @@ func (m *Manager) createNewDeposit(ctx context.Context, } // confirmationHeightForUtxo derives the first confirmation height of a wallet -// UTXO from the manager's current block height. Unconfirmed UTXOs return 0. -func confirmationHeightForUtxo(currentHeight uint32, +// UTXO from a stable lnd wallet-tip height. Unconfirmed UTXOs return 0. +func confirmationHeightForUtxo(confirmationTipHeight uint32, utxo *lnwallet.Utxo) (int64, error) { if utxo.Confirmations <= 0 { return 0, nil } - if currentHeight == 0 { - return 0, errors.New("current block height unavailable") + if confirmationTipHeight == 0 { + return 0, errors.New("confirmation tip height unavailable") } - firstConfirmationHeight := int64(currentHeight) - utxo.Confirmations + 1 + firstConfirmationHeight := int64(confirmationTipHeight) - + utxo.Confirmations + 1 if firstConfirmationHeight <= 0 { return 0, fmt.Errorf("invalid confirmation height %d for %v "+ - "with current height %d and %d confirmations", - firstConfirmationHeight, utxo.OutPoint, currentHeight, + "with wallet tip height %d and %d confirmations", + firstConfirmationHeight, utxo.OutPoint, confirmationTipHeight, utxo.Confirmations) } @@ -424,7 +521,7 @@ func confirmationHeightForUtxo(currentHeight uint32, // updateDepositConfirmations syncs first confirmation heights for deposits that // are visible in lnd's wallet view. func (m *Manager) updateDepositConfirmations(ctx context.Context, - utxos []*lnwallet.Utxo, currentHeight uint32) error { + utxos []*lnwallet.Utxo, confirmationTipHeight uint32) error { for _, utxo := range utxos { m.mu.Lock() @@ -438,10 +535,18 @@ func (m *Manager) updateDepositConfirmations(ctx context.Context, deposit.Lock() defer deposit.Unlock() + // A positive confirmation count can only be converted into an + // absolute height when the wallet query was bracketed by the + // same chain-synced lnd tip. An unconfirmed observation remains + // authoritative and clears a stale height after a reorg. + if utxo.Confirmations > 0 && confirmationTipHeight == 0 { + return nil + } + previousConfirmationHeight := deposit.ConfirmationHeight confirmationHeight, err := confirmationHeightForUtxo( - currentHeight, utxo, + confirmationTipHeight, utxo, ) if err != nil { return err diff --git a/staticaddr/deposit/manager_height_test.go b/staticaddr/deposit/manager_height_test.go index 78ad27bbf..557738d6a 100644 --- a/staticaddr/deposit/manager_height_test.go +++ b/staticaddr/deposit/manager_height_test.go @@ -5,6 +5,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" "github.com/lightningnetwork/lnd/lnwallet" "github.com/stretchr/testify/require" ) @@ -36,4 +37,84 @@ func TestConfirmationHeightForUtxo(t *testing.T) { }) require.ErrorContains(t, err, "invalid confirmation height") }) + + t.Run("snapshot unavailable", func(t *testing.T) { + _, err := confirmationHeightForUtxo(0, &lnwallet.Utxo{ + Confirmations: 6, + }) + require.ErrorContains(t, err, "confirmation tip height unavailable") + }) +} + +// TestStableConfirmationTip verifies that confirmation counts are combined +// with an lnd height only when the wallet query is bracketed by the same synced +// chain tip. +func TestStableConfirmationTip(t *testing.T) { + stableInfo := func() *lndclient.Info { + return &lndclient.Info{ + BlockHeight: 200, + BestBlockHash: chainhash.Hash{1}, + SyncedToChain: true, + } + } + + testCases := []struct { + name string + before *lndclient.Info + after *lndclient.Info + height uint32 + }{ + { + name: "stable", + before: stableInfo(), + after: stableInfo(), + height: 200, + }, + { + name: "catching up", + before: &lndclient.Info{ + BlockHeight: 200, + BestBlockHash: chainhash.Hash{1}, + }, + after: stableInfo(), + }, + { + name: "height changed", + before: stableInfo(), + after: &lndclient.Info{ + BlockHeight: 201, + BestBlockHash: chainhash.Hash{2}, + SyncedToChain: true, + }, + }, + { + name: "block hash changed at same height", + before: stableInfo(), + after: &lndclient.Info{ + BlockHeight: 200, + BestBlockHash: chainhash.Hash{2}, + SyncedToChain: true, + }, + }, + { + name: "missing observation", + before: stableInfo(), + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + height := stableConfirmationTip( + testCase.before, testCase.after, + ) + require.Equal(t, testCase.height, height) + }) + } +} + +// TestExpiryNotificationHeight verifies queued epochs cannot advance expiry +// beyond the authoritative lnd tip after a reorg or notification backlog. +func TestExpiryNotificationHeight(t *testing.T) { + require.EqualValues(t, 199, expiryNotificationHeight(199, 200)) + require.EqualValues(t, 200, expiryNotificationHeight(201, 200)) } diff --git a/staticaddr/deposit/manager_reconcile_test.go b/staticaddr/deposit/manager_reconcile_test.go index 15b2f0a69..f97e20a81 100644 --- a/staticaddr/deposit/manager_reconcile_test.go +++ b/staticaddr/deposit/manager_reconcile_test.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/fsm" "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/staticaddr/version" @@ -59,10 +60,11 @@ func TestReconcileDepositsSerialized(t *testing.T) { }) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: mockStore, - WalletKit: mockLnd.WalletKit, - Signer: mockLnd.Signer, + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, }) var wg sync.WaitGroup @@ -71,14 +73,16 @@ func TestReconcileDepositsSerialized(t *testing.T) { errs := make(chan error, 2) go func() { defer wg.Done() - errs <- manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) + errs <- err }() <-createEntered go func() { defer wg.Done() - errs <- manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) + errs <- err }() time.Sleep(100 * time.Millisecond) @@ -117,9 +121,9 @@ func TestReconcileDepositsSerialized(t *testing.T) { require.Equal(t, 2, errCount) } -// TestReconcileConfirmedDepositUsesCurrentHeight verifies confirmation heights -// are derived from the manager's current block height. -func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { +// TestReconcileConfirmedDepositUsesLndHeight verifies confirmation heights are +// derived from lnd's wallet tip instead of a potentially queued block epoch. +func TestReconcileConfirmedDepositUsesLndHeight(t *testing.T) { ctx := context.Background() mockLnd := test.NewMockLnd() utxo := &lnwallet.Utxo{ @@ -149,15 +153,166 @@ func TestReconcileConfirmedDepositUsesCurrentHeight(t *testing.T) { }) manager := NewManager(&ManagerConfig{ + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, + }) + manager.currentHeight.Store(36) + + _, err := manager.reconcileDeposits(ctx) + require.ErrorContains(t, err, "unable to start new deposit FSM") +} + +// TestReconcileCatchUpPreservesConfirmationHeight verifies changing wallet +// confirmation counts cannot corrupt a deposit's persisted first-confirmation +// height while lnd is catching up to a fixed backend tip. +func TestReconcileCatchUpPreservesConfirmationHeight(t *testing.T) { + ctx := t.Context() + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{9}, + Index: 1, + } + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 144, + } + deposit.SetState(Deposited) + + confirmationCounts := []int64{252, 287, 241, 185, 145, 144} + var listCall int + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return(func() []*lnwallet.Utxo { + utxo := &lnwallet.Utxo{ + OutPoint: outpoint, + Confirmations: confirmationCounts[listCall], + } + listCall++ + + return []*lnwallet.Utxo{utxo} + }, nil) + + infos := make([]*lndclient.Info, 0, len(confirmationCounts)*2) + for i := 0; i < len(confirmationCounts)-1; i++ { + infos = append(infos, + &lndclient.Info{ + BlockHeight: 287, + BestBlockHash: chainhash.Hash{1}, + }, + &lndclient.Info{ + BlockHeight: 287, + BestBlockHash: chainhash.Hash{1}, + }, + ) + } + stableInfo := &lndclient.Info{ + BlockHeight: 287, + BestBlockHash: chainhash.Hash{1}, + SyncedToChain: true, + } + infos = append(infos, stableInfo, stableInfo) + + var infoCall int + lightningClient := &testLightningClient{ + getInfo: func(context.Context) (*lndclient.Info, error) { + info := infos[infoCall] + infoCall++ + + return info, nil + }, + } + + mockStore := new(mockStore) + manager := NewManager(&ManagerConfig{ + LightningClient: lightningClient, + AddressManager: mockAddressManager, + Store: mockStore, + }) + manager.deposits[outpoint] = deposit + manager.activeDeposits[outpoint] = &FSM{deposit: deposit} + + for i := range confirmationCounts { + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + if i == len(confirmationCounts)-1 { + require.EqualValues(t, 287, confirmationTipHeight) + } else { + require.Zero(t, confirmationTipHeight) + } + require.EqualValues(t, 144, deposit.ConfirmationHeight) + } + + require.Equal(t, len(confirmationCounts), listCall) + require.Equal(t, len(infos), infoCall) + mockStore.AssertNotCalled( + t, "UpdateDeposit", mock.Anything, mock.Anything, + ) +} + +// TestReconcileDefersConfirmedDepositWithoutStableTip verifies a confirmed +// wallet output is not persisted with the zero height reserved for genuinely +// unconfirmed deposits while lnd is catching up. +func TestReconcileDefersConfirmedDepositWithoutStableTip(t *testing.T) { + ctx := t.Context() + utxo := &lnwallet.Utxo{ + OutPoint: wire.OutPoint{Hash: chainhash.Hash{10}}, + Confirmations: 6, + } + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{utxo}, nil) + + mockStore := new(mockStore) + manager := NewManager(&ManagerConfig{ + LightningClient: &testLightningClient{ + getInfo: func(context.Context) (*lndclient.Info, error) { + return &lndclient.Info{ + BlockHeight: 287, + BestBlockHash: chainhash.Hash{1}, + }, nil + }, + }, AddressManager: mockAddressManager, Store: mockStore, - WalletKit: mockLnd.WalletKit, - Signer: mockLnd.Signer, }) - manager.currentHeight.Store(100) - err := manager.reconcileDeposits(ctx) - require.ErrorContains(t, err, "unable to start new deposit FSM") + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.Zero(t, confirmationTipHeight) + require.Empty(t, manager.deposits) + mockStore.AssertNotCalled( + t, "CreateDeposit", mock.Anything, mock.Anything, + ) + + err = manager.EnsureDepositsFresh(ctx) + require.ErrorIs(t, err, ErrConfirmationSnapshotUnavailable) +} + +// TestEnsureDepositsFreshRejectsTipBelowKnownHeight verifies spending paths +// fail closed when lnd's stable wallet tip is behind a block epoch already +// consumed by the manager. +func TestEnsureDepositsFreshRejectsTipBelowKnownHeight(t *testing.T) { + ctx := t.Context() + + mockAddressManager := new(mockAddressManager) + mockAddressManager.On( + "ListUnspent", mock.Anything, int32(0), int32(MaxConfs), + ).Return([]*lnwallet.Utxo{}, nil) + + manager := NewManager(&ManagerConfig{ + LightningClient: syncedTestLightningClient(199), + AddressManager: mockAddressManager, + Store: new(mockStore), + }) + manager.currentHeight.Store(200) + + err := manager.EnsureDepositsFresh(ctx) + require.ErrorIs(t, err, ErrConfirmationSnapshotUnavailable) } // TestUpdateDepositConfirmationsResetsReorgedDeposit verifies that a deposit @@ -264,8 +419,9 @@ func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) { ).Return([]*lnwallet.Utxo{}, nil) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: new(mockStore), + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: new(mockStore), }) manager.deposits[outpoint] = deposit fsm := &FSM{ @@ -279,7 +435,9 @@ func TestReconcileDepositsDeactivatesVanishedUnconfirmedDeposit(t *testing.T) { }() manager.activeDeposits[outpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 100, confirmationTipHeight) require.Equal(t, Deposited, deposit.GetState()) require.Empty(t, manager.activeDeposits) select { @@ -312,8 +470,9 @@ func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) { ).Return([]*lnwallet.Utxo{}, nil) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: new(mockStore), + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: new(mockStore), }) manager.deposits[outpoint] = deposit fsm := &FSM{ @@ -327,7 +486,9 @@ func TestReconcileDepositsDeactivatesVanishedConfirmedDeposit(t *testing.T) { }() manager.activeDeposits[outpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 100, confirmationTipHeight) require.Equal(t, Deposited, deposit.GetState()) require.EqualValues(t, 123, deposit.ConfirmationHeight) require.Empty(t, manager.activeDeposits) @@ -506,14 +667,17 @@ func TestReconcileDepositsReactivatesReappearedDeposit(t *testing.T) { }) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: mockStore, + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: mockStore, }) manager.deposits[outpoint] = deposit // Reconciliation should reactivate the existing record instead of // creating a second deposit entry for the same outpoint. - require.NoError(t, manager.reconcileDeposits(ctx)) + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 100, confirmationTipHeight) require.Equal(t, Deposited, deposit.GetState()) require.Zero(t, deposit.ConfirmationHeight) require.Len(t, manager.activeDeposits, 1) @@ -566,12 +730,13 @@ func TestReconcileDepositsKeepsInactiveOnFSMStartFailure(t *testing.T) { }) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: mockStore, + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: mockStore, }) manager.deposits[outpoint] = deposit - err := manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) require.ErrorContains(t, err, "unable to sync active deposits") require.Equal(t, Deposited, deposit.GetState()) require.Zero(t, deposit.ConfirmationHeight) @@ -621,8 +786,9 @@ func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { ).Return((*script.Parameters)(nil), errors.New("fsm init failed")) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: new(mockStore), + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: new(mockStore), }) manager.deposits[visibleOutpoint] = visibleDeposit manager.deposits[vanishedOutpoint] = vanishedDeposit @@ -638,7 +804,7 @@ func TestReconcileDepositsDeactivatesBeforeActivationFailure(t *testing.T) { }() manager.activeDeposits[vanishedOutpoint] = vanishedFsm - err := manager.reconcileDeposits(ctx) + _, err := manager.reconcileDeposits(ctx) require.ErrorContains(t, err, "unable to sync active deposits") require.Empty(t, manager.activeDeposits) @@ -703,16 +869,19 @@ func TestReconcileReplacementDepositCreatesNewDeposit(t *testing.T) { }) manager := NewManager(&ManagerConfig{ - AddressManager: mockAddressManager, - Store: mockStore, - WalletKit: mockLnd.WalletKit, - Signer: mockLnd.Signer, + LightningClient: syncedTestLightningClient(100), + AddressManager: mockAddressManager, + Store: mockStore, + WalletKit: mockLnd.WalletKit, + Signer: mockLnd.Signer, }) manager.deposits[oldOutpoint] = deposit fsm := &FSM{} manager.activeDeposits[oldOutpoint] = fsm - require.NoError(t, manager.reconcileDeposits(ctx)) + confirmationTipHeight, err := manager.reconcileDeposits(ctx) + require.NoError(t, err) + require.EqualValues(t, 100, confirmationTipHeight) require.Same(t, deposit, manager.deposits[oldOutpoint]) require.Equal(t, oldOutpoint, deposit.OutPoint) diff --git a/staticaddr/deposit/manager_test.go b/staticaddr/deposit/manager_test.go index 78663f9a7..96824d6b8 100644 --- a/staticaddr/deposit/manager_test.go +++ b/staticaddr/deposit/manager_test.go @@ -205,6 +205,28 @@ type MockChainNotifier struct { mock.Mock } +type testLightningClient struct { + getInfo func(context.Context) (*lndclient.Info, error) +} + +func (c *testLightningClient) GetInfo(ctx context.Context) (*lndclient.Info, + error) { + + return c.getInfo(ctx) +} + +func syncedTestLightningClient(height uint32) LightningClient { + return &testLightningClient{ + getInfo: func(context.Context) (*lndclient.Info, error) { + return &lndclient.Info{ + BlockHeight: height, + BestBlockHash: chainhash.Hash{1}, + SyncedToChain: true, + }, nil + }, + } +} + func (m *MockChainNotifier) RawClientWithMacAuth( ctx context.Context) (context.Context, time.Duration, chainrpc.ChainNotifierClient) { @@ -481,6 +503,60 @@ func TestManagerSkipsExpiryNotificationOnReconcileFailure(t *testing.T) { } } +// TestManagerSkipsExpiryWhileLndIsCatchingUp verifies a recovered deposit +// cannot act on an expiry height until its confirmation height has been +// reconciled against a stable, chain-synced wallet tip. +func TestManagerSkipsExpiryWhileLndIsCatchingUp(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + testContext := newManagerTestContext(t) + testContext.manager.cfg.LightningClient = &testLightningClient{ + getInfo: func(context.Context) (*lndclient.Info, error) { + return &lndclient.Info{ + BlockHeight: defaultDepositConfirmations + + defaultExpiry, + }, nil + }, + } + + initChan := make(chan struct{}) + runErrChan := make(chan error, 1) + go func() { + runErrChan <- testContext.manager.Run(ctx, initChan) + }() + + testContext.blockChan <- int32( + defaultDepositConfirmations + defaultExpiry, + ) + + 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") + } + + select { + case <-testContext.mockLnd.SignOutputRawChannel: + t.Fatal("expiry sweep signed while lnd was catching up") + + 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 { @@ -574,6 +650,15 @@ func newManagerTestContext(t *testing.T) *ManagerTestContext { ) cfg := &ManagerConfig{ + LightningClient: &testLightningClient{ + getInfo: func(context.Context) (*lndclient.Info, error) { + return &lndclient.Info{ + BlockHeight: manager.currentHeight.Load(), + BestBlockHash: chainhash.Hash{1}, + SyncedToChain: true, + }, nil + }, + }, AddressManager: mockAddressManager, Store: mockStore, WalletKit: mockLnd.WalletKit,