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/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/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/db_ops_test.go b/wallet/db_ops_test.go index 9169904fa1..b4bad61b4c 100644 --- a/wallet/db_ops_test.go +++ b/wallet/db_ops_test.go @@ -10,6 +10,8 @@ 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/wallet/internal/db" "github.com/btcsuite/btcwallet/walletdb" _ "github.com/btcsuite/btcwallet/walletdb/bdb" "github.com/btcsuite/btcwallet/wtxmgr" @@ -251,7 +253,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 +280,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 +316,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 +381,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 +430,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 +490,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, @@ -530,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) - - 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 @@ -600,7 +576,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 +600,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 +633,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 +715,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 +758,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 +793,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 +835,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 +861,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 +928,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 +976,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 +1031,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 +1070,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 +1119,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 +1146,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 +1179,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 +1210,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 +1242,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 +1294,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 +1323,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 +1375,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 +1418,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/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 55b0104bf6..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, @@ -352,10 +356,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) @@ -367,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 diff --git a/wallet/syncer.go b/wallet/syncer.go index e06fe61b34..40e685703a 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 { @@ -223,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. @@ -437,10 +452,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) @@ -466,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) @@ -527,10 +534,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) @@ -559,21 +562,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 } @@ -700,10 +697,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, @@ -736,14 +729,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 @@ -763,14 +752,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 @@ -784,15 +769,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) @@ -895,14 +875,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 @@ -921,15 +897,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 @@ -1184,10 +1155,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 { @@ -1644,10 +1611,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 @@ -1964,9 +1931,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 +1982,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 +2021,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 +2342,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,11 +2396,81 @@ func (s *syncer) loadTargetedScanState(ctx context.Context, // loadTargetedScanData retrieves all necessary data from the database to // initialize the recovery state for a targeted rescan. +// +// 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) { - return s.DBGetScanData(ctx, targets) + resolved, err := s.resolveScanTargets(ctx, targets) + if err != nil { + return nil, nil, nil, err + } + + return s.loadStoreScanData(ctx, resolved) +} + +// 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 @@ -2406,19 +2480,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) } diff --git a/wallet/syncer_test.go b/wallet/syncer_test.go index ddea6cb9ac..4603a823ef 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,10 @@ 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 +100,10 @@ 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 +132,7 @@ func TestSyncerRun(t *testing.T) { s := newSyncer( Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + &walletmock.Store{}, 0, ) // context cancellation. @@ -150,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) + 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. @@ -168,29 +179,33 @@ 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}} - mockAddrStore.On("SyncedTo").Return(tip) - - // 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) + expectSyncedTip(store, tip) + + // 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) for i := range 10 { @@ -205,39 +220,42 @@ 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}} - mockAddrStore.On("SyncedTo").Return(tip) - - // 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) + expectSyncedTip(store, tip) + + // 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. remoteHashes := make([]chainhash.Hash, 10) @@ -259,31 +277,33 @@ 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 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() - mockTxStore.On("Rollback", mock.Anything, int32(96)).Return(nil).Once() // Act & Assert: Verify that checkRollback correctly identifies the // 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{} + store := &walletmock.Store{} mockPublisher := &mockTxPublisher{} s := newSyncer( - Config{Chain: mockChain}, mockAddrStore, nil, mockPublisher, + Config{Chain: mockChain}, nil, nil, mockPublisher, + store, 0, ) // Mock backend synchronization check. @@ -292,9 +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. - tip := waddrmgr.BlockStamp{Height: 0} - mockAddrStore.On("SyncedTo").Return(tip) + // 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. @@ -310,7 +331,10 @@ 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( @@ -338,58 +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, + 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}) @@ -399,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. @@ -415,7 +436,10 @@ 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 +484,7 @@ func TestScanBatchWithCFilters(t *testing.T) { s := newSyncer( Config{Chain: mockChain, DB: dbConn}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, ) mockAddrStore := &bwmock.AddrStore{} @@ -519,7 +544,10 @@ 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}} @@ -575,42 +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, + 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, + ).Maybe() + 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. @@ -622,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. @@ -642,7 +665,10 @@ 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) @@ -669,29 +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, + 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. @@ -705,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( @@ -783,62 +804,66 @@ 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. 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, + 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 @@ -939,6 +964,46 @@ 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() +} + +// 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) { @@ -952,7 +1017,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 +1062,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 +1115,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 +1189,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 +1373,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 +1480,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 +1548,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 +1769,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 +1801,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) @@ -1767,8 +1832,7 @@ func TestStampRecoveryAccountIDsCarriesStableID(t *testing.T) { store := &walletmock.Store{} s := newSyncer( - Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + Config{}, nil, nil, &mockTxPublisher{}, store, walletID, ) scanState := NewRecoveryState(0, &chainParams, nil) @@ -1881,7 +1945,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) @@ -1916,45 +1980,52 @@ 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{} s := newSyncer( Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + store, 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,27 +2038,22 @@ 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{} s := newSyncer( - Config{}, nil, nil, &mockTxPublisher{}, - syncerStoreConfig{store: store, walletID: walletID}, + Config{}, nil, nil, &mockTxPublisher{}, store, walletID, ) derived := waddrmgr.AccountScope{ @@ -2001,7 +2067,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 +2081,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 +2094,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) } @@ -2187,8 +2284,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 @@ -2237,6 +2333,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, @@ -2263,7 +2455,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( @@ -2323,7 +2515,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( @@ -2381,7 +2573,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( @@ -2427,7 +2619,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. @@ -2501,7 +2693,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} @@ -2546,7 +2738,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( @@ -2618,6 +2810,7 @@ func TestExtractAddrEntries(t *testing.T) { s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, mockPublisher, + &walletmock.Store{}, 0, ) addr, err := address.NewAddressPubKeyHash( @@ -2659,10 +2852,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{} @@ -2673,7 +2866,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 @@ -2695,9 +2888,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() @@ -2713,71 +2905,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{{Account: 1}}, + targets: []waddrmgr.AccountScope{{ + Scope: waddrmgr.KeyScopeBIP0084, + 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, - KeyScope: waddrmgr.KeyScopeBIP0084, - } - scopedMgr.On( - "AccountProperties", mock.Anything, uint32(1), - ).Return(props, nil).Twice() - - 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.AccountNumber != nil && - *query.AccountNumber == accountNumber && - query.SkipBalance - }, - )).Return(&db.AccountInfo{ - AccountID: &accountID, - AccountNumber: &accountNumber, - KeyScope: db.KeyScopeBIP0084, - }, 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), @@ -2804,7 +2955,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) } @@ -2812,44 +2963,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, + 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. @@ -2861,31 +3006,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, + 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) @@ -2899,16 +3047,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. @@ -2939,28 +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, + 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( @@ -2971,24 +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) + s := newSyncer( + 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 @@ -3009,6 +3151,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() @@ -3034,6 +3177,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( @@ -3071,6 +3215,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". @@ -3124,7 +3269,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) hashes := []chainhash.Hash{{0x01}} @@ -3158,7 +3306,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3195,7 +3346,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, &chainParams, nil) scanState.AddWatchedOutPoint(&wire.OutPoint{Index: 0}, nil) @@ -3217,27 +3371,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, + 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() @@ -3260,25 +3409,21 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("CheckRollback_Failure", func(t *testing.T) { t.Parallel() - db, 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{} - addrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + Config{Chain: mockChain}, 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() + 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()) @@ -3290,19 +3435,17 @@ func TestInitChainSync_Errors(t *testing.T) { t.Run("NotifyBlocks_Failure", func(t *testing.T) { t.Parallel() - db, 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{} - addrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, addrStore, nil, nil, + Config{Chain: mockChain}, 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}) + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) mockChain.On("NotifyBlocks").Return(errNotify).Once() // Act: Attempt initialization. @@ -3318,7 +3461,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. @@ -3332,21 +3475,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) + 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()) @@ -3360,34 +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, + 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)} @@ -3405,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 @@ -3421,6 +3568,7 @@ func TestDispatchScanStrategy_AutoFallback(t *testing.T) { SyncMethod: SyncMethodAuto, MaxCFilterItems: 1, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, &chainParams, nil) @@ -3458,27 +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) + s := newSyncer( + 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. @@ -3490,6 +3648,7 @@ func TestFilterBatch_EmptyFilter(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodCFilters}, nil, nil, nil, + &walletmock.Store{}, 0, ) emptyFilter, err := gcs.BuildGCSFilter( @@ -3530,7 +3689,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) closedChan := make(chan any) close(closedChan) @@ -3551,7 +3713,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) blockChan := make(chan any) mockChain.On("Notifications").Return((<-chan any)(blockChan)).Once() @@ -3572,7 +3737,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) state := NewRecoveryState(1, nil, nil) @@ -3604,7 +3772,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() @@ -3624,7 +3792,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{ @@ -3649,7 +3817,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("IsCurrent").Return(false).Maybe() @@ -3669,7 +3840,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -3690,20 +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) + s := newSyncer( + 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()) @@ -3716,18 +3899,16 @@ func TestBroadcastUnminedTxns_BroadcastError(t *testing.T) { func TestCheckRollback_DBError(t *testing.T) { t.Parallel() - // Arrange: Setup mock expectations where local block hash lookup fails - // during a rollback check. - db, cleanup := setupTestDB(t) - defer cleanup() - - mockAddrStore := &bwmock.AddrStore{} - s := newSyncer(Config{DB: db}, mockAddrStore, nil, nil) + // Arrange: Setup a store-backed syncer where the synced-block read + // fails during a rollback check. + store := &walletmock.Store{} + s := newSyncer( + Config{}, 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() + 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()) @@ -3741,22 +3922,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. - db, cleanup := setupTestDB(t) - defer cleanup() - + // Arrange: Setup a store-backed syncer where the remote hash lookup + // fails during a rollback check. mockChain := &bwmock.Chain{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, + Config{Chain: mockChain}, 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() + 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() @@ -3772,7 +3949,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{ @@ -3796,25 +3973,18 @@ func TestFilterBatch_NilFilter(t *testing.T) { func TestInitChainSync_NotifyBlocksError(t *testing.T) { t.Parallel() - db, 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{} - mockAddrStore := &bwmock.AddrStore{} + store := &walletmock.Store{} s := newSyncer( - Config{Chain: mockChain, DB: db}, - mockAddrStore, nil, nil, + 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() - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 0}).Once() - mockAddrStore.On("Birthday").Return(time.Time{}).Maybe() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 0}) // Act: Attempt chain sync initialization. err := s.initChainSync(t.Context()) @@ -3832,7 +4002,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return(([]chainhash.Hash)(nil), @@ -3851,7 +4024,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBlockHashes", mock.Anything, mock.Anything).Return([]chainhash.Hash{{}}, nil).Once() @@ -3871,38 +4047,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. - db, 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}, nil, nil, nil, + store, 0, ) - mockAddrStore.On("SyncedTo").Return( - waddrmgr.BlockStamp{Height: 101}).Once() + expectSyncedTip(store, waddrmgr.BlockStamp{Height: 101}) - hashA := &chainhash.Hash{0x0A} - hashB := &chainhash.Hash{0x0B} + 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() + // 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 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) + } + 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. @@ -3917,7 +4106,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{ @@ -3949,47 +4138,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) + 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( @@ -4024,7 +4198,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) + s := newSyncer( + Config{Chain: nc}, mockAddrStore, nil, nil, + &walletmock.Store{}, 0, + ) // Cancel context immediately to abort waitUntilBackendSynced. ctx, cancel := context.WithCancel(t.Context()) @@ -4045,7 +4222,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) scanState := NewRecoveryState(10, nil, nil) @@ -4078,7 +4258,10 @@ 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) @@ -4135,6 +4318,7 @@ func TestDispatchScanStrategy_Auto(t *testing.T) { SyncMethod: SyncMethodAuto, MaxCFilterItems: 1, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, nil, nil) @@ -4171,6 +4355,7 @@ func TestDispatchScanStrategy_AutoFallback_Final(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodAuto, }, nil, nil, nil, + &walletmock.Store{}, 0, ) scanState := NewRecoveryState(10, nil, nil) @@ -4198,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) + // 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( @@ -4216,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. @@ -4225,38 +4409,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, - ) + 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() @@ -4270,37 +4438,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, - ) + 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, @@ -4317,19 +4470,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) - - 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. @@ -4340,8 +4490,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") }) } @@ -4357,6 +4511,7 @@ func TestScanBatchWithCFilters_InitResultsError(t *testing.T) { Chain: mockChain, SyncMethod: SyncMethodCFilters, }, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -4379,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() }, }, @@ -4406,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() }, }, @@ -4420,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() }, }, @@ -4434,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( @@ -4457,19 +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, + nil, nil, nil, + store, uint32(0), ) - tc.setup(mockAddrStore, mockTxStore, mockChain) + tc.setup(store, mockChain) // Act err := s.processChainUpdate(t.Context(), tc.update) @@ -4486,7 +4641,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 @@ -4533,7 +4688,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( @@ -4557,7 +4712,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 @@ -4607,9 +4762,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{ @@ -4675,7 +4828,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 @@ -4704,7 +4857,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 @@ -4732,8 +4885,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} @@ -4761,38 +4913,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) { @@ -4800,7 +4920,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. @@ -4849,7 +4969,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) + 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. @@ -4874,39 +4997,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, + 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()) @@ -4935,6 +5052,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() @@ -4962,6 +5080,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() @@ -4994,6 +5113,7 @@ func TestDispatchScanStrategy_OtherMethods(t *testing.T) { Chain: mockChain, SyncMethod: 99, }, nil, nil, nil, + &walletmock.Store{}, 0, ) // Act: Dispatch the strategy. @@ -5012,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) + // 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( @@ -5038,35 +5156,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, + 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()) @@ -5090,7 +5203,10 @@ 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}} @@ -5159,32 +5275,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, + 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()) @@ -5197,22 +5303,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) - - 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()) @@ -5230,8 +5328,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{ @@ -5266,7 +5363,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) filters := []*gcs.Filter{nil} results := []scanResult{{ @@ -5297,7 +5397,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}} @@ -5318,21 +5421,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) + 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( @@ -5351,7 +5448,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) hashes := []chainhash.Hash{{0x01}} @@ -5376,6 +5476,7 @@ func TestDispatchScanStrategy_AutoError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodAuto}, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -5400,37 +5501,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, + 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()) @@ -5439,32 +5535,28 @@ 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. 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, + 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()) @@ -5481,7 +5573,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) + 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( @@ -5506,24 +5601,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, + 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()) @@ -5543,6 +5632,7 @@ func TestDispatchScanStrategy_FullBlocksError(t *testing.T) { s := newSyncer( Config{Chain: mockChain, SyncMethod: SyncMethodFullBlocks}, nil, nil, nil, + &walletmock.Store{}, 0, ) hashes := []chainhash.Hash{{0x01}} @@ -5570,6 +5660,7 @@ func TestExtractAddrEntries_NonStd(t *testing.T) { s := newSyncer( Config{ChainParams: &chainParams}, nil, nil, nil, + &walletmock.Store{}, 0, ) pkh, err := address.NewAddressPubKeyHash( @@ -5608,7 +5699,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) + s := newSyncer( + Config{Chain: mockChain}, nil, nil, nil, + &walletmock.Store{}, 0, + ) mockChain.On("GetBestBlock").Return((*chainhash.Hash)(nil), int32(0), errBestBlock).Once() @@ -5636,7 +5730,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( @@ -5674,7 +5768,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) @@ -5701,41 +5795,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, + 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()) @@ -5764,7 +5853,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. @@ -5845,7 +5934,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( @@ -5864,17 +5953,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. @@ -5901,7 +5984,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(