feature_assumeutxo.py raw

   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