From 049efcc5970ef3750c04cd4ff144c8f1ca86e75d Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 13:31:39 +0800 Subject: [PATCH 1/8] wallet: resolve targeted rescan accounts by durable identity The Store-backed targeted rescan resolved each AccountScope by number in storeScanHorizons. Both Store backends mask an imported account's number to 0, so an imported-xpub target was indistinguishable from the default derived account 0 and could seed the wrong horizon. Resolve the public targets into identity-aware scanTargets up front through the legacy manager, which still keys accounts by their non-masked number. The keyless imported-address bucket is skipped before any Store lookup; every other target carries its durable AccountName, and the Store horizon load prefers the name, mirroring the ScanHorizon contract and the SQL GetHorizonAccount behavior. A number lookup is never issued for an imported target. The legacy walletdb path is unchanged: it still resolves by number, which it does not mask. --- wallet/syncer.go | 183 +++++++++++++++++++++++++++++++++++++----- wallet/syncer_test.go | 124 +++++++++++++++++----------- 2 files changed, 240 insertions(+), 67 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index e06fe61b34..0a9f2b1a97 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -167,6 +167,33 @@ type scanReq struct { targets []waddrmgr.AccountScope } +// scanTarget is the internal, identity-aware form of a targeted-rescan +// account. The public Rescan API accepts waddrmgr.AccountScope, which only +// carries (scope, number). That is ambiguous for imported accounts: both Store +// backends mask an imported account's number to 0, so an imported-xpub target +// is indistinguishable from the default derived account 0 once it reaches a +// Store number lookup. scanTarget resolves the durable, non-masked identity +// up front -- before any Store lookup -- so the targeted path can carry +// AccountName as the source of truth and never mis-resolve an imported target +// by number. +type scanTarget struct { + // Scope is the key scope of the targeted account. + Scope waddrmgr.KeyScope + + // Account is the requested account number. For the keyless legacy + // imported-address bucket it is waddrmgr.ImportedAddrAccount; for every + // other target it is the non-masked number reported by the + // identity-aware backend at resolution time. + Account uint32 + + // AccountName is the durable, scope-unique account identity resolved + // from the identity-aware backend. It is empty only for the keyless + // imported-address bucket, which is never resolved. Store horizon + // loading prefers this name over Account so a masked imported number + // can never collide with the default derived account. + AccountName string +} + // scanResult holds the result of processing a single block during a batch // scan. type scanResult struct { @@ -1964,9 +1991,13 @@ func (s *syncer) scanWithTargets(ctx context.Context, req *scanReq) error { return nil } -// storeScanHorizons loads account horizon data through the store. +// storeScanHorizons loads account horizon data through the store. Targets are +// the identity-aware scanTargets produced by resolveScanTargets, so each one +// already carries the durable AccountName that lets the Store resolve an +// imported account without colliding with the default derived account at the +// masked number 0. func (s *syncer) storeScanHorizons(ctx context.Context, - targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, error) { + targets []scanTarget) ([]*waddrmgr.AccountProperties, error) { if len(targets) == 0 { return s.storeFullScanHorizons(ctx) @@ -2011,37 +2042,34 @@ func (s *syncer) storeFullScanHorizons( // storeTargetedScanHorizons loads recovery horizon accounts for already // resolved scan targets. func (s *syncer) storeTargetedScanHorizons(ctx context.Context, - targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, error) { + targets []scanTarget) ([]*waddrmgr.AccountProperties, error) { props := make([]*waddrmgr.AccountProperties, 0, len(targets)) for _, target := range targets { - // The legacy imported-address bucket is a keyless pseudo-account, - // not a numeric HD account. Its addresses are already watched via - // storeScanAddresses, so never resolve it through GetAccount where - // the backend may have no numeric row for it. + // The legacy imported-address bucket is a keyless + // pseudo-account, not a numeric HD account, and + // resolveScanTargets leaves its AccountName empty. Skip it + // before any Store lookup: its addresses are already watched + // through storeScanAddresses, and the backend has no row keyed + // by its reserved number. if target.Account == waddrmgr.ImportedAddrAccount { continue } - account := target.Account - - info, err := s.store.GetAccount(ctx, db.GetAccountQuery{ - WalletID: s.walletID, - Scope: db.KeyScope(target.Scope), - AccountNumber: &account, - SkipBalance: true, - }) + info, err := s.storeScanHorizonAccount(ctx, target) if err != nil { - return nil, fmt.Errorf("get scan account: %w", err) + return nil, err } - // Targeted Store scan loading learns durable imported-account - // identity in a later stack commit. Until then, keep the helper - // conservative and let imported addresses be watched separately. - if info.IsImported { + // The keyless imported-address bucket has no xpub to derive + // lookahead addresses from. Its materialized addresses are still + // watched by storeScanAddresses. + if keylessImportedAccount(*info) { continue } + account := target.Account + accountProps, err := s.storeAccountProperties(*info, &account) if err != nil { return nil, err @@ -2053,6 +2081,41 @@ func (s *syncer) storeTargetedScanHorizons(ctx context.Context, return props, nil } +// storeScanHorizonAccount resolves one targeted scanTarget into its Store +// account row. The Store lookup prefers the durable AccountName when set, +// mirroring the ScanHorizon contract and the SQL +// scanHorizonOps.GetHorizonAccount behavior: both backends mask an imported +// account's number to 0, so resolving an imported target by number would +// silently load the default derived account. A number lookup is therefore +// issued only as the fast path for derived targets whose name resolution is +// unavailable, never for an imported target. +func (s *syncer) storeScanHorizonAccount(ctx context.Context, + target scanTarget) (*db.AccountInfo, error) { + + query := db.GetAccountQuery{ + WalletID: s.walletID, + Scope: db.KeyScope(target.Scope), + SkipBalance: true, + } + + // Prefer the durable, scope-unique name so a masked imported number can + // never resolve to the default derived account; fall back to the number + // only when no name was resolved. + if target.AccountName != "" { + query.Name = &target.AccountName + } else { + account := target.Account + query.AccountNumber = &account + } + + info, err := s.store.GetAccount(ctx, query) + if err != nil { + return nil, fmt.Errorf("get scan account: %w", err) + } + + return info, nil +} + // storeAccountProperties converts store account metadata into the recovery // state horizon shape, preserving the non-masked account number used for // waddrmgr derivation when the Store public contract masks imported xpubs. @@ -2339,9 +2402,10 @@ func storeScanCredit(utxo db.UtxoInfo) (wtxmgr.Credit, error) { } // loadStoreScanData retrieves recovery scan initialization data through the -// store. +// store. Targets are the identity-aware scanTargets resolved up front; an +// empty slice loads horizons for every account (the untargeted path). func (s *syncer) loadStoreScanData(ctx context.Context, - targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, + targets []scanTarget) ([]*waddrmgr.AccountProperties, []address.Address, []wtxmgr.Credit, error) { horizons, err := s.storeScanHorizons(ctx, targets) @@ -2392,13 +2456,88 @@ func (s *syncer) loadTargetedScanState(ctx context.Context, // loadTargetedScanData retrieves all necessary data from the database to // initialize the recovery state for a targeted rescan. +// +// The Store path first resolves the public AccountScope targets into +// identity-aware scanTargets so it never resolves an imported account by its +// masked number. The legacy walletdb path keeps the AccountScope targets and +// resolves them by number, since the legacy manager does not mask imported +// accounts. func (s *syncer) loadTargetedScanData(ctx context.Context, targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, []address.Address, []wtxmgr.Credit, error) { + if s.store != nil { + resolved, err := s.resolveScanTargets(ctx, targets) + if err != nil { + return nil, nil, nil, err + } + + return s.loadStoreScanData(ctx, resolved) + } return s.DBGetScanData(ctx, targets) } +// resolveScanTargets converts the public AccountScope rescan targets into the +// internal, identity-aware scanTargets used by the Store path. It resolves each +// target once through the legacy address manager, which still keys accounts by +// their non-masked number and is therefore the only backend that can +// disambiguate an imported-xpub account from the default derived account before +// the Store (which masks imported numbers to 0) is consulted. +// +// The keyless legacy imported-address bucket is carried through with an empty +// AccountName so storeScanHorizons skips it before any lookup; every other +// target carries the durable AccountName resolved here so Store horizon loading +// can key on the name instead of the maskable number. +func (s *syncer) resolveScanTargets(_ context.Context, + targets []waddrmgr.AccountScope) ([]scanTarget, error) { + + resolved := make([]scanTarget, 0, len(targets)) + + err := walletdb.View(s.cfg.DB, func(tx walletdb.ReadTx) error { + ns := tx.ReadBucket(waddrmgrNamespaceKey) + + for _, target := range targets { + // The keyless imported-address bucket has no resolvable + // name; carry it verbatim so the Store path skips it + // before any lookup. + if target.Account == waddrmgr.ImportedAddrAccount { + resolved = append(resolved, scanTarget{ + Scope: target.Scope, + Account: target.Account, + }) + + continue + } + + scopedMgr, err := s.addrStore.FetchScopedKeyManager( + target.Scope, + ) + if err != nil { + return fmt.Errorf("fetch scoped manager: %w", + err) + } + + name, err := scopedMgr.AccountName(ns, target.Account) + if err != nil { + return fmt.Errorf("scan target name: %w", err) + } + + resolved = append(resolved, scanTarget{ + Scope: target.Scope, + Account: target.Account, + AccountName: name, + }) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("resolve scan targets: %w", err) + } + + return resolved, nil +} + // loadWalletScanData retrieves all necessary data from the database to // initialize the recovery state. This includes account horizons, active // addresses, and unspent outputs to watch. diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index ddea6cb9ac..e750eca13c 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -1916,12 +1916,15 @@ func TestStoreScanHorizonsListAccounts(t *testing.T) { store.AssertExpectations(t) } -// TestStoreScanHorizonsGetAccount verifies targeted scan horizon reads use the -// store account lookup. +// TestStoreScanHorizonsGetAccount verifies targeted scan horizon reads resolve +// the account by its durable AccountName, mirroring the ScanHorizon contract: +// the resolved scanTarget carries a name, so storeScanHorizons must query the +// store by name and never by the maskable account number. func TestStoreScanHorizonsGetAccount(t *testing.T) { t.Parallel() - // Arrange: Create a store-backed syncer and one targeted account. + // Arrange: Create a store-backed syncer and one resolved scanTarget that + // carries the durable account name. const walletID uint32 = 14 store := &walletmock.Store{} @@ -1930,31 +1933,35 @@ func TestStoreScanHorizonsGetAccount(t *testing.T) { syncerStoreConfig{store: store, walletID: walletID}, ) - target := waddrmgr.AccountScope{ - Scope: waddrmgr.KeyScopeBIP0084, - Account: 7, + target := scanTarget{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 7, + AccountName: "savings", } targetAccount := target.Account account := db.AccountInfo{ AccountNumber: &targetAccount, + AccountName: target.AccountName, ExternalKeyCount: 8, InternalKeyCount: 4, KeyScope: db.KeyScope(target.Scope), } + // The lookup must key on the durable name, not the account number. store.On("GetAccount", mock.Anything, mock.MatchedBy( func(query db.GetAccountQuery) bool { return query.WalletID == walletID && query.Scope == db.KeyScope(target.Scope) && - query.AccountNumber != nil && - *query.AccountNumber == target.Account && + query.AccountNumber == nil && + query.Name != nil && + *query.Name == target.AccountName && query.SkipBalance }, )).Return(&account, nil).Once() // Act: Load targeted scan horizons from the store. props, err := s.storeScanHorizons( - t.Context(), []waddrmgr.AccountScope{target}, + t.Context(), []scanTarget{target}, ) // Assert: The targeted account row was converted for RecoveryState. @@ -1967,21 +1974,17 @@ func TestStoreScanHorizonsGetAccount(t *testing.T) { store.AssertExpectations(t) } -// TestStoreScanHorizonsGetAccountSkipsImported verifies that targeted scan -// horizon reads skip every imported account -- both the keyless -// imported-address bucket and a true imported xpub account -- so an imported -// account never seeds the recovery state. Crucially the imported xpub account -// surfaces with the masked AccountNumber 0 that the store contract guarantees -// for imported rows, in the same scope as a default derived account that -// legitimately owns number 0. Withholding it proves the masked imported -// account cannot collide with or clobber the default account at (scope, 0). -func TestStoreScanHorizonsGetAccountSkipsImported(t *testing.T) { +// TestStoreScanHorizonsGetAccountKeepsImportedXpub verifies that targeted scan +// horizon reads skip only the keyless imported-address bucket while preserving +// a true imported xpub account by its durable name. +// +//nolint:cyclop // Asserts all three scan-target shapes in one scenario. +func TestStoreScanHorizonsGetAccountKeepsImportedXpub(t *testing.T) { t.Parallel() // Arrange: a store-backed syncer with three targets: a default derived // account at number 0, the keyless imported-address bucket, and a true - // imported xpub account that the store surfaces with the masked number 0 - // -- the same (scope, number) as the default account. + // imported xpub account whose Store row masks its number. const walletID uint32 = 22 store := &walletmock.Store{} @@ -2001,7 +2004,7 @@ func TestStoreScanHorizonsGetAccountSkipsImported(t *testing.T) { } importedXpub := waddrmgr.AccountScope{ Scope: waddrmgr.KeyScopeBIP0084, - Account: 0, + Account: 5, } // The default derived account legitimately owns number 0 and must be @@ -2015,9 +2018,8 @@ func TestStoreScanHorizonsGetAccountSkipsImported(t *testing.T) { } // The true imported xpub account carries an account public key and HD - // key counts, but the store masks its number to 0. It is still an - // imported account, so it must be skipped rather than collide with the - // default account at (scope, 0). + // key counts, but the store masks its number to nil. It must still seed a + // horizon under the non-masked target account number. importedXpubInfo := db.AccountInfo{ AccountNumber: nil, AccountName: "imported-xpub", @@ -2029,42 +2031,74 @@ func TestStoreScanHorizonsGetAccountSkipsImported(t *testing.T) { } // The imported-address bucket target must be skipped before lookup. The - // remaining derived and imported-xpub targets both arrive as account 0 in - // this regression, so return those two rows in order and allow no lookup - // for waddrmgr.ImportedAddrAccount. + // remaining derived and imported-xpub targets carry durable names, so the + // store lookups must key by name instead of masked account number 0. store.On("GetAccount", mock.Anything, mock.MatchedBy( func(query db.GetAccountQuery) bool { - return query.AccountNumber != nil && - *query.AccountNumber == 0 + return query.WalletID == walletID && + query.Scope == db.KeyScopeBIP0084 && + query.AccountNumber == nil && + query.Name != nil && + *query.Name == derivedInfo.AccountName && + query.SkipBalance }, )). Return(&derivedInfo, nil).Once() store.On("GetAccount", mock.Anything, mock.MatchedBy( func(query db.GetAccountQuery) bool { - return query.AccountNumber != nil && - *query.AccountNumber == 0 + return query.WalletID == walletID && + query.Scope == db.KeyScopeBIP0084 && + query.AccountNumber == nil && + query.Name != nil && + *query.Name == importedXpubInfo.AccountName && + query.SkipBalance }, )). Return(&importedXpubInfo, nil).Once() // Act: load targeted scan horizons. - props, err := s.storeScanHorizons(t.Context(), []waddrmgr.AccountScope{ - derived, bucket, importedXpub, + props, err := s.storeScanHorizons(t.Context(), []scanTarget{ + { + Scope: derived.Scope, + Account: derived.Account, + AccountName: derivedInfo.AccountName, + }, + { + Scope: bucket.Scope, + Account: bucket.Account, + }, + { + Scope: importedXpub.Scope, + Account: importedXpub.Account, + AccountName: importedXpubInfo.AccountName, + }, }) - // Assert: exactly one horizon is emitted -- the default derived account - // -- proving both imported accounts (including the masked-0 xpub) were - // withheld and did not clobber the default account at (scope, 0). + // Assert: the keyless imported-address bucket was skipped, while the + // default derived account and true imported xpub both emitted horizons. require.NoError(t, err) - require.Len(t, props, 1) - require.Equal(t, "default", props[0].AccountName) - require.Equal(t, uint32(0), props[0].AccountNumber) - require.Equal( - t, derivedInfo.ExternalKeyCount, props[0].ExternalKeyCount, - ) - require.Equal( - t, derivedInfo.InternalKeyCount, props[0].InternalKeyCount, - ) + require.Len(t, props, 2) + + byName := make(map[string]*waddrmgr.AccountProperties, len(props)) + for _, prop := range props { + byName[prop.AccountName] = prop + } + + defaultProps := byName[derivedInfo.AccountName] + require.NotNil(t, defaultProps) + require.Equal(t, derived.Account, defaultProps.AccountNumber) + require.Equal(t, derivedInfo.ExternalKeyCount, + defaultProps.ExternalKeyCount) + require.Equal(t, derivedInfo.InternalKeyCount, + defaultProps.InternalKeyCount) + + importedProps := byName[importedXpubInfo.AccountName] + require.NotNil(t, importedProps) + require.Equal(t, importedXpub.Account, importedProps.AccountNumber) + require.Equal(t, importedXpubInfo.ExternalKeyCount, + importedProps.ExternalKeyCount) + require.Equal(t, importedXpubInfo.InternalKeyCount, + importedProps.InternalKeyCount) store.AssertExpectations(t) } From c52b2e532f82fbc2966d3900445ff400201ba6e3 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 13:31:55 +0800 Subject: [PATCH 2/8] wallet: cover targeted rescan imported-target resolution Add real-kvdb-backed coverage for the targeted rescan identity fix: a syncer wired over a real, unlocked waddrmgr-backed Store. The keyless imported-address bucket produces no horizon and issues no number lookup (a real by-number lookup would surface ErrAccountNotFound). A setup with the default derived account 0 and an imported-xpub account masked to number 0 resolves the imported target by its durable name, classifies it as imported, and withholds it, so it is never mis-resolved as the default derived account. --- wallet/syncer_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index e750eca13c..4c7668b726 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -2271,6 +2271,102 @@ func createImportedXpubAccount(t *testing.T, s *syncer, mgr *waddrmgr.Manager, return internalNumber } +// TestStoreScanHorizonsTargetedImportedBucketSkipped verifies that a targeted +// rescan for the keyless legacy imported-address bucket never issues a Store +// number lookup and produces no horizon for the bucket. The real kvdb backend +// rejects a by-number lookup of waddrmgr.ImportedAddrAccount with +// ErrAccountNotFound, so a passing run (no error, no horizon) proves the bucket +// was skipped before any lookup rather than mis-resolved. +func TestStoreScanHorizonsTargetedImportedBucketSkipped(t *testing.T) { + t.Parallel() + + // Arrange: a store-backed syncer over a real manager and a single target + // for the keyless imported-address bucket. + s, _ := newStoreScanSyncer(t) + + targets := []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: waddrmgr.ImportedAddrAccount, + }} + + // Act: resolve the targets and load their horizons through the Store. + resolved, err := s.resolveScanTargets(t.Context(), targets) + require.NoError(t, err) + + props, err := s.storeScanHorizons(t.Context(), resolved) + + // Assert: the bucket was skipped before any Store lookup -- a number + // lookup would have surfaced ErrAccountNotFound -- so no horizon is + // emitted and no error is returned. + require.NoError(t, err) + require.Empty(t, props) +} + +// TestStoreScanHorizonsTargetedImportedNotResolvedAsDerived verifies the core +// fix: a targeted rescan setup containing both the default derived account +// (number 0) and an imported-xpub account whose public number is masked to 0 +// does not resolve the imported target as the default derived account. The +// imported target is addressed by its non-masked internal number and emitted as +// its own recovery horizon. +func TestStoreScanHorizonsTargetedImportedNotResolvedAsDerived(t *testing.T) { + t.Parallel() + + // Arrange: a real-backend syncer with the auto-created default derived + // account at number 0 and an imported-xpub account masked to number 0. + s, mgr := newStoreScanSyncer(t) + + scope := waddrmgr.KeyScopeBIP0084 + importedNumber := createImportedXpubAccount( + t, s, mgr, scope, "imported-xpub", + ) + + // The imported account's internal number must differ from the default + // derived account's number 0, yet the Store masks it back to 0. + require.NotEqual(t, uint32(waddrmgr.DefaultAccountNum), importedNumber) + + // Target both the default derived account (by its real number 0) and the + // imported-xpub account (by its non-masked internal number). + targets := []waddrmgr.AccountScope{ + {Scope: scope, Account: waddrmgr.DefaultAccountNum}, + {Scope: scope, Account: importedNumber}, + } + + // Act: resolve the targets and load their horizons through the Store. + resolved, err := s.resolveScanTargets(t.Context(), targets) + require.NoError(t, err) + + // The imported target must resolve to its durable name through the + // identity-aware manager, the identity Store horizon loading keys on + // instead of the maskable number. + require.Len(t, resolved, 2) + require.Equal(t, waddrmgr.DefaultAccountName, resolved[0].AccountName) + require.Equal(t, "imported-xpub", resolved[1].AccountName) + + props, err := s.storeScanHorizons(t.Context(), resolved) + require.NoError(t, err) + + // Assert: both horizons are emitted under distinct derivation numbers, + // proving the imported target was resolved by name and not mis-resolved as + // the default derived account at the shared masked number 0. + require.Len(t, props, 2) + + byName := make(map[string]*waddrmgr.AccountProperties, len(props)) + for _, prop := range props { + byName[prop.AccountName] = prop + } + + defaultProps := byName[waddrmgr.DefaultAccountName] + require.NotNil(t, defaultProps) + require.Equal(t, uint32(waddrmgr.DefaultAccountNum), + defaultProps.AccountNumber) + require.False(t, defaultProps.IsWatchOnly) + + importedProps := byName["imported-xpub"] + require.NotNil(t, importedProps) + require.Equal(t, importedNumber, importedProps.AccountNumber) + require.True(t, importedProps.IsWatchOnly) +} + // expectImportedScanAddressPage expects the Store scan path to page raw imports // using the accountless imported-address query shape. func expectImportedScanAddressPage(store *walletmock.Store, From cc2bafcc23482f6706e69478a425e31f4db9f0e8 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Jun 2026 07:58:51 +0800 Subject: [PATCH 3/8] wallet: drop syncer legacy DB fallbacks The syncer runtime helpers were routed through the transitional Store but kept an "if s.store == nil" branch that fell back to the legacy walletdb DB* helpers, plus two positive "if s.store != nil" scan-data conditionals that did the same. Production newSyncer is always constructed with the wallet's Store, so nil Store is impossible on these migrated paths and the fallback is dead, contradictory design: a migrated runtime path must always use the Store. Remove the nil-store fallbacks from rewindToBlock, syncedBlockHashes, unminedTxns, updateSyncTip, putTxNotifications, putBlockNotifications, putSyncBatch, putTargetedBatch, syncedTip, loadTargetedScanData, and loadWalletScanData so each helper always uses the Store, and rewrite the "store when configured, falling back to the legacy walletdb path" comments to describe the Store-only behavior. The legacy DB* helpers in db_ops.go are left in place for now; they are removed once no non-deferred call site needs them. --- wallet/syncer.go | 101 +++++++++-------------------------------------- 1 file changed, 19 insertions(+), 82 deletions(-) diff --git a/wallet/syncer.go b/wallet/syncer.go index 0a9f2b1a97..003d0f8564 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -464,10 +464,6 @@ func (s *syncer) checkRollback(ctx context.Context) error { func (s *syncer) rewindToBlock(ctx context.Context, block waddrmgr.BlockStamp) error { - if s.store == nil { - return s.DBPutRewind(ctx, block) - } - rollbackBoundary := int64(block.Height) + 1 rollbackHeight, err := db.Int64ToUint32(rollbackBoundary) @@ -554,10 +550,6 @@ func (s *syncer) resolveRewindBlock( func (s *syncer) syncedBlockHashes(ctx context.Context, startHeight, endHeight int32) ([]*chainhash.Hash, error) { - if s.store == nil { - return s.DBGetSyncedBlocks(ctx, startHeight, endHeight) - } - start, err := db.Int64ToUint32(int64(startHeight)) if err != nil { return nil, fmt.Errorf("start height %d: %w", startHeight, err) @@ -586,21 +578,15 @@ func (s *syncer) syncedBlockHashes(ctx context.Context, startHeight, return hashes, nil } -// syncedTo returns the wallet's current synced-to block. In store-backed mode -// it reads the tip from the Store so callers do not depend on the legacy -// addrStore tip being kept in lockstep by ApplyScanBatch; otherwise it falls -// back to the legacy addrStore. +// syncedTo returns the wallet's current synced-to block from the Store. func (s *syncer) syncedTo(ctx context.Context) (waddrmgr.BlockStamp, error) { - if s.store == nil { - return s.addrStore.SyncedTo(), nil - } - walletInfo, err := s.store.GetWallet(ctx, s.cfg.Name) if err != nil { return waddrmgr.BlockStamp{}, fmt.Errorf("get wallet sync tip: %w", err) } + // A nil SyncedTo means the wallet has not been synced to any block yet. if walletInfo.SyncedTo == nil { return waddrmgr.BlockStamp{Height: -1}, nil } @@ -727,10 +713,6 @@ func (s *syncer) broadcastUnminedTxns(ctx context.Context) error { // unminedTxns returns transactions that are still active in the wallet's // unmined set. func (s *syncer) unminedTxns(ctx context.Context) ([]*wire.MsgTx, error) { - if s.store == nil { - return s.DBGetUnminedTxns(ctx) - } - infos, err := s.store.ListTxns( ctx, db.ListTxnsQuery{ WalletID: s.walletID, @@ -763,14 +745,10 @@ func (s *syncer) unminedTxns(ctx context.Context) ([]*wire.MsgTx, error) { return wtxmgr.DependencySort(txSet), nil } -// updateSyncTip records the latest synced block for store-backed runtime paths. +// updateSyncTip records the latest synced block through the store. func (s *syncer) updateSyncTip(ctx context.Context, block wtxmgr.BlockMeta) error { - if s.store == nil { - return s.DBPutSyncTip(ctx, block) - } - storeBlock, err := storeBlockFromBlockMeta(block) if err != nil { return err @@ -790,14 +768,10 @@ func (s *syncer) updateSyncTip(ctx context.Context, } // putTxNotifications records relevant transaction notifications through the -// store when configured, falling back to the legacy walletdb path otherwise. +// store. func (s *syncer) putTxNotifications(ctx context.Context, matches TxEntries, blockMeta *wtxmgr.BlockMeta) error { - if s.store == nil { - return s.DBPutTxns(ctx, matches, blockMeta) - } - var block *db.Block if blockMeta != nil { var err error @@ -811,15 +785,10 @@ func (s *syncer) putTxNotifications(ctx context.Context, return s.applyStoreTxBatch(ctx, matches, block, nil) } -// putBlockNotifications records filtered block notifications through the store -// when configured, falling back to the legacy walletdb path otherwise. +// putBlockNotifications records filtered block notifications through the store. func (s *syncer) putBlockNotifications(ctx context.Context, matches TxEntries, blockMeta *wtxmgr.BlockMeta) error { - if s.store == nil { - return s.DBPutBlocks(ctx, matches, blockMeta) - } - if blockMeta == nil { return fmt.Errorf("filtered block is missing metadata: %w", db.ErrInvalidParam) @@ -922,14 +891,10 @@ func (s *syncer) txNotificationState(ctx context.Context, } // putSyncBatch records recovery scan results and synced blocks through the -// store when configured, falling back to the legacy walletdb path otherwise. +// store. func (s *syncer) putSyncBatch(ctx context.Context, scanState *RecoveryState, results []scanResult) error { - if s.store == nil { - return s.DBPutSyncBatch(ctx, results) - } - params, err := s.storeScanBatchParams(scanState, results, true) if err != nil { return err @@ -948,15 +913,10 @@ func (s *syncer) putSyncBatch(ctx context.Context, scanState *RecoveryState, return nil } -// putTargetedBatch records targeted recovery scan results through the store -// when configured, falling back to the legacy walletdb path otherwise. +// putTargetedBatch records targeted recovery scan results through the store. func (s *syncer) putTargetedBatch(ctx context.Context, scanState *RecoveryState, results []scanResult) error { - if s.store == nil { - return s.DBPutTargetedBatch(ctx, results) - } - params, err := s.storeScanBatchParams(scanState, results, false) if err != nil { return err @@ -1211,10 +1171,6 @@ func (s *syncer) stampRecoveryAccountIDs(ctx context.Context, scanState *RecoveryState, accounts []*waddrmgr.AccountProperties) error { - if s.store == nil { - return nil - } - for _, props := range accounts { accountID, err := s.accountPropertiesAccountID(ctx, props) if err != nil { @@ -1671,10 +1627,10 @@ func (s *syncer) advanceChainSync(ctx context.Context) (bool, error) { err) } - // Determine our current sync state. In store-backed mode this reads - // the synced tip from the Store rather than the legacy addrStore, so - // the next batch's start height no longer depends on ApplyScanBatch - // having mirrored the tip back into the legacy addrStore. + // Determine our current sync state. This reads the synced tip from the + // Store rather than the legacy addrStore, so the next batch's start + // height no longer depends on ApplyScanBatch having mirrored the tip + // back into the legacy addrStore. syncedTo, err := s.syncedTo(ctx) if err != nil { return false, err @@ -2457,24 +2413,19 @@ func (s *syncer) loadTargetedScanState(ctx context.Context, // loadTargetedScanData retrieves all necessary data from the database to // initialize the recovery state for a targeted rescan. // -// The Store path first resolves the public AccountScope targets into -// identity-aware scanTargets so it never resolves an imported account by its -// masked number. The legacy walletdb path keeps the AccountScope targets and -// resolves them by number, since the legacy manager does not mask imported -// accounts. +// It first resolves the public AccountScope targets into identity-aware +// scanTargets so it never resolves an imported account by its masked number, +// then loads the scan data through the Store. func (s *syncer) loadTargetedScanData(ctx context.Context, targets []waddrmgr.AccountScope) ([]*waddrmgr.AccountProperties, []address.Address, []wtxmgr.Credit, error) { - if s.store != nil { - resolved, err := s.resolveScanTargets(ctx, targets) - if err != nil { - return nil, nil, nil, err - } - return s.loadStoreScanData(ctx, resolved) + resolved, err := s.resolveScanTargets(ctx, targets) + if err != nil { + return nil, nil, nil, err } - return s.DBGetScanData(ctx, targets) + return s.loadStoreScanData(ctx, resolved) } // resolveScanTargets converts the public AccountScope rescan targets into the @@ -2545,19 +2496,5 @@ func (s *syncer) loadWalletScanData(ctx context.Context) ( []*waddrmgr.AccountProperties, []address.Address, []wtxmgr.Credit, error) { - if s.store != nil { - return s.loadStoreScanData(ctx, nil) - } - - var targets []waddrmgr.AccountScope - for _, scopedMgr := range s.addrStore.ActiveScopedKeyManagers() { - for _, accNum := range scopedMgr.ActiveAccounts() { - targets = append(targets, waddrmgr.AccountScope{ - Scope: scopedMgr.Scope(), - Account: accNum, - }) - } - } - - return s.DBGetScanData(ctx, targets) + return s.loadStoreScanData(ctx, nil) } From ecd0bc98c52a1b6b588ee5310ba1d3776464f0f7 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 09:25:29 +0800 Subject: [PATCH 4/8] wallet: make syncer Store mandatory newSyncer kept the Store optional behind a variadic syncerStoreConfig, but the migrated runtime paths now always read and write through the Store, so a nil Store is impossible. Drop the variadic and take store and walletID as mandatory positional parameters. Update every newSyncer call site mechanically to pass the Store: production wiring in manager.go, plus the benchmark, db_ops, and syncer test setups. The store-backed test bodies are rewritten in the following commits. --- wallet/benchmark_helpers_test.go | 2 +- wallet/db_ops_test.go | 103 +++++-- wallet/manager.go | 5 +- wallet/syncer.go | 32 +-- wallet/syncer_test.go | 465 +++++++++++++++++-------------- 5 files changed, 347 insertions(+), 260 deletions(-) diff --git a/wallet/benchmark_helpers_test.go b/wallet/benchmark_helpers_test.go index 68be136208..ff82dc0105 100644 --- a/wallet/benchmark_helpers_test.go +++ b/wallet/benchmark_helpers_test.go @@ -309,7 +309,7 @@ func setupBenchmarkWallet(tb testing.TB, } if w.sync == nil { - w.sync = newSyncer(w.cfg, w.addrStore, w.txStore, w) + w.sync = newSyncer(w.cfg, w.addrStore, w.txStore, w, w.store, w.id) } require.NotNil(tb, w.store) diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 9169904fa1..36a4e81ce0 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" bwmock "github.com/btcsuite/btcwallet/bwtest/mock" "github.com/btcsuite/btcwallet/waddrmgr" + walletmock "github.com/btcsuite/btcwallet/wallet/internal/bwtest/mock" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -251,7 +252,7 @@ func TestDBPutBlocks_Error(t *testing.T) { // Arrange: Create a syncer with mocked stores and setup a scenario // where address lookup fails during transaction resolution. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) addr, _ := address.NewAddressPubKeyHash( make([]byte, 20), &chaincfg.MainNetParams, @@ -278,7 +279,7 @@ func TestDBPutSyncBatch_Error(t *testing.T) { // Arrange: Create a syncer and a scan result that requires updating an // address manager's horizon. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) res := scanResult{ meta: &wtxmgr.BlockMeta{ @@ -314,7 +315,7 @@ func TestDBPutBlocks(t *testing.T) { // Arrange: Create a syncer and setup test data for a confirmed // transaction. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) tx := wire.NewMsgTx(1) rec, _ := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) @@ -379,7 +380,7 @@ func TestDBPutTxns(t *testing.T) { // Arrange: Create a syncer and setup test data for an unconfirmed // transaction. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) tx := wire.NewMsgTx(1) rec, _ := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) @@ -428,7 +429,7 @@ func TestPutAddrHorizons(t *testing.T) { // Arrange: Create a syncer and setup a scan result that indicates a // horizon expansion is needed for a specific BIP84 account. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) bs := waddrmgr.BranchScope{ Scope: waddrmgr.KeyScopeBIP0084, @@ -488,7 +489,7 @@ func TestDBGetScanData(t *testing.T) { // Arrange: Create a syncer and setup mock expectations for all data // required during rescan initialization. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) targets := []waddrmgr.AccountScope{{ Scope: waddrmgr.KeyScopeBIP0084, @@ -539,7 +540,7 @@ func TestLoadWalletScanDataKeepsImportedXpubHorizons(t *testing.T) { t.Parallel() w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) const importedAccount = uint32(9) @@ -600,7 +601,7 @@ func TestDBGetSyncedBlocks(t *testing.T) { // Arrange: Create a syncer and setup a mock expectation for fetching a // block hash from the address manager. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) hash := chainhash.Hash{0x01} mocks.addrStore.On("BlockHash", mock.Anything, int32(100)).Return( @@ -624,7 +625,7 @@ func TestDBPutRewind(t *testing.T) { // Arrange: Create a syncer and setup mock expectations for updating // the sync tip and rolling back the transaction store. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) bs := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} @@ -657,7 +658,7 @@ func TestDBPutRewind_RollbackFailureRestoresTip(t *testing.T) { // Arrange: SetSyncedTo succeeds (advancing the in-memory tip) and then // Rollback fails, forcing the restore path. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) bs := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} @@ -739,7 +740,7 @@ func TestDBGetScanData_MultipleTargets(t *testing.T) { // Arrange: Create a syncer and setup test data for multiple accounts // across different scopes. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) targets := []waddrmgr.AccountScope{ {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}, @@ -782,7 +783,7 @@ func TestDBGetScanData_Error(t *testing.T) { // Arrange: Create a syncer and setup a mock expectation for a failure // while fetching a scoped key manager. w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) + s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) targets := []waddrmgr.AccountScope{{ Scope: waddrmgr.KeyScopeBIP0084, @@ -817,7 +818,10 @@ func TestDBPutTargetedBatch_WithTxns(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) rec, err := wtxmgr.NewTxRecordFromMsgTx(wire.NewMsgTx(1), time.Now()) require.NoError(t, err) @@ -856,7 +860,10 @@ func TestDBPutSyncTip_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) mockAddrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return(errSetFail).Once() @@ -879,7 +886,10 @@ func TestDBPutTargetedBatch_Errors(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) rec, err := wtxmgr.NewTxRecordFromMsgTx(wire.NewMsgTx(1), time.Now()) require.NoError(t, err) @@ -943,7 +953,9 @@ func TestDBPutSyncBatchEvictsHorizonsOnSyncTipError(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0, + ) bs := waddrmgr.BranchScope{ Scope: waddrmgr.KeyScopeBIP0084, @@ -989,7 +1001,10 @@ func TestDBPutTargetedBatchEvictsHorizonsOnTxError(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) bs := waddrmgr.BranchScope{ Scope: waddrmgr.KeyScopeBIP0084, @@ -1041,7 +1056,10 @@ func TestDBPutTxns_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) addr, err := address.NewAddressPubKeyHash( make([]byte, 20), &chainParams, @@ -1077,7 +1095,10 @@ func TestDBPutTxns_UnconfirmedError(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) addr, err := address.NewAddressPubKeyHash( make([]byte, 20), &chainParams, @@ -1123,7 +1144,10 @@ func TestPutSyncTip_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) // Act: Execute sync tip update within a database transaction. err := walletdb.Update(db, func(tx walletdb.ReadWriteTx) error { @@ -1147,7 +1171,10 @@ func TestDBGetScanData_ManagerError(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) targets := []waddrmgr.AccountScope{ {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}, @@ -1177,7 +1204,10 @@ func TestDBGetScanData_UTXOError(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, mock.AnythingOfType("func(address.Address) error"), @@ -1205,7 +1235,10 @@ func TestPutAddrHorizons_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) results := []scanResult{ { @@ -1234,7 +1267,9 @@ func TestPutAddrHorizonsEvictsOnPostExtensionReadError(t *testing.T) { t.Parallel() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{}, mockAddrStore, nil, nil) + s := newSyncer( + Config{}, mockAddrStore, nil, nil, &walletmock.Store{}, 0, + ) bs := waddrmgr.BranchScope{ Scope: waddrmgr.KeyScopeBIP0084, @@ -1284,7 +1319,10 @@ func TestDBGetScanData_AddressError(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, mock.Anything).Return(errAddr).Once() @@ -1310,7 +1348,10 @@ func TestDBPutTxns_InternalAddressAsChange(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) addr, err := address.NewAddressPubKeyHash( make([]byte, 20), &chainParams, @@ -1359,7 +1400,10 @@ func TestDBPutTxns_AddressNotFound(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockTxStore := &bwmock.TxStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, mockTxStore, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, + ) addr, err := address.NewAddressPubKeyHash( make([]byte, 20), &chainParams, @@ -1399,7 +1443,10 @@ func TestDBPutRewind_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) // The synced tip is snapshotted before the update and conditionally // restored after the update fails so the in-memory tip is not left at diff --git a/wallet/manager.go b/wallet/manager.go index 55b0104bf6..5172724132 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -352,10 +352,7 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { } w.sync = newSyncer( - cfg, w.addrStore, w.txStore, w, syncerStoreConfig{ - store: w.store, - walletID: w.id, - }, + cfg, w.addrStore, w.txStore, w, w.store, w.id, ) w.state = newWalletState(w.sync) diff --git a/wallet/syncer.go b/wallet/syncer.go index 003d0f8564..40e685703a 100644 --- a/wallet/syncer.go +++ b/wallet/syncer.go @@ -250,34 +250,22 @@ type syncer struct { publisher TxPublisher } -// syncerStoreConfig contains store-backed runtime options for the syncer. -type syncerStoreConfig struct { - // store is the transitional database store used by migrated runtime paths. - store db.Store - - // walletID is the database wallet identifier used by store-backed paths. - walletID uint32 -} - -// newSyncer creates a new syncer instance. +// newSyncer creates a new syncer instance. The Store and its wallet ID are +// mandatory: every migrated runtime path reads and writes through the Store, +// so there is no nil-store fallback. func newSyncer(cfg Config, addrStore waddrmgr.AddrStore, - txStore wtxmgr.TxStore, publisher TxPublisher, - storeConfigs ...syncerStoreConfig) *syncer { + txStore wtxmgr.TxStore, publisher TxPublisher, store db.Store, + walletID uint32) *syncer { - s := &syncer{ + return &syncer{ cfg: cfg, addrStore: addrStore, txStore: txStore, scanReqChan: make(chan *scanReq, 1), publisher: publisher, + store: store, + walletID: walletID, } - - if len(storeConfigs) > 0 { - s.store = storeConfigs[0].store - s.walletID = storeConfigs[0].walletID - } - - return s } // syncState returns the current synchronization state of the wallet. @@ -489,10 +477,6 @@ func (s *syncer) rewindToBlock(ctx context.Context, func (s *syncer) rewindWallet(ctx context.Context, block waddrmgr.BlockStamp) error { - if s.store == nil { - return s.DBPutRewind(ctx, block) - } - storeBlock, err := s.resolveRewindBlock(block) if err != nil { return fmt.Errorf("rewind wallet block: %w", err) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 4c7668b726..a4ad567f61 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -44,6 +44,7 @@ func TestSyncerInitialization(t *testing.T) { s := newSyncer( Config{RecoveryWindow: 1}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, ) // Assert: Verify that the syncer is correctly initialized in the @@ -63,7 +64,7 @@ func TestSyncerRequestScan(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher) + s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher, &walletmock.Store{}, 0) req := &scanReq{ typ: scanTypeRewind, @@ -96,7 +97,7 @@ func TestSyncerRequestScanBlocked(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher) + s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher, &walletmock.Store{}, 0) // Fill the buffer (size 1). s.scanReqChan <- &scanReq{} @@ -125,6 +126,7 @@ func TestSyncerRun(t *testing.T) { s := newSyncer( Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) // context cancellation. @@ -150,7 +152,7 @@ func TestWaitUntilBackendSynced(t *testing.T) { // Arrange: Initialize a syncer and mock its chain to simulate a // delayed synchronization. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) // Simulate the backend not being current on the first check, but // becoming current on the second check. @@ -174,22 +176,34 @@ func TestCheckRollbackNoReorg(t *testing.T) { mockAddrStore := &bwmock.AddrStore{} mockChain := &bwmock.Chain{} + store := &walletmock.Store{} s := newSyncer( Config{Chain: mockChain, DB: dbConn}, mockAddrStore, nil, nil, + store, 0, ) tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} - mockAddrStore.On("SyncedTo").Return(tip) + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{ + Hash: tip.Hash, + Height: uint32(tip.Height), + }, + }, nil).Once() - // Mock retrieval of synced block hashes from the database for the - // last 10 blocks. - for i := int32(91); i <= 100; i++ { - hash := chainhash.Hash{byte(i)} - mockAddrStore.On( - "BlockHash", mock.Anything, i, - ).Return(&hash, nil) + // Mock retrieval of synced block hashes from the Store for the last 10 + // blocks. + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{ + Hash: chainhash.Hash{byte(i)}, + Height: i, + }) } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 91, + EndHeight: 100, + }).Return(localBlocks, nil).Once() // Mock retrieval of matching block hashes from the remote chain. remoteHashes := make([]chainhash.Hash, 10) @@ -220,23 +234,35 @@ func TestCheckRollbackDetected(t *testing.T) { mockChain := &bwmock.Chain{} mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( Config{Chain: mockChain, DB: dbConn}, mockAddrStore, mockTxStore, mockPublisher, + store, 0, ) tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} - mockAddrStore.On("SyncedTo").Return(tip) + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{ + Hash: tip.Hash, + Height: uint32(tip.Height), + }, + }, nil).Once() - // Mock retrieval of synced block hashes from the database for blocks - // 91 to 100. - for i := int32(91); i <= 100; i++ { - hash := chainhash.Hash{byte(i)} - mockAddrStore.On( - "BlockHash", mock.Anything, i, - ).Return(&hash, nil) + // Mock retrieval of synced block hashes from the Store for blocks 91 to + // 100. + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{ + Hash: chainhash.Hash{byte(i)}, + Height: i, + }) } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 91, + EndHeight: 100, + }).Return(localBlocks, nil).Once() // Mock retrieval of remote block hashes where a fork occurs at // height 95. @@ -259,12 +285,10 @@ func TestCheckRollbackDetected(t *testing.T) { header := &wire.BlockHeader{Timestamp: time.Now()} mockChain.On("GetBlockHeader", &forkHash).Return(header, nil).Once() - // Expect a rollback to the common ancestor at height 95 and a - // corresponding transaction store rollback. - mockAddrStore.On( - "SetSyncedTo", mock.Anything, mock.Anything, + // Expect a rollback to the common ancestor at height 95. + store.On( + "RollbackToBlock", mock.Anything, uint32(96), ).Return(nil).Once() - mockTxStore.On("Rollback", mock.Anything, int32(96)).Return(nil).Once() // Act & Assert: Verify that checkRollback correctly identifies the // fork and performs the rollback. @@ -281,9 +305,11 @@ func TestInitChainSync(t *testing.T) { mockChain := &bwmock.Chain{} mockAddrStore := &bwmock.AddrStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + store, 0, ) // Mock backend synchronization check. @@ -293,8 +319,9 @@ func TestInitChainSync(t *testing.T) { mockChain.On("NotifyBlocks").Return(nil).Once() // Mock rollback check at the start of synchronization. - tip := waddrmgr.BlockStamp{Height: 0} - mockAddrStore.On("SyncedTo").Return(tip) + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Height: 0}, + }, nil).Once() // Act & Assert: Verify that the initial chain synchronization // sequence completes successfully. @@ -310,7 +337,7 @@ func TestScanBatchHeadersOnly(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}, {0x02}} mockChain.On( @@ -354,6 +381,7 @@ func TestSyncerLoadScanState(t *testing.T) { ChainParams: &chainParams, }, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, ) // Mock active scoped key managers. @@ -415,7 +443,7 @@ func TestScanBatchWithFullBlocks(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) mockAddrStore := &bwmock.AddrStore{} scanState := NewRecoveryState( @@ -460,6 +488,7 @@ func TestScanBatchWithCFilters(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: dbConn}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, ) mockAddrStore := &bwmock.AddrStore{} @@ -519,7 +548,7 @@ func TestDispatchScanStrategy(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} @@ -587,6 +616,7 @@ func TestScanBatch(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) // Mock loading of the full scan state required by the batch scan. @@ -642,7 +672,7 @@ func TestFetchAndFilterBlocks(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher) + s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) // Create an empty recovery state for testing. scanState := NewRecoveryState(10, &chainParams, nil) @@ -682,6 +712,7 @@ func TestAdvanceChainSync(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, ) // Case 1: Test advancement when the wallet is already synced to the @@ -812,6 +843,7 @@ func TestHandleChainUpdate(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, ) // Case 1: Test handling of a BlockConnected notification. @@ -952,7 +984,7 @@ func TestProcessRelevantTxUsesStore(t *testing.T) { publisher := &mockTxPublisher{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, publisher, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -997,8 +1029,8 @@ func TestProcessRelevantTxPreservesUnminedMetadata(t *testing.T) { store := &walletmock.Store{} s := newSyncer( - Config{ChainParams: &chainParams}, nil, nil, nil, - syncerStoreConfig{store: store, walletID: walletID}, + Config{ChainParams: &chainParams}, nil, nil, nil, store, + walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -1050,7 +1082,7 @@ func TestProcessRelevantTxUsesBareMultisigMember(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, nil, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) memberAddr, memberScript, multiSigScript := @@ -1124,7 +1156,7 @@ func TestProcessRelevantTxUsesStoreConfirmedBlock(t *testing.T) { publisher := &mockTxPublisher{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, publisher, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -1308,7 +1340,7 @@ func TestProcessFilteredBlockBareMultisigCandidate(t *testing.T) { publisher := &mockTxPublisher{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, publisher, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) members, pkScript := newBareMultisigScript(t) @@ -1415,7 +1447,7 @@ func TestProcessFilteredBlockPassesCreditCandidates(t *testing.T) { publisher := &mockTxPublisher{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, publisher, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) firstAddr, err := address.NewAddressPubKeyHash( @@ -1483,7 +1515,7 @@ func TestProcessFilteredBlockUsesStore(t *testing.T) { publisher := &mockTxPublisher{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, publisher, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -1704,7 +1736,7 @@ func TestPutSyncBatchStore(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) fixture := newStoreScanBatchFixture(t) scanState := NewRecoveryState(0, &chainParams, nil) @@ -1736,7 +1768,7 @@ func TestPutTargetedBatchStore(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) fixture := newStoreScanBatchFixture(t) scanState := NewRecoveryState(0, &chainParams, nil) @@ -1768,7 +1800,7 @@ func TestStampRecoveryAccountIDsCarriesStableID(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) scanState := NewRecoveryState(0, &chainParams, nil) @@ -1881,7 +1913,7 @@ func TestStoreScanHorizonsListAccounts(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) accountNumber2 := uint32(2) @@ -1930,7 +1962,7 @@ func TestStoreScanHorizonsGetAccount(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) target := scanTarget{ @@ -1989,8 +2021,7 @@ func TestStoreScanHorizonsGetAccountKeepsImportedXpub(t *testing.T) { store := &walletmock.Store{} s := newSyncer( - Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + Config{}, nil, nil, &mockTxPublisher{}, store, walletID, ) derived := waddrmgr.AccountScope{ @@ -2221,8 +2252,7 @@ func newStoreScanSyncer(t *testing.T) (*syncer, *waddrmgr.Manager) { store := kvdb.NewStore(dbConn, txStore, mgr) s := newSyncer( Config{DB: dbConn, ChainParams: &chaincfg.SimNetParams}, mgr, - txStore, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: 0}, + txStore, &mockTxPublisher{}, store, 0, ) return s, mgr @@ -2393,7 +2423,7 @@ func TestStoreScanAddresses(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -2453,7 +2483,7 @@ func TestStoreScanAddressesIncludesImportedAlias(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -2511,7 +2541,7 @@ func TestStoreScanAddressesIncludesRawImportOnlyScope(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -2557,7 +2587,7 @@ func TestStoreScanAddressesNonDefaultScope(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) // A purpose outside waddrmgr.DefaultKeyScopes is a non-default scope. @@ -2631,7 +2661,7 @@ func TestStoreScanUnspent(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) outpoint := wire.OutPoint{Hash: chainhash.Hash{0x16}, Index: 2} @@ -2676,7 +2706,7 @@ func TestLoadWalletScanDataStore(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) addr, err := address.NewAddressPubKeyHash( @@ -2748,6 +2778,7 @@ func TestExtractAddrEntries(t *testing.T) { s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, ) addr, err := address.NewAddressPubKeyHash( @@ -2803,7 +2834,7 @@ func TestHandleScanReq(t *testing.T) { s := newSyncer( Config{DB: dbConn, Chain: mockChain}, mockAddrStore, mockTxStore, mockPublisher, - syncerStoreConfig{store: store, walletID: 0}, + store, 0, ) // Case 1: Test handling of a rewind scan request. The current tip is at @@ -2847,7 +2878,10 @@ func TestHandleScanReq(t *testing.T) { req = &scanReq{ typ: scanTypeTargeted, startBlock: waddrmgr.BlockStamp{Height: 100}, - targets: []waddrmgr.AccountScope{{Account: 1}}, + targets: []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: 1, + }}, } mockChain = &bwmock.Chain{} s.cfg.Chain = mockChain @@ -2864,11 +2898,15 @@ func TestHandleScanReq(t *testing.T) { // Set up mocks for initializing targeted scan state. props := &waddrmgr.AccountProperties{ AccountNumber: 1, + AccountName: "default", KeyScope: waddrmgr.KeyScopeBIP0084, } scopedMgr.On( "AccountProperties", mock.Anything, uint32(1), ).Return(props, nil).Twice() + scopedMgr.On( + "AccountName", mock.Anything, uint32(1), + ).Return("default", nil).Once() accountID := uint32(7) accountNumber := uint32(1) @@ -2876,15 +2914,26 @@ func TestHandleScanReq(t *testing.T) { func(query db.GetAccountQuery) bool { return query.WalletID == 0 && query.Scope == db.KeyScopeBIP0084 && - query.AccountNumber != nil && - *query.AccountNumber == accountNumber && + query.Name != nil && *query.Name == "default" && query.SkipBalance }, )).Return(&db.AccountInfo{ AccountID: &accountID, + AccountName: "default", AccountNumber: &accountNumber, KeyScope: db.KeyScopeBIP0084, - }, nil).Once() + }, nil).Twice() + store.On("ListAccounts", mock.Anything, mock.MatchedBy( + func(query db.ListAccountsQuery) bool { + return query.WalletID == 0 && query.SkipBalance + }, + )).Return([]db.AccountInfo(nil), nil).Once() + store.On( + "ListAddresses", mock.Anything, mock.Anything, + ).Return(page.Result[db.AddressInfo, uint32]{}, nil).Maybe() + store.On( + "ListOutputsToWatch", mock.Anything, uint32(0), + ).Return([]db.UtxoInfo(nil), nil).Once() store.On( "ApplyScanBatch", mock.Anything, mock.MatchedBy( func(params db.ScanBatchParams) bool { @@ -2957,6 +3006,7 @@ func TestWaitForEvent(t *testing.T) { DB: db, }, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) // Mock chain notifications channel. @@ -3003,6 +3053,7 @@ func TestSyncerFullRun(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) // Mock initial chain sync sequence. @@ -3082,6 +3133,7 @@ func TestProcessChainUpdate_Disconnect(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, ) mockAddrStore.On("SyncedTo").Return( @@ -3115,7 +3167,7 @@ func TestBroadcastUnminedTxns_Error(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) mockTxStore.On("UnminedTxs", mock.Anything).Return( ([]*wire.MsgTx)(nil), errDBMockSync, @@ -3139,6 +3191,7 @@ func TestInitChainSync_BackendNotSynced(t *testing.T) { s := newSyncer( Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) mockAddrStore.On("Birthday").Return(time.Now()).Once() @@ -3164,6 +3217,7 @@ func TestDispatchScanStrategy_CFilterFail(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodAuto}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, ) mockAddrStore := &bwmock.AddrStore{} scanState := NewRecoveryState( @@ -3201,6 +3255,7 @@ func TestFilterBatch_MatchFound(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodCFilters}, nil, nil, nil, + &walletmock.Store{}, 0, ) // Create a filter that matches "data". @@ -3254,7 +3309,7 @@ func TestScanBatchWithCFilters_GetHeadersFail(t *testing.T) { // Arrange: Setup a syncer and mock CFilter success but header retrieval // failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} @@ -3288,7 +3343,7 @@ func TestFetchAndFilterBlocks_NonEmpty(t *testing.T) { // Arrange: Setup a syncer with a non-empty scan state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3325,7 +3380,7 @@ func TestFetchAndFilterBlocks_Errors(t *testing.T) { // Arrange: Setup a syncer with a non-empty scan state and mock a hash // fetch failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3359,6 +3414,7 @@ func TestScanBatch_Empty(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockAddrStore.On("ActiveScopedKeyManagers").Return( @@ -3390,25 +3446,23 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("CheckRollback_Failure", func(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() // Arrange: Setup a syncer where DB operations fail during // rollback check. mockChain := &bwmock.Chain{} - addrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, + store, 0, ) mockChain.On("IsCurrent").Return(true).Maybe() - addrStore.On("Birthday").Return(time.Now()).Maybe() - addrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ) - addrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - &chainhash.Hash{}, errDBMock).Once() + store.On("GetWallet", mock.Anything, "").Return( + (*db.WalletInfo)(nil), errDBMock, + ).Once() // Act: Attempt initialization. err := s.initChainSync(t.Context()) @@ -3420,19 +3474,21 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("NotifyBlocks_Failure", func(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() // Arrange: Setup a syncer where block notifications fail. mockChain := &bwmock.Chain{} - addrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, + store, 0, ) mockChain.On("IsCurrent").Return(true).Maybe() - addrStore.On("Birthday").Return(time.Now()).Maybe() - addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 0}) + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Height: 0}, + }, nil).Once() mockChain.On("NotifyBlocks").Return(errNotify).Once() // Act: Attempt initialization. @@ -3448,7 +3504,7 @@ func TestHandleScanReq_Errors(t *testing.T) { t.Parallel() // Arrange: Setup a syncer already in syncing state. - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) s.state.Store(uint32(syncStateSyncing)) // Act: Attempt to handle a scan request. @@ -3469,7 +3525,7 @@ func TestSyncerRun_InitError(t *testing.T) { mockChain := &bwmock.Chain{} addrStore := &bwmock.AddrStore{} - s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil) + s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil, &walletmock.Store{}, 0) addrStore.On("Birthday").Return(time.Now()).Once() mockChain.On("IsCurrent").Return(true).Once() @@ -3504,6 +3560,7 @@ func TestHandleChainUpdate_BlockDisconnected(t *testing.T) { DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) // 1. BlockDisconnected. @@ -3551,6 +3608,7 @@ func TestDispatchScanStrategy_AutoFallback(t *testing.T) { SyncMethod: SyncMethodAuto, MaxCFilterItems: 1, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, &chainParams, nil) @@ -3596,7 +3654,7 @@ func TestBroadcastUnminedTxns_Success(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) tx := wire.NewMsgTx(1) mockTxStore.On("UnminedTxs", mock.Anything).Return( @@ -3620,6 +3678,7 @@ func TestFilterBatch_EmptyFilter(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodCFilters}, nil, nil, nil, + &walletmock.Store{}, 0, ) emptyFilter, err := gcs.BuildGCSFilter( @@ -3660,7 +3719,7 @@ func TestWaitForEvent_NotificationsClosed(t *testing.T) { // Arrange: Setup a syncer with a closed notification channel. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) closedChan := make(chan any) close(closedChan) @@ -3681,7 +3740,7 @@ func TestWaitForEvent_ContextCancelled(t *testing.T) { // Arrange: Setup a syncer with a blocking notification channel and a // cancelled context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) blockChan := make(chan any) mockChain.On("Notifications").Return((<-chan any)(blockChan)).Once() @@ -3702,7 +3761,7 @@ func TestMatchAndFetchBatch_GetBlocksError(t *testing.T) { // Arrange: Create a syncer and setup a recovery state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) state := NewRecoveryState(1, nil, nil) @@ -3734,7 +3793,7 @@ func TestFilterBatch_ContextCancelled(t *testing.T) { t.Parallel() // Arrange: Setup a syncer and a cancelled context. - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -3754,7 +3813,7 @@ func TestFilterBatch_BlockAlreadyFetched(t *testing.T) { // Arrange: Setup a syncer where the target block has already been // fetched. - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) hash := chainhash.Hash{0x01} results := []scanResult{ @@ -3779,7 +3838,7 @@ func TestInitChainSync_WaitUntilSyncedError(t *testing.T) { // Arrange: Setup mock expectations where the backend is not current, // then cancel the context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) mockChain.On("IsCurrent").Return(false).Maybe() @@ -3799,7 +3858,7 @@ func TestScanBatchHeadersOnly_ContextCancelled(t *testing.T) { // Arrange: Setup mock expectations and a cancelled context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -3827,7 +3886,7 @@ func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher) + s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) tx := wire.NewMsgTx(1) mockTxStore.On("UnminedTxs", mock.Anything).Return( @@ -3846,18 +3905,23 @@ func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { func TestCheckRollback_DBError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where local block hash lookup fails + // Arrange: Setup mock expectations where local Store block lookup fails // during a rollback check. - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + store := &walletmock.Store{} + s := newSyncer(Config{DB: dbConn}, nil, nil, nil, store, 0) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Once() - mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - (*chainhash.Hash)(nil), errBlockHash).Once() + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Height: 100}, + }, nil).Once() + store.On( + "ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 91, + EndHeight: 100, + }, + ).Return(nil, errBlockHash).Once() // Act: Perform a rollback check. err := s.checkRollback(t.Context()) @@ -3873,20 +3937,30 @@ func TestCheckRollback_RemoteError(t *testing.T) { // Arrange: Setup mock expectations where remote hash lookup fails // during a rollback check. - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, + Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Once() - mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - &chainhash.Hash{}, nil).Maybe() + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Height: 100}, + }, nil).Once() + + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{ + Hash: chainhash.Hash{byte(i)}, + Height: i, + }) + } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 91, + EndHeight: 100, + }).Return(localBlocks, nil).Once() mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( ([]chainhash.Hash)(nil), errRemote).Once() @@ -3902,7 +3976,7 @@ func TestFilterBatch_NilFilter(t *testing.T) { t.Parallel() // Arrange: Setup a batch with a nil filter. - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) hash := chainhash.Hash{0x01} results := []scanResult{ @@ -3926,15 +4000,14 @@ func TestFilterBatch_NilFilter(t *testing.T) { func TestInitChainSync_NotifyBlocksError(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() // Arrange: Setup mock expectations where block notification fails. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, + Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, ) mockChain.On("IsCurrent").Return(true).Once() @@ -3942,9 +4015,9 @@ func TestInitChainSync_NotifyBlocksError(t *testing.T) { []chainhash.Hash{}, nil).Once() mockChain.On("NotifyBlocks").Return(errNotify).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 0}).Once() - mockAddrStore.On("Birthday").Return(time.Time{}).Maybe() + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Height: 0}, + }, nil).Once() // Act: Attempt chain sync initialization. err := s.initChainSync(t.Context()) @@ -3962,7 +4035,7 @@ func TestScanBatchHeadersOnly_Errors(t *testing.T) { // Arrange: Setup mock expectations where GetBlockHashes fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return(([]chainhash.Hash)(nil), @@ -3981,7 +4054,7 @@ func TestScanBatchHeadersOnly_Errors(t *testing.T) { // Arrange: Setup mock expectations where GetBlockHeaders fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return([]chainhash.Hash{{}}, nil).Once() @@ -4003,29 +4076,38 @@ func TestCheckRollback_HeaderError(t *testing.T) { // Arrange: Setup mock expectations for a rollback check where a // header fetch failure occurs at the fork point. - db, cleanup := setupTestDB(t) + dbConn, cleanup := setupTestDB(t) defer cleanup() mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, + Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 101}).Once() - hashA := &chainhash.Hash{0x0A} hashB := &chainhash.Hash{0x0B} hashC := chainhash.Hash{0x0C} - mockAddrStore.On("BlockHash", mock.Anything, int32(101)).Return(hashB, - nil).Once() - mockAddrStore.On("BlockHash", mock.Anything, int32(100)).Return(hashA, - nil).Once() - mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - &chainhash.Hash{}, nil).Maybe() + store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ + SyncedTo: &db.Block{Hash: *hashB, Height: 101}, + }, nil).Once() + + localBlocks := make([]db.Block, 0, 10) + for i := uint32(92); i <= 99; i++ { + localBlocks = append(localBlocks, db.Block{ + Hash: chainhash.Hash{byte(i)}, + Height: i, + }) + } + localBlocks = append(localBlocks, + db.Block{Hash: *hashA, Height: 100}, + db.Block{Hash: *hashB, Height: 101}, + ) + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 92, + EndHeight: 101, + }).Return(localBlocks, nil).Once() remoteHashes := make([]chainhash.Hash, 10) remoteHashes[8] = *hashA @@ -4047,7 +4129,7 @@ func TestFilterBatch_Match(t *testing.T) { t.Parallel() // Arrange: Setup a batch with a matching filter. - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) hash := chainhash.Hash{0x01} results := []scanResult{ @@ -4097,7 +4179,7 @@ func TestScanWithTargets_Empty(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodAuto, MaxCFilterItems: 100, - }, mockAddrStore, mockTxStore, nil) + }, mockAddrStore, mockTxStore, nil, &walletmock.Store{}, 0) req := &scanReq{ startBlock: waddrmgr.BlockStamp{Height: 100}, @@ -4154,7 +4236,7 @@ func TestInitChainSync_Neutrino(t *testing.T) { // Birthday called by SetStartTime. mockAddrStore.On("Birthday").Return(time.Time{}).Once() - s := newSyncer(Config{Chain: nc}, mockAddrStore, nil, nil) + s := newSyncer(Config{Chain: nc}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) // Cancel context immediately to abort waitUntilBackendSynced. ctx, cancel := context.WithCancel(t.Context()) @@ -4175,7 +4257,7 @@ func TestFetchAndFilterBlocks_HeaderScan(t *testing.T) { // Arrange: Create a syncer with an empty scan state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, nil, nil) @@ -4208,7 +4290,7 @@ func TestScanBatchWithFullBlocks_ProcessError(t *testing.T) { defer cleanup() mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain, DB: db}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain, DB: db}, nil, nil, nil, &walletmock.Store{}, 0) addrStore := &bwmock.AccountStore{} rs := NewRecoveryState(10, &chainParams, nil) @@ -4265,6 +4347,7 @@ func TestDispatchScanStrategy_Auto(t *testing.T) { SyncMethod: SyncMethodAuto, MaxCFilterItems: 1, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, nil, nil) @@ -4301,6 +4384,7 @@ func TestDispatchScanStrategy_AutoFallback_Final(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodAuto, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, nil, nil) @@ -4334,7 +4418,7 @@ func TestProcessChainUpdate_Disconnected(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) mockAddrStore.On("SyncedTo").Return( waddrmgr.BlockStamp{Height: 0}).Once() @@ -4369,6 +4453,7 @@ func TestScanWithTargets_Errors(t *testing.T) { Chain: mockChain, DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) req := &scanReq{ @@ -4413,6 +4498,7 @@ func TestScanWithTargets_Errors(t *testing.T) { Chain: mockChain, DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) req := &scanReq{ @@ -4453,7 +4539,7 @@ func TestScanWithTargets_Errors(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return( nil, errFetchFail).Once() @@ -4487,6 +4573,7 @@ func TestScanBatchWithCFilters_InitResultsError(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodCFilters, }, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -4597,6 +4684,7 @@ func TestProcessChainUpdate(t *testing.T) { DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) tc.setup(mockAddrStore, mockTxStore, mockChain) @@ -4616,7 +4704,7 @@ func TestProcessChainUpdateRoutesSyncTip(t *testing.T) { t.Parallel() store := &walletmock.Store{} - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) s.store = store s.walletID = 77 @@ -4663,7 +4751,7 @@ func TestAdvanceChainSyncUsesStoreSyncedTo(t *testing.T) { s := newSyncer( Config{Name: walletName, Chain: chain}, nil, nil, nil, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) chain.On("GetBestBlock").Return( @@ -4687,7 +4775,7 @@ func TestBroadcastUnminedTxnsRoutesStore(t *testing.T) { store := &walletmock.Store{} publisher := &mockTxPublisher{} - s := newSyncer(Config{}, nil, nil, publisher) + s := newSyncer(Config{}, nil, nil, publisher, &walletmock.Store{}, 0) s.store = store s.walletID = 66 @@ -4737,9 +4825,7 @@ func TestBroadcastUnminedTxnsStoreSortsDependencies(t *testing.T) { store := &walletmock.Store{} publisher := &mockTxPublisher{} - s := newSyncer(Config{}, nil, nil, publisher) - s.store = store - s.walletID = 67 + s := newSyncer(Config{}, nil, nil, publisher, store, 67) parent := wire.NewMsgTx(2) parent.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ @@ -4805,7 +4891,7 @@ func TestSyncedBlockHashesRoutesStore(t *testing.T) { t.Parallel() store := &walletmock.Store{} - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) s.store = store s.walletID = 88 @@ -4834,7 +4920,7 @@ func TestRewindToBlockRoutesStore(t *testing.T) { t.Parallel() store := &walletmock.Store{} - s := newSyncer(Config{}, nil, nil, nil) + s := newSyncer(Config{}, nil, nil, nil, &walletmock.Store{}, 0) s.store = store s.walletID = 99 @@ -4862,8 +4948,7 @@ func TestScanWithRewindRoutesStoreRewind(t *testing.T) { store := &walletmock.Store{} s := newSyncer( - Config{Name: walletName}, nil, nil, nil, - syncerStoreConfig{store: store, walletID: 100}, + Config{Name: walletName}, nil, nil, nil, store, 100, ) current := &db.Block{Hash: chainhash.Hash{100}, Height: 100} @@ -4891,38 +4976,6 @@ func TestScanWithRewindRoutesStoreRewind(t *testing.T) { store.AssertExpectations(t) } -// TestRewindToBlockFallsBackToLegacy verifies that, when no runtime store is -// configured, rewindToBlock still routes through the legacy DBPutRewind path -// (SetSyncedTo + wtxmgr Rollback) rather than the store. -func TestRewindToBlockFallsBackToLegacy(t *testing.T) { - t.Parallel() - - w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil) - require.Nil(t, s.store) - - bs := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} - - // DBPutRewind snapshots the live synced tip before the rollback so it - // can restore it on failure, so SyncedTo is consulted first. The - // rollback here succeeds, so the snapshot is never restored; a valid - // pre-rewind tip just satisfies the read. - preRewindTip := waddrmgr.BlockStamp{ - Height: 200, Hash: chainhash.Hash{0x02}, - } - mocks.addrStore.On("SyncedTo").Return(preRewindTip).Once() - mocks.addrStore.On("SetSyncedTo", mock.Anything, &bs).Return(nil).Once() - mocks.txStore.On("Rollback", - mock.Anything, int32(101), - ).Return(nil).Once() - - err := s.rewindToBlock(t.Context(), bs) - require.NoError(t, err) - - mocks.addrStore.AssertExpectations(t) - mocks.txStore.AssertExpectations(t) -} - // TestHandleChainUpdate_SpecialNotifs verifies RescanProgress and // RescanFinished. func TestHandleChainUpdate_SpecialNotifs(t *testing.T) { @@ -4930,7 +4983,7 @@ func TestHandleChainUpdate_SpecialNotifs(t *testing.T) { // Arrange: Setup a syncer for special notification handling. mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{}, mockAddrStore, nil, nil) + s := newSyncer(Config{}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) // 1. RescanProgress // Act: Handle RescanProgress. @@ -4979,7 +5032,7 @@ func TestFetchAndFilterBlocks_BatchCapping(t *testing.T) { // Arrange: Setup a syncer with expectations for batch capping. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) scanState := NewRecoveryState(10, nil, nil) // Expect GetBlockHashes with a capped range based on recoveryBatchSize. @@ -5017,6 +5070,7 @@ func TestRunSyncStep_Unfinished(t *testing.T) { Chain: mockChain, DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockAddrStore.On("SyncedTo").Return( @@ -5065,6 +5119,7 @@ func TestDispatchScanStrategy_OtherMethods(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodFullBlocks, }, nil, nil, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetBlocks", hashes).Return([]*wire.MsgBlock{ wire.NewMsgBlock(&wire.BlockHeader{})}, nil).Once() @@ -5092,6 +5147,7 @@ func TestDispatchScanStrategy_OtherMethods(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodCFilters, }, nil, nil, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetCFilters", hashes, mock.Anything).Return( []*gcs.Filter{{}}, nil).Once() @@ -5124,6 +5180,7 @@ func TestDispatchScanStrategy_OtherMethods(t *testing.T) { Chain: mockChain, SyncMethod: 99, }, nil, nil, nil, + &walletmock.Store{}, 0, ) // Act: Dispatch the strategy. @@ -5148,7 +5205,7 @@ func TestHandleChainUpdate_Error(t *testing.T) { // Arrange: Setup a syncer where chain update processing will fail due // to a database error. mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) mockAddrStore.On("SyncedTo").Return( waddrmgr.BlockStamp{Height: 100}).Maybe() @@ -5181,6 +5238,7 @@ func TestRunSyncStep_Success(t *testing.T) { Chain: mockChain, DB: db, }, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockAddrStore.On("SyncedTo").Return( @@ -5220,7 +5278,7 @@ func TestScanBatchWithCFilters_HorizonExpansion(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil) + s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}, {0x02}} @@ -5299,6 +5357,7 @@ func TestRunSyncStep_AdvanceError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, ) mockAddrStore.On("SyncedTo").Return( @@ -5333,7 +5392,7 @@ func TestLoadFullScanState_Error(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) mgr := &bwmock.AccountStore{} mgr.On("ActiveAccounts").Return([]uint32{0}).Once() @@ -5360,8 +5419,7 @@ func TestScanWithRewind_Error(t *testing.T) { // rewind fails. store := &walletmock.Store{} s := newSyncer( - Config{}, nil, nil, nil, - syncerStoreConfig{store: store, walletID: 0}, + Config{}, nil, nil, nil, store, 0, ) rewindTip := waddrmgr.BlockStamp{ @@ -5396,7 +5454,7 @@ func TestMatchAndFetchBatch_GetBlockHeadersError(t *testing.T) { // Arrange: Create a nil filter to force a match, bypassing complex // filter logic, then mock a block fetch failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) filters := []*gcs.Filter{nil} results := []scanResult{{ @@ -5427,7 +5485,7 @@ func TestScanBatchWithCFilters_FilterBatchError(t *testing.T) { // Arrange: Setup mock expectations where CFilter retrieval fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}} @@ -5454,7 +5512,7 @@ func TestScanBatch_GetScanDataError(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) mgr := &bwmock.AccountStore{} mockAddrStore.On("ActiveScopedKeyManagers").Return( @@ -5481,7 +5539,7 @@ func TestInitResultsForCFilterScan_Error(t *testing.T) { // Arrange: Setup mock expectations where header retrieval fails during // initialization for a CFilter scan. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}} @@ -5506,6 +5564,7 @@ func TestDispatchScanStrategy_AutoError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodAuto}, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -5542,6 +5601,7 @@ func TestAdvanceChainSync_SmallGap(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), @@ -5587,6 +5647,7 @@ func TestRunSyncStep_BroadcastError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), @@ -5611,7 +5672,7 @@ func TestFetchAndFilterBlocks_DispatchError(t *testing.T) { // Arrange: Setup mock expectations where an invalid sync method is // encountered during block filtering. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain, SyncMethod: 99}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain, SyncMethod: 99}, nil, nil, nil, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}} mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( @@ -5644,6 +5705,7 @@ func TestAdvanceChainSync_ScanBatchError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), @@ -5673,6 +5735,7 @@ func TestDispatchScanStrategy_FullBlocksError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodFullBlocks}, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -5700,6 +5763,7 @@ func TestExtractAddrEntries_NonStd(t *testing.T) { s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, nil, + &walletmock.Store{}, 0, ) pkh, err := address.NewAddressPubKeyHash( @@ -5738,7 +5802,7 @@ func TestAdvanceChainSync_GetBestBlockError(t *testing.T) { // Arrange: Setup mock expectations where GetBestBlock fails during // chain sync advancement. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil) + s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) mockChain.On("GetBestBlock").Return((*chainhash.Hash)(nil), int32(0), errBestBlock).Once() @@ -5766,7 +5830,7 @@ func TestAdvanceChainSyncStoreSyncedTip(t *testing.T) { s := newSyncer( Config{Chain: mockChain, Name: "store-sync"}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) mockChain.On("GetBestBlock").Return( @@ -5804,7 +5868,7 @@ func TestDispatchScanStrategy_AutoDefaultThreshold(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodAuto, MaxCFilterItems: 0, - }, nil, nil, nil) + }, nil, nil, nil, &walletmock.Store{}, 0) hashes := []chainhash.Hash{{0x01}} scanState := NewRecoveryState(1, nil, nil) @@ -5843,6 +5907,7 @@ func TestAdvanceChainSync_LargeGap(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, nil, + &walletmock.Store{}, 0, ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(110), @@ -5894,7 +5959,7 @@ func TestCheckRollbackStoreSyncedTip(t *testing.T) { s := newSyncer( Config{Chain: mockChain, Name: "rollback-store"}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) // syncedTip reads the current tip (height 100) from the Store. @@ -5975,7 +6040,7 @@ func TestScanWithRewindStoreSyncedTip(t *testing.T) { store := &walletmock.Store{} s := newSyncer( Config{Name: "rewind-store"}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) store.On("GetWallet", mock.Anything, "rewind-store").Return( @@ -5994,17 +6059,11 @@ func TestScanWithRewindStoreSyncedTip(t *testing.T) { } // Because the Store tip (100) is above the requested start (50), - // the manual rewind rolls this wallet's tx state and sync metadata - // back to the requested start block. + // the manual rewind rewinds this wallet's tx state and sync metadata + // without deleting shared block rows. store.On( - "RewindWallet", mock.Anything, db.RewindWalletParams{ - WalletID: walletID, - Block: db.Block{ - Hash: start.Hash, - Height: uint32(start.Height), - Timestamp: start.Timestamp, - }, - }, + "RewindWallet", mock.Anything, + matchRewindWalletParams(walletID, start), ).Return(nil).Once() // Act: request a rewind rescan. @@ -6031,7 +6090,7 @@ func TestScanWithRewindStoreSyncedTip(t *testing.T) { s := newSyncer( Config{Name: "rewind-noop"}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, walletID, ) store.On("GetWallet", mock.Anything, "rewind-noop").Return( From ca1bfae8128d6df96e57ddeef47d87088f6eeeaf Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 09:29:16 +0800 Subject: [PATCH 5/8] wallet: make syncer rollback and lifecycle tests Store-only Rewrite the rollback, initialization, run-loop, run-step, wait, and init-chain-sync tests to drive the syncer through the Store instead of the legacy addrStore/walletdb setup: replace setupTestDB and mockAddrStore wiring with walletmock.Store expectations, and add the expectSyncedTip helper that stubs the synced tip via Store.GetWallet. Drop TestLoadScanDataLegacyFallback and TestRewindToBlockFallsBackToLegacy, which covered the now-removed nil-store fallbacks. --- wallet/syncer_test.go | 619 +++++++++++++++++++++--------------------- 1 file changed, 309 insertions(+), 310 deletions(-) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index a4ad567f61..217d48b274 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -64,7 +64,10 @@ func TestSyncerRequestScan(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, + ) req := &scanReq{ typ: scanTypeRewind, @@ -97,7 +100,10 @@ func TestSyncerRequestScanBlocked(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{}, mockAddrStore, mockTxStore, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{}, mockAddrStore, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, + ) // Fill the buffer (size 1). s.scanReqChan <- &scanReq{} @@ -152,7 +158,10 @@ func TestWaitUntilBackendSynced(t *testing.T) { // Arrange: Initialize a syncer and mock its chain to simulate a // delayed synchronization. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) // Simulate the backend not being current on the first check, but // becoming current on the second check. @@ -170,28 +179,19 @@ func TestWaitUntilBackendSynced(t *testing.T) { func TestCheckRollbackNoReorg(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and mock chain. - dbConn, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} + // Arrange: Initialize a store-backed syncer and mock chain. mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, mockAddrStore, nil, nil, + Config{Chain: mockChain}, nil, nil, nil, store, 0, ) tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{ - Hash: tip.Hash, - Height: uint32(tip.Height), - }, - }, nil).Once() + expectSyncedTip(store, tip) - // Mock retrieval of synced block hashes from the Store for the last 10 + // Mock retrieval of synced block hashes from the store for the last 10 // blocks. localBlocks := make([]db.Block, 0, 10) for i := uint32(91); i <= 100; i++ { @@ -200,6 +200,7 @@ func TestCheckRollbackNoReorg(t *testing.T) { Height: i, }) } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ StartHeight: 91, EndHeight: 100, @@ -219,39 +220,29 @@ func TestCheckRollbackNoReorg(t *testing.T) { // and no rollback is triggered when hashes match. err := s.checkRollback(t.Context()) require.NoError(t, err) + store.AssertExpectations(t) } // TestCheckRollbackDetected verifies checkRollback when reorg is detected. func TestCheckRollbackDetected(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and mocks to - // simulate a chain reorganization. - dbConn, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} + // Arrange: Initialize a store-backed syncer and mocks to simulate a + // chain reorganization. mockChain := &bwmock.Chain{} - mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, mockAddrStore, mockTxStore, - mockPublisher, + Config{Chain: mockChain}, nil, nil, mockPublisher, store, 0, ) tip := waddrmgr.BlockStamp{Height: 100, Hash: chainhash.Hash{0x01}} - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{ - Hash: tip.Hash, - Height: uint32(tip.Height), - }, - }, nil).Once() + expectSyncedTip(store, tip) - // Mock retrieval of synced block hashes from the Store for blocks 91 to - // 100. + // Mock retrieval of synced block hashes from the store for blocks 91 + // to 100. localBlocks := make([]db.Block, 0, 10) for i := uint32(91); i <= 100; i++ { localBlocks = append(localBlocks, db.Block{ @@ -259,6 +250,7 @@ func TestCheckRollbackDetected(t *testing.T) { Height: i, }) } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ StartHeight: 91, EndHeight: 100, @@ -285,7 +277,9 @@ func TestCheckRollbackDetected(t *testing.T) { header := &wire.BlockHeader{Timestamp: time.Now()} mockChain.On("GetBlockHeader", &forkHash).Return(header, nil).Once() - // Expect a rollback to the common ancestor at height 95. + // Expect a single atomic rollback to the common ancestor: rewindToBlock + // rolls transaction state back and rewinds the sync tip together via + // RollbackToBlock for the rollback boundary (fork height 95 + 1). store.On( "RollbackToBlock", mock.Anything, uint32(96), ).Return(nil).Once() @@ -294,21 +288,21 @@ func TestCheckRollbackDetected(t *testing.T) { // fork and performs the rollback. err := s.checkRollback(t.Context()) require.NoError(t, err) + store.AssertExpectations(t) } // TestInitChainSync verifies the initial synchronization sequence. func TestInitChainSync(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer and mock its dependencies for the - // initial synchronization sequence. + // Arrange: Initialize a store-backed syncer and mock its dependencies + // for the initial synchronization sequence. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockPublisher := &mockTxPublisher{} store := &walletmock.Store{} + mockPublisher := &mockTxPublisher{} s := newSyncer( - Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + Config{Chain: mockChain}, nil, nil, mockPublisher, store, 0, ) @@ -318,10 +312,10 @@ func TestInitChainSync(t *testing.T) { // Mock block notification registration. mockChain.On("NotifyBlocks").Return(nil).Once() - // Mock rollback check at the start of synchronization. - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Height: 0}, - }, nil).Once() + // Mock the synced tip read at the start of the rollback check. A height + // of 0 means checkRollback reads the tip and returns without scanning + // any block ranges. + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) // Act & Assert: Verify that the initial chain synchronization // sequence completes successfully. @@ -337,7 +331,10 @@ func TestScanBatchHeadersOnly(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}, {0x02}} mockChain.On( @@ -365,59 +362,54 @@ func TestScanBatchHeadersOnly(t *testing.T) { func TestSyncerLoadScanState(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and set up complex - // mock expectations for loading wallet scan data. - dbConn, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: Initialize a store-backed syncer and set up mock + // expectations for loading wallet scan data. Scan-data loading reads + // account horizons, addresses, and watch outputs through the store, + // while the recovery state still derives the lookahead window through + // the legacy address manager. + const walletID uint32 = 21 + store := &walletmock.Store{} mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} s := newSyncer( Config{ - DB: dbConn, RecoveryWindow: 10, ChainParams: &chainParams, }, - mockAddrStore, mockTxStore, mockPublisher, - &walletmock.Store{}, 0, + mockAddrStore, nil, mockPublisher, + store, walletID, ) - // Mock active scoped key managers. - scopedMgr := &bwmock.AccountStore{} - mockAddrStore.On( - "ActiveScopedKeyManagers", - ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() - - // Mock active accounts for the key manager scope. - scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() - scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + // The store returns one derived BIP0084 account, used for both the + // horizon and address loads. + accountNumber0 := uint32(0) + store.On("ListAccounts", mock.Anything, mock.MatchedBy( + func(query db.ListAccountsQuery) bool { + return query.WalletID == walletID && query.SkipBalance + }, + )).Return([]db.AccountInfo{{ + AccountNumber: &accountNumber0, + KeyScope: db.KeyScopeBIP0084, + }}, nil).Twice() + expectRecoveryAccountIDLookups(store) - // Mock database operations to fetch scan data, including key managers, - // account properties, active addresses, and outputs to watch. - mockAddrStore.On( - "FetchScopedKeyManager", mock.Anything, - ).Return(scopedMgr, nil).Times(3) + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil, + ).Maybe() - props := &waddrmgr.AccountProperties{ - AccountNumber: 0, - KeyScope: waddrmgr.KeyScopeBIP0084, - } - scopedMgr.On( - "AccountProperties", mock.Anything, uint32(0), - ).Return(props, nil).Twice() + store.On( + "ListOutputsToWatch", mock.Anything, walletID, + ).Return([]db.UtxoInfo(nil), nil).Once() + // The recovery state derives the lookahead window for the account's + // branches through the legacy address manager. + scopedMgr := &bwmock.AccountStore{} mockAddrStore.On( - "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, - ).Return(nil).Once() - - mockTxStore.On( - "OutputsToWatch", mock.Anything, - ).Return([]wtxmgr.Credit(nil), nil).Once() + "FetchScopedKeyManager", mock.Anything, + ).Return(scopedMgr, nil).Maybe() - // Mock address derivation for the lookahead window (10 addresses for - // each branch). mockAddr := &bwmock.Address{} mockAddr.On("EncodeAddress").Return("addr") mockAddr.On("ScriptAddress").Return([]byte{0x00}) @@ -427,12 +419,13 @@ func TestSyncerLoadScanState(t *testing.T) { mockAddr, []byte{0x00}, nil, ).Maybe() - // Act: Load the full scan state from the database. + // Act: Load the full scan state. state, err := s.loadFullScanState(t.Context()) // Assert: Verify that the scan state is correctly loaded and not nil. require.NoError(t, err) require.NotNil(t, state) + store.AssertExpectations(t) } // TestScanBatchWithFullBlocks verifies fallback scan logic. @@ -443,7 +436,10 @@ func TestScanBatchWithFullBlocks(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, + ) mockAddrStore := &bwmock.AddrStore{} scanState := NewRecoveryState( @@ -548,7 +544,10 @@ func TestDispatchScanStrategy(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} @@ -672,7 +671,10 @@ func TestFetchAndFilterBlocks(t *testing.T) { mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, + ) // Create an empty recovery state for testing. scanState := NewRecoveryState(10, &chainParams, nil) @@ -971,6 +973,34 @@ func matchUnminedTxBatch(walletID uint32, tx *wire.MsgTx, ) } +// expectSyncedTip mocks Store.GetWallet so syncedTip returns the given synced +// tip for any wallet name. A height of -1 is encoded as a nil SyncedTo, +// matching how an unsynced wallet is represented; any other height is encoded +// as a stored block. +func expectSyncedTip(store *walletmock.Store, tip waddrmgr.BlockStamp) { + var info db.WalletInfo + if tip.Height >= 0 { + info.SyncedTo = &db.Block{ + Hash: tip.Hash, + Height: uint32(tip.Height), + Timestamp: tip.Timestamp, + } + } + + store.On("GetWallet", mock.Anything, mock.Anything).Return( + &info, nil).Maybe() +} + +// expectRecoveryAccountIDLookups allows scan-state fixtures to satisfy the +// Store account ID lookup without asserting a specific backend row identity. +func expectRecoveryAccountIDLookups(store *walletmock.Store) { + store.On("GetAccount", mock.Anything, mock.MatchedBy( + func(query db.GetAccountQuery) bool { + return query.SkipBalance + }, + )).Return(&db.AccountInfo{}, nil).Maybe() +} + // TestProcessRelevantTxUsesStore verifies that relevant transaction // notifications are routed through the store when store wiring is available. func TestProcessRelevantTxUsesStore(t *testing.T) { @@ -1799,8 +1829,7 @@ func TestStampRecoveryAccountIDsCarriesStableID(t *testing.T) { store := &walletmock.Store{} s := newSyncer( - Config{}, nil, nil, &mockTxPublisher{}, - store, walletID, + Config{}, nil, nil, &mockTxPublisher{}, store, walletID, ) scanState := NewRecoveryState(0, &chainParams, nil) @@ -2991,45 +3020,38 @@ func TestHandleScanReq(t *testing.T) { func TestWaitForEvent(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer and mock its dependencies for testing - // the event loop. + // Arrange: Initialize a store-backed syncer for testing the event loop. mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} - mockAddrStore := &bwmock.AddrStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{ - Chain: mockChain, - DB: db, - }, - mockAddrStore, nil, mockPublisher, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, mockPublisher, + store, uint32(0), ) // Mock chain notifications channel. notificationChan := make(chan any, 1) mockChain.On("Notifications").Return((<-chan any)(notificationChan)) - // Case 1: Test event handling when a chain notification arrives. + // Case 1: Test event handling when a chain notification arrives, which + // advances the synced tip through the store. notificationChan <- chain.BlockConnected{} - // Mock sync progress update resulting from the chain notification. - mockAddrStore.On( - "SetSyncedTo", mock.Anything, mock.Anything, - ).Return(nil).Once() + store.On("UpdateWallet", mock.Anything, mock.Anything).Return( + nil).Once() // Act & Assert: Call waitForEvent and verify it correctly processes // the arriving notification. err := s.waitForEvent(t.Context()) require.NoError(t, err) - // Case 2: Test event handling when a scan request arrives. + // Case 2: Test event handling when a scan request arrives. The + // requested rewind start is at or beyond the current synced tip, so the + // rewind is a no-op. s.scanReqChan <- &scanReq{typ: scanTypeRewind} - mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{}).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{}) // Act & Assert: Call waitForEvent and verify it correctly processes // the arriving scan request. @@ -3041,32 +3063,34 @@ func TestWaitForEvent(t *testing.T) { func TestSyncerFullRun(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and set up - // extensive mocks to simulate a full run loop execution. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Initialize a store-backed syncer and set up extensive mocks + // to simulate a full run loop execution. The synced tip and unmined + // transactions are read through the store; the legacy address manager + // only supplies the backend birthday. mockChain := &bwmock.Chain{} mockAddrStore := &bwmock.AddrStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, mockAddrStore, nil, - mockPublisher, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + store, uint32(0), ) // Mock initial chain sync sequence. mockAddrStore.On("Birthday").Return(time.Now()).Once() mockChain.On("IsCurrent").Return(true).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) - // Mock rollback check dependencies. - mockAddrStore.On( - "BlockHash", mock.Anything, mock.Anything, - ).Return(&chainhash.Hash{}, nil).Maybe() + // Mock rollback check dependencies. The store and the remote chain + // agree, so no rollback occurs. + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{Height: i}) + } + + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + localBlocks, nil).Maybe() // Mock remote hashes for rollback check (batch size 10). remoteHashes := make([]chainhash.Hash, 10) @@ -3080,16 +3104,9 @@ func TestSyncerFullRun(t *testing.T) { "GetBestBlock", ).Return(&chainhash.Hash{}, int32(100), nil).Once() - // Mock synced state retrieval. - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ).Once() - - // Mock retrieval of unmined transactions from the store. - mockTxStore := &bwmock.TxStore{} - s.txStore = mockTxStore - mockTxStore.On("UnminedTxs", mock.Anything).Return( - []*wire.MsgTx(nil), nil, + // Mock retrieval of unmined transactions through the store. + store.On("ListTxns", mock.Anything, mock.Anything).Return( + []db.TxInfo(nil), nil, ).Once() // Set up for the event waiting phase of the run loop. @@ -3167,7 +3184,10 @@ func TestBroadcastUnminedTxns_Error(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{DB: db}, nil, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, + ) mockTxStore.On("UnminedTxs", mock.Anything).Return( ([]*wire.MsgTx)(nil), errDBMockSync, @@ -3446,23 +3466,21 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("CheckRollback_Failure", func(t *testing.T) { t.Parallel() - dbConn, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup a syncer where DB operations fail during - // rollback check. + // Arrange: Setup a store-backed syncer where the synced-block + // read fails during the rollback check. mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, + Config{Chain: mockChain}, nil, nil, nil, store, 0, ) mockChain.On("IsCurrent").Return(true).Maybe() - store.On("GetWallet", mock.Anything, "").Return( - (*db.WalletInfo)(nil), errDBMock, - ).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On( + "ListSyncedBlocks", mock.Anything, mock.Anything, + ).Return(([]db.Block)(nil), errDBMock).Once() // Act: Attempt initialization. err := s.initChainSync(t.Context()) @@ -3474,21 +3492,17 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("NotifyBlocks_Failure", func(t *testing.T) { t.Parallel() - dbConn, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup a syncer where block notifications fail. + // Arrange: Setup a store-backed syncer where block notifications + // fail. mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, + Config{Chain: mockChain}, nil, nil, nil, store, 0, ) mockChain.On("IsCurrent").Return(true).Maybe() - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Height: 0}, - }, nil).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) mockChain.On("NotifyBlocks").Return(errNotify).Once() // Act: Attempt initialization. @@ -3518,21 +3532,23 @@ func TestHandleScanReq_Errors(t *testing.T) { func TestSyncerRun_InitError(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup a syncer where initialization fails. + // Arrange: Setup a store-backed syncer where initialization fails + // because the synced-block read errors during the rollback check. mockChain := &bwmock.Chain{} addrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} - s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, addrStore, nil, nil, + store, uint32(0), + ) addrStore.On("Birthday").Return(time.Now()).Once() mockChain.On("IsCurrent").Return(true).Once() - addrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 100}) - addrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - &chainhash.Hash{}, errDBMock).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + ([]db.Block)(nil), errDBMock).Once() // Act: Run the syncer. err := s.run(t.Context()) @@ -3654,7 +3670,10 @@ func TestBroadcastUnminedTxns_Success(t *testing.T) { mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{DB: db}, nil, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, + ) tx := wire.NewMsgTx(1) mockTxStore.On("UnminedTxs", mock.Anything).Return( @@ -3719,7 +3738,10 @@ func TestWaitForEvent_NotificationsClosed(t *testing.T) { // Arrange: Setup a syncer with a closed notification channel. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) closedChan := make(chan any) close(closedChan) @@ -3740,7 +3762,10 @@ func TestWaitForEvent_ContextCancelled(t *testing.T) { // Arrange: Setup a syncer with a blocking notification channel and a // cancelled context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) blockChan := make(chan any) mockChain.On("Notifications").Return((<-chan any)(blockChan)).Once() @@ -3838,7 +3863,10 @@ func TestInitChainSync_WaitUntilSyncedError(t *testing.T) { // Arrange: Setup mock expectations where the backend is not current, // then cancel the context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("IsCurrent").Return(false).Maybe() @@ -3886,7 +3914,10 @@ func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - s := newSyncer(Config{DB: db}, nil, mockTxStore, mockPublisher, &walletmock.Store{}, 0) + s := newSyncer( + Config{DB: db}, nil, mockTxStore, mockPublisher, + &walletmock.Store{}, 0, + ) tx := wire.NewMsgTx(1) mockTxStore.On("UnminedTxs", mock.Anything).Return( @@ -3905,23 +3936,16 @@ func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { func TestCheckRollback_DBError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where local Store block lookup fails - // during a rollback check. - dbConn, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer where the synced-block read + // fails during a rollback check. store := &walletmock.Store{} - s := newSyncer(Config{DB: dbConn}, nil, nil, nil, store, 0) + s := newSyncer( + Config{}, nil, nil, nil, store, 0, + ) - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Height: 100}, - }, nil).Once() - store.On( - "ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ - StartHeight: 91, - EndHeight: 100, - }, - ).Return(nil, errBlockHash).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + ([]db.Block)(nil), errBlockHash).Once() // Act: Perform a rollback check. err := s.checkRollback(t.Context()) @@ -3935,32 +3959,18 @@ func TestCheckRollback_DBError(t *testing.T) { func TestCheckRollback_RemoteError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where remote hash lookup fails - // during a rollback check. - dbConn, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer where the remote hash lookup + // fails during a rollback check. mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, 0, ) - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Height: 100}, - }, nil).Once() - - localBlocks := make([]db.Block, 0, 10) - for i := uint32(91); i <= 100; i++ { - localBlocks = append(localBlocks, db.Block{ - Hash: chainhash.Hash{byte(i)}, - Height: i, - }) - } - store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ - StartHeight: 91, - EndHeight: 100, - }).Return(localBlocks, nil).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + []db.Block{}, nil).Maybe() mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( ([]chainhash.Hash)(nil), errRemote).Once() @@ -4000,24 +4010,18 @@ func TestFilterBatch_NilFilter(t *testing.T) { func TestInitChainSync_NotifyBlocksError(t *testing.T) { t.Parallel() - dbConn, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup mock expectations where block notification fails. + // Arrange: Setup a store-backed syncer where block notification fails. mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, 0, ) mockChain.On("IsCurrent").Return(true).Once() - mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( - []chainhash.Hash{}, nil).Once() mockChain.On("NotifyBlocks").Return(errNotify).Once() - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Height: 0}, - }, nil).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) // Act: Attempt chain sync initialization. err := s.initChainSync(t.Context()) @@ -4035,7 +4039,10 @@ func TestScanBatchHeadersOnly_Errors(t *testing.T) { // Arrange: Setup mock expectations where GetBlockHashes fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return(([]chainhash.Hash)(nil), @@ -4054,7 +4061,10 @@ func TestScanBatchHeadersOnly_Errors(t *testing.T) { // Arrange: Setup mock expectations where GetBlockHeaders fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return([]chainhash.Hash{{}}, nil).Once() @@ -4074,47 +4084,51 @@ func TestScanBatchHeadersOnly_Errors(t *testing.T) { func TestCheckRollback_HeaderError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations for a rollback check where a + // Arrange: Setup a store-backed syncer for a rollback check where a // header fetch failure occurs at the fork point. - dbConn, cleanup := setupTestDB(t) - defer cleanup() - mockChain := &bwmock.Chain{} store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: dbConn}, nil, nil, nil, store, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, 0, ) - hashA := &chainhash.Hash{0x0A} - hashB := &chainhash.Hash{0x0B} - hashC := chainhash.Hash{0x0C} + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 101}) - store.On("GetWallet", mock.Anything, "").Return(&db.WalletInfo{ - SyncedTo: &db.Block{Hash: *hashB, Height: 101}, - }, nil).Once() + hashA := chainhash.Hash{0x0A} + hashB := chainhash.Hash{0x0B} + hashC := chainhash.Hash{0x0C} + // The store returns the local synced blocks for the range [92, 101] + // ascending: heights 92..99 are unique fillers, height 100 is hashA and + // height 101 is hashB. localBlocks := make([]db.Block, 0, 10) - for i := uint32(92); i <= 99; i++ { - localBlocks = append(localBlocks, db.Block{ - Hash: chainhash.Hash{byte(i)}, - Height: i, - }) + for h := uint32(92); h <= 101; h++ { + block := db.Block{Hash: chainhash.Hash{byte(h)}, Height: h} + switch h { + case 100: + block.Hash = hashA + case 101: + block.Hash = hashB + } + + localBlocks = append(localBlocks, block) } - localBlocks = append(localBlocks, - db.Block{Hash: *hashA, Height: 100}, - db.Block{Hash: *hashB, Height: 101}, - ) + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ StartHeight: 92, EndHeight: 101, }).Return(localBlocks, nil).Once() + // The remote chain agrees up to height 100 (hashA at index 8) but + // diverges at the tip (hashC at index 9), so the fork point is height + // 100 and the syncer fetches that block's header. remoteHashes := make([]chainhash.Hash, 10) - remoteHashes[8] = *hashA + remoteHashes[8] = hashA remoteHashes[9] = hashC mockChain.On("GetBlockHashes", int64(92), int64(101)).Return( remoteHashes, nil).Once() - mockChain.On("GetBlockHeader", hashA).Return( + mockChain.On("GetBlockHeader", &hashA).Return( (*wire.BlockHeader)(nil), errHeader).Once() // Act: Perform the rollback check. @@ -4236,7 +4250,10 @@ func TestInitChainSync_Neutrino(t *testing.T) { // Birthday called by SetStartTime. mockAddrStore.On("Birthday").Return(time.Time{}).Once() - s := newSyncer(Config{Chain: nc}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: nc}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) // Cancel context immediately to abort waitUntilBackendSynced. ctx, cancel := context.WithCancel(t.Context()) @@ -4290,7 +4307,10 @@ func TestScanBatchWithFullBlocks_ProcessError(t *testing.T) { defer cleanup() mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain, DB: db}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain, DB: db}, nil, nil, nil, + &walletmock.Store{}, 0, + ) addrStore := &bwmock.AccountStore{} rs := NewRecoveryState(10, &chainParams, nil) @@ -4539,7 +4559,10 @@ func TestScanWithTargets_Errors(t *testing.T) { defer cleanup() mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{DB: db}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return( nil, errFetchFail).Once() @@ -5057,40 +5080,33 @@ func TestFetchAndFilterBlocks_BatchCapping(t *testing.T) { func TestRunSyncStep_Unfinished(t *testing.T) { t.Parallel() - // Arrange: Setup a syncer and mock an incomplete sync state. + // Arrange: Setup a store-backed syncer with an incomplete sync state. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{ - Chain: mockChain, - DB: db, - }, mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 90}).Maybe() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 90}) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), nil).Once() - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore(nil)).Maybe() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil).Maybe() - - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Maybe() + // The scan over the gap reads empty scan data through the store and + // advances the synced tip through the store batch. + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), nil).Maybe() + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil).Maybe() + store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( + ([]db.UtxoInfo)(nil), nil).Maybe() mockChain.On("GetBlockHashes", int64(91), int64(100)).Return( []chainhash.Hash{{0x01}}, nil).Once() mockChain.On("GetBlockHeaders", mock.Anything).Return( []*wire.BlockHeader{{}}, nil).Once() - mockAddrStore.On("SetSyncedTo", mock.Anything, - mock.Anything).Return(nil).Maybe() + store.On("ApplyScanBatch", mock.Anything, mock.Anything).Return( + nil).Maybe() // Act: Execute a sync step. err := s.runSyncStep(t.Context()) @@ -5225,36 +5241,30 @@ func TestHandleChainUpdate_Error(t *testing.T) { func TestRunSyncStep_Success(t *testing.T) { t.Parallel() - // Arrange: Setup a syncer and mock a notification arrival to trigger - // the idle processing path. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer and mock a notification arrival + // to trigger the idle processing path. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} + store := &walletmock.Store{} s := newSyncer( - Config{ - Chain: mockChain, - DB: db, - }, mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Maybe() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), nil).Once() - mockTxStore.On("UnminedTxs", mock.Anything).Return([]*wire.MsgTx{}, - nil).Once() + store.On("ListTxns", mock.Anything, mock.Anything).Return( + []db.TxInfo{}, nil).Once() notifChan := make(chan any, 1) mockChain.On("Notifications").Return((<-chan any)(notifChan)).Maybe() notifChan <- chain.BlockConnected{Block: wtxmgr.Block{Height: 101}} - mockAddrStore.On("SetSyncedTo", mock.Anything, - mock.Anything).Return(nil).Once() + // The queued BlockConnected notification advances the synced tip + // through the store. + store.On("UpdateWallet", mock.Anything, mock.Anything).Return( + nil).Once() // Act: Execute a sync step. err := s.runSyncStep(t.Context()) @@ -5278,7 +5288,10 @@ func TestScanBatchWithCFilters_HorizonExpansion(t *testing.T) { db, cleanup := setupTestDB(t) defer cleanup() - s := newSyncer(Config{Chain: mockChain, DB: db}, addrStore, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}, {0x02}} @@ -5347,33 +5360,22 @@ func TestScanBatchWithCFilters_HorizonExpansion(t *testing.T) { func TestRunSyncStep_AdvanceError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations to simulate a failure during - // loadFullScanState within runSyncStep. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer where loading the scan state + // fails within runSyncStep. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Maybe() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) mockChain.On("GetBestBlock").Return( &chainhash.Hash{}, int32(101), nil).Once() - mgr := &bwmock.AccountStore{} - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore{mgr}).Once() - mgr.On("ActiveAccounts").Return([]uint32{0}).Once() - mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() - - mockAddrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0084).Return(nil, errLoadStateFail).Once() + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), errLoadStateFail).Once() // Act: Execute a single sync step. err := s.runSyncStep(t.Context()) @@ -5635,27 +5637,21 @@ func TestAdvanceChainSync_SmallGap(t *testing.T) { func TestRunSyncStep_BroadcastError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where a broadcast-related failure - // occurs during a sync step. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer where the unmined-transaction + // read fails during a sync step. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), nil).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Maybe() - mockTxStore.On("UnminedTxs", mock.Anything).Return([]*wire.MsgTx(nil), - errBroadcast).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListTxns", mock.Anything, mock.Anything).Return( + ([]db.TxInfo)(nil), errBroadcast).Once() // Act: Execute a sync step. err := s.runSyncStep(t.Context()) @@ -5672,7 +5668,10 @@ func TestFetchAndFilterBlocks_DispatchError(t *testing.T) { // Arrange: Setup mock expectations where an invalid sync method is // encountered during block filtering. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain, SyncMethod: 99}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain, SyncMethod: 99}, nil, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}} mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( From 8f0fc5968790d9920302824bd6222b05d05e7c78 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 09:29:30 +0800 Subject: [PATCH 6/8] wallet: make syncer scan tests Store-only Rewrite the scan-strategy, scan-batch, scan-target, advance-chain-sync, fetch-and-filter, dispatch, scan-data loading, and handle-scan-req tests to source horizons, addresses, watched outputs, and the synced tip from the Store via walletmock expectations, replacing the legacy addrStore and walletdb-backed setup. --- wallet/common_test.go | 1 - wallet/syncer_test.go | 598 +++++++++++++++++------------------------- 2 files changed, 236 insertions(+), 363 deletions(-) diff --git a/wallet/common_test.go b/wallet/common_test.go index c09b2eaa82..05500053a9 100644 --- a/wallet/common_test.go +++ b/wallet/common_test.go @@ -28,7 +28,6 @@ var ( errDeriveFail = errors.New("derive fail") errLoadStateFail = errors.New("load state fail") errRollbackFail = errors.New("rollback fail") - errFetchFail = errors.New("fetch fail") errCFilterFail = errors.New("cfilter fail") errActiveMgrsFail = errors.New("active managers fail") diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index 217d48b274..f424e523a6 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -603,43 +603,36 @@ func TestDispatchScanStrategy(t *testing.T) { func TestScanBatch(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and set up mocks - // for the batch scan. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Initialize a store-backed syncer and set up mocks for the + // batch scan. Scan data (one derived account, no addresses, no watch + // outputs) is loaded through the store. mockAddrStore := &bwmock.AddrStore{} mockChain := &bwmock.Chain{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, mockAddrStore, nil, - mockPublisher, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + store, uint32(0), ) - // Mock loading of the full scan state required by the batch scan. + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + []db.AccountInfo{{KeyScope: db.KeyScopeBIP0084}}, nil, + ).Twice() + expectRecoveryAccountIDLookups(store) + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil, + ).Twice() + store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( + []db.UtxoInfo(nil), nil, + ).Once() + + // The recovery state resolves the account's branches through the legacy + // address manager. scopedMgr := &bwmock.AccountStore{} - scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() - scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() - scopedMgr.On( - "AccountProperties", mock.Anything, uint32(0), - ).Return(&waddrmgr.AccountProperties{}, nil).Twice() - mockAddrStore.On( - "ActiveScopedKeyManagers", - ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() mockAddrStore.On( "FetchScopedKeyManager", mock.Anything, - ).Return(scopedMgr, nil).Times(3) - mockAddrStore.On( - "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, - ).Return(nil).Once() - - mockTxStore := &bwmock.TxStore{} - s.txStore = mockTxStore - mockTxStore.On( - "OutputsToWatch", mock.Anything, - ).Return([]wtxmgr.Credit(nil), nil).Once() + ).Return(scopedMgr, nil).Maybe() // Mock expectations for header-only scanning when no targets are // present. @@ -651,16 +644,17 @@ func TestScanBatch(t *testing.T) { "GetBlockHeaders", hashes, ).Return([]*wire.BlockHeader{{}}, nil).Once() - // Expect the sync progress to be updated in the database. - mockAddrStore.On( - "SetSyncedTo", mock.Anything, mock.Anything, - ).Return(nil).Once() + // Expect the scan batch to be written through the store, advancing the + // synced tip. + store.On("ApplyScanBatch", mock.Anything, mock.Anything).Return( + nil).Once() // Act: Perform a batch scan from height 10 to 11. err := s.scanBatch(t.Context(), waddrmgr.BlockStamp{Height: 10}, 11) // Assert: Verify that the batch scan completed successfully. require.NoError(t, err) + store.AssertExpectations(t) } // TestFetchAndFilterBlocks verifies the block fetching and filtering helper. @@ -701,30 +695,29 @@ func TestFetchAndFilterBlocks(t *testing.T) { func TestAdvanceChainSync(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and mocks to - // test the chain synchronization advancement logic. - db, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: Initialize a store-backed syncer and mocks to test the + // chain synchronization advancement logic. + const walletID uint32 = 22 mockChain := &bwmock.Chain{} mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, - mockPublisher, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + store, walletID, ) + // The synced tip is read from the store as height 100 for both the + // already-synced case and the behind case. + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + // Case 1: Test advancement when the wallet is already synced to the // best block. mockChain.On( "GetBestBlock", ).Return(&chainhash.Hash{}, int32(100), nil).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ).Once() // Act & Assert: Advance the chain sync and verify that it correctly // identifies the synced state. @@ -738,37 +731,32 @@ func TestAdvanceChainSync(t *testing.T) { mockChain.On("GetBestBlock").Return( &chainhash.Hash{}, int32(105), nil, ).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, + + // Set up mocks for the batch scan triggered by advancement. Scan data + // is loaded through the store: one derived BIP0084 account, no active + // addresses, and no watch outputs. + accountNumber0 := uint32(0) + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + []db.AccountInfo{{ + AccountNumber: &accountNumber0, + KeyScope: db.KeyScopeBIP0084, + }}, nil, + ).Twice() + expectRecoveryAccountIDLookups(store) + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil, + ).Maybe() + store.On("ListOutputsToWatch", mock.Anything, walletID).Return( + []db.UtxoInfo(nil), nil, ).Once() - // Set up mocks for the batch scan triggered by advancement. - // Mock loading of the full scan state. + // The recovery state resolves the account's branches through the legacy + // address manager. With a zero recovery window no lookahead addresses + // are derived, so DeriveAddr is optional. scopedMgr := &bwmock.AccountStore{} - mockAddrStore.On( - "ActiveScopedKeyManagers", - ).Return([]waddrmgr.AccountStore{scopedMgr}).Once() - scopedMgr.On("ActiveAccounts").Return([]uint32{0}).Once() - scopedMgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() mockAddrStore.On( "FetchScopedKeyManager", mock.Anything, - ).Return(scopedMgr, nil).Times(3) - - props := &waddrmgr.AccountProperties{ - AccountNumber: 0, - KeyScope: waddrmgr.KeyScopeBIP0084, - } - scopedMgr.On( - "AccountProperties", mock.Anything, uint32(0), - ).Return(props, nil).Twice() - mockAddrStore.On( - "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, - ).Return(nil).Once() - - mockTxStore.On( - "OutputsToWatch", mock.Anything, - ).Return([]wtxmgr.Credit(nil), nil).Once() - + ).Return(scopedMgr, nil).Maybe() scopedMgr.On( "DeriveAddr", mock.Anything, mock.Anything, mock.Anything, ).Return( @@ -816,16 +804,17 @@ func TestAdvanceChainSync(t *testing.T) { mockChain.On("GetBlocks", hashes).Return(blocks, nil).Once() - // Expect the sync progress to be updated for each block in the batch. - mockAddrStore.On( - "SetSyncedTo", mock.Anything, mock.Anything, - ).Return(nil).Times(5) + // Expect the batch scan results to be written through the store, which + // advances the synced tip for the batch. + store.On("ApplyScanBatch", mock.Anything, mock.Anything).Return( + nil).Once() // Act & Assert: Advance the chain sync and verify that it triggers // the expected batch scan. finished, err = s.advanceChainSync(t.Context()) require.NoError(t, err) require.False(t, finished) + store.AssertExpectations(t) } // TestHandleChainUpdate verifies notification handling. @@ -2849,10 +2838,10 @@ func matchRewindWalletParams(walletID uint32, func TestHandleScanReq(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer with a test database and mocks to - // test handling of different scan request types. + // Arrange: Initialize a store-backed syncer and mocks to test handling + // of different scan request types. dbConn, cleanup := setupTestDB(t) - defer cleanup() + t.Cleanup(cleanup) mockAddrStore := &bwmock.AddrStore{} mockChain := &bwmock.Chain{} @@ -2885,9 +2874,8 @@ func TestHandleScanReq(t *testing.T) { Timestamp: rewindHeader.Timestamp, } - store.On("GetWallet", mock.Anything, mock.Anything).Return( - &db.WalletInfo{SyncedTo: &db.Block{Height: 100}}, nil, - ).Once() + preRewindTip := waddrmgr.BlockStamp{Height: 100} + expectSyncedTip(store, preRewindTip) mockChain.On("GetBlockHash", int64(start.Height)).Return( &rewindHash, nil, ).Once() @@ -2903,89 +2891,30 @@ func TestHandleScanReq(t *testing.T) { err := s.handleScanReq(t.Context(), req) require.NoError(t, err) - // Case 2: Test handling of a targeted scan request. + // Case 2: Test handling of a targeted scan request. A real-backend + // syncer is used so resolveScanTargets resolves the target to its + // durable name through the manager and the targeted scan reads and + // writes through the real Store. + sTargeted, _ := newStoreScanSyncer(t) + + mockChain = &bwmock.Chain{} + sTargeted.cfg.Chain = mockChain + sTargeted.cfg.RecoveryWindow = MinRecoveryWindow + + // Target the auto-created default derived account at number 0. req = &scanReq{ typ: scanTypeTargeted, startBlock: waddrmgr.BlockStamp{Height: 100}, targets: []waddrmgr.AccountScope{{ Scope: waddrmgr.KeyScopeBIP0084, - Account: 1, + Account: waddrmgr.DefaultAccountNum, }}, } - mockChain = &bwmock.Chain{} - s.cfg.Chain = mockChain + mockChain.On("GetBestBlock").Return( &chainhash.Hash{}, int32(101), nil, ).Once() - // Mock loading of targeted scan data. - scopedMgr := &bwmock.AccountStore{} - mockAddrStore.On( - "FetchScopedKeyManager", mock.Anything, - ).Return(scopedMgr, nil).Times(3) - - // Set up mocks for initializing targeted scan state. - props := &waddrmgr.AccountProperties{ - AccountNumber: 1, - AccountName: "default", - KeyScope: waddrmgr.KeyScopeBIP0084, - } - scopedMgr.On( - "AccountProperties", mock.Anything, uint32(1), - ).Return(props, nil).Twice() - scopedMgr.On( - "AccountName", mock.Anything, uint32(1), - ).Return("default", nil).Once() - - accountID := uint32(7) - accountNumber := uint32(1) - store.On("GetAccount", mock.Anything, mock.MatchedBy( - func(query db.GetAccountQuery) bool { - return query.WalletID == 0 && - query.Scope == db.KeyScopeBIP0084 && - query.Name != nil && *query.Name == "default" && - query.SkipBalance - }, - )).Return(&db.AccountInfo{ - AccountID: &accountID, - AccountName: "default", - AccountNumber: &accountNumber, - KeyScope: db.KeyScopeBIP0084, - }, nil).Twice() - store.On("ListAccounts", mock.Anything, mock.MatchedBy( - func(query db.ListAccountsQuery) bool { - return query.WalletID == 0 && query.SkipBalance - }, - )).Return([]db.AccountInfo(nil), nil).Once() - store.On( - "ListAddresses", mock.Anything, mock.Anything, - ).Return(page.Result[db.AddressInfo, uint32]{}, nil).Maybe() - store.On( - "ListOutputsToWatch", mock.Anything, uint32(0), - ).Return([]db.UtxoInfo(nil), nil).Once() - store.On( - "ApplyScanBatch", mock.Anything, mock.MatchedBy( - func(params db.ScanBatchParams) bool { - return params.WalletID == 0 - }, - ), - ).Return(nil).Once() - - // ActiveAccounts might not be called in targeted scan flow. - scopedMgr.On("ActiveAccounts").Return([]uint32{1}).Maybe() - mockAddrStore.On( - "ForEachRelevantActiveAddress", mock.Anything, mock.Anything, - ).Return(nil).Once() - mockTxStore.On( - "OutputsToWatch", mock.Anything, - ).Return([]wtxmgr.Credit(nil), nil).Once() - - // DeriveAddr is called multiple times during state initialization. - // Use Maybe() to avoid assertions on specific iteration counts. - scopedMgr.On( - "DeriveAddr", mock.Anything, mock.Anything, mock.Anything, - ).Return(&bwmock.Address{}, []byte{}, nil).Maybe() - // Mock block hash retrieval for the targeted scan range. mockChain.On( "GetBlockHashes", int64(100), int64(101), @@ -3012,7 +2941,7 @@ func TestHandleScanReq(t *testing.T) { // Act & Assert: Verify that a targeted scan request is correctly // handled. - err = s.handleScanReq(t.Context(), req) + err = sTargeted.handleScanReq(t.Context(), req) require.NoError(t, err) } @@ -3329,7 +3258,10 @@ func TestScanBatchWithCFilters_GetHeadersFail(t *testing.T) { // Arrange: Setup a syncer and mock CFilter success but header retrieval // failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} @@ -3363,7 +3295,10 @@ func TestFetchAndFilterBlocks_NonEmpty(t *testing.T) { // Arrange: Setup a syncer with a non-empty scan state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3400,7 +3335,10 @@ func TestFetchAndFilterBlocks_Errors(t *testing.T) { // Arrange: Setup a syncer with a non-empty scan state and mock a hash // fetch failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3422,28 +3360,22 @@ func TestFetchAndFilterBlocks_Errors(t *testing.T) { func TestScanBatch_Empty(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup a syncer that returns empty blocks during a batch - // scan. + // Arrange: Setup a store-backed syncer that returns empty blocks during + // a batch scan. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore{}).Once() - - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Once() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil).Once() + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), nil).Twice() + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil).Maybe() + store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( + ([]db.UtxoInfo)(nil), nil).Once() mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( []chainhash.Hash{}, nil).Once() @@ -3786,7 +3718,10 @@ func TestMatchAndFetchBatch_GetBlocksError(t *testing.T) { // Arrange: Create a syncer and setup a recovery state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) state := NewRecoveryState(1, nil, nil) @@ -3886,7 +3821,10 @@ func TestScanBatchHeadersOnly_ContextCancelled(t *testing.T) { // Arrange: Setup mock expectations and a cancelled context. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -4175,47 +4113,32 @@ func TestFilterBatch_Match(t *testing.T) { func TestScanWithTargets_Empty(t *testing.T) { t.Parallel() - // Arrange: Setup a targeted scan where the resulting block batch is - // empty. - db, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: a real-backend syncer so resolveScanTargets resolves the + // targeted default account to its durable name through the manager, and + // storeScanHorizons loads its horizon by that name. The targeted scan + // then returns an empty block batch. + s, _ := newStoreScanSyncer(t) mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - defer mockChain.AssertExpectations(t) - defer mockAddrStore.AssertExpectations(t) - defer mockTxStore.AssertExpectations(t) - - s := newSyncer(Config{ - DB: db, - Chain: mockChain, - SyncMethod: SyncMethodAuto, - MaxCFilterItems: 100, - }, mockAddrStore, mockTxStore, nil, &walletmock.Store{}, 0) + s.cfg.Chain = mockChain + s.cfg.SyncMethod = SyncMethodAuto + s.cfg.MaxCFilterItems = 100 + s.cfg.RecoveryWindow = MinRecoveryWindow + + // Target the auto-created default derived account at number 0. It + // resolves to its durable name and seeds the recovery state with a + // lookahead window, so the scan exercises the filter path rather than + // the header-only shortcut for an empty watchlist. req := &scanReq{ startBlock: waddrmgr.BlockStamp{Height: 100}, - targets: []waddrmgr.AccountScope{ - {Scope: waddrmgr.KeyScopeBIP0084, Account: 0}}, + targets: []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + Account: waddrmgr.DefaultAccountNum, + }}, } - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit{{PkScript: []byte{0x01}}}, nil).Once() - - mgr := &bwmock.AccountStore{} - mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return(mgr, - nil).Times(3) - mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( - &waddrmgr.AccountProperties{}, nil).Once() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.AnythingOfType("func(address.Address) error")).Return( - nil).Once() - // SyncedTo is not called in the targeted scan path. - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Maybe() - mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(100), nil).Once() mockChain.On("GetBlockHashes", int64(100), int64(100)).Return( @@ -4274,7 +4197,10 @@ func TestFetchAndFilterBlocks_HeaderScan(t *testing.T) { // Arrange: Create a syncer with an empty scan state. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, nil, nil) @@ -4459,39 +4385,22 @@ func TestScanWithTargets_Errors(t *testing.T) { t.Run("GetBestBlock_Failure", func(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where GetBestBlock fails - // during targeted scan initialization. - db, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: a real-backend syncer where GetBestBlock fails after + // the targeted scan state has loaded through the Store. + s, _ := newStoreScanSyncer(t) mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - - s := newSyncer( - Config{ - Chain: mockChain, - DB: db, - }, mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, - ) + s.cfg.Chain = mockChain + s.cfg.RecoveryWindow = MinRecoveryWindow req := &scanReq{ startBlock: waddrmgr.BlockStamp{Height: 100}, targets: []waddrmgr.AccountScope{{ - Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + Scope: waddrmgr.KeyScopeBIP0084, + Account: waddrmgr.DefaultAccountNum, }}, } - mgr := &bwmock.AccountStore{} - mockAddrStore.On("FetchScopedKeyManager", - mock.Anything).Return(mgr, nil) - mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( - &waddrmgr.AccountProperties{}, nil) - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil) - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil) mockChain.On("GetBestBlock").Return(nil, int32(0), errBestBlock).Once() @@ -4505,38 +4414,22 @@ func TestScanWithTargets_Errors(t *testing.T) { t.Run("GetBlockHashes_Failure", func(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where GetBlockHashes fails. - db, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: a real-backend syncer where GetBlockHashes fails after + // the targeted scan state has loaded through the Store. + s, _ := newStoreScanSyncer(t) mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - - s := newSyncer( - Config{ - Chain: mockChain, - DB: db, - }, mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, - ) + s.cfg.Chain = mockChain + s.cfg.RecoveryWindow = MinRecoveryWindow req := &scanReq{ startBlock: waddrmgr.BlockStamp{Height: 100}, targets: []waddrmgr.AccountScope{{ - Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + Scope: waddrmgr.KeyScopeBIP0084, + Account: waddrmgr.DefaultAccountNum, }}, } - mgr := &bwmock.AccountStore{} - mockAddrStore.On("FetchScopedKeyManager", - mock.Anything).Return(mgr, nil) - mgr.On("AccountProperties", mock.Anything, mock.Anything).Return( - &waddrmgr.AccountProperties{}, nil).Once() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil).Once() - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Once() mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(200), nil).Once() mockChain.On("GetBlockHashes", mock.Anything, @@ -4553,22 +4446,16 @@ func TestScanWithTargets_Errors(t *testing.T) { t.Run("FetchScopedKeyManager_Failure", func(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations to simulate a fetch failure during - // targeted scan initialization. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer( - Config{DB: db}, mockAddrStore, nil, nil, - &walletmock.Store{}, 0, - ) - - mockAddrStore.On("FetchScopedKeyManager", mock.Anything).Return( - nil, errFetchFail).Once() + // Arrange: a real-backend syncer targeting a scope with no + // registered scoped key manager. resolveScanTargets resolves + // every non-imported target's durable name up front through the + // manager, so the unknown scope fails there -- before any Store + // horizon lookup. + s, _ := newStoreScanSyncer(t) targets := []waddrmgr.AccountScope{{ - Scope: waddrmgr.KeyScopeBIP0084, Account: 0, + Scope: waddrmgr.KeyScope{Purpose: 99, Coin: 0}, + Account: waddrmgr.DefaultAccountNum, }} // Act: Attempt a targeted scan. @@ -4579,8 +4466,12 @@ func TestScanWithTargets_Errors(t *testing.T) { }, ) - // Assert: Verify propagation. - require.ErrorContains(t, err, "fetch fail") + // Assert: the failure surfaces from resolving the scan target's + // scoped manager, not from a later Store lookup. + var mgrErr waddrmgr.ManagerError + require.ErrorAs(t, err, &mgrErr) + require.Equal(t, waddrmgr.ErrScopeNotFound, mgrErr.ErrorCode) + require.ErrorContains(t, err, "fetch scoped manager") }) } @@ -5055,7 +4946,10 @@ func TestFetchAndFilterBlocks_BatchCapping(t *testing.T) { // Arrange: Setup a syncer with expectations for batch capping. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, nil, nil) // Expect GetBlockHashes with a capped range based on recoveryBatchSize. @@ -5388,22 +5282,14 @@ func TestRunSyncStep_AdvanceError(t *testing.T) { func TestLoadFullScanState_Error(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations to simulate a database failure - // when loading scan state. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) - - mgr := &bwmock.AccountStore{} - mgr.On("ActiveAccounts").Return([]uint32{0}).Once() - mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() + // Arrange: Setup a store-backed syncer where loading scan data fails. + store := &walletmock.Store{} + s := newSyncer( + Config{}, nil, nil, nil, store, uint32(0), + ) - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore{mgr}).Once() - mockAddrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0084).Return(nil, errDBMock).Once() + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), errDBMock).Once() // Act: Attempt to load the full scan state. state, err := s.loadFullScanState(t.Context()) @@ -5456,7 +5342,10 @@ func TestMatchAndFetchBatch_GetBlockHeadersError(t *testing.T) { // Arrange: Create a nil filter to force a match, bypassing complex // filter logic, then mock a block fetch failure. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) filters := []*gcs.Filter{nil} results := []scanResult{{ @@ -5487,7 +5376,10 @@ func TestScanBatchWithCFilters_FilterBatchError(t *testing.T) { // Arrange: Setup mock expectations where CFilter retrieval fails. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}} @@ -5508,21 +5400,15 @@ func TestScanBatchWithCFilters_FilterBatchError(t *testing.T) { func TestScanBatch_GetScanDataError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where scan data loading fails + // Arrange: Setup a store-backed syncer where scan data loading fails // during a batch scan. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) + store := &walletmock.Store{} + s := newSyncer( + Config{}, nil, nil, nil, store, uint32(0), + ) - mgr := &bwmock.AccountStore{} - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore{mgr}).Once() - mgr.On("ActiveAccounts").Return([]uint32{0}).Once() - mgr.On("Scope").Return(waddrmgr.KeyScopeBIP0084).Once() - mockAddrStore.On("FetchScopedKeyManager", - waddrmgr.KeyScopeBIP0084).Return(nil, errActiveMgrsFail).Once() + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), errActiveMgrsFail).Once() // Act: Attempt to execute scanBatch. err := s.scanBatch( @@ -5541,7 +5427,10 @@ func TestInitResultsForCFilterScan_Error(t *testing.T) { // Arrange: Setup mock expectations where header retrieval fails during // initialization for a CFilter scan. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}} @@ -5591,38 +5480,32 @@ func TestDispatchScanStrategy_AutoError(t *testing.T) { func TestAdvanceChainSync_SmallGap(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations for a small gap where silent sync - // is preferred. + // Arrange: Setup a store-backed syncer for a small gap where silent + // sync is preferred. The wallet has no accounts to scan, so the scan + // batch only advances the synced tip. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), nil).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Once() - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore(nil)).Once() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil).Once() - - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), nil).Twice() + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil).Maybe() + store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( + ([]db.UtxoInfo)(nil), nil).Once() mockChain.On("GetBlockHashes", int64(101), int64(105)).Return( []chainhash.Hash{{0x01}}, nil).Once() mockChain.On("GetBlockHeaders", mock.Anything).Return( []*wire.BlockHeader{{}}, nil).Once() - mockAddrStore.On("SetSyncedTo", mock.Anything, - mock.Anything).Return(nil).Once() + store.On("ApplyScanBatch", mock.Anything, mock.Anything).Return( + nil).Once() // Act: Advance chain sync. finished, err := s.advanceChainSync(t.Context()) @@ -5631,6 +5514,7 @@ func TestAdvanceChainSync_SmallGap(t *testing.T) { require.NoError(t, err) require.False(t, finished) require.Equal(t, uint32(syncStateBackendSyncing), s.state.Load()) + store.AssertExpectations(t) } // TestRunSyncStep_BroadcastError verifies error propagation. @@ -5696,25 +5580,18 @@ func TestAdvanceChainSync_ScanBatchError(t *testing.T) { // Arrange: Setup mock expectations where address iteration fails // during chain sync advancement. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(105), nil).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Once() - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore(nil)).Once() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(errScan).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), errScan).Once() // Act: Advance chain sync. finished, err := s.advanceChainSync(t.Context()) @@ -5801,7 +5678,10 @@ func TestAdvanceChainSync_GetBestBlockError(t *testing.T) { // Arrange: Setup mock expectations where GetBestBlock fails during // chain sync advancement. mockChain := &bwmock.Chain{} - s := newSyncer(Config{Chain: mockChain}, nil, nil, nil, &walletmock.Store{}, 0) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBestBlock").Return((*chainhash.Hash)(nil), int32(0), errBestBlock).Once() @@ -5894,42 +5774,36 @@ func TestDispatchScanStrategy_AutoDefaultThreshold(t *testing.T) { func TestAdvanceChainSync_LargeGap(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations for a large sync gap where explicit - // scanning is triggered. + // Arrange: Setup a store-backed syncer for a large sync gap where the + // syncing state is set before the scan runs. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, nil, + store, uint32(0), ) mockChain.On("GetBestBlock").Return(&chainhash.Hash{}, int32(110), nil).Once() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Once() - - // The following mocks use Maybe() because for a large gap, the syncer - // transitions to SyncStateSyncing and returns early, skipping these - // calls. - mockAddrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore(nil)).Maybe() - mockAddrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything).Return(nil).Maybe() - - mockTxStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil).Maybe() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + + // The scan over the gap reads empty scan data and advances the synced + // tip through the store batch. These use Maybe() because the assertion + // only cares that the syncing state is set, which happens before the + // scan. + store.On("ListAccounts", mock.Anything, mock.Anything).Return( + ([]db.AccountInfo)(nil), nil).Maybe() + store.On("ListAddresses", mock.Anything, mock.Anything).Return( + page.Result[db.AddressInfo, uint32]{}, nil).Maybe() + store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( + ([]db.UtxoInfo)(nil), nil).Maybe() mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( []chainhash.Hash{{0x01}}, nil).Maybe() mockChain.On("GetBlockHeaders", mock.Anything).Return( []*wire.BlockHeader{{}}, nil).Maybe() - mockAddrStore.On("SetSyncedTo", mock.Anything, - mock.Anything).Return(nil).Maybe() + store.On("ApplyScanBatch", mock.Anything, mock.Anything).Return( + nil).Maybe() // Act: Advance chain sync. finished, err := s.advanceChainSync(t.Context()) From b4e68de81f5b9c2aadce717501b120c8bdc92c8b Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Wed, 10 Jun 2026 09:29:46 +0800 Subject: [PATCH 7/8] wallet: make syncer chain-update and broadcast tests Store-only Rewrite the handle-chain-update, process-chain-update, and broadcast unmined-txn tests to read the synced tip and unmined transactions from the Store via walletmock expectations, and add the serializeTx helper that encodes a transaction for stubbing TxInfo.SerializedTx. --- wallet/syncer_test.go | 299 ++++++++++++++++++++++-------------------- 1 file changed, 160 insertions(+), 139 deletions(-) diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index f424e523a6..4603a823ef 100644 --- a/wallet/syncer_test.go +++ b/wallet/syncer_test.go @@ -622,7 +622,7 @@ func TestScanBatch(t *testing.T) { expectRecoveryAccountIDLookups(store) store.On("ListAddresses", mock.Anything, mock.Anything).Return( page.Result[db.AddressInfo, uint32]{}, nil, - ).Twice() + ).Maybe() store.On("ListOutputsToWatch", mock.Anything, mock.Anything).Return( []db.UtxoInfo(nil), nil, ).Once() @@ -821,47 +821,49 @@ func TestAdvanceChainSync(t *testing.T) { func TestHandleChainUpdate(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer and mock its dependencies for - // handling chain updates. - db, cleanup := setupTestDB(t) - defer cleanup() + // Arrange: Initialize a store-backed syncer for handling chain + // updates. + const walletID uint32 = 23 mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, + Config{Chain: mockChain, ChainParams: &chainParams}, nil, nil, mockPublisher, - &walletmock.Store{}, 0, + store, walletID, ) - // Case 1: Test handling of a BlockConnected notification. + // Case 1: Test handling of a BlockConnected notification, which + // advances the synced tip through the store. meta := wtxmgr.BlockMeta{Block: wtxmgr.Block{Height: 100}} - mockAddrStore.On( - "SetSyncedTo", mock.Anything, mock.Anything, - ).Return(nil).Once() + store.On("UpdateWallet", mock.Anything, mock.Anything).Return( + nil).Once() // Act & Assert: Verify that a BlockConnected notification is // correctly processed. err := s.handleChainUpdate(t.Context(), chain.BlockConnected(meta)) require.NoError(t, err) - // Case 2: Test handling of a RelevantTx notification. + // Case 2: Test handling of a RelevantTx notification, which records the + // transaction through the store batch path. tx := wire.NewMsgTx(1) rec, err := wtxmgr.NewTxRecordFromMsgTx(tx, time.Now()) require.NoError(t, err) - mockTxStore.On( - "InsertUnconfirmedTx", mock.Anything, mock.Anything, - mock.Anything, - ).Return(nil).Once() + store.On("GetTx", mock.Anything, db.GetTxQuery{ + WalletID: walletID, + Txid: rec.Hash, + }).Return(nil, db.ErrTxNotFound).Once() + store.On("ApplyTxBatch", mock.Anything, mock.Anything).Return( + nil).Once() // Act & Assert: Verify that a RelevantTx notification is correctly // processed. err = s.handleChainUpdate(t.Context(), chain.RelevantTx{TxRecord: rec}) require.NoError(t, err) + store.AssertExpectations(t) } // newBareMultisigCreditScript returns one member address, that member's own @@ -990,6 +992,18 @@ func expectRecoveryAccountIDLookups(store *walletmock.Store) { )).Return(&db.AccountInfo{}, nil).Maybe() } +// serializeTx returns the wire encoding of tx, for stubbing TxInfo.SerializedTx +// in store-backed unmined-transaction reads. +func serializeTx(t *testing.T, tx *wire.MsgTx) []byte { + t.Helper() + + var buf bytes.Buffer + + require.NoError(t, tx.Serialize(&buf)) + + return buf.Bytes() +} + // TestProcessRelevantTxUsesStore verifies that relevant transaction // notifications are routed through the store when store wiring is available. func TestProcessRelevantTxUsesStore(t *testing.T) { @@ -3066,29 +3080,28 @@ var ( func TestProcessChainUpdate_Disconnect(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer and mock its dependencies for handling - // a block disconnect. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Initialize a store-backed syncer for handling a block + // disconnect. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} mockPublisher := &mockTxPublisher{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, mockAddrStore, mockTxStore, - mockPublisher, - &walletmock.Store{}, 0, + Config{Chain: mockChain}, nil, nil, mockPublisher, + store, uint32(0), ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) - mockAddrStore.On( - "BlockHash", mock.Anything, mock.Anything, - ).Return(&chainhash.Hash{}, nil).Maybe() + // The store and the remote chain agree (all-zero hashes), so the + // rollback check finds no reorg. + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{Height: i}) + } + + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + localBlocks, nil).Once() remoteHashes := make([]chainhash.Hash, 10) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return( @@ -3099,27 +3112,25 @@ func TestProcessChainUpdate_Disconnect(t *testing.T) { // that it triggers a rollback check. err := s.processChainUpdate(t.Context(), chain.BlockDisconnected{}) require.NoError(t, err) + store.AssertExpectations(t) } // TestBroadcastUnminedTxns_Error verifies error handling. func TestBroadcastUnminedTxns_Error(t *testing.T) { t.Parallel() - // Arrange: Initialize a syncer and mock an error during unmined - // transactions retrieval. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockTxStore := &bwmock.TxStore{} + // Arrange: Initialize a store-backed syncer and mock an error during + // unmined transaction retrieval. + store := &walletmock.Store{} mockPublisher := &mockTxPublisher{} s := newSyncer( - Config{DB: db}, nil, mockTxStore, mockPublisher, - &walletmock.Store{}, 0, + Config{}, nil, nil, mockPublisher, + store, uint32(0), ) - mockTxStore.On("UnminedTxs", mock.Anything).Return( - ([]*wire.MsgTx)(nil), errDBMockSync, + store.On("ListTxns", mock.Anything, mock.Anything).Return( + ([]db.TxInfo)(nil), errDBMockSync, ).Once() // Act & Assert: Verify that broadcasting unmined transactions @@ -3494,35 +3505,35 @@ func TestSyncerRun_InitError(t *testing.T) { func TestHandleChainUpdate_BlockDisconnected(t *testing.T) { t.Parallel() - // Arrange: Setup a syncer and dependencies for handling updates. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} + // Arrange: Setup a store-backed syncer for handling updates. mockChain := &bwmock.Chain{} + store := &walletmock.Store{} s := newSyncer( Config{ Chain: mockChain, ChainParams: &chainParams, - DB: db, }, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + nil, nil, nil, + store, uint32(0), ) - // 1. BlockDisconnected. - mockTxStore.On("Rollback", mock.Anything, int32(100)).Return(nil).Once() - mockAddrStore.On("SetSyncedTo", mock.Anything, mock.Anything).Return( - nil).Once() - mockAddrStore.On("SyncedTo").Return(waddrmgr.BlockStamp{Height: 100}) + // 1. BlockDisconnected. The store and the remote chain agree on blocks + // 91..100, so the rollback check finds no reorg. + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) - for i := int32(91); i <= 100; i++ { - hash := chainhash.Hash{byte(i)} - mockAddrStore.On("BlockHash", mock.Anything, i).Return( - &hash, nil).Maybe() + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{ + Hash: chainhash.Hash{byte(i)}, + Height: i, + }) } + store.On("ListSyncedBlocks", mock.Anything, db.ListSyncedBlocksQuery{ + StartHeight: 91, + EndHeight: 100, + }).Return(localBlocks, nil).Once() + remoteHashes := make([]chainhash.Hash, 10) for i := range 10 { remoteHashes[i] = chainhash.Hash{byte(91 + i)} @@ -3540,6 +3551,7 @@ func TestHandleChainUpdate_BlockDisconnected(t *testing.T) { // Assert: Verify success. require.NoError(t, err) + store.AssertExpectations(t) } // TestDispatchScanStrategy_AutoFallback verifies fallback to full blocks @@ -3594,30 +3606,37 @@ func TestDispatchScanStrategy_AutoFallback(t *testing.T) { func TestBroadcastUnminedTxns_Success(t *testing.T) { t.Parallel() - // Arrange: Setup a syncer and mock successful transaction retrieval - // and broadcast. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockTxStore := &bwmock.TxStore{} + // Arrange: Setup a store-backed syncer and mock successful unmined + // transaction retrieval and broadcast. + store := &walletmock.Store{} mockPublisher := &mockTxPublisher{} s := newSyncer( - Config{DB: db}, nil, mockTxStore, mockPublisher, - &walletmock.Store{}, 0, + Config{}, nil, nil, mockPublisher, + store, uint32(0), ) tx := wire.NewMsgTx(1) - mockTxStore.On("UnminedTxs", mock.Anything).Return( - []*wire.MsgTx{tx}, nil, + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x10}, + }}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{0x51}}) + store.On("ListTxns", mock.Anything, mock.Anything).Return( + []db.TxInfo{{ + Status: db.TxStatusPublished, + SerializedTx: serializeTx(t, tx), + }}, nil, ).Once() - mockPublisher.On("Broadcast", mock.Anything, tx, "").Return(nil).Once() + mockPublisher.On( + "Broadcast", mock.Anything, mock.Anything, "", + ).Return(nil).Once() // Act: Broadcast unmined transactions. err := s.broadcastUnminedTxns(t.Context()) // Assert: Verify success. require.NoError(t, err) + store.AssertExpectations(t) } // TestFilterBatch_EmptyFilter verifies that empty filters force download. @@ -3845,23 +3864,29 @@ func TestScanBatchHeadersOnly_ContextCancelled(t *testing.T) { func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where a transaction broadcast fails. + // Arrange: Setup a store-backed syncer where a transaction broadcast + // fails. mockPublisher := &mockTxPublisher{} - mockTxStore := &bwmock.TxStore{} - - db, cleanup := setupTestDB(t) - defer cleanup() + store := &walletmock.Store{} s := newSyncer( - Config{DB: db}, nil, mockTxStore, mockPublisher, - &walletmock.Store{}, 0, + Config{}, nil, nil, mockPublisher, + store, uint32(0), ) tx := wire.NewMsgTx(1) - mockTxStore.On("UnminedTxs", mock.Anything).Return( - []*wire.MsgTx{tx}, nil).Once() - mockPublisher.On("Broadcast", mock.Anything, tx, "").Return( - errBroadcast).Once() + tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{0x10}, + }}) + tx.AddTxOut(&wire.TxOut{Value: 1000, PkScript: []byte{0x51}}) + store.On("ListTxns", mock.Anything, mock.Anything).Return( + []db.TxInfo{{ + Status: db.TxStatusPublished, + SerializedTx: serializeTx(t, tx), + }}, nil).Once() + mockPublisher.On( + "Broadcast", mock.Anything, mock.Anything, "", + ).Return(errBroadcast).Once() // Act: Broadcast unmined transactions. err := s.broadcastUnminedTxns(t.Context()) @@ -4358,16 +4383,14 @@ func TestDispatchScanStrategy_AutoFallback_Final(t *testing.T) { func TestProcessChainUpdate_Disconnected(t *testing.T) { t.Parallel() - // Arrange: Create a syncer with a database and verify initial sync - // state. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) + // Arrange: Create a store-backed syncer reporting an unsynced tip, so + // the rollback check returns without scanning any block ranges. + store := &walletmock.Store{} + s := newSyncer( + Config{}, nil, nil, nil, store, uint32(0), + ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 0}).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) // Act: Process a BlockDisconnected update. err := s.processChainUpdate( @@ -4376,6 +4399,7 @@ func TestProcessChainUpdate_Disconnected(t *testing.T) { // Assert: Verify success. require.NoError(t, err) + store.AssertExpectations(t) } // TestScanWithTargets_Errors verifies error paths in scanWithTargets. @@ -4510,25 +4534,21 @@ func TestScanBatchWithCFilters_InitResultsError(t *testing.T) { func TestProcessChainUpdate(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) - t.Cleanup(cleanup) - tests := []struct { name string update interface{} - setup func(*bwmock.AddrStore, *bwmock.TxStore, *bwmock.Chain) + setup func(*walletmock.Store, *bwmock.Chain) }{ { name: "BlockConnected", update: chain.BlockConnected{ Block: wtxmgr.Block{Height: 100}, }, - setup: func(as *bwmock.AddrStore, ts *bwmock.TxStore, - c *bwmock.Chain) { - - as.On("SetSyncedTo", mock.Anything, mock.MatchedBy( - func(bs *waddrmgr.BlockStamp) bool { - return bs.Height == 100 + setup: func(store *walletmock.Store, c *bwmock.Chain) { + store.On("UpdateWallet", mock.Anything, mock.MatchedBy( + func(params db.UpdateWalletParams) bool { + return params.SyncedTo != nil && + params.SyncedTo.Height == 100 })).Return(nil).Once() }, }, @@ -4537,10 +4557,11 @@ func TestProcessChainUpdate(t *testing.T) { update: chain.RelevantTx{ TxRecord: &wtxmgr.TxRecord{MsgTx: *wire.NewMsgTx(1)}, }, - setup: func(as *bwmock.AddrStore, ts *bwmock.TxStore, - c *bwmock.Chain) { - - ts.On("InsertUnconfirmedTx", mock.Anything, mock.Anything, + setup: func(store *walletmock.Store, c *bwmock.Chain) { + store.On("GetTx", mock.Anything, db.GetTxQuery{ + WalletID: 0, + }).Return(nil, db.ErrTxNotFound).Once() + store.On("ApplyTxBatch", mock.Anything, mock.Anything).Return(nil).Once() }, }, @@ -4551,12 +4572,11 @@ func TestProcessChainUpdate(t *testing.T) { Block: wtxmgr.Block{Height: 102}, }, }, - setup: func(as *bwmock.AddrStore, ts *bwmock.TxStore, - c *bwmock.Chain) { - - as.On("SetSyncedTo", mock.Anything, mock.MatchedBy( - func(bs *waddrmgr.BlockStamp) bool { - return bs.Height == 102 + setup: func(store *walletmock.Store, c *bwmock.Chain) { + store.On("ApplyTxBatch", mock.Anything, mock.MatchedBy( + func(params db.TxBatchParams) bool { + return params.SyncedTo != nil && + params.SyncedTo.Height == 102 })).Return(nil).Once() }, }, @@ -4565,15 +4585,20 @@ func TestProcessChainUpdate(t *testing.T) { update: chain.BlockDisconnected{ Block: wtxmgr.Block{Height: 100, Hash: chainhash.Hash{0x01}}, }, - setup: func(as *bwmock.AddrStore, ts *bwmock.TxStore, - c *bwmock.Chain) { - - as.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}, - ).Once() - as.On( - "BlockHash", mock.Anything, mock.Anything, - ).Return(&chainhash.Hash{}, nil).Maybe() + setup: func(store *walletmock.Store, c *bwmock.Chain) { + expectSyncedTip( + store, waddrmgr.BlockStamp{Height: 100}, + ) + + localBlocks := make([]db.Block, 0, 10) + for i := uint32(91); i <= 100; i++ { + localBlocks = append(localBlocks, db.Block{ + Height: i, + }) + } + store.On( + "ListSyncedBlocks", mock.Anything, mock.Anything, + ).Return(localBlocks, nil).Once() remoteHashes := make([]chainhash.Hash, 10) c.On( @@ -4588,20 +4613,18 @@ func TestProcessChainUpdate(t *testing.T) { t.Parallel() // Arrange - mockAddrStore := &bwmock.AddrStore{} - mockTxStore := &bwmock.TxStore{} + store := &walletmock.Store{} mockChain := &bwmock.Chain{} s := newSyncer( Config{ Chain: mockChain, ChainParams: &chainParams, - DB: db, }, - mockAddrStore, mockTxStore, nil, - &walletmock.Store{}, 0, + nil, nil, nil, + store, uint32(0), ) - tc.setup(mockAddrStore, mockTxStore, mockChain) + tc.setup(store, mockChain) // Act err := s.processChainUpdate(t.Context(), tc.update) @@ -5109,18 +5132,16 @@ func TestDispatchScanStrategy_OtherMethods(t *testing.T) { func TestHandleChainUpdate_Error(t *testing.T) { t.Parallel() - db, cleanup := setupTestDB(t) - defer cleanup() - - // Arrange: Setup a syncer where chain update processing will fail due - // to a database error. - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil, &walletmock.Store{}, 0) + // Arrange: Setup a store-backed syncer where chain update processing + // fails because the synced-block read errors. + store := &walletmock.Store{} + s := newSyncer( + Config{}, nil, nil, nil, store, uint32(0), + ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 100}).Maybe() - mockAddrStore.On("BlockHash", mock.Anything, mock.Anything).Return( - (*chainhash.Hash)(nil), errDBFail).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 100}) + store.On("ListSyncedBlocks", mock.Anything, mock.Anything).Return( + ([]db.Block)(nil), errDBFail).Once() // Act: Attempt to handle a BlockDisconnected update. err := s.handleChainUpdate( From 701d8bdeeddea59f4c305addebac033ae0982d32 Mon Sep 17 00:00:00 2001 From: yyforyongyu Date: Tue, 9 Jun 2026 08:32:56 +0800 Subject: [PATCH 8/8] wallet: route manager master fingerprint read through Store resolveMasterFingerprint read the master HD public key with a direct walletdb.View against the address manager during wallet load. The master public key is public wallet metadata, so route it through Store.GetWallet instead, removing the last direct walletdb transaction for public-metadata reads from wallet/manager.go. To serve the read, add MasterHDPubKey to the waddrmgr.AddrStore interface (already implemented on *waddrmgr.Manager) and have kvdb's GetWallet fill WalletInfo.MasterPubKey from it, tolerating ErrNoExist for shell, watch-only, and pre-master-key wallets. The MasterHDPubKey read is extracted into a small readMasterPubKey helper to keep GetWallet within the complexity limit. The DBCreateWallet/DBLoadWallet create/load path is left as-is: kvdb's Store.CreateWallet is unimplemented and the Store does not return the legacy waddrmgr.Manager/wtxmgr.Store the runtime needs, so that remains justified runtime-construction scope. Passphrase, private-key, and signer flows are unchanged (deferred to ADR 0010). TestManagerLoadSuccess now asserts the loaded wallet's cached master fingerprint is non-zero and matches the create-time value, locking in the Store-routed read. --- bwtest/mock/addr_store.go | 11 +++ waddrmgr/interface.go | 5 ++ wallet/db_ops_test.go | 71 +++++++------------- wallet/internal/db/kvdb/walletstore.go | 32 ++++++++- wallet/manager.go | 93 +++++++++----------------- wallet/manager_test.go | 6 ++ 6 files changed, 106 insertions(+), 112 deletions(-) diff --git a/bwtest/mock/addr_store.go b/bwtest/mock/addr_store.go index 4c0f8ef4a1..3f7f288052 100644 --- a/bwtest/mock/addr_store.go +++ b/bwtest/mock/addr_store.go @@ -189,6 +189,17 @@ func (m *AddrStore) BirthdayBlock( return args.Get(0).(waddrmgr.BlockStamp), args.Bool(1), args.Error(2) } +// MasterHDPubKey returns the plaintext master HD public key bytes persisted +// for the wallet. +func (m *AddrStore) MasterHDPubKey(ns walletdb.ReadBucket) ([]byte, error) { + args := m.Called(ns) + if raw, ok := args.Get(0).([]byte); ok { + return raw, args.Error(1) + } + + return nil, args.Error(1) +} + // IsWatchOnlyAccount determines if the account with the given key scope // is set up as watch-only. func (m *AddrStore) IsWatchOnlyAccount(ns walletdb.ReadBucket, diff --git a/waddrmgr/interface.go b/waddrmgr/interface.go index 0d1d133c83..948b418943 100644 --- a/waddrmgr/interface.go +++ b/waddrmgr/interface.go @@ -151,6 +151,11 @@ type AddrStore interface { // BirthdayBlock returns the birthday block of the address store. BirthdayBlock(ns walletdb.ReadBucket) (BlockStamp, bool, error) + // MasterHDPubKey returns the plaintext master HD public key bytes + // persisted for the wallet, or ErrNoExist when none is persisted (shell, + // watch-only, or pre-master-key wallets). + MasterHDPubKey(ns walletdb.ReadBucket) ([]byte, error) + // IsWatchOnlyAccount determines if the account with the given key scope // is set up as watch-only. IsWatchOnlyAccount(ns walletdb.ReadBucket, keyScope KeyScope, diff --git a/wallet/db_ops_test.go b/wallet/db_ops_test.go index 36a4e81ce0..b4bad61b4c 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -11,6 +11,7 @@ import ( bwmock "github.com/btcsuite/btcwallet/bwtest/mock" "github.com/btcsuite/btcwallet/waddrmgr" walletmock "github.com/btcsuite/btcwallet/wallet/internal/bwtest/mock" + "github.com/btcsuite/btcwallet/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -531,66 +532,40 @@ func TestDBGetScanData(t *testing.T) { require.Empty(t, initialUnspent) } -// TestLoadWalletScanDataKeepsImportedXpubHorizons verifies that full-scan setup -// builds its horizon targets from the legacy address manager's active accounts. -// Imported xpub accounts have real waddrmgr account numbers that must remain in -// the lookahead set even though SQL account metadata may not expose a BIP44 -// account number for them. +// TestLoadWalletScanDataKeepsImportedXpubHorizons verifies full-scan setup +// preserves imported xpub recovery horizons under their non-masked waddrmgr +// account number while still skipping the keyless imported-address bucket. func TestLoadWalletScanDataKeepsImportedXpubHorizons(t *testing.T) { t.Parallel() - w, mocks := createTestWalletWithMocks(t) - s := newSyncer(w.cfg, w.addrStore, w.txStore, nil, &walletmock.Store{}, 0) - - const importedAccount = uint32(9) - + s, mgr := newStoreScanSyncer(t) scope := waddrmgr.KeyScopeBIP0084 - props := &waddrmgr.AccountProperties{ - AccountNumber: importedAccount, - AccountName: "imported-xpub", - ExternalKeyCount: 5, - InternalKeyCount: 2, - KeyScope: scope, - IsWatchOnly: true, - } - - scopedMgr := &bwmock.AccountStore{} - mocks.addrStore.On("ActiveScopedKeyManagers").Return( - []waddrmgr.AccountStore{scopedMgr}, - ).Once() - - scopedMgr.On("ActiveAccounts").Return([]uint32{importedAccount}).Once() - scopedMgr.On("Scope").Return(scope).Once() - - mocks.addrStore.On("FetchScopedKeyManager", scope).Return( - scopedMgr, nil, - ).Once() - - scopedMgr.On("AccountProperties", mock.Anything, importedAccount).Return( - props, nil, - ).Once() - - mocks.addrStore.On("ForEachRelevantActiveAddress", mock.Anything, - mock.Anything, - ).Return(nil).Once() - - mocks.txStore.On("OutputsToWatch", mock.Anything).Return( - []wtxmgr.Credit(nil), nil, - ).Once() + importedNumber := createImportedXpubAccount( + t, s, mgr, scope, "imported-xpub", + ) horizonData, initialAddrs, initialUnspent, err := s.loadWalletScanData( t.Context(), ) require.NoError(t, err) - require.Len(t, horizonData, 1) - require.Equal(t, props, horizonData[0]) - require.Equal(t, importedAccount, horizonData[0].AccountNumber) + + var importedProps *waddrmgr.AccountProperties + for _, props := range horizonData { + require.NotEqual(t, db.DefaultImportedAccountName, + props.AccountName) + + if props.AccountName == "imported-xpub" { + importedProps = props + } + } + + require.NotNil(t, importedProps) + require.Equal(t, importedNumber, importedProps.AccountNumber) + require.Equal(t, scope, importedProps.KeyScope) + require.True(t, importedProps.IsWatchOnly) require.Empty(t, initialAddrs) require.Empty(t, initialUnspent) - - mocks.addrStore.AssertExpectations(t) - scopedMgr.AssertExpectations(t) } // TestDBGetSyncedBlocks verifies that the wallet can successfully retrieve a diff --git a/wallet/internal/db/kvdb/walletstore.go b/wallet/internal/db/kvdb/walletstore.go index 47478c663c..2f67733282 100644 --- a/wallet/internal/db/kvdb/walletstore.go +++ b/wallet/internal/db/kvdb/walletstore.go @@ -39,6 +39,25 @@ func (s *Store) CreateWallet(ctx context.Context, return nil, notImplemented(ctx, "CreateWallet") } +// readMasterPubKey returns the plaintext master HD public key persisted for +// the wallet, or nil for shell, watch-only, and pre-master-key wallets, which +// persist none and surface ErrNoExist. +func readMasterPubKey(addrStore waddrmgr.AddrStore, + ns walletdb.ReadBucket) ([]byte, error) { + + pubKey, err := addrStore.MasterHDPubKey(ns) + switch { + case err == nil: + return pubKey, nil + + case waddrmgr.IsError(err, waddrmgr.ErrNoExist): + return nil, nil + + default: + return nil, fmt.Errorf("get master HD pubkey: %w", err) + } +} + // GetWallet reads wallet runtime metadata from the legacy address manager. // // NOTE: kvdb is a single-wallet legacy backend. The supplied name is not @@ -55,7 +74,10 @@ func (s *Store) GetWallet(_ context.Context, addrStore := s.addrStore - var birthdayBlock *db.Block + var ( + birthdayBlock *db.Block + masterPubKey []byte + ) err := walletdb.View(s.db, func(tx walletdb.ReadTx) error { ns := tx.ReadBucket(waddrmgr.NamespaceKey) @@ -63,6 +85,13 @@ func (s *Store) GetWallet(_ context.Context, return errMissingAddrmgrNamespace } + var err error + + masterPubKey, err = readMasterPubKey(addrStore, ns) + if err != nil { + return err + } + block, verified, err := addrStore.BirthdayBlock(ns) if err != nil { if waddrmgr.IsError(err, waddrmgr.ErrBirthdayBlockNotSet) { @@ -96,6 +125,7 @@ func (s *Store) GetWallet(_ context.Context, BirthdayBlock: birthdayBlock, SyncedTo: syncedTo, IsWatchOnly: addrStore.WatchOnly(), + MasterPubKey: masterPubKey, }, nil } diff --git a/wallet/manager.go b/wallet/manager.go index 5172724132..26624f84fb 100644 --- a/wallet/manager.go +++ b/wallet/manager.go @@ -10,9 +10,9 @@ import ( "github.com/btcsuite/btcd/btcutil/v2/hdkeychain" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet/internal/db" kvdb "github.com/btcsuite/btcwallet/wallet/internal/db/kvdb" "github.com/btcsuite/btcwallet/wallet/internal/keyvault" - "github.com/btcsuite/btcwallet/walletdb" ) var ( @@ -323,18 +323,22 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { <-lockTimer.C } + store := kvdb.NewStore(cfg.DB, txMgr, addrMgr) + // Cache the wallet's master HD fingerprint up-front, before any // context/cancel is set up so an error here doesn't leak a - // cancellable context. - masterFingerprint, err := resolveMasterFingerprint(cfg.DB, addrMgr) + // cancellable context. The master public key is public wallet metadata, + // so it is read through the Store rather than a direct database + // transaction. + masterFingerprint, err := resolveMasterFingerprint( + context.Background(), store, cfg.Name, + ) if err != nil { return nil, fmt.Errorf("cache master fingerprint: %w", err) } lifetimeCtx, cancel := context.WithCancel(context.Background()) - store := kvdb.NewStore(cfg.DB, txMgr, addrMgr) - w := &Wallet{ cfg: cfg, id: walletID, @@ -364,72 +368,35 @@ func (m *Manager) Load(cfg Config) (*Wallet, error) { return w, nil } -// errMissingWaddrmgrNamespace is returned when the waddrmgr namespace -// bucket is missing from the wallet database. DBLoadWallet would have -// already failed if this were a real wallet, so encountering it here -// indicates a wiring bug. -var errMissingWaddrmgrNamespace = errors.New( - "missing waddrmgr namespace", -) - -// resolveMasterFingerprint reads the persisted master HD pubkey via -// addrMgr, parses it, and returns its BIP32 fingerprint. Shell, -// watch-only, and pre-master-key wallets have no pubkey persisted — +// resolveMasterFingerprint reads the wallet's persisted master HD public key +// through the Store, parses it, and returns its BIP32 fingerprint. Shell, +// watch-only, and pre-master-key wallets have no pubkey persisted, so the // fingerprint is zero. -func resolveMasterFingerprint(db walletdb.DB, - addrMgr *waddrmgr.Manager) (uint32, error) { - - var fingerprint uint32 - - err := walletdb.View(db, func(tx walletdb.ReadTx) error { - ns := tx.ReadBucket(waddrmgr.NamespaceKey) - if ns == nil { - return errMissingWaddrmgrNamespace - } - - masterHDPubKey, err := addrMgr.MasterHDPubKey(ns) - switch { - case err == nil: - // Continue to parse below. - - case waddrmgr.IsError(err, waddrmgr.ErrNoExist): - // Shell / watch-only / pre-master-key wallets: - // no pubkey persisted. Leave fingerprint at zero - // and continue. - return nil - - default: - return fmt.Errorf("read master HD pubkey: %w", err) - } +func resolveMasterFingerprint(ctx context.Context, store db.Store, + name string) (uint32, error) { - if len(masterHDPubKey) == 0 { - // Defensive: persisted-but-empty is treated the - // same as the ErrNoExist case above. - return nil - } - - extKey, err := hdkeychain.NewKeyFromString( - string(masterHDPubKey), - ) - if err != nil { - return fmt.Errorf("parse master HD pubkey: %w", - err) - } + info, err := store.GetWallet(ctx, name) + if err != nil { + return 0, fmt.Errorf("get wallet: %w", err) + } - mfp, err := masterKeyFingerprint(extKey) - if err != nil { - return fmt.Errorf("master fingerprint: %w", err) - } + // Shell / watch-only / pre-master-key wallets persist no master HD + // public key, so the fingerprint stays zero. + if len(info.MasterPubKey) == 0 { + return 0, nil + } - fingerprint = mfp + extKey, err := hdkeychain.NewKeyFromString(string(info.MasterPubKey)) + if err != nil { + return 0, fmt.Errorf("parse master HD pubkey: %w", err) + } - return nil - }) + mfp, err := masterKeyFingerprint(extKey) if err != nil { - return 0, fmt.Errorf("read master fingerprint: %w", err) + return 0, fmt.Errorf("master fingerprint: %w", err) } - return fingerprint, nil + return mfp, nil } // prepareWalletCreation validates the configuration and parameters, and derives diff --git a/wallet/manager_test.go b/wallet/manager_test.go index 7ac61275f2..63894cc784 100644 --- a/wallet/manager_test.go +++ b/wallet/manager_test.go @@ -398,6 +398,12 @@ func TestManagerLoadSuccess(t *testing.T) { require.True(t, ok) require.Same(t, w, loadedW) require.Zero(t, w.ID()) + + // The master HD fingerprint is read through the Store during load. A + // ModeGenSeed wallet persists a master HD public key, so the cached + // fingerprint is non-zero and matches the value resolved at create time. + require.NotZero(t, w.masterFingerprint) + require.Equal(t, wCreated.masterFingerprint, w.masterFingerprint) } // TestManagerLoad_ExistingWallet verifies that if Load is called for a wallet