1 #!/usr/bin/env python3
2 # Copyright (c) 2023-present The Bitcoin Core 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 for assumeutxo wallet related behavior.
6 See feature_assumeutxo.py for background.
7 """
8 from test_framework.address import address_to_scriptpubkey
9 from test_framework.descriptors import descsum_create
10 from test_framework.test_framework import BitcoinTestFramework
11 from test_framework.messages import COIN
12 from test_framework.util import (
13 assert_equal,
14 assert_greater_than,
15 assert_raises_rpc_error,
16 dumb_sync_blocks,
17 ensure_for,
18 )
19 from test_framework.wallet import MiniWallet
20 from test_framework.wallet_util import get_generate_key
21 22 START_HEIGHT = 199
23 SNAPSHOT_BASE_HEIGHT = 299
24 FINAL_HEIGHT = 399
25 26 27 class AssumeutxoTest(BitcoinTestFramework):
28 def skip_test_if_missing_module(self):
29 self.skip_if_no_wallet()
30 31 def set_test_params(self):
32 """Use the pregenerated, deterministic chain up to height 199."""
33 self.num_nodes = 4
34 self.rpc_timeout = 120
35 self.extra_args = [
36 [],
37 [],
38 [],
39 ["-fastprune", "-prune=1"],
40 ]
41 42 def setup_network(self):
43 """Start with the nodes disconnected so that one can generate a snapshot
44 including blocks the other hasn't yet seen."""
45 self.add_nodes(self.num_nodes, self.extra_args)
46 self.start_nodes()
47 48 def import_descriptor(self, node, wallet_name, key, timestamp):
49 import_request = [{"desc": descsum_create("pkh(" + key.pubkey + ")"),
50 "timestamp": timestamp,
51 "label": "Descriptor import test"}]
52 wrpc = node.get_wallet_rpc(wallet_name)
53 return wrpc.importdescriptors(import_request)
54 55 def validate_snapshot_import(self, node, loaded, base_hash):
56 assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
57 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
58 59 normal, snapshot = node.getchainstates()["chainstates"]
60 assert_equal(normal['blocks'], START_HEIGHT)
61 assert 'snapshot_blockhash' not in normal
62 assert_equal(normal['validated'], True)
63 assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
64 assert_equal(snapshot['snapshot_blockhash'], base_hash)
65 assert_equal(snapshot['validated'], False)
66 67 assert_equal(node.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
68 69 def complete_background_validation(self, node):
70 self.connect_nodes(0, node.index)
71 72 # Ensuring snapshot chain syncs to tip
73 self.wait_until(lambda: node.getchainstates()['chainstates'][-1]['blocks'] == FINAL_HEIGHT)
74 self.sync_blocks(nodes=(self.nodes[0], node))
75 76 # Ensuring background validation completes
77 self.wait_until(lambda: len(node.getchainstates()['chainstates']) == 1)
78 79 def test_backup_during_background_sync_pruned_node(self, n3, dump_output, expected_error_message):
80 self.log.info("Backup from the snapshot height can be loaded during background sync (pruned node)")
81 loaded = n3.loadtxoutset(dump_output['path'])
82 assert_greater_than(n3.pruneblockchain(START_HEIGHT), 0)
83 self.validate_snapshot_import(n3, loaded, dump_output['base_hash'])
84 n3.restorewallet("w", "backup_w.dat")
85 # Balance of w wallet is still 0 because n3 has not synced yet
86 assert_equal(n3.getbalance(), 0)
87 88 n3.unloadwallet("w")
89 self.log.info("Backup from before the snapshot height can't be loaded during background sync (pruned node)")
90 assert_raises_rpc_error(-4, expected_error_message, n3.restorewallet, "w2", "backup_w2.dat")
91 92 def test_restore_wallet_pruneheight(self, n3):
93 self.log.info("Ensuring wallet can't be restored from a backup that was created before the pruneheight (pruned node)")
94 self.complete_background_validation(n3)
95 # After background sync, pruneheight is reset to 0, so mine 200 blocks
96 # and prune the chain again
97 self.generate(n3, nblocks=200, sync_fun=self.no_op)
98 assert_equal(n3.pruneblockchain(FINAL_HEIGHT), 298) # 298 is the height of the last block pruned (pruneheight 299)
99 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)"
100 # This backup (backup_w2.dat) was created at height 199, so it can't be restored in a node with a pruneheight of 299
101 assert_raises_rpc_error(-4, error_message, n3.restorewallet, "w2_pruneheight", "backup_w2.dat")
102 103 self.log.info("Ensuring wallet can be restored from a backup that was created at the pruneheight (pruned node)")
104 # This backup (backup_w.dat) was created at height 299, so it can be restored in a node with a pruneheight of 299
105 n3.restorewallet("w_alt", "backup_w.dat")
106 # Check balance of w_alt wallet
107 w_alt = n3.get_wallet_rpc("w_alt")
108 assert_equal(w_alt.getbalance(), 34)
109 110 def run_test(self):
111 """
112 Bring up four (disconnected) nodes:
113 - n0: mine some blocks and create a UTXO snapshot
114 - n1: load the snapshot and test loading a wallet backup and descriptors during and after background sync
115 - n2: load the snapshot and check the wallet balance during background sync
116 - n3: load the snapshot, prune the chain, and test loading a wallet backup during and after background sync
117 """
118 n0 = self.nodes[0]
119 n1 = self.nodes[1]
120 n2 = self.nodes[2]
121 n3 = self.nodes[3]
122 123 self.mini_wallet = MiniWallet(n0)
124 125 # Mock time for a deterministic chain
126 for n in self.nodes:
127 n.setmocktime(n.getblockheader(n.getbestblockhash())['time'])
128 129 # Create a wallet that we will create a backup for later (at snapshot height)
130 n0.createwallet('w')
131 w = n0.get_wallet_rpc("w")
132 w_address = w.getnewaddress()
133 134 # Create another wallet and backup now (before snapshot height)
135 n0.createwallet('w2')
136 w2 = n0.get_wallet_rpc("w2")
137 w2_address = w2.getnewaddress()
138 w2.backupwallet("backup_w2.dat")
139 140 # Generate a series of blocks that `n0` will have in the snapshot,
141 # but that n1 doesn't yet see. In order for the snapshot to activate,
142 # though, we have to ferry over the new headers to n1 so that it
143 # isn't waiting forever to see the header of the snapshot's base block
144 # while disconnected from n0.
145 for i in range(100):
146 if i % 3 == 0:
147 self.mini_wallet.send_self_transfer(from_node=n0)
148 self.generate(n0, nblocks=1, sync_fun=self.no_op)
149 newblock = n0.getblock(n0.getbestblockhash(), 0)
150 151 # make n1 aware of the new header, but don't give it the block.
152 n1.submitheader(newblock)
153 n2.submitheader(newblock)
154 n3.submitheader(newblock)
155 # Ensure everyone is seeing the same headers.
156 for n in self.nodes:
157 assert_equal(n.getblockchaininfo()[
158 "headers"], SNAPSHOT_BASE_HEIGHT)
159 160 # This backup is created at the snapshot height, so it's
161 # not part of the background sync anymore
162 w.backupwallet("backup_w.dat")
163 164 self.log.info("-- Testing assumeutxo")
165 166 assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
167 assert_equal(n1.getblockcount(), START_HEIGHT)
168 169 self.log.info(
170 f"Creating a UTXO snapshot at height {SNAPSHOT_BASE_HEIGHT}")
171 dump_output = n0.dumptxoutset('utxos.dat', "latest")
172 173 assert_equal(
174 dump_output['txoutset_hash'],
175 "106b2c56233e378a824cf0d5ff2be42ed32c72f1605c9be288d00942908a40ac")
176 assert_equal(dump_output["nchaintx"], 334)
177 assert_equal(n0.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
178 179 # Mine more blocks on top of the snapshot that n1 hasn't yet seen. This
180 # will allow us to test n1's sync-to-tip on top of a snapshot.
181 w_skp = address_to_scriptpubkey(w_address)
182 w2_skp = address_to_scriptpubkey(w2_address)
183 for i in range(100):
184 if i % 3 == 0:
185 self.mini_wallet.send_to(from_node=n0, scriptPubKey=w_skp, amount=1 * COIN)
186 self.mini_wallet.send_to(from_node=n0, scriptPubKey=w2_skp, amount=10 * COIN)
187 self.generate(n0, nblocks=1, sync_fun=self.no_op)
188 189 assert_equal(n0.getblockcount(), FINAL_HEIGHT)
190 assert_equal(n1.getblockcount(), START_HEIGHT)
191 assert_equal(n2.getblockcount(), START_HEIGHT)
192 193 assert_equal(n0.getblockchaininfo()["blocks"], FINAL_HEIGHT)
194 195 self.log.info(
196 f"Loading snapshot into second node from {dump_output['path']}")
197 loaded = n1.loadtxoutset(dump_output['path'])
198 self.validate_snapshot_import(n1, loaded, dump_output['base_hash'])
199 200 self.log.info("Backup from the snapshot height can be loaded during background sync")
201 n1.restorewallet("w", "backup_w.dat")
202 # Balance of w wallet is still 0 because n1 has not synced yet
203 assert_equal(n1.getbalance(), 0)
204 205 self.log.info("Backup from before the snapshot height can't be loaded during background sync")
206 # Error message for wallets that need blocks before the snapshot height.
207 def loading_error(height):
208 return f"Wallet loading failed. Error loading wallet. Wallet requires blocks to be downloaded, and software does not currently support loading wallets while blocks are being downloaded out of order when using assumeutxo snapshots. Wallet should be able to load successfully after node sync reaches height {height}"
209 # The target height is SNAPSHOT_BASE_HEIGHT because that's when background sync completes.
210 assert_raises_rpc_error(-4, loading_error(SNAPSHOT_BASE_HEIGHT), n1.restorewallet, "w2", "backup_w2.dat")
211 212 self.test_backup_during_background_sync_pruned_node(n3, dump_output, loading_error(SNAPSHOT_BASE_HEIGHT))
213 214 self.log.info("Test loading descriptors during background sync")
215 wallet_name = "w1"
216 n1.createwallet(wallet_name, disable_private_keys=True)
217 key = get_generate_key()
218 block_info = n1.getblockchaininfo()
219 time = block_info["time"]
220 def expected_rescan_error(timestamp):
221 return f"Rescan failed for descriptor with timestamp {timestamp}. There was an error reading a block from time {time}, which is after or within 7200 seconds of key creation, and could contain transactions pertaining to the desc. As a result, transactions and coins using this desc may not appear in the wallet. This error is likely caused by an in-progress assumeutxo background sync. Check logs or getchainstates RPC for assumeutxo background sync progress and try again later."
222 timestamp = 0
223 result = self.import_descriptor(n1, wallet_name, key, timestamp)
224 assert_equal(result[0]['error']['code'], -1)
225 assert_equal(result[0]['error']['message'], expected_rescan_error(timestamp))
226 now_timestamp = block_info["mediantime"]
227 result = self.import_descriptor(n1, wallet_name, get_generate_key(), "now")
228 assert_equal(result[0]['error']['code'], -1)
229 assert_equal(result[0]['error']['message'], expected_rescan_error(now_timestamp))
230 231 self.log.info("Test that rescanning blocks from before the snapshot fails when blocks are not available from the background sync yet")
232 w1 = n1.get_wallet_rpc(wallet_name)
233 assert_raises_rpc_error(-1, "Failed to rescan unavailable blocks likely due to an in-progress assumeutxo background sync. Check logs or getchainstates RPC for assumeutxo background sync progress and try again later.", w1.rescanblockchain, 100)
234 235 PAUSE_HEIGHT = FINAL_HEIGHT - 40
236 237 self.log.info(f"Unload wallets and sync node up to height {PAUSE_HEIGHT}")
238 n1.unloadwallet("w")
239 n1.unloadwallet(wallet_name)
240 dumb_sync_blocks(src=n0, dst=n1, height=PAUSE_HEIGHT)
241 242 self.log.info("Verify node state during background sync")
243 # Verify there are still two chainstates (background validation not complete)
244 chainstates = n1.getchainstates()['chainstates']
245 assert_equal(len(chainstates), 2)
246 # The background chainstate should still be at START_HEIGHT
247 assert_equal(chainstates[0]['blocks'], START_HEIGHT)
248 assert_equal(chainstates[1]["blocks"], PAUSE_HEIGHT)
249 250 # After restart, wallets that existed before cannot be loaded because
251 # the wallet loading code checks if required blocks are available for
252 # rescanning. During assumeutxo background sync, blocks before the
253 # snapshot are not available, so wallet loading fails.
254 # After restart, the required height is SNAPSHOT_BASE_HEIGHT + 1 for all wallets.
255 assert_raises_rpc_error(-4, loading_error(SNAPSHOT_BASE_HEIGHT + 1), n1.loadwallet, "w")
256 assert_raises_rpc_error(-4, loading_error(SNAPSHOT_BASE_HEIGHT + 1), n1.loadwallet, wallet_name)
257 258 # Verify backup from before snapshot height still can't be restored
259 assert_raises_rpc_error(-4, loading_error(SNAPSHOT_BASE_HEIGHT + 1), n1.restorewallet, "w2_test", "backup_w2.dat")
260 261 self.complete_background_validation(n1)
262 263 self.log.info("Ensuring wallet can be restored from a backup that was created before the snapshot height")
264 n1.restorewallet("w2", "backup_w2.dat")
265 # Check balance of w2 wallet
266 assert_equal(n1.getbalance(), 340)
267 268 # Check balance of w wallet after node is synced
269 n1.loadwallet("w")
270 w = n1.get_wallet_rpc("w")
271 assert_equal(w.getbalance(), 34)
272 273 self.log.info("Check balance of a wallet that is active during snapshot completion")
274 n2.restorewallet("w", "backup_w.dat")
275 loaded = n2.loadtxoutset(dump_output['path'])
276 self.connect_nodes(0, 2)
277 self.wait_until(lambda: len(n2.getchainstates()['chainstates']) == 1)
278 ensure_for(duration=1, f=lambda: (n2.getbalance() == 34))
279 280 self.log.info("Ensuring descriptors can be loaded after background sync")
281 n1.loadwallet(wallet_name)
282 result = self.import_descriptor(n1, wallet_name, key, timestamp)
283 assert_equal(result[0]['success'], True)
284 285 self.test_restore_wallet_pruneheight(n3)
286 287 if __name__ == '__main__':
288 AssumeutxoTest(__file__).main()
289