Skip to content

Commit a4eee6d

Browse files
committed
Merge bitcoin/bitcoin#29124: test: Test that migration automatically repairs corrupted metadata with doubled derivation path
c7e2b9e tests: Test migration cleans up bad inactive chain derivation path (Ava Chow) Pull request description: A bug in 0.21.x and 22.x resulted in some wallets having invalid derivation paths that are the concatenation of two derivation paths. These appear only when inactive hd chains are topped up. Since key metadata is a legacy wallet only record, migrating legacy wallets to descriptor wallets will fix this issue as all key metadata records are deleted. The derivation path information is derived on-the-fly from the descriptor that is produced for the inactive hd chain. Thus we only need a test to verify that the derivation paths are good, and that all key metadata records are deleted from the migrated wallet. ACKs for top commit: murchandamus: re-ACK c7e2b9e via range-diff: rkrux: re-ACK c7e2b9e furszy: utACK c7e2b9e Tree-SHA512: 3117c4a43798972109fe2d3539341a8b69db70c6457fcabdd019e6044834dc4b17212abbc006d7b8008f560dce4b7856142b057981b9404f406d58fa0955cbd9
2 parents af6cffa + c7e2b9e commit a4eee6d

File tree

1 file changed

+83
-1
lines changed

1 file changed

+83
-1
lines changed

test/functional/wallet_backwards_compatibility.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
assert_raises_rpc_error,
2727
)
2828

29+
LAST_KEYPOOL_INDEX = 9 # Index of the last derived address with the keypool size of 10
2930

3031
class BackwardsCompatibilityTest(BitcoinTestFramework):
3132
def set_test_params(self):
@@ -38,7 +39,7 @@ def set_test_params(self):
3839
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v25.0
3940
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v24.0.1
4041
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v23.0
41-
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v22.0
42+
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]", f"-keypool={LAST_KEYPOOL_INDEX + 1}"], # v22.0
4243
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v0.21.0
4344
["-nowallet", "-walletrbf=1", "-addresstype=bech32", "[email protected]"], # v0.20.1
4445
]
@@ -81,6 +82,85 @@ def major_version_at_least(self, node, major):
8182
node_major, _, _ = self.split_version(node)
8283
return node_major >= major
8384

85+
def test_v22_inactivehdchain_path(self):
86+
self.log.info("Testing inactive hd chain bad derivation path cleanup")
87+
# 0.21.x and 22.x would both produce bad derivation paths when topping up an inactive hd chain
88+
# Make sure that this is being automatically cleaned up by migration
89+
node_master = self.nodes[1]
90+
node_v22 = self.nodes[self.num_nodes - 5]
91+
wallet_name = "bad_deriv_path"
92+
node_v22.createwallet(wallet_name=wallet_name, descriptors=False)
93+
bad_deriv_wallet = node_v22.get_wallet_rpc(wallet_name)
94+
95+
# Make a dump of the wallet to get an unused address
96+
dump_path = node_v22.wallets_path / f"{wallet_name}.dump"
97+
bad_deriv_wallet.dumpwallet(dump_path)
98+
addr = None
99+
seed = None
100+
with open(dump_path, encoding="utf8") as f:
101+
for line in f:
102+
if f"hdkeypath=m/0'/0'/{LAST_KEYPOOL_INDEX}'" in line:
103+
addr = line.split(" ")[4].split("=")[1]
104+
elif " hdseed=1 " in line:
105+
seed = line.split(" ")[0]
106+
assert addr is not None
107+
assert seed is not None
108+
# Rotate seed and unload
109+
bad_deriv_wallet.sethdseed()
110+
bad_deriv_wallet.unloadwallet()
111+
# Receive at addr to trigger inactive chain topup on next load
112+
self.nodes[0].sendtoaddress(addr, 1)
113+
self.generate(self.nodes[0], 1, sync_fun=self.no_op)
114+
self.sync_all(nodes=[self.nodes[0], node_master, node_v22])
115+
node_v22.loadwallet(wallet_name)
116+
117+
# Dump again to find bad hd keypath
118+
bad_deriv_path = f"m/0'/0'/{LAST_KEYPOOL_INDEX}'/0'/0'/{LAST_KEYPOOL_INDEX + 1}'"
119+
good_deriv_path = f"m/0h/0h/{LAST_KEYPOOL_INDEX + 1}h"
120+
os.unlink(dump_path)
121+
bad_deriv_wallet.dumpwallet(dump_path)
122+
bad_path_addr = None
123+
with open(dump_path, encoding="utf8") as f:
124+
for line in f:
125+
if f"hdkeypath={bad_deriv_path}" in line:
126+
bad_path_addr = line.split(" ")[4].split("=")[1]
127+
assert bad_path_addr is not None
128+
assert_equal(bad_deriv_wallet.getaddressinfo(bad_path_addr)["hdkeypath"], bad_deriv_path)
129+
130+
# Verify that this bad derivation path addr is actually at m/0'/0'/10' by making a new wallet with the same seed but larger keypool
131+
node_v22.createwallet(wallet_name="path_verify", descriptors=False, blank=True)
132+
verify_wallet = node_v22.get_wallet_rpc("path_verify")
133+
verify_wallet.sethdseed(True, seed)
134+
# Bad addr is after keypool, so need to generate it by refilling
135+
verify_wallet.keypoolrefill(LAST_KEYPOOL_INDEX + 2)
136+
assert_equal(verify_wallet.getaddressinfo(bad_path_addr)["hdkeypath"], good_deriv_path.replace("h", "'"))
137+
138+
# Migrate with master
139+
# Since all keymeta records are now deleted after migration, the derivation path
140+
# should now be correct as it is derived on-the-fly from the inactive hd chain's descriptor
141+
backup_path = node_v22.wallets_path / f"{wallet_name}.bak"
142+
bad_deriv_wallet.backupwallet(backup_path)
143+
wallet_dir_master = os.path.join(node_master.wallets_path, wallet_name)
144+
os.makedirs(wallet_dir_master, exist_ok=True)
145+
shutil.copy(backup_path, os.path.join(wallet_dir_master, "wallet.dat"))
146+
node_master.migratewallet(wallet_name)
147+
bad_deriv_wallet_master = node_master.get_wallet_rpc(wallet_name)
148+
assert_equal(bad_deriv_wallet_master.getaddressinfo(bad_path_addr)["hdkeypath"], good_deriv_path)
149+
bad_deriv_wallet_master.unloadwallet()
150+
151+
# If we have sqlite3, verify that there are no keymeta records
152+
try:
153+
import sqlite3
154+
wallet_db = node_master.wallets_path / wallet_name / "wallet.dat"
155+
conn = sqlite3.connect(wallet_db)
156+
with conn:
157+
# Retrieve all records that have the "keymeta" prefix. The remaining key data varies for each record.
158+
keymeta_rec = conn.execute("SELECT value FROM main where key >= x'076b65796d657461' AND key < x'076b65796d657462'").fetchone()
159+
assert_equal(keymeta_rec, None)
160+
conn.close()
161+
except ImportError:
162+
self.log.warning("sqlite3 module not available, skipping lack of keymeta records check")
163+
84164
def run_test(self):
85165
node_miner = self.nodes[0]
86166
node_master = self.nodes[1]
@@ -300,5 +380,7 @@ def run_test(self):
300380
# Legacy wallets are no longer supported. Trying to load these should result in an error
301381
assert_raises_rpc_error(-18, "The wallet appears to be a Legacy wallet, please use the wallet migration tool (migratewallet RPC)", node_master.restorewallet, wallet_name, backup_path)
302382

383+
self.test_v22_inactivehdchain_path()
384+
303385
if __name__ == '__main__':
304386
BackwardsCompatibilityTest(__file__).main()

0 commit comments

Comments
 (0)