Merge bitcoin/bitcoin#34156: wallet: fix unnamed legacy wallet migration failure
b7c34d08ddtest: coverage for migration failure when last sync is beyond prune height (furszy)82caa8193awallet: migration, fix watch-only and solvables wallets names (furszy)d70b159c42wallet: improve post-migration logging (furszy)f011e0f068test: restorewallet, coverage for existing dirs, unnamed wallet and prune failure (furszy)36093bde63test: add coverage for unnamed wallet migration failure (furszy)f4c7e28e80wallet: fix unnamed wallet migration failure (furszy)4ed0693a3fwallet: 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: ACKb7c34d08dddavidgumberg: crACK https://github.com/bitcoin/bitcoin/commit/b7c34d08dd9549a95cffc6ec1ffa4bb4f81e35eb w0xlt: ACK https://github.com/bitcoin/bitcoin/commit/b7c34d08dd9549a95cffc6ec1ffa4bb4f81e35eb willcl-ark: ACKb7c34d08ddTree-SHA512: d0be14c0ed6417f999c3f2f429652c2407097d0cc18453c91653e57ae4b5375b327ad3b2553d9ea6ff46a3ae00cdbd5ab325b94eba763072c4fc5a773b85618b
This commit is contained in:
3 files changed
+252
-22
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user