wallet_backup.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2014-2022 The Limenka developers
3 # Distributed under the MIT software license, see the accompanying
4 # file COPYING or http://www.opensource.org/licenses/mit-license.php.
5 """Test the wallet backup features.
6
7 Test case is:
8 4 nodes. 1 2 and 3 send transactions between each other,
9 fourth node is a miner.
10 1 2 3 each mine a block to start, then
11 Miner creates 100 blocks so 1 2 3 each have 50 mature
12 coins to spend.
13 Then 5 iterations of 1/2/3 sending coins amongst
14 themselves to get transactions in the wallets,
15 and the miner mining one block.
16
17 Wallets are backed up using dumpwallet/backupwallet.
18 Then 5 more iterations of transactions and mining a block.
19
20 Miner then generates 101 more blocks, so any
21 transaction fees paid mature.
22
23 Sanity check:
24 Sum(1,2,3,4 balances) == 114*50
25
26 1/2/3 are shutdown, and their wallets erased.
27 Then restore using wallet.dat backup. And
28 confirm 1/2/3/4 balances are same as before.
29
30 Shutdown again, restore using importwallet,
31 and confirm again balances are correct.
32 """
33 from decimal import Decimal
34 import os
35 from random import randint
36 import shutil
37
38 from test_framework.blocktools import COINBASE_MATURITY
39 from test_framework.test_framework import LimenkaTestFramework
40 from test_framework.util import (
41 assert_equal,
42 assert_raises_rpc_error,
43 sha256sum_file,
44 )
45
46
47 class WalletBackupTest(LimenkaTestFramework):
48 def add_options(self, parser):
49 self.add_wallet_options(parser)
50
51 def set_test_params(self):
52 self.num_nodes = 4
53 self.setup_clean_chain = True
54 # whitelist peers to speed up tx relay / mempool sync
55 self.noban_tx_relay = True
56 # nodes 1, 2, 3 are spenders, let's give them a keypool=100
57 self.extra_args = [
58 ["-keypool=100"],
59 ["-keypool=100"],
60 ["-keypool=100"],
61 [],
62 ]
63 self.rpc_timeout = 120
64
65 def skip_test_if_missing_module(self):
66 self.skip_if_no_wallet()
67
68 def setup_network(self):
69 self.setup_nodes()
70 self.connect_nodes(0, 3)
71 self.connect_nodes(1, 3)
72 self.connect_nodes(2, 3)
73 self.connect_nodes(2, 0)
74 self.sync_all()
75
76 def one_send(self, from_node, to_address):
77 if (randint(1,2) == 1):
78 amount = Decimal(randint(1,10)) / Decimal(10)
79 self.nodes[from_node].sendtoaddress(to_address, amount)
80
81 def do_one_round(self):
82 a0 = self.nodes[0].getnewaddress()
83 a1 = self.nodes[1].getnewaddress()
84 a2 = self.nodes[2].getnewaddress()
85
86 self.one_send(0, a1)
87 self.one_send(0, a2)
88 self.one_send(1, a0)
89 self.one_send(1, a2)
90 self.one_send(2, a0)
91 self.one_send(2, a1)
92
93 # Have the miner (node3) mine a block.
94 # Must sync mempools before mining.
95 self.sync_mempools()
96 self.generate(self.nodes[3], 1)
97
98 # As above, this mirrors the original bash test.
99 def start_three(self, args=()):
100 self.start_node(0, self.extra_args[0] + list(args))
101 self.start_node(1, self.extra_args[1] + list(args))
102 self.start_node(2, self.extra_args[2] + list(args))
103 self.connect_nodes(0, 3)
104 self.connect_nodes(1, 3)
105 self.connect_nodes(2, 3)
106 self.connect_nodes(2, 0)
107
108 def stop_three(self):
109 self.stop_node(0)
110 self.stop_node(1)
111 self.stop_node(2)
112
113 def erase_three(self):
114 for node_num in range(3):
115 (self.nodes[node_num].wallets_path / self.default_wallet_name / self.wallet_data_filename).unlink()
116
117 def restore_invalid_wallet(self):
118 node = self.nodes[3]
119 invalid_wallet_file = self.nodes[0].datadir_path / 'invalid_wallet_file.bak'
120 open(invalid_wallet_file, 'a', encoding="utf8").write('invald wallet')
121 wallet_name = "res0"
122 not_created_wallet_file = node.wallets_path / wallet_name
123 error_message = "Wallet file verification failed. Failed to load database path '{}'. Data is not in recognized format.".format(not_created_wallet_file)
124 assert_raises_rpc_error(-18, error_message, node.restorewallet, wallet_name, invalid_wallet_file)
125 assert not not_created_wallet_file.exists()
126
127 def restore_nonexistent_wallet(self):
128 node = self.nodes[3]
129 nonexistent_wallet_file = self.nodes[0].datadir_path / 'nonexistent_wallet.bak'
130 wallet_name = "res0"
131 assert_raises_rpc_error(-8, "Backup file does not exist", node.restorewallet, wallet_name, nonexistent_wallet_file)
132 not_created_wallet_file = node.wallets_path / wallet_name
133 assert not not_created_wallet_file.exists()
134
135 def restore_wallet_existent_name(self):
136 node = self.nodes[3]
137 backup_file = self.nodes[0].datadir_path / 'wallet.bak'
138 wallet_name = "res0"
139 wallet_file = node.wallets_path / wallet_name
140 error_message = "Failed to restore wallet. Database file exists in '{}'.".format(wallet_file / "wallet.dat")
141 assert_raises_rpc_error(-36, error_message, node.restorewallet, wallet_name, backup_file)
142 assert wallet_file.exists()
143
144 def test_restore_existent_dir(self):
145 self.log.info("Test restore on an existent empty directory")
146 node = self.nodes[3]
147 backup_file = self.nodes[0].datadir_path / 'wallet.bak'
148 wallet_name = "restored_wallet"
149 wallet_dir = node.wallets_path / wallet_name
150 os.mkdir(wallet_dir)
151 res = node.restorewallet(wallet_name, backup_file)
152 assert_equal(res['name'], wallet_name)
153 node.unloadwallet(wallet_name)
154
155 self.log.info("Test restore succeeds when the target directory contains non-wallet files")
156 wallet_file = node.wallets_path / wallet_name / "wallet.dat"
157 os.remove(wallet_file)
158 extra_file = node.wallets_path / wallet_name / "not_a_wallet.txt"
159 extra_file.touch()
160 res = node.restorewallet(wallet_name, backup_file)
161 assert_equal(res['name'], wallet_name)
162 assert extra_file.exists() # extra file was not removed by mistake
163 node.unloadwallet(wallet_name)
164
165 self.log.info("Test restore failure due to existing db file in the destination directory")
166 original_shasum = sha256sum_file(wallet_file)
167 error_message = "Failed to restore wallet. Database file exists in '{}'.".format(wallet_dir / "wallet.dat")
168 assert_raises_rpc_error(-36, error_message, node.restorewallet, wallet_name, backup_file)
169 # Ensure the wallet file remains untouched
170 assert wallet_dir.exists()
171 assert_equal(original_shasum, sha256sum_file(wallet_file))
172
173 self.log.info("Test restore succeeds when the .dat file in the destination has a different name")
174 second_wallet = wallet_dir / "hidden_storage.dat"
175 os.rename(wallet_dir / "wallet.dat", second_wallet)
176 original_shasum = sha256sum_file(second_wallet)
177 res = node.restorewallet(wallet_name, backup_file)
178 assert_equal(res['name'], wallet_name)
179 assert (wallet_dir / "hidden_storage.dat").exists()
180 assert_equal(original_shasum, sha256sum_file(second_wallet))
181 node.unloadwallet(wallet_name)
182
183 # Clean for follow-up tests
184 os.remove(wallet_file)
185
186 def test_restore_into_unnamed_wallet(self):
187 self.log.info("Test restore into a default unnamed wallet")
188 # This is also useful to test the migration recovery after failure logic
189 node = self.nodes[3]
190 if not self.options.descriptors:
191 node.unloadwallet("")
192 os.rename(node.wallets_path / "wallet.dat", node.wallets_path / "default.wallet.dat")
193 backup_file = self.nodes[0].datadir_path / 'wallet.bak'
194 wallet_name = ""
195 res = node.restorewallet(wallet_name, backup_file)
196 assert_equal(res['name'], "")
197 assert (node.wallets_path / "wallet.dat").exists()
198 # Clean for follow-up tests
199 node.unloadwallet("")
200 os.remove(node.wallets_path / "wallet.dat")
201 if not self.options.descriptors:
202 os.rename(node.wallets_path / "default.wallet.dat", node.wallets_path / "wallet.dat")
203 node.loadwallet("")
204
205 def test_pruned_wallet_backup(self):
206 self.log.info("Test loading backup on a pruned node when the backup was created close to the prune height of the restoring node")
207 node = self.nodes[3]
208 self.restart_node(3, ["-prune=1", "-fastprune=1"])
209 # Ensure the chain tip is at height 214, because this test assume it is.
210 assert_equal(node.getchaintips()[0]["height"], 214)
211 # We need a few more blocks so we can actually get above an realistic
212 # minimal prune height
213 self.generate(node, 50, sync_fun=self.no_op)
214 # Backup created at block height 264
215 node.backupwallet(node.datadir_path / 'wallet_pruned.bak')
216 # Generate more blocks so we can actually prune the older blocks
217 self.generate(node, 300, sync_fun=self.no_op)
218 # This gives us an actual prune height roughly in the range of 220 - 240
219 node.pruneblockchain(250)
220 # The backup should be updated with the latest height (locator) for
221 # the backup to load successfully this close to the prune height
222 node.restorewallet('pruned', node.datadir_path / 'wallet_pruned.bak')
223
224 self.log.info("Test restore on a pruned node when the backup was beyond the pruning point")
225 if not self.options.descriptors:
226 node.unloadwallet("")
227 os.rename(node.wallets_path / "wallet.dat", node.wallets_path / "default.wallet.dat")
228 backup_file = self.nodes[0].datadir_path / 'wallet.bak'
229 wallet_name = ""
230 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)"
231 assert_raises_rpc_error(-4, error_message, node.restorewallet, wallet_name, backup_file)
232 assert node.wallets_path.exists() # ensure the wallets dir exists
233 if not self.options.descriptors:
234 os.rename(node.wallets_path / "default.wallet.dat", node.wallets_path / "wallet.dat")
235 node.loadwallet("")
236
237 def run_test(self):
238 self.log.info("Generating initial blockchain")
239 self.generate(self.nodes[0], 1)
240 self.generate(self.nodes[1], 1)
241 self.generate(self.nodes[2], 1)
242 self.generate(self.nodes[3], COINBASE_MATURITY)
243
244 assert_equal(self.nodes[0].getbalance(), 50)
245 assert_equal(self.nodes[1].getbalance(), 50)
246 assert_equal(self.nodes[2].getbalance(), 50)
247 assert_equal(self.nodes[3].getbalance(), 0)
248
249 self.log.info("Creating transactions")
250 # Five rounds of sending each other transactions.
251 for _ in range(5):
252 self.do_one_round()
253
254 self.log.info("Backing up")
255
256 for node_num in range(3):
257 self.nodes[node_num].backupwallet(self.nodes[node_num].datadir_path / 'wallet.bak')
258
259 if not self.options.descriptors:
260 for node_num in range(3):
261 self.nodes[node_num].dumpwallet(self.nodes[node_num].datadir_path / 'wallet.dump')
262
263 self.log.info("More transactions")
264 for _ in range(5):
265 self.do_one_round()
266
267 # Generate 101 more blocks, so any fees paid mature
268 self.generate(self.nodes[3], COINBASE_MATURITY + 1)
269
270 balance0 = self.nodes[0].getbalance()
271 balance1 = self.nodes[1].getbalance()
272 balance2 = self.nodes[2].getbalance()
273 balance3 = self.nodes[3].getbalance()
274 total = balance0 + balance1 + balance2 + balance3
275
276 # At this point, there are 214 blocks (103 for setup, then 10 rounds, then 101.)
277 # 114 are mature, so the sum of all wallets should be 114 * 50 = 5700.
278 assert_equal(total, 5700)
279
280 ##
281 # Test restoring spender wallets from backups
282 ##
283 self.log.info("Restoring wallets on node 3 using backup files")
284
285 self.restore_invalid_wallet()
286 self.restore_nonexistent_wallet()
287
288 backup_files = []
289 for node_num in range(3):
290 backup_files.append(self.nodes[node_num].datadir_path / 'wallet.bak')
291
292 for idx, backup_file in enumerate(backup_files):
293 self.nodes[3].restorewallet(f'res{idx}', backup_file)
294 assert (self.nodes[3].wallets_path / f'res{idx}').exists()
295
296 res0_rpc = self.nodes[3].get_wallet_rpc("res0")
297 res1_rpc = self.nodes[3].get_wallet_rpc("res1")
298 res2_rpc = self.nodes[3].get_wallet_rpc("res2")
299
300 assert_equal(res0_rpc.getbalance(), balance0)
301 assert_equal(res1_rpc.getbalance(), balance1)
302 assert_equal(res2_rpc.getbalance(), balance2)
303
304 self.restore_wallet_existent_name()
305 self.test_restore_existent_dir()
306 self.test_restore_into_unnamed_wallet()
307
308 if not self.options.descriptors:
309 self.log.info("Restoring using dumped wallet")
310 self.stop_three()
311 self.erase_three()
312
313 #start node2 with no chain
314 shutil.rmtree(self.nodes[2].blocks_path)
315 shutil.rmtree(self.nodes[2].chain_path / 'chainstate')
316
317 self.start_three(["-nowallet"])
318 # Create new wallets for the three nodes.
319 # We will use this empty wallets to test the 'importwallet()' RPC command below.
320 for node_num in range(3):
321 self.nodes[node_num].createwallet(wallet_name=self.default_wallet_name, descriptors=self.options.descriptors, load_on_startup=True)
322 assert_equal(self.nodes[node_num].getbalance(), 0)
323 self.nodes[node_num].importwallet(self.nodes[node_num].datadir_path / 'wallet.dump')
324
325 self.sync_blocks()
326
327 assert_equal(self.nodes[0].getbalance(), balance0)
328 assert_equal(self.nodes[1].getbalance(), balance1)
329 assert_equal(self.nodes[2].getbalance(), balance2)
330
331 # Backup to source wallet file must fail
332 sourcePaths = [
333 os.path.join(self.nodes[0].wallets_path, self.default_wallet_name, self.wallet_data_filename),
334 os.path.join(self.nodes[0].wallets_path, '.', self.default_wallet_name, self.wallet_data_filename),
335 os.path.join(self.nodes[0].wallets_path, self.default_wallet_name),
336 os.path.join(self.nodes[0].wallets_path)]
337
338 for sourcePath in sourcePaths:
339 assert_raises_rpc_error(-4, "backup failed", self.nodes[0].backupwallet, sourcePath)
340
341 self.test_pruned_wallet_backup()
342
343
344 if __name__ == '__main__':
345 WalletBackupTest(__file__).main()
346