Merge bitcoin/bitcoin#34156: wallet: fix unnamed legacy wallet migration failure

b7c34d08dd test: coverage for migration failure when last sync is beyond prune height (furszy)
82caa8193a wallet: migration, fix watch-only and solvables wallets names (furszy)
d70b159c42 wallet: improve post-migration logging (furszy)
f011e0f068 test: restorewallet, coverage for existing dirs, unnamed wallet and prune failure (furszy)
36093bde63 test: add coverage for unnamed wallet migration failure (furszy)
f4c7e28e80 wallet: fix unnamed wallet migration failure (furszy)
4ed0693a3f wallet: RestoreWallet failure, erase only what was created (furszy)

Pull request description:

  Minimal fix for #34128.

  The issue occurs during the migration of a legacy unnamed wallet
  (the legacy "default" wallet). When the migration fails, the cleanup
  logic is triggered to roll back the state, which involves erasing the
  newly created descriptor wallets directories. Normally, this only
  affects the parent directories of named wallets, since they each
  reside in their own directories. However, because the unnamed
  wallet resides directly in the top-level `/wallets/` folder, this
  logic accidentally deletes the main directory.

  The fix ensures that only the wallet.dat file of the unnamed wallet
  is touched and restored, preserving the wallet in BDB format and
  leaving the main `/wallets/` directory intact.

  #### Story Line:
  #32273 fixed a different set of issues and, in doing so, uncovered
  this one.
  Before the mentioned PR, backups were stored in the same directory
  as the wallet.dat file. On a migration failure, the backup was then
  copied to the top-level `/wallets/` directory. For the unnamed legacy
  wallet, the wallet directory is the `/wallets/` directory, so the source
  and destination paths were identical. As a result, we threw early in the
  `fs::copy_file` call ([here](https://github.com/bitcoin/bitcoin/blob/29.x/src/wallet/wallet.cpp#L4572)) because the file already existed, as we
  were trying to copy the file onto itself. This caused the cleanup logic
  to abort early on and never reach the removal line.

  #### Testing Notes:
  Cherry-pick the test commit on top of master and run it. You will
  see the failure and realize the reason by reading the test code.

ACKs for top commit:
  achow101:
    ACK b7c34d08dd
  davidgumberg:
    crACK https://github.com/bitcoin/bitcoin/commit/b7c34d08dd9549a95cffc6ec1ffa4bb4f81e35eb
  w0xlt:
    ACK https://github.com/bitcoin/bitcoin/commit/b7c34d08dd9549a95cffc6ec1ffa4bb4f81e35eb
  willcl-ark:
    ACK b7c34d08dd

Tree-SHA512: d0be14c0ed6417f999c3f2f429652c2407097d0cc18453c91653e57ae4b5375b327ad3b2553d9ea6ff46a3ae00cdbd5ab325b94eba763072c4fc5a773b85618b
This commit is contained in:
merge-script committed 2026-01-07 11:08:57 +00:00
commit 90d651a81f
3 files changed
+252 -22

No files matched your search

+66 -1
View File
@@ -39,6 +39,7 @@ from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_raises_rpc_error,
sha256sum_file,
)
@@ -132,10 +133,65 @@ 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()
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 = [
+97
View File
@@ -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
@@ -659,6 +660,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("")
@@ -675,6 +684,58 @@ 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()
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")
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 / "default_wallet_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
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")
wallet = self.create_legacy_wallet("plainfile")
@@ -1542,6 +1603,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]
@@ -1560,7 +1652,9 @@ 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_default_wallet_watch_only()
self.test_direct_file()
self.test_addressbook()
self.test_migrate_raw_p2sh()
@@ -1580,5 +1674,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()