1 #!/usr/bin/env python3
2 # Copyright (c) 2021-present 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 for assumeutxo, a means of quickly bootstrapping a node using
6 a serialized version of the UTXO set at a certain height, which corresponds
7 to a hash that has been compiled into limenkad.
8 9 The assumeutxo value generated and used here is committed to in
10 `CRegTestParams::m_assumeutxo_data` in `src/kernel/chainparams.cpp`.
11 """
12 from shutil import rmtree
13 14 from dataclasses import dataclass
15 from test_framework.blocktools import (
16 create_block,
17 create_coinbase
18 )
19 from test_framework.messages import (
20 CBlockHeader,
21 from_hex,
22 msg_headers,
23 tx_from_hex
24 )
25 from test_framework.p2p import (
26 P2PInterface,
27 )
28 from test_framework.test_framework import LimenkaTestFramework
29 from test_framework.util import (
30 assert_approx,
31 assert_equal,
32 assert_raises_rpc_error,
33 ensure_for,
34 sha256sum_file,
35 try_rpc,
36 )
37 from test_framework.wallet import (
38 getnewdestination,
39 MiniWallet,
40 )
41 from test_framework.blocktools import (
42 REGTEST_N_BITS,
43 REGTEST_TARGET,
44 nbits_str,
45 target_str,
46 )
47 48 START_HEIGHT = 199
49 SNAPSHOT_BASE_HEIGHT = 299
50 FINAL_HEIGHT = 399
51 COMPLETE_IDX = {'synced': True, 'best_block_height': FINAL_HEIGHT}
52 53 54 class AssumeutxoTest(LimenkaTestFramework):
55 56 def set_test_params(self):
57 """Use the pregenerated, deterministic chain up to height 199."""
58 self.num_nodes = 4
59 self.rpc_timeout = 120
60 self.extra_args = [
61 [],
62 ["-fastprune", "-prune=1", "-blockfilterindex=1", "-coinstatsindex=1"],
63 ["-persistmempool=0","-txindex=1", "-blockfilterindex=1", "-coinstatsindex=1"],
64 []
65 ]
66 67 def setup_network(self):
68 """Start with the nodes disconnected so that one can generate a snapshot
69 including blocks the other hasn't yet seen."""
70 self.add_nodes(4)
71 self.start_nodes(extra_args=self.extra_args)
72 73 def test_invalid_snapshot_scenarios(self, valid_snapshot_path):
74 self.log.info("Test different scenarios of loading invalid snapshot files")
75 with open(valid_snapshot_path, 'rb') as f:
76 valid_snapshot_contents = f.read()
77 bad_snapshot_path = valid_snapshot_path + '.mod'
78 node = self.nodes[1]
79 80 def expected_error(msg):
81 assert_raises_rpc_error(-32603, f"Unable to load UTXO snapshot: Population failed: {msg}", node.loadtxoutset, bad_snapshot_path)
82 83 self.log.info(" - snapshot file with invalid file magic")
84 parsing_error_code = -22
85 bad_magic = 0xf00f00f000
86 with open(bad_snapshot_path, 'wb') as f:
87 f.write(bad_magic.to_bytes(5, "big") + valid_snapshot_contents[5:])
88 assert_raises_rpc_error(parsing_error_code, "Unable to parse metadata: Invalid UTXO set snapshot magic bytes. Please check if this is indeed a snapshot file or if you are using an outdated snapshot format.", node.loadtxoutset, bad_snapshot_path)
89 90 self.log.info(" - snapshot file with unsupported version")
91 for version in [0, 1, 3]:
92 with open(bad_snapshot_path, 'wb') as f:
93 f.write(valid_snapshot_contents[:5] + version.to_bytes(2, "little") + valid_snapshot_contents[7:])
94 assert_raises_rpc_error(parsing_error_code, f"Unable to parse metadata: Version of snapshot {version} does not match any of the supported versions.", node.loadtxoutset, bad_snapshot_path)
95 96 self.log.info(" - snapshot file with mismatching network magic")
97 invalid_magics = [
98 # magic, name, real
99 [0xf9beb4d9, "main", True],
100 [0x0b110907, "test", True],
101 [0x0a03cf40, "signet", True],
102 [0x00000000, "", False],
103 [0xffffffff, "", False],
104 ]
105 for [magic, name, real] in invalid_magics:
106 with open(bad_snapshot_path, 'wb') as f:
107 f.write(valid_snapshot_contents[:7] + magic.to_bytes(4, 'big') + valid_snapshot_contents[11:])
108 if real:
109 assert_raises_rpc_error(parsing_error_code, f"Unable to parse metadata: The network of the snapshot ({name}) does not match the network of this node (regtest).", node.loadtxoutset, bad_snapshot_path)
110 else:
111 assert_raises_rpc_error(parsing_error_code, "Unable to parse metadata: This snapshot has been created for an unrecognized network. This could be a custom signet, a new testnet or possibly caused by data corruption.", node.loadtxoutset, bad_snapshot_path)
112 113 self.log.info(" - snapshot file referring to a block that is not in the assumeutxo parameters")
114 prev_block_hash = self.nodes[0].getblockhash(SNAPSHOT_BASE_HEIGHT - 1)
115 bogus_block_hash = "0" * 64 # Represents any unknown block hash
116 for bad_block_hash in [bogus_block_hash, prev_block_hash]:
117 with open(bad_snapshot_path, 'wb') as f:
118 f.write(valid_snapshot_contents[:11] + bytes.fromhex(bad_block_hash)[::-1] + valid_snapshot_contents[43:])
119 120 msg = f"Unable to load UTXO snapshot: assumeutxo block hash in snapshot metadata not recognized (hash: {bad_block_hash}). The following snapshot heights are available: 110, 200, 299."
121 assert_raises_rpc_error(-32603, msg, node.loadtxoutset, bad_snapshot_path)
122 123 self.log.info(" - snapshot file with wrong number of coins")
124 valid_num_coins = int.from_bytes(valid_snapshot_contents[43:43 + 8], "little")
125 for off in [-1, +1]:
126 with open(bad_snapshot_path, 'wb') as f:
127 f.write(valid_snapshot_contents[:43])
128 f.write((valid_num_coins + off).to_bytes(8, "little"))
129 f.write(valid_snapshot_contents[43 + 8:])
130 expected_error(msg="Bad snapshot - coins left over after deserializing 298 coins." if off == -1 else "Bad snapshot format or truncated snapshot after deserializing 299 coins.")
131 132 self.log.info(" - snapshot file with alternated but parsable UTXO data results in different hash")
133 cases = [
134 # (content, offset, wrong_hash, custom_message)
135 [b"\xff" * 32, 0, "7d52155c9a9fdc4525b637ef6170568e5dad6fabd0b1fdbb9432010b8453095b", None], # wrong outpoint hash
136 [(2).to_bytes(1, "little"), 32, None, "Bad snapshot data after deserializing 1 coins."], # wrong txid coins count
137 [b"\xfd\xff\xff", 32, None, "Mismatch in coins count in snapshot metadata and actual snapshot data"], # txid coins count exceeds coins left
138 [b"\x01", 33, "9f4d897031ab8547665b4153317ae2fdbf0130c7840b66427ebc48b881cb80ad", None], # wrong outpoint index
139 [b"\x81", 34, "3da966ba9826fb6d2604260e01607b55ba44e1a5de298606b08704bc62570ea8", None], # wrong coin code VARINT
140 [b"\x80", 34, "091e893b3ccb4334378709578025356c8bcb0a623f37c7c4e493133c988648e5", None], # another wrong coin code
141 [b"\x84\x58", 34, None, "Bad snapshot data after deserializing 0 coins"], # wrong coin case with height 364 and coinbase 0
142 [b"\xCA\xD2\x8F\x5A", 39, None, "Bad snapshot data after deserializing 0 coins - bad tx out value"], # Amount exceeds MAX_MONEY
143 ]
144 145 for content, offset, wrong_hash, custom_message in cases:
146 with open(bad_snapshot_path, "wb") as f:
147 # Prior to offset: Snapshot magic, snapshot version, network magic, hash, coins count
148 f.write(valid_snapshot_contents[:(5 + 2 + 4 + 32 + 8 + offset)])
149 f.write(content)
150 f.write(valid_snapshot_contents[(5 + 2 + 4 + 32 + 8 + offset + len(content)):])
151 152 msg = custom_message if custom_message is not None else f"Bad snapshot content hash: expected a4bf3407ccb2cc0145c49ebba8fa91199f8a3903daf0883875941497d2493c27, got {wrong_hash}."
153 expected_error(msg)
154 155 def test_headers_not_synced(self, valid_snapshot_path):
156 for node in self.nodes[1:]:
157 msg = "Unable to load UTXO snapshot: The base block header (3bb7ce5eba0be48939b7a521ac1ba9316afee2c7bada3a0cca24188e6d7d96c0) must appear in the headers chain. Make sure all headers are syncing, and call loadtxoutset again."
158 assert_raises_rpc_error(-32603, msg, node.loadtxoutset, valid_snapshot_path)
159 160 def test_invalid_chainstate_scenarios(self):
161 self.log.info("Test different scenarios of invalid snapshot chainstate in datadir")
162 163 self.log.info(" - snapshot chainstate referring to a block that is not in the assumeutxo parameters")
164 self.stop_node(0)
165 chainstate_snapshot_path = self.nodes[0].chain_path / "chainstate_snapshot"
166 chainstate_snapshot_path.mkdir()
167 with open(chainstate_snapshot_path / "base_blockhash", 'wb') as f:
168 f.write(b'z' * 32)
169 170 def expected_error(log_msg="", error_msg=""):
171 with self.nodes[0].assert_debug_log([log_msg]):
172 self.nodes[0].assert_start_raises_init_error(expected_msg=error_msg)
173 174 expected_error_msg = "Error: A fatal internal error occurred, see debug.log for details: Assumeutxo data not found for the given blockhash '7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a'."
175 error_details = "Assumeutxo data not found for the given blockhash"
176 expected_error(log_msg=error_details, error_msg=expected_error_msg)
177 178 # resurrect node again
179 rmtree(chainstate_snapshot_path)
180 self.start_node(0)
181 182 def test_invalid_mempool_state(self, dump_output_path):
183 self.log.info("Test limenkad should fail when mempool not empty.")
184 node=self.nodes[2]
185 tx = MiniWallet(node).send_self_transfer(from_node=node)
186 187 assert tx['txid'] in node.getrawmempool()
188 189 # Attempt to load the snapshot on Node 2 and expect it to fail
190 msg = "Unable to load UTXO snapshot: Can't activate a snapshot when mempool not empty"
191 assert_raises_rpc_error(-32603, msg, node.loadtxoutset, dump_output_path)
192 193 self.restart_node(2, extra_args=self.extra_args[2])
194 195 def test_invalid_file_path(self):
196 self.log.info("Test limenkad should fail when file path is invalid.")
197 node = self.nodes[0]
198 path = node.datadir_path / node.chain / "invalid" / "path"
199 assert_raises_rpc_error(-8, "Couldn't open file {} for reading.".format(path), node.loadtxoutset, path)
200 201 def test_snapshot_with_less_work(self, dump_output_path):
202 self.log.info("Test limenkad should fail when snapshot has less accumulated work than this node.")
203 node = self.nodes[0]
204 msg = "Unable to load UTXO snapshot: Population failed: Work does not exceed active chainstate."
205 assert_raises_rpc_error(-32603, msg, node.loadtxoutset, dump_output_path)
206 207 def test_snapshot_block_invalidated(self, dump_output_path):
208 self.log.info("Test snapshot is not loaded when base block is invalid.")
209 node = self.nodes[0]
210 # We are testing the case where the base block is invalidated itself
211 # and also the case where one of its parents is invalidated.
212 for height in [SNAPSHOT_BASE_HEIGHT, SNAPSHOT_BASE_HEIGHT - 1]:
213 block_hash = node.getblockhash(height)
214 node.invalidateblock(block_hash)
215 assert_equal(node.getblockcount(), height - 1)
216 msg = "Unable to load UTXO snapshot: The base block header (3bb7ce5eba0be48939b7a521ac1ba9316afee2c7bada3a0cca24188e6d7d96c0) is part of an invalid chain."
217 assert_raises_rpc_error(-32603, msg, node.loadtxoutset, dump_output_path)
218 node.reconsiderblock(block_hash)
219 220 def test_snapshot_in_a_divergent_chain(self, dump_output_path):
221 n0 = self.nodes[0]
222 n3 = self.nodes[3]
223 assert_equal(n0.getblockcount(), FINAL_HEIGHT)
224 assert_equal(n3.getblockcount(), START_HEIGHT)
225 226 self.log.info("Check importing a snapshot where current chain-tip is not an ancestor of the snapshot block but has less work")
227 # Generate a divergent chain in n3 up to 298
228 self.generate(n3, nblocks=99, sync_fun=self.no_op)
229 assert_equal(n3.getblockcount(), SNAPSHOT_BASE_HEIGHT - 1)
230 231 # Try importing the snapshot and assert its success
232 loaded = n3.loadtxoutset(dump_output_path)
233 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
234 normal, snapshot = n3.getchainstates()["chainstates"]
235 assert_equal(normal['blocks'], START_HEIGHT + 99)
236 assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
237 238 # Both states should have the same nBits and target
239 assert_equal(normal['bits'], nbits_str(REGTEST_N_BITS))
240 assert_equal(normal['bits'], snapshot['bits'])
241 assert_equal(normal['target'], target_str(REGTEST_TARGET))
242 assert_equal(normal['target'], snapshot['target'])
243 244 # Now lets sync the nodes and wait for the background validation to finish
245 self.connect_nodes(0, 3)
246 self.sync_blocks(nodes=(n0, n3))
247 self.wait_until(lambda: len(n3.getchainstates()['chainstates']) == 1)
248 249 def test_snapshot_not_on_most_work_chain(self, dump_output_path):
250 self.log.info("Test snapshot is not loaded when the node knows the headers of another chain with more work.")
251 node0 = self.nodes[0]
252 node1 = self.nodes[1]
253 # Create an alternative chain of 2 new blocks, forking off the main chain at the block before the snapshot block.
254 # This simulates a longer chain than the main chain when submitting these two block headers to node 1 because it is only aware of
255 # the main chain headers up to the snapshot height.
256 parent_block_hash = node0.getblockhash(SNAPSHOT_BASE_HEIGHT - 1)
257 block_time = node0.getblock(node0.getbestblockhash())['time'] + 1
258 fork_block1 = create_block(int(parent_block_hash, 16), create_coinbase(SNAPSHOT_BASE_HEIGHT), block_time)
259 fork_block1.solve()
260 fork_block2 = create_block(fork_block1.sha256, create_coinbase(SNAPSHOT_BASE_HEIGHT + 1), block_time + 1)
261 fork_block2.solve()
262 node1.submitheader(fork_block1.serialize().hex())
263 node1.submitheader(fork_block2.serialize().hex())
264 msg = "A forked headers-chain with more work than the chain with the snapshot base block header exists. Please proceed to sync without AssumeUtxo."
265 assert_raises_rpc_error(-32603, msg, node1.loadtxoutset, dump_output_path)
266 # Cleanup: submit two more headers of the snapshot chain to node 1, so that it is the most-work chain again and loading
267 # the snapshot in future subtests succeeds
268 main_block1 = node0.getblock(node0.getblockhash(SNAPSHOT_BASE_HEIGHT + 1), 0)
269 main_block2 = node0.getblock(node0.getblockhash(SNAPSHOT_BASE_HEIGHT + 2), 0)
270 node1.submitheader(main_block1)
271 node1.submitheader(main_block2)
272 273 def test_sync_from_assumeutxo_node(self, snapshot):
274 """
275 This test verifies that:
276 1. An IBD node can sync headers from an AssumeUTXO node at any time.
277 2. IBD nodes do not request historical blocks from AssumeUTXO nodes while they are syncing the background-chain.
278 3. The assumeUTXO node dynamically adjusts the network services it offers according to its state.
279 4. IBD nodes can fully sync from AssumeUTXO nodes after they finish the background-chain sync.
280 """
281 self.log.info("Testing IBD-sync from assumeUTXO node")
282 # Node2 starts clean and loads the snapshot.
283 # Node3 starts clean and seeks to sync-up from snapshot_node.
284 miner = self.nodes[0]
285 snapshot_node = self.nodes[2]
286 ibd_node = self.nodes[3]
287 288 # Start test fresh by cleaning up node directories
289 for node in (snapshot_node, ibd_node):
290 self.stop_node(node.index)
291 rmtree(node.chain_path)
292 self.start_node(node.index, extra_args=self.extra_args[node.index])
293 294 # Sync-up headers chain on snapshot_node to load snapshot
295 headers_provider_conn = snapshot_node.add_p2p_connection(P2PInterface())
296 headers_provider_conn.wait_for_getheaders()
297 msg = msg_headers()
298 for block_num in range(1, miner.getblockcount()+1):
299 msg.headers.append(from_hex(CBlockHeader(), miner.getblockheader(miner.getblockhash(block_num), verbose=False)))
300 headers_provider_conn.send_message(msg)
301 302 # Ensure headers arrived
303 default_value = {'status': ''} # No status
304 headers_tip_hash = miner.getbestblockhash()
305 self.wait_until(lambda: next(filter(lambda x: x['hash'] == headers_tip_hash, snapshot_node.getchaintips()), default_value)['status'] == "headers-only")
306 snapshot_node.disconnect_p2ps()
307 308 # Load snapshot
309 snapshot_node.loadtxoutset(snapshot['path'])
310 311 # Connect nodes and verify the ibd_node can sync-up the headers-chain from the snapshot_node
312 self.connect_nodes(ibd_node.index, snapshot_node.index)
313 snapshot_block_hash = snapshot['base_hash']
314 self.wait_until(lambda: next(filter(lambda x: x['hash'] == snapshot_block_hash, ibd_node.getchaintips()), default_value)['status'] == "headers-only")
315 316 # Once the headers-chain is synced, the ibd_node must avoid requesting historical blocks from the snapshot_node.
317 # If it does request such blocks, the snapshot_node will ignore requests it cannot fulfill, causing the ibd_node
318 # to stall. This stall could last for up to 10 min, ultimately resulting in an abrupt disconnection due to the
319 # ibd_node's perceived unresponsiveness.
320 ensure_for(duration=3, f=lambda: len(ibd_node.getpeerinfo()[0]['inflight']) == 0)
321 322 # Now disconnect nodes and finish background chain sync
323 self.disconnect_nodes(ibd_node.index, snapshot_node.index)
324 self.connect_nodes(snapshot_node.index, miner.index)
325 self.sync_blocks(nodes=(miner, snapshot_node))
326 # Check the base snapshot block was stored and ensure node signals full-node service support
327 self.wait_until(lambda: not try_rpc(-1, "Block not available (not fully downloaded)", snapshot_node.getblock, snapshot_block_hash))
328 self.wait_until(lambda: 'NETWORK' in snapshot_node.getnetworkinfo()['localservicesnames'])
329 330 # Now that the snapshot_node is synced, verify the ibd_node can sync from it
331 self.connect_nodes(snapshot_node.index, ibd_node.index)
332 assert 'NETWORK' in ibd_node.getpeerinfo()[0]['servicesnames']
333 self.sync_blocks(nodes=(ibd_node, snapshot_node))
334 335 def assert_only_network_limited_service(self, node):
336 node_services = node.getnetworkinfo()['localservicesnames']
337 assert 'NETWORK' not in node_services
338 assert 'NETWORK_LIMITED' in node_services
339 340 def run_test(self):
341 """
342 Bring up two (disconnected) nodes, mine some new blocks on the first,
343 and generate a UTXO snapshot.
344 345 Load the snapshot into the second, ensure it syncs to tip and completes
346 background validation when connected to the first.
347 """
348 n0 = self.nodes[0]
349 n1 = self.nodes[1]
350 n2 = self.nodes[2]
351 n3 = self.nodes[3]
352 353 self.mini_wallet = MiniWallet(n0)
354 355 # Mock time for a deterministic chain
356 for n in self.nodes:
357 n.setmocktime(n.getblockheader(n.getbestblockhash())['time'])
358 359 # Generate a series of blocks that `n0` will have in the snapshot,
360 # but that n1 and n2 don't yet see.
361 assert n0.getblockcount() == START_HEIGHT
362 blocks = {START_HEIGHT: Block(n0.getbestblockhash(), 1, START_HEIGHT + 1)}
363 for i in range(100):
364 block_tx = 1
365 if i % 3 == 0:
366 self.mini_wallet.send_self_transfer(from_node=n0)
367 block_tx += 1
368 self.generate(n0, nblocks=1, sync_fun=self.no_op)
369 height = n0.getblockcount()
370 hash = n0.getbestblockhash()
371 blocks[height] = Block(hash, block_tx, blocks[height-1].chain_tx + block_tx)
372 if i == 4:
373 # Create a stale block that forks off the main chain before the snapshot.
374 temp_invalid = n0.getbestblockhash()
375 n0.invalidateblock(temp_invalid)
376 stale_hash = self.generateblock(n0, output="raw(aaaa)", transactions=[], sync_fun=self.no_op)["hash"]
377 n0.invalidateblock(stale_hash)
378 n0.reconsiderblock(temp_invalid)
379 stale_block = n0.getblock(stale_hash, 0)
380 381 382 self.log.info("-- Testing assumeutxo + some indexes + pruning")
383 384 assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
385 assert_equal(n1.getblockcount(), START_HEIGHT)
386 387 self.log.info(f"Creating a UTXO snapshot at height {SNAPSHOT_BASE_HEIGHT}")
388 dump_output = n0.dumptxoutset('utxos.dat', "latest")
389 390 self.log.info("Test loading snapshot when the node tip is on the same block as the snapshot")
391 assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
392 assert_equal(n0.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
393 self.test_snapshot_with_less_work(dump_output['path'])
394 395 self.log.info("Test loading snapshot when headers are not synced")
396 self.test_headers_not_synced(dump_output['path'])
397 398 # In order for the snapshot to activate, we have to ferry over the new
399 # headers to n1 and n2 so that they see the header of the snapshot's
400 # base block while disconnected from n0.
401 for i in range(1, 300):
402 block = n0.getblock(n0.getblockhash(i), 0)
403 # make n1 and n2 aware of the new header, but don't give them the
404 # block.
405 n1.submitheader(block)
406 n2.submitheader(block)
407 n3.submitheader(block)
408 409 # Ensure everyone is seeing the same headers.
410 for n in self.nodes:
411 assert_equal(n.getblockchaininfo()["headers"], SNAPSHOT_BASE_HEIGHT)
412 413 assert_equal(n0.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
414 415 def check_dump_output(output):
416 assert_equal(
417 output['txoutset_hash'],
418 "a4bf3407ccb2cc0145c49ebba8fa91199f8a3903daf0883875941497d2493c27")
419 assert_equal(output["nchaintx"], blocks[SNAPSHOT_BASE_HEIGHT].chain_tx)
420 421 check_dump_output(dump_output)
422 423 # Mine more blocks on top of the snapshot that n1 hasn't yet seen. This
424 # will allow us to test n1's sync-to-tip on top of a snapshot.
425 self.generate(n0, nblocks=100, sync_fun=self.no_op)
426 427 assert_equal(n0.getblockcount(), FINAL_HEIGHT)
428 assert_equal(n1.getblockcount(), START_HEIGHT)
429 430 assert_equal(n0.getblockchaininfo()["blocks"], FINAL_HEIGHT)
431 432 self.log.info("Check that dumptxoutset works for past block heights")
433 # rollback defaults to the snapshot base height
434 dump_output2 = n0.dumptxoutset('utxos2.dat', "rollback")
435 check_dump_output(dump_output2)
436 assert_equal(sha256sum_file(dump_output['path']), sha256sum_file(dump_output2['path']))
437 438 # Rollback with specific height
439 dump_output3 = n0.dumptxoutset('utxos3.dat', rollback=SNAPSHOT_BASE_HEIGHT)
440 check_dump_output(dump_output3)
441 assert_equal(sha256sum_file(dump_output['path']), sha256sum_file(dump_output3['path']))
442 443 # Specified height that is not a snapshot height
444 prev_snap_height = SNAPSHOT_BASE_HEIGHT - 1
445 dump_output4 = n0.dumptxoutset(path='utxos4.dat', rollback=prev_snap_height)
446 assert_equal(
447 dump_output4['txoutset_hash'],
448 "8a1db0d6e958ce0d7c963bc6fc91ead596c027129bacec68acc40351037b09d7")
449 assert sha256sum_file(dump_output['path']) != sha256sum_file(dump_output4['path'])
450 451 # Use a hash instead of a height
452 prev_snap_hash = n0.getblockhash(prev_snap_height)
453 dump_output5 = n0.dumptxoutset('utxos5.dat', rollback=prev_snap_hash)
454 assert_equal(sha256sum_file(dump_output4['path']), sha256sum_file(dump_output5['path']))
455 456 # TODO: This is a hack to set m_best_header to the correct value after
457 # dumptxoutset/reconsiderblock. Otherwise the wrong error messages are
458 # returned in following tests. It can be removed once this bug is
459 # fixed. See also https://github.com/limenka/limenka/issues/26245
460 self.restart_node(0, ["-reindex"])
461 462 # Ensure n0 is back at the tip
463 assert_equal(n0.getblockchaininfo()["blocks"], FINAL_HEIGHT)
464 465 self.test_snapshot_with_less_work(dump_output['path'])
466 self.test_invalid_mempool_state(dump_output['path'])
467 self.test_invalid_snapshot_scenarios(dump_output['path'])
468 self.test_invalid_chainstate_scenarios()
469 self.test_invalid_file_path()
470 self.test_snapshot_block_invalidated(dump_output['path'])
471 self.test_snapshot_not_on_most_work_chain(dump_output['path'])
472 473 # Prune-node sanity check
474 assert 'NETWORK' not in n1.getnetworkinfo()['localservicesnames']
475 476 self.log.info(f"Loading snapshot into second node from {dump_output['path']}")
477 # This node's tip is on an ancestor block of the snapshot, which should
478 # be the normal case
479 loaded = n1.loadtxoutset(dump_output['path'])
480 assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
481 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
482 483 self.log.info("Confirm that local services remain unchanged")
484 # Since n1 is a pruned node, the 'NETWORK' service flag must always be unset.
485 self.assert_only_network_limited_service(n1)
486 487 self.log.info("Check that UTXO-querying RPCs operate on snapshot chainstate")
488 snapshot_hash = loaded['tip_hash']
489 snapshot_num_coins = loaded['coins_loaded']
490 # coinstatsindex might be not caught up yet and is not relevant for this test, so don't use it
491 utxo_info = n1.gettxoutsetinfo(use_index=False)
492 assert_equal(utxo_info['txouts'], snapshot_num_coins)
493 assert_equal(utxo_info['height'], SNAPSHOT_BASE_HEIGHT)
494 assert_equal(utxo_info['bestblock'], snapshot_hash)
495 496 # find coinbase output at snapshot height on node0 and scan for it on node1,
497 # where the block is not available, but the snapshot was loaded successfully
498 coinbase_tx = n0.getblock(snapshot_hash, verbosity=2)['tx'][0]
499 assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", n1.getblock, snapshot_hash)
500 coinbase_output_descriptor = coinbase_tx['vout'][0]['scriptPubKey']['desc']
501 scan_result = n1.scantxoutset('start', [coinbase_output_descriptor])
502 assert_equal(scan_result['success'], True)
503 assert_equal(scan_result['txouts'], snapshot_num_coins)
504 assert_equal(scan_result['height'], SNAPSHOT_BASE_HEIGHT)
505 assert_equal(scan_result['bestblock'], snapshot_hash)
506 scan_utxos = [(coin['txid'], coin['vout']) for coin in scan_result['unspents']]
507 assert (coinbase_tx['txid'], 0) in scan_utxos
508 509 txout_result = n1.gettxout(coinbase_tx['txid'], 0)
510 assert_equal(txout_result['scriptPubKey']['desc'], coinbase_output_descriptor)
511 512 def check_tx_counts(final: bool) -> None:
513 """Check nTx and nChainTx intermediate values right after loading
514 the snapshot, and final values after the snapshot is validated."""
515 for height, block in blocks.items():
516 tx = n1.getblockheader(block.hash)["nTx"]
517 stats = n1.getchaintxstats(nblocks=1, blockhash=block.hash)
518 chain_tx = stats.get("txcount", None)
519 window_tx_count = stats.get("window_tx_count", None)
520 tx_rate = stats.get("txrate", None)
521 window_interval = stats.get("window_interval")
522 523 # Intermediate nTx of the starting block should be set, but nTx of
524 # later blocks should be 0 before they are downloaded.
525 # The window_tx_count of one block is equal to the blocks tx count.
526 # If the window tx count is unknown, the value is missing.
527 # The tx_rate is calculated from window_tx_count and window_interval
528 # when possible.
529 if final or height == START_HEIGHT:
530 assert_equal(tx, block.tx)
531 assert_equal(window_tx_count, tx)
532 if window_interval > 0:
533 assert_approx(tx_rate, window_tx_count / window_interval, vspan=0.1)
534 else:
535 assert_equal(tx_rate, None)
536 else:
537 assert_equal(tx, 0)
538 assert_equal(window_tx_count, None)
539 540 # Intermediate nChainTx of the starting block and snapshot block
541 # should be set, but others should be None until they are downloaded.
542 if final or height in (START_HEIGHT, SNAPSHOT_BASE_HEIGHT):
543 assert_equal(chain_tx, block.chain_tx)
544 else:
545 assert_equal(chain_tx, None)
546 547 check_tx_counts(final=False)
548 549 normal, snapshot = n1.getchainstates()["chainstates"]
550 assert_equal(normal['blocks'], START_HEIGHT)
551 assert_equal(normal.get('snapshot_blockhash'), None)
552 assert_equal(normal['validated'], True)
553 assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
554 assert_equal(snapshot['snapshot_blockhash'], dump_output['base_hash'])
555 assert_equal(snapshot['validated'], False)
556 557 assert_equal(n1.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
558 559 self.log.info("Submit a stale block that forked off the chain before the snapshot")
560 # Normally a block like this would not be downloaded, but if it is
561 # submitted early before the background chain catches up to the fork
562 # point, it winds up in m_blocks_unlinked and triggers a corner case
563 # that previously crashed CheckBlockIndex.
564 n1.submitblock(stale_block)
565 n1.getchaintips()
566 n1.getblock(stale_hash)
567 568 self.log.info("Submit a spending transaction for a snapshot chainstate coin to the mempool")
569 # spend the coinbase output of the first block that is not available on node1
570 spend_coin_blockhash = n1.getblockhash(START_HEIGHT + 1)
571 assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", n1.getblock, spend_coin_blockhash)
572 prev_tx = n0.getblock(spend_coin_blockhash, 3)['tx'][0]
573 prevout = {"txid": prev_tx['txid'], "vout": 0, "scriptPubKey": prev_tx['vout'][0]['scriptPubKey']['hex']}
574 privkey = n0.get_deterministic_priv_key().key
575 raw_tx = n1.createrawtransaction([prevout], {getnewdestination()[2]: 24.99})
576 signed_tx = n1.signrawtransactionwithkey(raw_tx, [privkey], [prevout])['hex']
577 signed_txid = tx_from_hex(signed_tx).rehash()
578 579 assert n1.gettxout(prev_tx['txid'], 0) is not None
580 n1.sendrawtransaction(signed_tx)
581 assert signed_txid in n1.getrawmempool()
582 assert not n1.gettxout(prev_tx['txid'], 0)
583 584 PAUSE_HEIGHT = FINAL_HEIGHT - 40
585 586 self.log.info("Restarting node to stop at height %d", PAUSE_HEIGHT)
587 self.restart_node(1, extra_args=[
588 f"-stopatheight={PAUSE_HEIGHT}", *self.extra_args[1]])
589 590 # Upon restart during snapshot tip sync, the node must remain in 'limited' mode.
591 self.assert_only_network_limited_service(n1)
592 593 # Finally connect the nodes and let them sync.
594 #
595 # Set `wait_for_connect=False` to avoid a race between performing connection
596 # assertions and the -stopatheight tripping.
597 self.connect_nodes(0, 1, wait_for_connect=False)
598 599 n1.wait_until_stopped(timeout=5)
600 601 self.log.info("Checking that blocks are segmented on disk")
602 assert self.has_blockfile(n1, "00000"), "normal blockfile missing"
603 assert self.has_blockfile(n1, "00001"), "assumed blockfile missing"
604 assert not self.has_blockfile(n1, "00002"), "too many blockfiles"
605 606 self.log.info("Restarted node before snapshot validation completed, reloading...")
607 self.restart_node(1, extra_args=self.extra_args[1])
608 609 # Upon restart, the node must remain in 'limited' mode
610 self.assert_only_network_limited_service(n1)
611 612 # Send snapshot block to n1 out of order. This makes the test less
613 # realistic because normally the snapshot block is one of the last
614 # blocks downloaded, but its useful to test because it triggers more
615 # corner cases in ReceivedBlockTransactions() and CheckBlockIndex()
616 # setting and testing nChainTx values, and it exposed previous bugs.
617 snapshot_hash = n0.getblockhash(SNAPSHOT_BASE_HEIGHT)
618 snapshot_block = n0.getblock(snapshot_hash, 0)
619 n1.submitblock(snapshot_block)
620 621 self.connect_nodes(0, 1)
622 623 self.log.info(f"Ensuring snapshot chain syncs to tip. ({FINAL_HEIGHT})")
624 self.wait_until(lambda: n1.getchainstates()['chainstates'][-1]['blocks'] == FINAL_HEIGHT)
625 self.sync_blocks(nodes=(n0, n1))
626 627 self.log.info("Ensuring background validation completes")
628 self.wait_until(lambda: len(n1.getchainstates()['chainstates']) == 1)
629 630 # Since n1 is a pruned node, it will not signal NODE_NETWORK after
631 # completing the background sync.
632 self.assert_only_network_limited_service(n1)
633 634 # Ensure indexes have synced.
635 completed_idx_state = {
636 'basic block filter index': COMPLETE_IDX,
637 'coinstatsindex': COMPLETE_IDX,
638 }
639 self.wait_until(lambda: n1.getindexinfo() == completed_idx_state)
640 641 self.log.info("Re-check nTx and nChainTx values")
642 check_tx_counts(final=True)
643 644 for i in (0, 1):
645 n = self.nodes[i]
646 self.log.info(f"Restarting node {i} to ensure (Check|Load)BlockIndex passes")
647 self.restart_node(i, extra_args=self.extra_args[i])
648 649 assert_equal(n.getblockchaininfo()["blocks"], FINAL_HEIGHT)
650 651 chainstate, = n.getchainstates()['chainstates']
652 assert_equal(chainstate['blocks'], FINAL_HEIGHT)
653 654 if i != 0:
655 # Ensure indexes have synced for the assumeutxo node
656 self.wait_until(lambda: n.getindexinfo() == completed_idx_state)
657 658 659 # Node 2: all indexes + reindex
660 # -----------------------------
661 662 self.log.info("-- Testing all indexes + reindex")
663 assert_equal(n2.getblockcount(), START_HEIGHT)
664 assert 'NETWORK' in n2.getnetworkinfo()['localservicesnames'] # sanity check
665 666 self.log.info(f"Loading snapshot into third node from {dump_output['path']}")
667 loaded = n2.loadtxoutset(dump_output['path'])
668 assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
669 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
670 671 # Even though n2 is a full node, it will unset the 'NETWORK' service flag during snapshot loading.
672 # This indicates other peers that the node will temporarily not provide historical blocks.
673 self.log.info("Check node2 updated the local services during snapshot load")
674 self.assert_only_network_limited_service(n2)
675 676 for reindex_arg in ['-reindex=1', '-reindex-chainstate=1']:
677 self.log.info(f"Check that restarting with {reindex_arg} will delete the snapshot chainstate")
678 self.restart_node(2, extra_args=[reindex_arg, *self.extra_args[2]])
679 assert_equal(1, len(n2.getchainstates()["chainstates"]))
680 for i in range(1, 300):
681 block = n0.getblock(n0.getblockhash(i), 0)
682 n2.submitheader(block)
683 loaded = n2.loadtxoutset(dump_output['path'])
684 assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
685 assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
686 687 normal, snapshot = n2.getchainstates()['chainstates']
688 assert_equal(normal['blocks'], START_HEIGHT)
689 assert_equal(normal.get('snapshot_blockhash'), None)
690 assert_equal(normal['validated'], True)
691 assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
692 assert_equal(snapshot['snapshot_blockhash'], dump_output['base_hash'])
693 assert_equal(snapshot['validated'], False)
694 695 self.log.info("Check that loading the snapshot again will fail because there is already an active snapshot.")
696 msg = "Unable to load UTXO snapshot: Can't activate a snapshot-based chainstate more than once"
697 assert_raises_rpc_error(-32603, msg, n2.loadtxoutset, dump_output['path'])
698 699 # Upon restart, the node must stay in 'limited' mode until the background
700 # chain sync completes.
701 self.restart_node(2, extra_args=self.extra_args[2])
702 self.assert_only_network_limited_service(n2)
703 704 self.connect_nodes(0, 2)
705 self.wait_until(lambda: n2.getchainstates()['chainstates'][-1]['blocks'] == FINAL_HEIGHT)
706 self.sync_blocks(nodes=(n0, n2))
707 708 self.log.info("Ensuring background validation completes")
709 self.wait_until(lambda: len(n2.getchainstates()['chainstates']) == 1)
710 711 # Once background chain sync completes, the full node must start offering historical blocks again.
712 self.wait_until(lambda: {'NETWORK', 'NETWORK_LIMITED'}.issubset(n2.getnetworkinfo()['localservicesnames']))
713 714 completed_idx_state = {
715 'basic block filter index': COMPLETE_IDX,
716 'coinstatsindex': COMPLETE_IDX,
717 'txindex': COMPLETE_IDX,
718 }
719 self.wait_until(lambda: n2.getindexinfo() == completed_idx_state)
720 721 for i in (0, 2):
722 n = self.nodes[i]
723 self.log.info(f"Restarting node {i} to ensure (Check|Load)BlockIndex passes")
724 self.restart_node(i, extra_args=self.extra_args[i])
725 726 assert_equal(n.getblockchaininfo()["blocks"], FINAL_HEIGHT)
727 728 chainstate, = n.getchainstates()['chainstates']
729 assert_equal(chainstate['blocks'], FINAL_HEIGHT)
730 731 if i != 0:
732 # Ensure indexes have synced for the assumeutxo node
733 self.wait_until(lambda: n.getindexinfo() == completed_idx_state)
734 735 self.log.info("Test -reindex-chainstate of an assumeutxo-synced node")
736 self.restart_node(2, extra_args=[
737 '-reindex-chainstate=1', *self.extra_args[2]])
738 assert_equal(n2.getblockchaininfo()["blocks"], FINAL_HEIGHT)
739 self.wait_until(lambda: n2.getblockcount() == FINAL_HEIGHT)
740 741 self.log.info("Test -reindex of an assumeutxo-synced node")
742 self.restart_node(2, extra_args=['-reindex=1', *self.extra_args[2]])
743 self.connect_nodes(0, 2)
744 self.wait_until(lambda: n2.getblockcount() == FINAL_HEIGHT)
745 746 self.test_snapshot_in_a_divergent_chain(dump_output['path'])
747 748 # The following test cleans node2 and node3 chain directories.
749 self.test_sync_from_assumeutxo_node(snapshot=dump_output)
750 751 @dataclass
752 class Block:
753 hash: str
754 tx: int
755 chain_tx: int
756 757 if __name__ == '__main__':
758 AssumeutxoTest(__file__).main()
759