wallet_assumeutxo.py raw

   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