From 454ac8e7db0f8b2c000b1d2e943bde95dff0127d Mon Sep 17 00:00:00 2001 From: furszy Date: Mon, 5 Jan 2026 18:12:40 -0500 Subject: [PATCH 01/11] wallet: RestoreWallet failure, erase only what was created Track what RestoreWallet creates so only those files and directories are removed during a failure and nothing else. Preexisting paths must be left untouched. Note: Using fs::remove_all() instead of fs::remove() in RestoreWallet does not cause any problems currently, but the change is necessary for the next commit which extends RestoreWallet to work with existing directories, which may contain files that must not be deleted. Github-Pull: #34156 Rebased-From: 4ed0693a3f2a427ef9e7ad016930ec29fa244995 --- src/wallet/wallet.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index b51e7d1109b..b86c6e56e6d 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -469,6 +469,8 @@ std::shared_ptr RestoreWallet(WalletContext& context, const fs::path& b const fs::path wallet_path = fsbridge::AbsPathJoin(GetWalletDir(), fs::u8path(wallet_name)); auto wallet_file = wallet_path / "wallet.dat"; std::shared_ptr wallet; + bool wallet_file_copied = false; + bool created_parent_dir = false; try { if (!fs::exists(backup_file)) { @@ -477,13 +479,22 @@ std::shared_ptr RestoreWallet(WalletContext& context, const fs::path& b return nullptr; } - if (fs::exists(wallet_path) || !TryCreateDirectories(wallet_path)) { + if (fs::exists(wallet_path)) { error = Untranslated(strprintf("Failed to create database path '%s'. Database already exists.", fs::PathToString(wallet_path))); status = DatabaseStatus::FAILED_ALREADY_EXISTS; return nullptr; + } else { + // The directory doesn't exist, create it + if (!TryCreateDirectories(wallet_path)) { + error = Untranslated(strprintf("Failed to restore database path '%s'.", fs::PathToString(wallet_path))); + status = DatabaseStatus::FAILED_ALREADY_EXISTS; + return nullptr; + } + created_parent_dir = true; } fs::copy_file(backup_file, wallet_file, fs::copy_options::none); + wallet_file_copied = true; if (load_after_restore) { wallet = LoadWallet(context, wallet_name, load_on_start, options, status, error, warnings); @@ -496,7 +507,13 @@ std::shared_ptr RestoreWallet(WalletContext& context, const fs::path& b // Remove created wallet path only when loading fails if (load_after_restore && !wallet) { - fs::remove_all(wallet_path); + if (wallet_file_copied) fs::remove(wallet_file); + // Clean up the parent directory if we created it during restoration. + // As we have created it, it must be empty after deleting the wallet file. + if (created_parent_dir) { + Assume(fs::is_empty(wallet_path)); + fs::remove(wallet_path); + } } return wallet; From ac4d0956ccccb43082991d89a8601c39d490d3a5 Mon Sep 17 00:00:00 2001 From: furszy Date: Fri, 26 Dec 2025 20:22:55 -0500 Subject: [PATCH 02/11] wallet: fix unnamed wallet migration failure When migrating any legacy unnamed wallet, a failed migration would cause the cleanup logic to remove its parent directory. Since this type of legacy wallet lives directly in the main '/wallets/' folder, this resulted in unintentionally erasing all wallets, including the backup file. To be fully safe, we will no longer call `fs::remove_all`. Instead, we only erase the individual db files we have created, leaving everything else intact. The created wallets parent directories are erased only if they are empty. As part of this last change, `RestoreWallet` was modified to allow an existing directory as the destination, since we no longer remove the original wallet directory (we only remove the files we created inside it). This also fixes the restore of top-level default wallets during failures, which were failing due to the directory existence check that always returns true for the /wallets/ directory. This bug started after: https://github.com/bitcoin/bitcoin/commit/f6ee59b6e2995a3916fb4f0d4cbe15ece2054494 Previously, the `fs::copy_file` call was failing for top-level wallets, which prevented the `fs::remove_all` call from being reached. Github-Pull: #34156 Rebased-From: f4c7e28e80bf9af50b03a770b641fd309a801589 --- src/wallet/wallet.cpp | 61 +++++++++++++++++++++++++------- test/functional/wallet_backup.py | 2 +- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index b86c6e56e6d..942f7ff9e71 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -479,10 +479,22 @@ std::shared_ptr RestoreWallet(WalletContext& context, const fs::path& b return nullptr; } + // Wallet directories are allowed to exist, but must not contain a .dat file. + // Any existing wallet database is treated as a hard failure to prevent overwriting. if (fs::exists(wallet_path)) { - error = Untranslated(strprintf("Failed to create database path '%s'. Database already exists.", fs::PathToString(wallet_path))); - status = DatabaseStatus::FAILED_ALREADY_EXISTS; - return nullptr; + // If this is a file, it is the db and we don't want to overwrite it. + if (!fs::is_directory(wallet_path)) { + error = Untranslated(strprintf("Failed to restore wallet. Database file exists '%s'.", fs::PathToString(wallet_path))); + status = DatabaseStatus::FAILED_ALREADY_EXISTS; + return nullptr; + } + + // Check we are not going to overwrite an existing db file + if (fs::exists(wallet_file)) { + error = Untranslated(strprintf("Failed to restore wallet. Database file exists in '%s'.", fs::PathToString(wallet_file))); + status = DatabaseStatus::FAILED_ALREADY_EXISTS; + return nullptr; + } } else { // The directory doesn't exist, create it if (!TryCreateDirectories(wallet_path)) { @@ -4312,11 +4324,28 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr } } - // In case of loading failure, we need to remember the wallet dirs to remove. + // In case of loading failure, we need to remember the wallet files we have created to remove. // A `set` is used as it may be populated with the same wallet directory paths multiple times, // both before and after loading. This ensures the set is complete even if one of the wallets // fails to load. - std::set wallet_dirs; + std::set wallet_files_to_remove; + std::set wallet_empty_dirs_to_remove; + + // Helper to track wallet files and directories for cleanup on failure. + // Only directories of wallets created during migration (not the main wallet) are tracked. + auto track_for_cleanup = [&](const CWallet& wallet) { + const auto files = wallet.GetDatabase().Files(); + wallet_files_to_remove.insert(files.begin(), files.end()); + if (wallet.GetName() != wallet_name) { + // If this isn’t the main wallet, mark its directory for removal. + // This applies to the watch-only and solvable wallets. + // Wallets stored directly as files in the top-level directory + // (e.g. default unnamed wallets) don’t have a removable parent directory. + wallet_empty_dirs_to_remove.insert(fs::PathFromString(wallet.GetDatabase().Filename()).parent_path()); + } + }; + + if (success) { Assume(!res.wallet); // We will set it here. // Check if the local wallet is empty after migration @@ -4324,15 +4353,15 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr // This wallet has no records. We can safely remove it. std::vector paths_to_remove = local_wallet->GetDatabase().Files(); local_wallet.reset(); - for (const auto& path_to_remove : paths_to_remove) fs::remove_all(path_to_remove); + for (const auto& path_to_remove : paths_to_remove) fs::remove(path_to_remove); } // Migration successful, load all the migrated wallets. for (std::shared_ptr* wallet_ptr : {&local_wallet, &res.watchonly_wallet, &res.solvables_wallet}) { if (success && *wallet_ptr) { std::shared_ptr& wallet = *wallet_ptr; - // Save db path and load wallet - wallet_dirs.insert(fs::PathFromString(wallet->GetDatabase().Filename()).parent_path()); + // Track db path and load wallet + track_for_cleanup(*wallet); assert(wallet.use_count() == 1); std::string wallet_name = wallet->GetName(); wallet.reset(); @@ -4355,8 +4384,8 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr if (res.solvables_wallet) created_wallets.push_back(std::move(res.solvables_wallet)); // Get the directories to remove after unloading - for (std::shared_ptr& w : created_wallets) { - wallet_dirs.emplace(fs::PathFromString(w->GetDatabase().Filename()).parent_path()); + for (std::shared_ptr& wallet : created_wallets) { + track_for_cleanup(*wallet); } // Unload the wallets @@ -4375,9 +4404,15 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr } } - // Delete the wallet directories - for (const fs::path& dir : wallet_dirs) { - fs::remove_all(dir); + // First, delete the db files we have created throughout this process and nothing else + for (const fs::path& file : wallet_files_to_remove) { + fs::remove(file); + } + + // Second, delete the created wallet directories and nothing else. They must be empty at this point. + for (const fs::path& dir : wallet_empty_dirs_to_remove) { + Assume(fs::is_empty(dir)); + fs::remove(dir); } // Restore the backup diff --git a/test/functional/wallet_backup.py b/test/functional/wallet_backup.py index 6f3637a0dda..bc87e64d412 100755 --- a/test/functional/wallet_backup.py +++ b/test/functional/wallet_backup.py @@ -132,7 +132,7 @@ class WalletBackupTest(BitcoinTestFramework): backup_file = self.nodes[0].datadir_path / 'wallet.bak' wallet_name = "res0" wallet_file = node.wallets_path / wallet_name - error_message = "Failed to create database path '{}'. Database already exists.".format(wallet_file) + error_message = "Failed to restore wallet. Database file exists in '{}'.".format(wallet_file / "wallet.dat") assert_raises_rpc_error(-36, error_message, node.restorewallet, wallet_name, backup_file) assert wallet_file.exists() From 8e5c02a77f1b18ae37829200f96ef3fa1cc91246 Mon Sep 17 00:00:00 2001 From: furszy Date: Fri, 26 Dec 2025 20:23:02 -0500 Subject: [PATCH 03/11] test: add coverage for unnamed wallet migration failure Verifies that a failed migration of the unnamed (default) wallet does not erase the main /wallets/ directory, and also that the backup file exists. Github-Pull: #34156 Rebased-From: 36093bde63286e19821a9e62cdff1712b6245dc7 --- test/functional/wallet_migration.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py index 085f6249e7d..644f277c76c 100755 --- a/test/functional/wallet_migration.py +++ b/test/functional/wallet_migration.py @@ -654,6 +654,38 @@ class WalletMigrationTest(BitcoinTestFramework): # migrate_and_get_rpc already checks for backup file existence assert os.path.basename(res["backup_path"]).startswith("default_wallet") + wallet.unloadwallet() + + def test_default_wallet_failure(self): + self.log.info("Test failure during unnamed (default) wallet migration") + master_wallet = self.master_node.get_wallet_rpc(self.default_wallet_name) + wallet = self.create_legacy_wallet("", blank=True) + wallet.importaddress(master_wallet.getnewaddress(address_type="legacy")) + + # Create wallet directory with the watch-only name and a wallet file. + # Because the wallet dir exists, this will cause migration to fail. + watch_only_dir = self.master_node.wallets_path / "_watchonly" + os.mkdir(watch_only_dir) + shutil.copyfile(self.old_node.wallets_path / "wallet.dat", watch_only_dir / "wallet.dat") + + mocked_time = int(time.time()) + self.master_node.setmocktime(mocked_time) + assert_raises_rpc_error(-4, "Failed to create database", self.migrate_and_get_rpc, "") + self.master_node.setmocktime(0) + + # Verify the /wallets/ path exists + assert self.master_node.wallets_path.exists() + # Check backup file exists. Because the wallet has no name, the backup is prefixed with 'default_wallet' + backup_path = self.master_node.wallets_path / f"default_wallet_{mocked_time}.legacy.bak" + assert backup_path.exists() + # Verify the original unnamed wallet was restored + assert (self.master_node.wallets_path / "wallet.dat").exists() + # And verify it is still a BDB wallet + self.assert_is_bdb("") + + # Test cleanup: clear default wallet for next test + os.remove(self.old_node.wallets_path / "wallet.dat") + def test_direct_file(self): self.log.info("Test migration of a wallet that is not in a wallet directory") wallet = self.create_legacy_wallet("plainfile") @@ -1539,6 +1571,7 @@ class WalletMigrationTest(BitcoinTestFramework): self.test_wallet_with_relative_path() self.test_wallet_with_path("path/to/mywallet/") self.test_wallet_with_path("path/that/ends/in/..") + self.test_default_wallet_failure() self.test_default_wallet() self.test_direct_file() self.test_addressbook() From ac940ac2ca6d427970b7ebbd8227154cf0159875 Mon Sep 17 00:00:00 2001 From: furszy Date: Mon, 5 Jan 2026 16:08:13 -0500 Subject: [PATCH 04/11] test: restorewallet, coverage for existing dirs, unnamed wallet and prune failure The first test verifies that restoring into an existing empty directory or a directory with no .dat db files succeeds, while restoring into a dir with a .dat file fails. The second test covers restoring into the default unnamed wallet (wallet.dat), which also implicitly exercises the recovery path used after a failed migration. The third test covers failure during restore on a prune node. When the wallet last sync was beyond the pruning height. Github-Pull: #34156 Rebased-From: f011e0f0680a8c39988ae57dae57eb86e92dd449 --- test/functional/wallet_backup.py | 65 ++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/functional/wallet_backup.py b/test/functional/wallet_backup.py index bc87e64d412..5e67c63a1f1 100755 --- a/test/functional/wallet_backup.py +++ b/test/functional/wallet_backup.py @@ -39,6 +39,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, assert_raises_rpc_error, + sha256sum_file, ) @@ -136,6 +137,61 @@ class WalletBackupTest(BitcoinTestFramework): assert_raises_rpc_error(-36, error_message, node.restorewallet, wallet_name, backup_file) assert wallet_file.exists() + def test_restore_existent_dir(self): + self.log.info("Test restore on an existent empty directory") + node = self.nodes[3] + backup_file = self.nodes[0].datadir_path / 'wallet.bak' + wallet_name = "restored_wallet" + wallet_dir = node.wallets_path / wallet_name + os.mkdir(wallet_dir) + res = node.restorewallet(wallet_name, backup_file) + assert_equal(res['name'], wallet_name) + node.unloadwallet(wallet_name) + + self.log.info("Test restore succeeds when the target directory contains non-wallet files") + wallet_file = node.wallets_path / wallet_name / "wallet.dat" + os.remove(wallet_file) + extra_file = node.wallets_path / wallet_name / "not_a_wallet.txt" + extra_file.touch() + res = node.restorewallet(wallet_name, backup_file) + assert_equal(res['name'], wallet_name) + assert extra_file.exists() # extra file was not removed by mistake + node.unloadwallet(wallet_name) + + self.log.info("Test restore failure due to existing db file in the destination directory") + original_shasum = sha256sum_file(wallet_file) + error_message = "Failed to restore wallet. Database file exists in '{}'.".format(wallet_dir / "wallet.dat") + assert_raises_rpc_error(-36, error_message, node.restorewallet, wallet_name, backup_file) + # Ensure the wallet file remains untouched + assert wallet_dir.exists() + assert_equal(original_shasum, sha256sum_file(wallet_file)) + + self.log.info("Test restore succeeds when the .dat file in the destination has a different name") + second_wallet = wallet_dir / "hidden_storage.dat" + os.rename(wallet_dir / "wallet.dat", second_wallet) + original_shasum = sha256sum_file(second_wallet) + res = node.restorewallet(wallet_name, backup_file) + assert_equal(res['name'], wallet_name) + assert (wallet_dir / "hidden_storage.dat").exists() + assert_equal(original_shasum, sha256sum_file(second_wallet)) + node.unloadwallet(wallet_name) + + # Clean for follow-up tests + os.remove(wallet_file) + + def test_restore_into_unnamed_wallet(self): + self.log.info("Test restore into a default unnamed wallet") + # This is also useful to test the migration recovery after failure logic + node = self.nodes[3] + backup_file = self.nodes[0].datadir_path / 'wallet.bak' + wallet_name = "" + res = node.restorewallet(wallet_name, backup_file) + assert_equal(res['name'], "") + assert (node.wallets_path / "wallet.dat").exists() + # Clean for follow-up tests + node.unloadwallet("") + os.remove(node.wallets_path / "wallet.dat") + def test_pruned_wallet_backup(self): self.log.info("Test loading backup on a pruned node when the backup was created close to the prune height of the restoring node") node = self.nodes[3] @@ -155,6 +211,13 @@ class WalletBackupTest(BitcoinTestFramework): # the backup to load successfully this close to the prune height node.restorewallet('pruned', node.datadir_path / 'wallet_pruned.bak') + self.log.info("Test restore on a pruned node when the backup was beyond the pruning point") + backup_file = self.nodes[0].datadir_path / 'wallet.bak' + wallet_name = "" + error_message = "Wallet loading failed. Prune: last wallet synchronisation goes beyond pruned data. You need to -reindex (download the whole blockchain again in case of a pruned node)" + assert_raises_rpc_error(-4, error_message, node.restorewallet, wallet_name, backup_file) + assert node.wallets_path.exists() # ensure the wallets dir exists + def run_test(self): self.log.info("Generating initial blockchain") self.generate(self.nodes[0], 1) @@ -219,6 +282,8 @@ class WalletBackupTest(BitcoinTestFramework): assert_equal(res2_rpc.getbalance(), balance2) self.restore_wallet_existent_name() + self.test_restore_existent_dir() + self.test_restore_into_unnamed_wallet() # Backup to source wallet file must fail sourcePaths = [ From bef4b1fdee1f386662855fb66409c323eee4bae4 Mon Sep 17 00:00:00 2001 From: furszy Date: Sat, 27 Dec 2025 13:54:59 -0500 Subject: [PATCH 05/11] wallet: improve post-migration logging Right now, after migration the last message users see is "migration completed", but the migration isn't actually finished yet. We still need to load the new wallets to ensure consistency, and if that fails, the migration will be rolled back. This can be confusing for users. This change logs the post-migration loading step and if a wallet fails to load and the migration will be rolled back. Github-Pull: #34156 Rebased-From: d70b159c42008ac3b63d1c43d99d4f1316d2f1ef --- src/wallet/wallet.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 942f7ff9e71..70b2b49a834 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4356,6 +4356,7 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr for (const auto& path_to_remove : paths_to_remove) fs::remove(path_to_remove); } + LogInfo("Loading new wallets after migration...\n"); // Migration successful, load all the migrated wallets. for (std::shared_ptr* wallet_ptr : {&local_wallet, &res.watchonly_wallet, &res.solvables_wallet}) { if (success && *wallet_ptr) { @@ -4366,10 +4367,16 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr std::string wallet_name = wallet->GetName(); wallet.reset(); wallet = LoadWallet(context, wallet_name, /*load_on_start=*/std::nullopt, options, status, error, warnings); - success = (wallet != nullptr); + if (!wallet) { + LogError("Failed to load wallet '%s' after migration. Rolling back migration to preserve consistency. " + "Error cause: %s\n", wallet_name, error.original); + success = false; + break; + } - // When no wallet is set, set the main wallet. - if (success && !res.wallet) { + // Set the first successfully loaded wallet as the main one. + // The loop order is intentional and must always start with the local wallet. + if (!res.wallet) { res.wallet_name = wallet->GetName(); res.wallet = std::move(wallet); } From bc71372c0e3b552fff174772ef9e5c65177405c3 Mon Sep 17 00:00:00 2001 From: furszy Date: Sat, 27 Dec 2025 14:32:11 -0500 Subject: [PATCH 06/11] wallet: migration, fix watch-only and solvables wallets names Because the default wallet has no name, the watch-only and solvables wallets created during migration end up having no name either. This fixes it by applying the same prefix name we use for the backup file for an unnamed default wallet. Before: watch-only wallet named "_watchonly" After: watch-only wallet named "default_wallet_watchonly" Github-Pull: #34156 Rebased-From: 82caa8193a3e36f248dcc949e0cd41def191efac --- src/wallet/wallet.cpp | 15 ++++++++++--- test/functional/wallet_migration.py | 34 +++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 70b2b49a834..622ee963afa 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -4086,6 +4086,15 @@ bool CWallet::CanGrindR() const return !IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS); } +// Returns wallet prefix for migration. +// Used to name the backup file and newly created wallets. +// E.g. a watch-only wallet is named "_watchonly". +static std::string MigrationPrefixName(CWallet& wallet) +{ + const std::string& name{wallet.GetName()}; + return name.empty() ? "default_wallet" : name; +} + bool DoMigration(CWallet& wallet, WalletContext& context, bilingual_str& error, MigrationResult& res) EXCLUSIVE_LOCKS_REQUIRED(wallet.cs_wallet) { AssertLockHeld(wallet.cs_wallet); @@ -4117,7 +4126,7 @@ bool DoMigration(CWallet& wallet, WalletContext& context, bilingual_str& error, DatabaseStatus status; std::vector warnings; - std::string wallet_name = wallet.GetName() + "_watchonly"; + std::string wallet_name = MigrationPrefixName(wallet) + "_watchonly"; std::unique_ptr database = MakeWalletDatabase(wallet_name, options, status, error); if (!database) { error = strprintf(_("Wallet file creation failed: %s"), error); @@ -4156,7 +4165,7 @@ bool DoMigration(CWallet& wallet, WalletContext& context, bilingual_str& error, DatabaseStatus status; std::vector warnings; - std::string wallet_name = wallet.GetName() + "_solvables"; + std::string wallet_name = MigrationPrefixName(wallet) + "_solvables"; std::unique_ptr database = MakeWalletDatabase(wallet_name, options, status, error); if (!database) { error = strprintf(_("Wallet file creation failed: %s"), error); @@ -4271,7 +4280,7 @@ util::Result MigrateLegacyToDescriptor(std::shared_ptr // cases, but in the case where the wallet name is a path to a data file, // the name of the data file is used, and in the case where the wallet name // is blank, "default_wallet" is used. - const std::string backup_prefix = wallet_name.empty() ? "default_wallet" : [&] { + const std::string backup_prefix = wallet_name.empty() ? MigrationPrefixName(*local_wallet) : [&] { // fs::weakly_canonical resolves relative specifiers and remove trailing slashes. const auto legacy_wallet_path = fs::weakly_canonical(GetWalletDir() / fs::PathFromString(wallet_name)); return fs::PathToString(legacy_wallet_path.filename()); diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py index 644f277c76c..88b1aaccc37 100755 --- a/test/functional/wallet_migration.py +++ b/test/functional/wallet_migration.py @@ -3,6 +3,7 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. """Test Migrating a wallet from legacy to descriptor.""" +from pathlib import Path import os.path import random import shutil @@ -638,6 +639,14 @@ class WalletMigrationTest(BitcoinTestFramework): assert_equal(bals, wallet.getbalances()) + def clear_default_wallet(self, backup_file): + # Test cleanup: Clear unnamed default wallet for subsequent tests + (self.old_node.wallets_path / "wallet.dat").unlink() + (self.master_node.wallets_path / "wallet.dat").unlink(missing_ok=True) + shutil.rmtree(self.master_node.wallets_path / "default_wallet_watchonly", ignore_errors=True) + shutil.rmtree(self.master_node.wallets_path / "default_wallet_solvables", ignore_errors=True) + backup_file.unlink() + def test_default_wallet(self): self.log.info("Test migration of the wallet named as the empty string") wallet = self.create_legacy_wallet("") @@ -655,6 +664,26 @@ class WalletMigrationTest(BitcoinTestFramework): assert os.path.basename(res["backup_path"]).startswith("default_wallet") wallet.unloadwallet() + self.clear_default_wallet(backup_file=Path(res["backup_path"])) + + def test_default_wallet_watch_only(self): + self.log.info("Test unnamed (default) watch-only wallet migration") + master_wallet = self.master_node.get_wallet_rpc(self.default_wallet_name) + wallet = self.create_legacy_wallet("", blank=True) + wallet.importaddress(master_wallet.getnewaddress(address_type="legacy")) + + res, wallet = self.migrate_and_get_rpc("") + + info = wallet.getwalletinfo() + assert_equal(info["descriptors"], True) + assert_equal(info["format"], "sqlite") + assert_equal(info["private_keys_enabled"], False) + assert_equal(info["walletname"], "default_wallet_watchonly") + # Check the default wallet is not available anymore + assert not (self.master_node.wallets_path / "wallet.dat").exists() + + wallet.unloadwallet() + self.clear_default_wallet(backup_file=Path(res["backup_path"])) def test_default_wallet_failure(self): self.log.info("Test failure during unnamed (default) wallet migration") @@ -664,7 +693,7 @@ class WalletMigrationTest(BitcoinTestFramework): # Create wallet directory with the watch-only name and a wallet file. # Because the wallet dir exists, this will cause migration to fail. - watch_only_dir = self.master_node.wallets_path / "_watchonly" + watch_only_dir = self.master_node.wallets_path / "default_wallet_watchonly" os.mkdir(watch_only_dir) shutil.copyfile(self.old_node.wallets_path / "wallet.dat", watch_only_dir / "wallet.dat") @@ -684,7 +713,7 @@ class WalletMigrationTest(BitcoinTestFramework): self.assert_is_bdb("") # Test cleanup: clear default wallet for next test - os.remove(self.old_node.wallets_path / "wallet.dat") + self.clear_default_wallet(backup_path) def test_direct_file(self): self.log.info("Test migration of a wallet that is not in a wallet directory") @@ -1573,6 +1602,7 @@ class WalletMigrationTest(BitcoinTestFramework): self.test_wallet_with_path("path/that/ends/in/..") self.test_default_wallet_failure() self.test_default_wallet() + self.test_default_wallet_watch_only() self.test_direct_file() self.test_addressbook() self.test_migrate_raw_p2sh() From 185ca0e391aea530db37dd5bbc0e150286083ea9 Mon Sep 17 00:00:00 2001 From: furszy Date: Sun, 4 Jan 2026 12:25:21 -0500 Subject: [PATCH 07/11] test: coverage for migration failure when last sync is beyond prune height Github-Pull: #34156 Rebased-From: b7c34d08dd9549a95cffc6ec1ffa4bb4f81e35eb --- test/functional/wallet_migration.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py index 88b1aaccc37..0912f99a2c6 100755 --- a/test/functional/wallet_migration.py +++ b/test/functional/wallet_migration.py @@ -1582,6 +1582,37 @@ class WalletMigrationTest(BitcoinTestFramework): self.start_node(self.old_node.index) self.connect_nodes(1, 0) + def unsynced_wallet_on_pruned_node_fails(self): + self.log.info("Test migration of an unsynced wallet on a pruned node fails gracefully") + wallet = self.create_legacy_wallet("", load_on_startup=False) + last_wallet_synced_block = wallet.getwalletinfo()['lastprocessedblock']['height'] + wallet.unloadwallet() + + shutil.copyfile(self.old_node.wallets_path / "wallet.dat", self.master_node.wallets_path / "wallet.dat") + + # Generate blocks just so the wallet best block is pruned + self.restart_node(0, ["-fastprune", "-prune=1", "-nowallet"]) + self.connect_nodes(0, 1) + self.generate(self.master_node, 450, sync_fun=self.no_op) + self.master_node.pruneblockchain(250) + # Ensure next block to sync is unavailable + assert_raises_rpc_error(-1, "Block not available (pruned data)", self.master_node.getblock, self.master_node.getblockhash(last_wallet_synced_block + 1)) + + # Check migration failure + mocked_time = int(time.time()) + self.master_node.setmocktime(mocked_time) + assert_raises_rpc_error(-4, "last wallet synchronisation goes beyond pruned data. You need to -reindex (download the whole blockchain again in case of a pruned node)", self.master_node.migratewallet, wallet_name="") + self.master_node.setmocktime(0) + + # Verify the /wallets/ path exists, the wallet is still BDB and the backup file is there. + assert self.master_node.wallets_path.exists() + self.assert_is_bdb("") + backup_path = self.master_node.wallets_path / f"default_wallet_{mocked_time}.legacy.bak" + assert backup_path.exists() + + self.clear_default_wallet(backup_path) + + def run_test(self): self.master_node = self.nodes[0] self.old_node = self.nodes[1] @@ -1622,5 +1653,8 @@ class WalletMigrationTest(BitcoinTestFramework): self.test_solvable_no_privs() self.test_loading_failure_after_migration() + # Note: After this test the first 250 blocks of 'master_node' are pruned + self.unsynced_wallet_on_pruned_node_fails() + if __name__ == '__main__': WalletMigrationTest(__file__).main() From c4082a45e64d81dda5ec093a8ba3b6dad4b946a8 Mon Sep 17 00:00:00 2001 From: Ava Chow Date: Tue, 6 Jan 2026 16:09:38 -0800 Subject: [PATCH 08/11] wallettool: do not use fs::remove_all in createfromdump cleanup Github-Pull: #34215 Rebased-From: f78f6f1dc8e16d5a8a23749e77bc3bf17c91ae42 --- src/wallet/dump.cpp | 8 +++++++- test/functional/tool_wallet.py | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/wallet/dump.cpp b/src/wallet/dump.cpp index 6b193ad72f3..e863dd55ecd 100644 --- a/src/wallet/dump.cpp +++ b/src/wallet/dump.cpp @@ -276,11 +276,17 @@ bool CreateFromDump(const ArgsManager& args, const std::string& name, const fs:: dump_file.close(); } + // On failure, gather the paths to remove + std::vector paths_to_remove = wallet->GetDatabase().Files(); + if (!name.empty()) paths_to_remove.push_back(wallet_path); + wallet.reset(); // The pointer deleter will close the wallet for us. // Remove the wallet dir if we have a failure if (!ret) { - fs::remove_all(wallet_path); + for (const auto& p : paths_to_remove) { + fs::remove(p); + } } return ret; diff --git a/test/functional/tool_wallet.py b/test/functional/tool_wallet.py index bbf84d7a017..ed361a38a95 100755 --- a/test/functional/tool_wallet.py +++ b/test/functional/tool_wallet.py @@ -319,6 +319,12 @@ class ToolWalletTest(BitcoinTestFramework): self.write_dump(dump_data, bad_sum_wallet_dump) self.assert_raises_tool_error('Error: Checksum is not the correct size', '-wallet=badload', '-dumpfile={}'.format(bad_sum_wallet_dump), 'createfromdump') assert not (self.nodes[0].wallets_path / "badload").is_dir() + self.assert_raises_tool_error('Error: Checksum is not the correct size', '-wallet=', '-dumpfile={}'.format(bad_sum_wallet_dump), 'createfromdump') + assert self.nodes[0].wallets_path.exists() + assert not (self.nodes[0].wallets_path / "wallet.dat").exists() + + self.log.info('Checking createfromdump with an unnamed wallet') + self.do_tool_createfromdump("", "wallet.dump") def test_chainless_conflicts(self): self.log.info("Test wallet tool when wallet contains conflicting transactions") From cc3cdbe9214f80ab43b66af121fb78abcb9e2d1d Mon Sep 17 00:00:00 2001 From: fanquake Date: Tue, 6 Jan 2026 15:49:24 +0000 Subject: [PATCH 09/11] doc: update release notes for v30.2rc1 --- doc/release-notes.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/doc/release-notes.md b/doc/release-notes.md index 683ec38da5c..920b26b2bdd 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -1,9 +1,9 @@ -v30.x Release Notes +v30.2rc1 Release Notes =================== -Bitcoin Core version v30.x is now available from: +Bitcoin Core version v30.2rc1 is now available from: - + This release includes new features, various bug fixes and performance improvements, as well as updated translations. @@ -40,6 +40,11 @@ unsupported systems. Notable changes =============== +### Wallet + +- #34156 wallet: fix unnamed legacy wallet migration failure +- #34215 wallettool: fix unnamed createfromdump failure walletsdir deletion + ### IPC - #33511 init: Fix Ctrl-C shutdown hangs during wait calls @@ -70,8 +75,10 @@ Credits Thanks to everyone who directly contributed to this release: +- Ava Chow - brunoerg - fanquake +- furszy - Hennadii Stepanov - MarcoFalke - Ryan Ofsky From 747a863f5b6f45f6a4bb8ad6811ef800cf4afb58 Mon Sep 17 00:00:00 2001 From: fanquake Date: Tue, 6 Jan 2026 15:49:56 +0000 Subject: [PATCH 10/11] build: bump version to v30.2rc1 --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a7983c0c33..2a345c5f1dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,9 +28,9 @@ get_directory_property(precious_variables CACHE_VARIABLES) #============================= set(CLIENT_NAME "Bitcoin Core") set(CLIENT_VERSION_MAJOR 30) -set(CLIENT_VERSION_MINOR 1) +set(CLIENT_VERSION_MINOR 2) set(CLIENT_VERSION_BUILD 0) -set(CLIENT_VERSION_RC 0) +set(CLIENT_VERSION_RC 1) set(CLIENT_VERSION_IS_RELEASE "true") set(COPYRIGHT_YEAR "2026") From 483d158f538c3d7c1cac30d2b299bc10b256972e Mon Sep 17 00:00:00 2001 From: fanquake Date: Tue, 6 Jan 2026 16:15:54 +0000 Subject: [PATCH 11/11] doc: update manual pages for v30.2rc1 --- doc/man/bitcoin-cli.1 | 8 ++++---- doc/man/bitcoin-qt.1 | 8 ++++---- doc/man/bitcoin-tx.1 | 8 ++++---- doc/man/bitcoin-util.1 | 8 ++++---- doc/man/bitcoin-wallet.1 | 8 ++++---- doc/man/bitcoin.1 | 6 +++--- doc/man/bitcoind.1 | 8 ++++---- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/doc/man/bitcoin-cli.1 b/doc/man/bitcoin-cli.1 index 0d7d2cb15ef..3124c13995b 100644 --- a/doc/man/bitcoin-cli.1 +++ b/doc/man/bitcoin-cli.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN-CLI "1" "December 2025" "bitcoin-cli v30.1.0" "User Commands" +.TH BITCOIN-CLI "1" "January 2026" "bitcoin-cli v30.2.0rc1" "User Commands" .SH NAME -bitcoin-cli \- manual page for bitcoin-cli v30.1.0 +bitcoin-cli \- manual page for bitcoin-cli v30.2.0rc1 .SH SYNOPSIS .B bitcoin-cli [\fI\,options\/\fR] \fI\, \/\fR[\fI\,params\/\fR] @@ -15,7 +15,7 @@ bitcoin-cli \- manual page for bitcoin-cli v30.1.0 .B bitcoin-cli [\fI\,options\/\fR] \fI\,help \/\fR .SH DESCRIPTION -Bitcoin Core RPC client version v30.1.0 +Bitcoin Core RPC client version v30.2.0rc1 .PP The bitcoin\-cli utility provides a command line interface to interact with a Bitcoin Core RPC server. .PP @@ -188,7 +188,7 @@ additional "outonly" (or "o") argument can be passed to see outbound peers only. Pass "help" (or "h") for detailed help documentation. .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoin-qt.1 b/doc/man/bitcoin-qt.1 index c0e86f0ed11..7daba3a885a 100644 --- a/doc/man/bitcoin-qt.1 +++ b/doc/man/bitcoin-qt.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN-QT "1" "December 2025" "bitcoin-qt v30.1.0" "User Commands" +.TH BITCOIN-QT "1" "January 2026" "bitcoin-qt v30.2.0rc1" "User Commands" .SH NAME -bitcoin-qt \- manual page for bitcoin-qt v30.1.0 +bitcoin-qt \- manual page for bitcoin-qt v30.2.0rc1 .SH SYNOPSIS .B bitcoin-qt [\fI\,options\/\fR] [\fI\,URI\/\fR] .SH DESCRIPTION -Bitcoin Core version v30.1.0 +Bitcoin Core version v30.2.0rc1 .PP The bitcoin\-qt application provides a graphical interface for interacting with Bitcoin Core. .PP @@ -839,7 +839,7 @@ Reset all settings changed in the GUI .IP Show splash screen on startup (default: 1) .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoin-tx.1 b/doc/man/bitcoin-tx.1 index 8c6854520e7..e1b49f617d2 100644 --- a/doc/man/bitcoin-tx.1 +++ b/doc/man/bitcoin-tx.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN-TX "1" "December 2025" "bitcoin-tx v30.1.0" "User Commands" +.TH BITCOIN-TX "1" "January 2026" "bitcoin-tx v30.2.0rc1" "User Commands" .SH NAME -bitcoin-tx \- manual page for bitcoin-tx v30.1.0 +bitcoin-tx \- manual page for bitcoin-tx v30.2.0rc1 .SH SYNOPSIS .B bitcoin-tx [\fI\,options\/\fR] \fI\, \/\fR[\fI\,commands\/\fR] @@ -9,7 +9,7 @@ bitcoin-tx \- manual page for bitcoin-tx v30.1.0 .B bitcoin-tx [\fI\,options\/\fR] \fI\,-create \/\fR[\fI\,commands\/\fR] .SH DESCRIPTION -Bitcoin Core bitcoin\-tx utility version v30.1.0 +Bitcoin Core bitcoin\-tx utility version v30.2.0rc1 .PP The bitcoin\-tx tool is used for creating and modifying bitcoin transactions. .PP @@ -146,7 +146,7 @@ set=NAME:JSON\-STRING .IP Set register NAME to given JSON\-STRING .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoin-util.1 b/doc/man/bitcoin-util.1 index 3058c209db2..7635ed1a981 100644 --- a/doc/man/bitcoin-util.1 +++ b/doc/man/bitcoin-util.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN-UTIL "1" "December 2025" "bitcoin-util v30.1.0" "User Commands" +.TH BITCOIN-UTIL "1" "January 2026" "bitcoin-util v30.2.0rc1" "User Commands" .SH NAME -bitcoin-util \- manual page for bitcoin-util v30.1.0 +bitcoin-util \- manual page for bitcoin-util v30.2.0rc1 .SH SYNOPSIS .B bitcoin-util [\fI\,options\/\fR] [\fI\,command\/\fR] @@ -9,7 +9,7 @@ bitcoin-util \- manual page for bitcoin-util v30.1.0 .B bitcoin-util [\fI\,options\/\fR] \fI\,grind \/\fR .SH DESCRIPTION -Bitcoin Core bitcoin\-util utility version v30.1.0 +Bitcoin Core bitcoin\-util utility version v30.2.0rc1 .PP The bitcoin\-util tool provides bitcoin related functionality that does not rely on the ability to access a running node. Available [commands] are listed below. .SH OPTIONS @@ -65,7 +65,7 @@ grind .IP Perform proof of work on hex header string .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoin-wallet.1 b/doc/man/bitcoin-wallet.1 index 7aead0e45c3..3064467bbc6 100644 --- a/doc/man/bitcoin-wallet.1 +++ b/doc/man/bitcoin-wallet.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN-WALLET "1" "December 2025" "bitcoin-wallet v30.1.0" "User Commands" +.TH BITCOIN-WALLET "1" "January 2026" "bitcoin-wallet v30.2.0rc1" "User Commands" .SH NAME -bitcoin-wallet \- manual page for bitcoin-wallet v30.1.0 +bitcoin-wallet \- manual page for bitcoin-wallet v30.2.0rc1 .SH SYNOPSIS .B bitcoin-wallet [\fI\,options\/\fR] \fI\,\/\fR .SH DESCRIPTION -Bitcoin Core bitcoin\-wallet utility version v30.1.0 +Bitcoin Core bitcoin\-wallet utility version v30.2.0rc1 .PP bitcoin\-wallet is an offline tool for creating and interacting with Bitcoin Core wallet files. .PP @@ -100,7 +100,7 @@ info .IP Get wallet info .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoin.1 b/doc/man/bitcoin.1 index 29edf725bf4..a04b51f9064 100644 --- a/doc/man/bitcoin.1 +++ b/doc/man/bitcoin.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIN "1" "December 2025" "bitcoin v30.1.0" "User Commands" +.TH BITCOIN "1" "January 2026" "bitcoin v30.2.0rc1" "User Commands" .SH NAME -bitcoin \- manual page for bitcoin v30.1.0 +bitcoin \- manual page for bitcoin v30.2.0rc1 .SH SYNOPSIS .B bitcoin [\fI\,OPTIONS\/\fR] \fI\,COMMAND\/\fR... @@ -46,7 +46,7 @@ chainstate [ARGS] Run bitcoin kernel chainstate util, equivalent to running 'bit test [ARGS] Run unit tests, equivalent to running 'test_bitcoin [ARGS]'. test\-gui [ARGS] Run GUI unit tests, equivalent to running 'test_bitcoin\-qt [ARGS]'. .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software. diff --git a/doc/man/bitcoind.1 b/doc/man/bitcoind.1 index 78e933983b8..cb4fb805382 100644 --- a/doc/man/bitcoind.1 +++ b/doc/man/bitcoind.1 @@ -1,12 +1,12 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH BITCOIND "1" "December 2025" "bitcoind v30.1.0" "User Commands" +.TH BITCOIND "1" "January 2026" "bitcoind v30.2.0rc1" "User Commands" .SH NAME -bitcoind \- manual page for bitcoind v30.1.0 +bitcoind \- manual page for bitcoind v30.2.0rc1 .SH SYNOPSIS .B bitcoind [\fI\,options\/\fR] .SH DESCRIPTION -Bitcoin Core daemon version v30.1.0 bitcoind +Bitcoin Core daemon version v30.2.0rc1 bitcoind .PP The Bitcoin Core daemon (bitcoind) is a headless program that connects to the Bitcoin network to validate and relay transactions and blocks, as well as relaying addresses. .PP @@ -817,7 +817,7 @@ subject to empty whitelists. .IP Accept command line and JSON\-RPC commands .SH COPYRIGHT -Copyright (C) 2009-2025 The Bitcoin Core developers +Copyright (C) 2009-2026 The Bitcoin Core developers Please contribute if you find Bitcoin Core useful. Visit for further information about the software.