wallet_assumeutxo.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2023-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 wallet related behavior.
   6  See feature_assumeutxo.py for background.
   7  
   8  ## Possible test improvements
   9  
  10  - TODO: test loading a wallet (backup) on a pruned node
  11  
  12  """
  13  from test_framework.address import address_to_scriptpubkey
  14  from test_framework.descriptors import descsum_create
  15  from test_framework.test_framework import LimenkaTestFramework
  16  from test_framework.messages import COIN
  17  from test_framework.util import (
  18      assert_equal,
  19      assert_raises_rpc_error,
  20      ensure_for,
  21  )
  22  from test_framework.wallet import MiniWallet
  23  from test_framework.wallet_util import get_generate_key
  24  
  25  START_HEIGHT = 199
  26  SNAPSHOT_BASE_HEIGHT = 299
  27  FINAL_HEIGHT = 399
  28  
  29  
  30  class AssumeutxoTest(LimenkaTestFramework):
  31      def skip_test_if_missing_module(self):
  32          self.skip_if_no_wallet()
  33  
  34      def add_options(self, parser):
  35          self.add_wallet_options(parser, legacy=False)
  36  
  37      def set_test_params(self):
  38          """Use the pregenerated, deterministic chain up to height 199."""
  39          self.num_nodes = 3
  40          self.rpc_timeout = 120
  41          self.extra_args = [
  42              [],
  43              [],
  44              [],
  45          ]
  46  
  47      def setup_network(self):
  48          """Start with the nodes disconnected so that one can generate a snapshot
  49          including blocks the other hasn't yet seen."""
  50          self.add_nodes(3)
  51          self.start_nodes(extra_args=self.extra_args)
  52  
  53      def import_descriptor(self, node, wallet_name, key, timestamp):
  54          import_request = [{"desc": descsum_create("pkh(" + key.pubkey + ")"),
  55                             "timestamp": timestamp,
  56                             "label": "Descriptor import test"}]
  57          wrpc = node.get_wallet_rpc(wallet_name)
  58          return wrpc.importdescriptors(import_request)
  59  
  60      def run_test(self):
  61          """
  62          Bring up two (disconnected) nodes, mine some new blocks on the first,
  63          and generate a UTXO snapshot.
  64  
  65          Load the snapshot into the second, ensure it syncs to tip and completes
  66          background validation when connected to the first.
  67          """
  68          n0 = self.nodes[0]
  69          n1 = self.nodes[1]
  70          n2 = self.nodes[2]
  71  
  72          self.mini_wallet = MiniWallet(n0)
  73  
  74          # Mock time for a deterministic chain
  75          for n in self.nodes:
  76              n.setmocktime(n.getblockheader(n.getbestblockhash())['time'])
  77  
  78          # Create a wallet that we will create a backup for later (at snapshot height)
  79          n0.createwallet('w')
  80          w = n0.get_wallet_rpc("w")
  81          w_address = w.getnewaddress()
  82  
  83          # Create another wallet and backup now (before snapshot height)
  84          n0.createwallet('w2')
  85          w2 = n0.get_wallet_rpc("w2")
  86          w2_address = w2.getnewaddress()
  87          w2.backupwallet("backup_w2.dat")
  88  
  89          # Generate a series of blocks that `n0` will have in the snapshot,
  90          # but that n1 doesn't yet see. In order for the snapshot to activate,
  91          # though, we have to ferry over the new headers to n1 so that it
  92          # isn't waiting forever to see the header of the snapshot's base block
  93          # while disconnected from n0.
  94          for i in range(100):
  95              if i % 3 == 0:
  96                  self.mini_wallet.send_self_transfer(from_node=n0)
  97              self.generate(n0, nblocks=1, sync_fun=self.no_op)
  98              newblock = n0.getblock(n0.getbestblockhash(), 0)
  99  
 100              # make n1 aware of the new header, but don't give it the block.
 101              n1.submitheader(newblock)
 102              n2.submitheader(newblock)
 103  
 104          # Ensure everyone is seeing the same headers.
 105          for n in self.nodes:
 106              assert_equal(n.getblockchaininfo()[
 107                           "headers"], SNAPSHOT_BASE_HEIGHT)
 108  
 109          # This backup is created at the snapshot height, so it's
 110          # not part of the background sync anymore
 111          w.backupwallet("backup_w.dat")
 112  
 113          self.log.info("-- Testing assumeutxo")
 114  
 115          assert_equal(n0.getblockcount(), SNAPSHOT_BASE_HEIGHT)
 116          assert_equal(n1.getblockcount(), START_HEIGHT)
 117  
 118          self.log.info(
 119              f"Creating a UTXO snapshot at height {SNAPSHOT_BASE_HEIGHT}")
 120          dump_output = n0.dumptxoutset('utxos.dat', "latest")
 121  
 122          assert_equal(
 123              dump_output['txoutset_hash'],
 124              "a4bf3407ccb2cc0145c49ebba8fa91199f8a3903daf0883875941497d2493c27")
 125          assert_equal(dump_output["nchaintx"], 334)
 126          assert_equal(n0.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
 127  
 128          # Mine more blocks on top of the snapshot that n1 hasn't yet seen. This
 129          # will allow us to test n1's sync-to-tip on top of a snapshot.
 130          w_skp = address_to_scriptpubkey(w_address)
 131          w2_skp = address_to_scriptpubkey(w2_address)
 132          for i in range(100):
 133              if i % 3 == 0:
 134                  self.mini_wallet.send_to(from_node=n0, scriptPubKey=w_skp, amount=1 * COIN)
 135                  self.mini_wallet.send_to(from_node=n0, scriptPubKey=w2_skp, amount=10 * COIN)
 136              self.generate(n0, nblocks=1, sync_fun=self.no_op)
 137  
 138          assert_equal(n0.getblockcount(), FINAL_HEIGHT)
 139          assert_equal(n1.getblockcount(), START_HEIGHT)
 140          assert_equal(n2.getblockcount(), START_HEIGHT)
 141  
 142          assert_equal(n0.getblockchaininfo()["blocks"], FINAL_HEIGHT)
 143  
 144          self.log.info(
 145              f"Loading snapshot into second node from {dump_output['path']}")
 146          loaded = n1.loadtxoutset(dump_output['path'])
 147          assert_equal(loaded['coins_loaded'], SNAPSHOT_BASE_HEIGHT)
 148          assert_equal(loaded['base_height'], SNAPSHOT_BASE_HEIGHT)
 149  
 150          normal, snapshot = n1.getchainstates()["chainstates"]
 151          assert_equal(normal['blocks'], START_HEIGHT)
 152          assert_equal(normal.get('snapshot_blockhash'), None)
 153          assert_equal(normal['validated'], True)
 154          assert_equal(snapshot['blocks'], SNAPSHOT_BASE_HEIGHT)
 155          assert_equal(snapshot['snapshot_blockhash'], dump_output['base_hash'])
 156          assert_equal(snapshot['validated'], False)
 157  
 158          assert_equal(n1.getblockchaininfo()["blocks"], SNAPSHOT_BASE_HEIGHT)
 159  
 160          self.log.info("Backup from the snapshot height can be loaded during background sync")
 161          n1.restorewallet("w", "backup_w.dat")
 162          # Balance of w wallet is still still 0 because n1 has not synced yet
 163          assert_equal(n1.getbalance(), 0)
 164  
 165          self.log.info("Backup from before the snapshot height can't be loaded during background sync")
 166          assert_raises_rpc_error(-4, "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 299", n1.restorewallet, "w2", "backup_w2.dat")
 167  
 168          self.log.info("Test loading descriptors during background sync")
 169          wallet_name = "w1"
 170          n1.createwallet(wallet_name, disable_private_keys=True)
 171          key = get_generate_key()
 172          time = n1.getblockchaininfo()['time']
 173          timestamp = 0
 174          expected_error_message = 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."
 175          result = self.import_descriptor(n1, wallet_name, key, timestamp)
 176          assert_equal(result[0]['error']['code'], -1)
 177          assert_equal(result[0]['error']['message'], expected_error_message)
 178  
 179          self.log.info("Test that rescanning blocks from before the snapshot fails when blocks are not available from the background sync yet")
 180          w1 = n1.get_wallet_rpc(wallet_name)
 181          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)
 182  
 183          PAUSE_HEIGHT = FINAL_HEIGHT - 40
 184  
 185          self.log.info("Restarting node to stop at height %d", PAUSE_HEIGHT)
 186          self.restart_node(1, extra_args=[
 187              f"-stopatheight={PAUSE_HEIGHT}", *self.extra_args[1]])
 188  
 189          # Finally connect the nodes and let them sync.
 190          #
 191          # Set `wait_for_connect=False` to avoid a race between performing connection
 192          # assertions and the -stopatheight tripping.
 193          self.connect_nodes(0, 1, wait_for_connect=False)
 194  
 195          n1.wait_until_stopped(timeout=5)
 196  
 197          self.log.info(
 198              "Restarted node before snapshot validation completed, reloading...")
 199          self.restart_node(1, extra_args=self.extra_args[1])
 200  
 201          # TODO: inspect state of e.g. the wallet before reconnecting
 202          self.connect_nodes(0, 1)
 203  
 204          self.log.info(
 205              f"Ensuring snapshot chain syncs to tip. ({FINAL_HEIGHT})")
 206          self.wait_until(lambda: n1.getchainstates()[
 207                          'chainstates'][-1]['blocks'] == FINAL_HEIGHT)
 208          self.sync_blocks(nodes=(n0, n1))
 209  
 210          self.log.info("Ensuring background validation completes")
 211          self.wait_until(lambda: len(n1.getchainstates()['chainstates']) == 1)
 212  
 213          self.log.info("Ensuring wallet can be restored from a backup that was created before the snapshot height")
 214          n1.restorewallet("w2", "backup_w2.dat")
 215          # Check balance of w2 wallet
 216          assert_equal(n1.getbalance(), 340)
 217  
 218          # Check balance of w wallet after node is synced
 219          n1.loadwallet("w")
 220          w = n1.get_wallet_rpc("w")
 221          assert_equal(w.getbalance(), 34)
 222  
 223          self.log.info("Check balance of a wallet that is active during snapshot completion")
 224          n2.restorewallet("w", "backup_w.dat")
 225          loaded = n2.loadtxoutset(dump_output['path'])
 226          self.connect_nodes(0, 2)
 227          self.wait_until(lambda: len(n2.getchainstates()['chainstates']) == 1)
 228          ensure_for(duration=1, f=lambda: (n2.getbalance() == 34))
 229  
 230          self.log.info("Ensuring descriptors can be loaded after background sync")
 231          n1.loadwallet(wallet_name)
 232          result = self.import_descriptor(n1, wallet_name, key, timestamp)
 233          assert_equal(result[0]['success'], True)
 234  
 235  
 236  if __name__ == '__main__':
 237      AssumeutxoTest(__file__).main()
 238