mirror of
https://github.com/bitcoin/bitcoin.git
synced 2026-09-11 21:20:39 +02:00
Merge bitcoin/bitcoin#33186: wallet, test: Ancient Wallet Migration from v0.14.3 (no-HD and Single Chain)
ea59f17220test: cover v0.14.3 wallet migration (w0xlt)18b8afd093test: support v0.14.x in dumb_sync_blocks (w0xlt) Pull request description: This PR adds test coverage for migrating legacy Bitcoin Core wallets from v0.14.3 (released in 2017) to the descriptor wallet format. The test validates that users can safely upgrade their wallets while preserving all funds, transaction history, and addresses. This test was originally developed on top of #32977, as it was requested in reviews. However, since it also increases test coverage, it can be merged independently. The test covers two wallet migration scenarios: * Non-HD Wallet Migration - Tests migration of non-HD wallets (created with `-usehd=0`) * Single Chain HD Wallet Migration - Tests migration of HD wallets from v0.14.3 (`VERSION_HD_BASE`) The node v0.14.3 cannot be synced using the normal test framework helpers because it does not have the `syncwithvalidationinterfacequeue` RPC, so the test uses `dumb_sync_blocks` to submit blocks from the ancient node to the modern node before migrating the wallet. Each scenario uses its own dedicated old/new node pair, which keeps the setup isolated and makes this testing best managed in a separate file rather than in the existing migration test files. On the Windows cross-built CI job, this test is excluded from the main functional test runner and re-run sequentially in an ASCII-only tmpdir, because the v0.14.3 binary cannot handle non-ASCII characters in the temporary directory path. ACKs for top commit: furszy: utACKea59f17220pablomartin4btc: reACKea59f17220Tree-SHA512: 35ef7173e10fe52f20db0d6d1f144c5a1343ff406d294ab7d0e346d79546797f3f145f2893d99bb7b57d71aa14580690ab80eae148ec94b91498eb0938b2d15e
This commit is contained in:
25
.github/ci-windows-cross.py
vendored
25
.github/ci-windows-cross.py
vendored
@@ -99,23 +99,26 @@ def run_functional_tests():
|
||||
f"--tmpdirprefix={workspace / '_ _'}",
|
||||
"--combinedlogslen=99999999",
|
||||
*shlex.split(os.environ.get("TEST_RUNNER_EXTRA", "").strip()),
|
||||
# feature_unsupported_utxo_db.py fails on Windows because of emojis in the test data directory.
|
||||
# Tests using ancient releases fail on Windows because of emojis in the test data directory.
|
||||
"--exclude",
|
||||
"feature_unsupported_utxo_db.py",
|
||||
"--exclude",
|
||||
"wallet_ancient_migration.py",
|
||||
]
|
||||
run(test_runner_cmd)
|
||||
|
||||
# Run feature_unsupported_utxo_db sequentially in ASCII-only tmp dir,
|
||||
# because it is excluded above due to lack of UTF-8 support in the
|
||||
# Run ancient release tests sequentially in ASCII-only tmp dir,
|
||||
# because they are excluded above due to lack of UTF-8 support in the
|
||||
# ancient release.
|
||||
cmd_feature_unsupported_db = [
|
||||
sys.executable,
|
||||
str(workspace / "test" / "functional" / "feature_unsupported_utxo_db.py"),
|
||||
"--previous-releases",
|
||||
"--tmpdir",
|
||||
str(Path(workspace) / "test_feature_unsupported_utxo_db"),
|
||||
]
|
||||
run(cmd_feature_unsupported_db)
|
||||
for test_name in ["feature_unsupported_utxo_db", "wallet_ancient_migration"]:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(workspace / "test" / "functional" / f"{test_name}.py"),
|
||||
"--previous-releases",
|
||||
"--tmpdir",
|
||||
str(workspace / f"test_{test_name}"),
|
||||
]
|
||||
run(cmd)
|
||||
|
||||
|
||||
def run_unit_tests():
|
||||
|
||||
@@ -704,7 +704,8 @@ def dumb_sync_blocks(*, src, dst, height=None):
|
||||
height = height or src.getblockcount()
|
||||
for i in range(dst.getblockcount() + 1, height + 1):
|
||||
block_hash = src.getblockhash(i)
|
||||
block = src.getblock(blockhash=block_hash, verbosity=0)
|
||||
# Use boolean verbosity for v0.14.x compatibility.
|
||||
block = src.getblock(blockhash=block_hash, verbose=False)
|
||||
dst.submitblock(block)
|
||||
assert_equal(dst.getblockcount(), height)
|
||||
|
||||
|
||||
@@ -400,6 +400,7 @@ BASE_SCRIPTS = [
|
||||
'p2p_ibd_txrelay.py',
|
||||
'p2p_seednode.py',
|
||||
'rpc_openrpc.py',
|
||||
'wallet_ancient_migration.py',
|
||||
# Don't append tests at the end to avoid merge conflicts
|
||||
# Put them in a random line within the section that fits their approximate run-time
|
||||
]
|
||||
|
||||
229
test/functional/wallet_ancient_migration.py
Executable file
229
test/functional/wallet_ancient_migration.py
Executable file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026-present The Bitcoin Core developers
|
||||
# Distributed under the MIT software license, see the accompanying
|
||||
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
"""Test wallet migration from ancient versions (v0.14.3) to descriptor wallets.
|
||||
|
||||
Previous releases are required by this test, see test/README.md.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from test_framework.blocktools import COINBASE_MATURITY
|
||||
from test_framework.test_framework import BitcoinTestFramework
|
||||
from test_framework.util import (
|
||||
assert_equal,
|
||||
assert_greater_than,
|
||||
assert_is_hash_string,
|
||||
assert_raises_rpc_error,
|
||||
dumb_sync_blocks,
|
||||
)
|
||||
|
||||
# Wallet version constants from ancient Bitcoin Core (pre-0.15)
|
||||
FEATURE_COMPRPUBKEY = 60000 # Non-HD wallet version
|
||||
FEATURE_HD = 130000 # Single-chain HD wallet version (pre-v0.15)
|
||||
WALLET_PASSPHRASE = "test_passphrase"
|
||||
|
||||
|
||||
def get_vout_addresses(decoded_tx):
|
||||
for vout in decoded_tx["vout"]:
|
||||
script_pub_key = vout["scriptPubKey"]
|
||||
if "address" in script_pub_key:
|
||||
yield script_pub_key["address"]
|
||||
else:
|
||||
yield from script_pub_key.get("addresses", [])
|
||||
|
||||
|
||||
def normalize_listtransactions(txs):
|
||||
"""Return cross-version stable fields from listtransactions output."""
|
||||
return sorted(
|
||||
(tx["txid"], tx["amount"], tx["category"], tx["confirmations"])
|
||||
for tx in txs
|
||||
)
|
||||
|
||||
|
||||
class WalletAncientMigrationTest(BitcoinTestFramework):
|
||||
def set_test_params(self):
|
||||
self.setup_clean_chain = True
|
||||
self.num_nodes = 8
|
||||
|
||||
def skip_test_if_missing_module(self):
|
||||
self.skip_if_no_wallet()
|
||||
self.skip_if_no_previous_releases()
|
||||
|
||||
def setup_network(self):
|
||||
# Use dedicated old/new node pairs for each migration scenario to avoid
|
||||
# resetting node datadirs between sub-tests.
|
||||
self.add_nodes(self.num_nodes, versions=[140300, None] * 4)
|
||||
|
||||
def run_migration_test(
|
||||
self,
|
||||
wallet_type,
|
||||
old_node_idx,
|
||||
new_node_idx,
|
||||
extra_args_old,
|
||||
expected_version,
|
||||
expect_hd,
|
||||
passphrase=None,
|
||||
):
|
||||
"""Test migration of a v0.14.3 wallet to descriptor wallet."""
|
||||
old_node = self.nodes[old_node_idx]
|
||||
new_node = self.nodes[new_node_idx]
|
||||
|
||||
self.log.info(f"Testing {wallet_type} wallet migration")
|
||||
self.start_node(old_node_idx, extra_args=extra_args_old)
|
||||
|
||||
# Create addresses and verify wallet version
|
||||
old_addresses = [old_node.getnewaddress() for _ in range(4)]
|
||||
unfunded_address = old_node.getnewaddress()
|
||||
old_wallet_info = old_node.getwalletinfo()
|
||||
assert_equal(old_wallet_info['walletversion'], expected_version)
|
||||
# v0.14.3 predates HD split keypool (v0.15+).
|
||||
assert_equal('keypoolsize_hd_internal' in old_wallet_info, False)
|
||||
assert_equal('hdmasterkeyid' in old_wallet_info, expect_hd)
|
||||
|
||||
# Generate blocks and create transaction history
|
||||
self.generatetoaddress(old_node, COINBASE_MATURITY + 1, old_addresses[0], sync_fun=self.no_op)
|
||||
send_txs = []
|
||||
for i, amount in enumerate([0.1, 0.2, 0.3], start=1):
|
||||
send_txs.append((old_node.sendtoaddress(old_addresses[i], amount), old_addresses[i]))
|
||||
self.generatetoaddress(old_node, 1, old_addresses[0], sync_fun=self.no_op)
|
||||
|
||||
old_balance = old_node.getbalance()
|
||||
old_txs = old_node.listtransactions("*", 1000)
|
||||
old_wallet_state = old_node.getwalletinfo()
|
||||
assert_equal(old_balance, Decimal("99.99986440"))
|
||||
assert_equal(len(old_txs), 108)
|
||||
|
||||
# Collect all addresses including change; listtransactions omits change
|
||||
# addresses for self-sends, so decode the sent transactions explicitly.
|
||||
change_addresses = set()
|
||||
for txid, destination in send_txs:
|
||||
decoded_tx = old_node.decoderawtransaction(old_node.gettransaction(txid)["hex"])
|
||||
for address in get_vout_addresses(decoded_tx):
|
||||
if address != destination:
|
||||
change_addresses.add(address)
|
||||
|
||||
all_addresses = {tx['address'] for tx in old_txs if 'address' in tx} | change_addresses
|
||||
assert_equal(unfunded_address in all_addresses, False)
|
||||
assert_greater_than(len(change_addresses), 0)
|
||||
assert_greater_than(len(all_addresses), 0)
|
||||
|
||||
# Sync blocks to modern node via RPC (avoids filesystem copy and reindex)
|
||||
self.start_node(new_node_idx)
|
||||
dumb_sync_blocks(src=old_node, dst=new_node)
|
||||
|
||||
if passphrase is None:
|
||||
self.stop_node(old_node_idx)
|
||||
else:
|
||||
# v0.14.3 shuts down after encrypting the wallet.
|
||||
old_node.encryptwallet(passphrase)
|
||||
old_node.wait_until_stopped()
|
||||
|
||||
# Copy and migrate wallet
|
||||
self.log.info("Migrating wallet to descriptor format")
|
||||
old_wallet_path = old_node.chain_path / "wallet.dat"
|
||||
migrated_wallet_dir = new_node.wallets_path / "migrated_wallet"
|
||||
migrated_wallet_dir.mkdir(parents=True)
|
||||
shutil.copy2(old_wallet_path, migrated_wallet_dir / "wallet.dat")
|
||||
|
||||
with new_node.assert_debug_log(expected_msgs=[], unexpected_msgs=["Rescanning"]):
|
||||
migration_result = new_node.migratewallet("migrated_wallet", passphrase=passphrase)
|
||||
assert_equal(migration_result['wallet_name'], 'migrated_wallet')
|
||||
assert_equal('watchonly_name' in migration_result, False)
|
||||
assert_equal('solvables_name' in migration_result, False)
|
||||
assert Path(migration_result['backup_path']).is_file()
|
||||
new_wallet = new_node.get_wallet_rpc(migration_result['wallet_name'])
|
||||
|
||||
# Verify migration results
|
||||
self.log.info("Verifying migration")
|
||||
new_wallet_info = new_wallet.getwalletinfo()
|
||||
assert_equal(new_wallet_info['format'], 'sqlite')
|
||||
assert_equal(new_wallet_info['descriptors'], True)
|
||||
|
||||
# v0.14.3 HD wallets use a single external chain. Encrypting an HD
|
||||
# wallet rotates its seed, leaving the original chain inactive.
|
||||
single_chain_hd_descriptors = [
|
||||
descriptor for descriptor in new_wallet.listdescriptors()['descriptors']
|
||||
if '/0h/0h/*h)' in descriptor['desc']
|
||||
]
|
||||
expected_single_chain_hd_descriptors = 0
|
||||
if expect_hd:
|
||||
expected_single_chain_hd_descriptors = 1
|
||||
if passphrase is not None:
|
||||
expected_single_chain_hd_descriptors += 1
|
||||
assert_equal(len(single_chain_hd_descriptors), expected_single_chain_hd_descriptors)
|
||||
|
||||
assert_equal(new_wallet_info['txcount'], 105)
|
||||
if passphrase is not None:
|
||||
assert_equal(new_wallet_info['unlocked_until'], 0)
|
||||
assert_equal(new_wallet.getbalance(), old_balance)
|
||||
new_balances = new_wallet.getbalances()['mine']
|
||||
assert_equal(new_balances['trusted'], old_wallet_state['balance'])
|
||||
assert_equal(new_balances['untrusted_pending'], old_wallet_state['unconfirmed_balance'])
|
||||
assert_equal(new_balances['immature'], old_wallet_state['immature_balance'])
|
||||
assert_equal(new_wallet_info['txcount'], old_wallet_state['txcount'])
|
||||
new_txs = new_wallet.listtransactions("*", 1000)
|
||||
assert_equal(normalize_listtransactions(new_txs), normalize_listtransactions(old_txs))
|
||||
|
||||
# Verify all addresses are still owned
|
||||
for addr in all_addresses:
|
||||
assert_equal(new_wallet.getaddressinfo(addr)['ismine'], True)
|
||||
assert_equal(new_wallet.getaddressinfo(unfunded_address)['ismine'], True)
|
||||
|
||||
# Test post-migration functionality
|
||||
new_addr = new_wallet.getnewaddress()
|
||||
if passphrase is not None:
|
||||
assert_raises_rpc_error(-13, "Please enter the wallet passphrase", new_wallet.sendtoaddress, new_addr, 0.1)
|
||||
new_wallet.walletpassphrase(passphrase, 60)
|
||||
txid = new_wallet.sendtoaddress(new_addr, 0.1)
|
||||
assert_is_hash_string(txid)
|
||||
|
||||
self.stop_node(new_node_idx)
|
||||
self.log.info(f"{wallet_type} wallet migration successful")
|
||||
|
||||
def run_test(self):
|
||||
self.log.info("Testing wallet migration from v0.14.3")
|
||||
|
||||
self.run_migration_test(
|
||||
wallet_type="non-HD",
|
||||
old_node_idx=0,
|
||||
new_node_idx=1,
|
||||
extra_args_old=["-usehd=0", "-keypool=10"],
|
||||
expected_version=FEATURE_COMPRPUBKEY,
|
||||
expect_hd=False,
|
||||
)
|
||||
|
||||
self.run_migration_test(
|
||||
wallet_type="encrypted non-HD",
|
||||
old_node_idx=2,
|
||||
new_node_idx=3,
|
||||
extra_args_old=["-usehd=0", "-keypool=10"],
|
||||
expected_version=FEATURE_COMPRPUBKEY,
|
||||
expect_hd=False,
|
||||
passphrase=WALLET_PASSPHRASE,
|
||||
)
|
||||
|
||||
self.run_migration_test(
|
||||
wallet_type="HD (VERSION_HD_BASE)",
|
||||
old_node_idx=4,
|
||||
new_node_idx=5,
|
||||
extra_args_old=["-keypool=10"],
|
||||
expected_version=FEATURE_HD,
|
||||
expect_hd=True,
|
||||
)
|
||||
|
||||
self.run_migration_test(
|
||||
wallet_type="encrypted HD (VERSION_HD_BASE)",
|
||||
old_node_idx=6,
|
||||
new_node_idx=7,
|
||||
extra_args_old=["-keypool=10"],
|
||||
expected_version=FEATURE_HD,
|
||||
expect_hd=True,
|
||||
passphrase=WALLET_PASSPHRASE,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
WalletAncientMigrationTest(__file__).main()
|
||||
Reference in New Issue
Block a user