test: tests wallet migration with load_wallet disabled
What changed, and why it matters
This commit only adds new automated tests for Bitcoin Core's wallet migration feature. It checks that when a wallet is migrated with the 'load_wallet=False' option, the wallet is not automatically loaded afterward, settings are updated correctly, and auxiliary wallet names are reported. There is no change to production wallet code, so this commit does not introduce or fix a security vulnerability on its own.
No security action required. Review the related production change that introduced the load_wallet parameter to migratewallet if assessing security impact; this commit only adds regression tests.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff extends test/functional/wallet_migration.py with three new test cases and supporting assertions: test_no_load_after_migration verifies migratewallet(…, load_wallet=False) leaves the wallet on disk in SQLite format without loading it and that reloading works; test_no_load_unnamed_wallet_does_not_autoload ensures the unnamed wallet is not autoloaded after migration and that the wallet settings entry becomes empty; test_no_load_reports_auxiliary_wallet_names checks that watchonly and solvables auxiliary wallet names are returned and removed from startup settings. A helper assertion is updated to optionally skip the RPC-based format check when the wallet is unloaded. The commit is purely test code.
Changed components
test/functional/wallet_migration.pyInspect captured patch +144 / −3
diff --git a/test/functional/wallet_migration.py b/test/functional/wallet_migration.py
index e28cfd57..d2c15593 100755
--- a/test/functional/wallet_migration.py
+++ b/test/functional/wallet_migration.py
@@ -5,6 +5,7 @@
"""Test Migrating a wallet from legacy to descriptor."""
from contextlib import suppress
from pathlib import Path
+import json
import os.path
import random
import shutil
@@ -62,12 +63,14 @@ class WalletMigrationTest(BitcoinTestFramework):
self.start_nodes()
self.init_wallet(node=0)
- def assert_is_sqlite(self, wallet_name):
+ def assert_is_sqlite(self, wallet_name, wallet_loaded = True):
wallet_file_path = self.master_node.wallets_path / wallet_name / self.wallet_data_filename
with open(wallet_file_path, 'rb') as f:
file_magic = f.read(16)
assert_equal(file_magic, b'SQLite format 3\x00')
- assert_equal(self.master_node.get_wallet_rpc(wallet_name).getwalletinfo()["format"], "sqlite")
+ # if the wallet is not loaded we can't ask the node about it
+ if wallet_loaded:
+ assert_equal(self.master_node.get_wallet_rpc(wallet_name).getwalletinfo()["format"], "sqlite")
def assert_is_bdb(self, wallet_name):
with open(self.master_node.wallets_path / wallet_name / self.wallet_data_filename, "rb") as f:
@@ -1493,6 +1496,134 @@ class WalletMigrationTest(BitcoinTestFramework):
assert_equal(watchonly.getaddressinfo(tr_addr)["ismine"], True)
assert_equal(watchonly.getaddressinfo(tr_script_addr)["ismine"], True)
+ def test_no_load_after_migration(self):
+ self.log.info("Test migration with load_wallet disabled")
+ default = self.master_node.get_wallet_rpc(self.default_wallet_name)
+
+ wallet_name = "no_load_after_migration"
+ wallet = self.create_legacy_wallet(wallet_name)
+ addr = wallet.getnewaddress()
+ txid = default.sendtoaddress(addr, 1)
+ self.generate(self.master_node, 1)
+ bals = wallet.getbalances()
+
+ # Copy wallet from old node to master node
+ self.old_node.unloadwallet(wallet_name)
+ shutil.copytree(
+ self.old_node.wallets_path / wallet_name,
+ self.master_node.wallets_path / wallet_name,
+ dirs_exist_ok=True,
+ )
+
+ migrate_info = self.master_node.migratewallet(wallet_name=wallet_name, load_wallet=False)
+
+ # The wallet should NOT be loaded after migration
+ assert wallet_name not in self.master_node.listwallets()
+
+ # The returned wallet_name should be populated correctly
+ assert_equal(migrate_info["wallet_name"], wallet_name)
+
+ # The backup path should be reported and exist on disk
+ assert os.path.exists(migrate_info["backup_path"])
+
+ # The migrated wallet files should be on disk in SQLite format
+ self.assert_is_sqlite(wallet_name, wallet_loaded = False)
+
+ # Load the wallet and verify its state is correct after migration
+ self.master_node.loadwallet(wallet_name)
+ loaded_wallet = self.master_node.get_wallet_rpc(wallet_name)
+ info = loaded_wallet.getwalletinfo()
+ assert_equal(info["descriptors"], True)
+ assert_equal(info["format"], "sqlite")
+ loaded_wallet.gettransaction(txid)
+ assert_equal(loaded_wallet.getbalance(), bals["mine"]["trusted"])
+ loaded_wallet.unloadwallet()
+
+ def test_no_load_unnamed_wallet_does_not_autoload(self):
+ self.log.info("Test no-load migration of unnamed wallet suppresses default autoload")
+ self.master_node = self.nodes[0]
+ self.old_node = self.nodes[1]
+
+ wallet = self.create_legacy_wallet("", load_on_startup=False)
+ wallet.unloadwallet()
+
+ self.stop_node(0)
+
+ def remove_wallet_setting(node):
+ settings_path = node.chain_path / "settings.json"
+ if not settings_path.exists():
+ return
+ with settings_path.open(encoding="utf8") as settings_file:
+ settings = json.load(settings_file)
+ settings.pop("wallet", None)
+ with settings_path.open("w", encoding="utf8") as settings_file:
+ json.dump(settings, settings_file, indent=4)
+ settings_file.write("\n")
+
+ remove_wallet_setting(self.master_node)
+ (self.master_node.wallets_path / "wallet.dat").unlink(missing_ok=True)
+ shutil.copyfile(self.old_node.wallets_path / "wallet.dat", self.master_node.wallets_path / "wallet.dat")
+ self.start_node(0)
+
+ assert_equal(self.master_node.listwallets(), [])
+
+ migrate_info = self.master_node.migratewallet(wallet_name="", load_wallet=False)
+ assert_equal(migrate_info["wallet_name"], "")
+ backup_path = Path(migrate_info["backup_path"])
+ assert "" not in self.master_node.listwallets()
+
+ with (self.master_node.chain_path / "settings.json").open(encoding="utf8") as settings_file:
+ wallet_setting_after_migration = json.load(settings_file).get("wallet")
+
+ self.restart_node(0)
+ wallets_after_restart = self.master_node.listwallets()
+
+ assert "" not in wallets_after_restart
+
+ self.clear_default_wallet(backup_path)
+ self.master_node.loadwallet(self.default_wallet_name, load_on_startup=True)
+
+ assert_equal(wallet_setting_after_migration, [])
+
+ def test_no_load_reports_auxiliary_wallet_names(self):
+ self.log.info("Test no-load migration reports auxiliary wallet names")
+ wallet_name = "no_load_auxiliary_names"
+ wallet = self.create_legacy_wallet(wallet_name)
+
+ wallet.importaddress(address=self.master_node.get_wallet_rpc(self.default_wallet_name).getnewaddress(), rescan=False)
+ _, pubkey = generate_keypair(compressed=True, wif=True)
+ wallet.addmultisigaddress(nrequired=1, keys=[pubkey.hex()])
+
+ # Simulate that the wallet was created by the master node with an old version
+ # so it should already know the wallet prior migration. Set load_on_startup true,
+ # to simulate that the wallet was automatically being loaded on each restart.
+ self.master_node.createwallet(wallet_name=wallet_name, load_on_startup=True)
+ self.master_node.unloadwallet(wallet_name)
+ self.cleanup_folder(self.master_node.wallets_path / wallet_name)
+ with (self.master_node.chain_path / "settings.json").open(encoding="utf8") as settings_file:
+ assert wallet_name in json.load(settings_file).get("wallet", [])
+
+ self.old_node.unloadwallet(wallet_name)
+ shutil.copytree(self.old_node.wallets_path / wallet_name, self.master_node.wallets_path / wallet_name)
+
+ migrate_info = self.master_node.migratewallet(wallet_name=wallet_name, load_wallet=False)
+
+ assert_equal(migrate_info["wallet_name"], wallet_name)
+ assert_equal(migrate_info["watchonly_name"], f"{wallet_name}_watchonly")
+ assert_equal(migrate_info["solvables_name"], f"{wallet_name}_solvables")
+ assert wallet_name not in self.master_node.listwallets()
+ assert f"{wallet_name}_watchonly" not in self.master_node.listwallets()
+ assert f"{wallet_name}_solvables" not in self.master_node.listwallets()
+
+ # Migrate wallet with load_wallet=False should have overwritten the wallet settings
+ # and the load_on_startup setting must removed.
+ with (self.master_node.chain_path / "settings.json").open(encoding="utf8") as settings_file:
+ startup_wallets = json.load(settings_file).get("wallet", [])
+ assert wallet_name not in startup_wallets
+ assert f"{wallet_name}_watchonly" not in startup_wallets
+ assert f"{wallet_name}_solvables" not in startup_wallets
+
+
def test_solvable_no_privs(self):
self.log.info("Test migrating a multisig that we do not have any private keys for")
wallet = self.create_legacy_wallet("multisig_noprivs")
@@ -1544,7 +1675,7 @@ class WalletMigrationTest(BitcoinTestFramework):
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")
+ self.log.info("Test migration of an unsynced wallet on a pruned node fails gracefully if loadwallet is set")
wallet = self.create_legacy_wallet("", load_on_startup=False)
last_wallet_synced_block = wallet.getwalletinfo()['lastprocessedblock']['height']
wallet.unloadwallet()
@@ -1571,6 +1702,13 @@ class WalletMigrationTest(BitcoinTestFramework):
backup_path = self.master_node.wallets_path / f"default_wallet_{mocked_time}.legacy.bak"
assert backup_path.exists()
+ self.log.info("Test migration of an unsynced wallet on a pruned node does not fails if loadwallet is not set")
+ self.master_node.migratewallet("", load_wallet=False)
+ # The wallet should NOT be loaded after migration
+ assert "" not in self.master_node.listwallets()
+ # Load the wallet should fail
+ 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.loadwallet, filename="")
+
self.clear_default_wallet(backup_path)
@@ -1617,6 +1755,9 @@ class WalletMigrationTest(BitcoinTestFramework):
self.test_disallowed_p2wsh()
self.test_miniscript()
self.test_taproot()
+ self.test_no_load_after_migration()
+ self.test_no_load_unnamed_wallet_does_not_autoload()
+ self.test_no_load_reports_auxiliary_wallet_names()
self.test_solvable_no_privs()
self.test_loading_failure_after_migration()
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.