feature_coinstatsindex.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2020-2022 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 coinstatsindex across nodes.
   6  
   7  Test that the values returned by gettxoutsetinfo are consistent
   8  between a node running the coinstatsindex and a node without
   9  the index.
  10  """
  11  
  12  from decimal import Decimal
  13  
  14  from test_framework.blocktools import (
  15      COINBASE_MATURITY,
  16      create_block,
  17      create_coinbase,
  18  )
  19  from test_framework.messages import (
  20      COIN,
  21      CTxOut,
  22  )
  23  from test_framework.script import (
  24      CScript,
  25      OP_FALSE,
  26      OP_RETURN,
  27  )
  28  from test_framework.test_framework import LimenkaTestFramework
  29  from test_framework.util import (
  30      assert_equal,
  31      assert_raises_rpc_error,
  32  )
  33  from test_framework.wallet import (
  34      MiniWallet,
  35      getnewdestination,
  36  )
  37  
  38  
  39  class CoinStatsIndexTest(LimenkaTestFramework):
  40      def set_test_params(self):
  41          self.setup_clean_chain = True
  42          self.num_nodes = 2
  43          self.supports_cli = False
  44          self.extra_args = [
  45              [],
  46              ["-coinstatsindex"]
  47          ]
  48  
  49      def run_test(self):
  50          self.wallet = MiniWallet(self.nodes[0])
  51          self._test_coin_stats_index()
  52          self._test_use_index_option()
  53          self._test_reorg_index()
  54          self._test_index_rejects_hash_serialized()
  55          self._test_init_index_after_reorg()
  56  
  57      def block_sanity_check(self, block_info):
  58          block_subsidy = 50
  59          assert_equal(
  60              block_info['prevout_spent'] + block_subsidy,
  61              block_info['new_outputs_ex_coinbase'] + block_info['coinbase'] + block_info['unspendable']
  62          )
  63  
  64      def sync_index_node(self):
  65          self.wait_until(lambda: self.nodes[1].getindexinfo()['coinstatsindex']['synced'] is True)
  66  
  67      def _test_coin_stats_index(self):
  68          node = self.nodes[0]
  69          index_node = self.nodes[1]
  70          # Both none and muhash options allow the usage of the index
  71          index_hash_options = ['none', 'muhash']
  72  
  73          # Generate a normal transaction and mine it
  74          self.generate(self.wallet, COINBASE_MATURITY + 1)
  75          self.wallet.send_self_transfer(from_node=node)
  76          self.generate(node, 1)
  77  
  78          self.log.info("Test that gettxoutsetinfo() output is consistent with or without coinstatsindex option")
  79          res0 = node.gettxoutsetinfo('none')
  80  
  81          # The fields 'disk_size' and 'transactions' do not exist on the index
  82          del res0['disk_size'], res0['transactions']
  83  
  84          for hash_option in index_hash_options:
  85              res1 = index_node.gettxoutsetinfo(hash_option)
  86              # The fields 'block_info' and 'total_unspendable_amount' only exist on the index
  87              del res1['block_info'], res1['total_unspendable_amount']
  88              res1.pop('muhash', None)
  89  
  90              # Everything left should be the same
  91              assert_equal(res1, res0)
  92  
  93          self.log.info("Test that gettxoutsetinfo() can get fetch data on specific heights with index")
  94  
  95          # Generate a new tip
  96          self.generate(node, 5)
  97  
  98          for hash_option in index_hash_options:
  99              # Fetch old stats by height
 100              res2 = index_node.gettxoutsetinfo(hash_option, 102)
 101              del res2['block_info'], res2['total_unspendable_amount']
 102              res2.pop('muhash', None)
 103              assert_equal(res0, res2)
 104  
 105              # Fetch old stats by hash
 106              res3 = index_node.gettxoutsetinfo(hash_option, res0['bestblock'])
 107              del res3['block_info'], res3['total_unspendable_amount']
 108              res3.pop('muhash', None)
 109              assert_equal(res0, res3)
 110  
 111              # It does not work without coinstatsindex
 112              assert_raises_rpc_error(-8, "Querying specific block heights requires coinstatsindex", node.gettxoutsetinfo, hash_option, 102)
 113  
 114          self.log.info("Test gettxoutsetinfo() with index and verbose flag")
 115  
 116          for hash_option in index_hash_options:
 117              # Genesis block is unspendable
 118              res4 = index_node.gettxoutsetinfo(hash_option, 0)
 119              assert_equal(res4['total_unspendable_amount'], 50)
 120              assert_equal(res4['block_info'], {
 121                  'unspendable': 50,
 122                  'prevout_spent': 0,
 123                  'new_outputs_ex_coinbase': 0,
 124                  'coinbase': 0,
 125                  'unspendables': {
 126                      'genesis_block': 50,
 127                      'bip30': 0,
 128                      'scripts': 0,
 129                      'unclaimed_rewards': 0
 130                  }
 131              })
 132              self.block_sanity_check(res4['block_info'])
 133  
 134              # Test an older block height that included a normal tx
 135              res5 = index_node.gettxoutsetinfo(hash_option, 102)
 136              assert_equal(res5['total_unspendable_amount'], 50)
 137              assert_equal(res5['block_info'], {
 138                  'unspendable': 0,
 139                  'prevout_spent': 50,
 140                  'new_outputs_ex_coinbase': Decimal('49.99968800'),
 141                  'coinbase': Decimal('50.00031200'),
 142                  'unspendables': {
 143                      'genesis_block': 0,
 144                      'bip30': 0,
 145                      'scripts': 0,
 146                      'unclaimed_rewards': 0,
 147                  }
 148              })
 149              self.block_sanity_check(res5['block_info'])
 150  
 151          # Generate and send a normal tx with two outputs
 152          tx1 = self.wallet.send_to(
 153              from_node=node,
 154              scriptPubKey=self.wallet.get_output_script(),
 155              amount=21 * COIN,
 156          )
 157  
 158          # Find the right position of the 21 BTC output
 159          tx1_out_21 = self.wallet.get_utxo(txid=tx1["txid"], vout=tx1["sent_vout"])
 160  
 161          # Generate and send another tx with an OP_RETURN output (which is unspendable)
 162          tx2 = self.wallet.create_self_transfer(utxo_to_spend=tx1_out_21)['tx']
 163          tx2_val = '20.99'
 164          tx2.vout = [CTxOut(int(Decimal(tx2_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
 165          tx2_hex = tx2.serialize().hex()
 166          self.nodes[0].sendrawtransaction(tx2_hex, 0, tx2_val)
 167  
 168          # Include both txs in a block
 169          self.generate(self.nodes[0], 1)
 170  
 171          for hash_option in index_hash_options:
 172              # Check all amounts were registered correctly
 173              res6 = index_node.gettxoutsetinfo(hash_option, 108)
 174              assert_equal(res6['total_unspendable_amount'], Decimal('70.99000000'))
 175              assert_equal(res6['block_info'], {
 176                  'unspendable': Decimal('20.99000000'),
 177                  'prevout_spent': 71,
 178                  'new_outputs_ex_coinbase': Decimal('49.99999000'),
 179                  'coinbase': Decimal('50.01001000'),
 180                  'unspendables': {
 181                      'genesis_block': 0,
 182                      'bip30': 0,
 183                      'scripts': Decimal('20.99000000'),
 184                      'unclaimed_rewards': 0,
 185                  }
 186              })
 187              self.block_sanity_check(res6['block_info'])
 188  
 189          # Create a coinbase that does not claim full subsidy and also
 190          # has two outputs
 191          cb = create_coinbase(109, nValue=35)
 192          cb.vout.append(CTxOut(5 * COIN, CScript([OP_FALSE])))
 193          cb.rehash()
 194  
 195          # Generate a block that includes previous coinbase
 196          tip = self.nodes[0].getbestblockhash()
 197          block_time = self.nodes[0].getblock(tip)['time'] + 1
 198          block = create_block(int(tip, 16), cb, block_time)
 199          block.solve()
 200          self.nodes[0].submitblock(block.serialize().hex())
 201          self.sync_all()
 202  
 203          for hash_option in index_hash_options:
 204              res7 = index_node.gettxoutsetinfo(hash_option, 109)
 205              assert_equal(res7['total_unspendable_amount'], Decimal('80.99000000'))
 206              assert_equal(res7['block_info'], {
 207                  'unspendable': 10,
 208                  'prevout_spent': 0,
 209                  'new_outputs_ex_coinbase': 0,
 210                  'coinbase': 40,
 211                  'unspendables': {
 212                      'genesis_block': 0,
 213                      'bip30': 0,
 214                      'scripts': 0,
 215                      'unclaimed_rewards': 10
 216                  }
 217              })
 218              self.block_sanity_check(res7['block_info'])
 219  
 220          self.log.info("Test that the index is robust across restarts")
 221  
 222          res8 = index_node.gettxoutsetinfo('muhash')
 223          self.restart_node(1, extra_args=self.extra_args[1])
 224          res9 = index_node.gettxoutsetinfo('muhash')
 225          assert_equal(res8, res9)
 226  
 227          self.generate(index_node, 1, sync_fun=self.no_op)
 228          res10 = index_node.gettxoutsetinfo('muhash')
 229          assert res8['txouts'] < res10['txouts']
 230  
 231          self.log.info("Test that the index works with -reindex")
 232  
 233          self.restart_node(1, extra_args=["-coinstatsindex", "-reindex"])
 234          self.sync_index_node()
 235          res11 = index_node.gettxoutsetinfo('muhash')
 236          assert_equal(res11, res10)
 237  
 238          self.log.info("Test that the index works with -reindex-chainstate")
 239  
 240          self.restart_node(1, extra_args=["-coinstatsindex", "-reindex-chainstate"])
 241          self.sync_index_node()
 242          res12 = index_node.gettxoutsetinfo('muhash')
 243          assert_equal(res12, res10)
 244  
 245          self.log.info("Test obtaining info for a non-existent block hash")
 246          assert_raises_rpc_error(-5, "Block not found", index_node.gettxoutsetinfo, hash_type="none", hash_or_height="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", use_index=True)
 247  
 248      def _test_use_index_option(self):
 249          self.log.info("Test use_index option for nodes running the index")
 250  
 251          self.connect_nodes(0, 1)
 252          self.nodes[0].waitforblockheight(110)
 253          res = self.nodes[0].gettxoutsetinfo('muhash')
 254          option_res = self.nodes[1].gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
 255          del res['disk_size'], option_res['disk_size']
 256          assert_equal(res, option_res)
 257  
 258      def _test_reorg_index(self):
 259          self.log.info("Test that index can handle reorgs")
 260  
 261          # Generate two block, let the index catch up, then invalidate the blocks
 262          index_node = self.nodes[1]
 263          reorg_blocks = self.generatetoaddress(index_node, 2, getnewdestination()[2])
 264          reorg_block = reorg_blocks[1]
 265          self.sync_index_node()
 266          res_invalid = index_node.gettxoutsetinfo('muhash')
 267          index_node.invalidateblock(reorg_blocks[0])
 268          assert_equal(index_node.gettxoutsetinfo('muhash')['height'], 110)
 269  
 270          # Add two new blocks
 271          block = self.generate(index_node, 2, sync_fun=self.no_op)[1]
 272          res = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
 273  
 274          # Test that the result of the reorged block is not returned for its old block height
 275          res2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=112)
 276          assert_equal(res["bestblock"], block)
 277          assert_equal(res["muhash"], res2["muhash"])
 278          assert res["muhash"] != res_invalid["muhash"]
 279  
 280          # Test that requesting reorged out block by hash is still returning correct results
 281          res_invalid2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=reorg_block)
 282          assert_equal(res_invalid2["muhash"], res_invalid["muhash"])
 283          assert res["muhash"] != res_invalid2["muhash"]
 284  
 285          # Add another block, so we don't depend on reconsiderblock remembering which
 286          # blocks were touched by invalidateblock
 287          self.generate(index_node, 1)
 288  
 289          # Ensure that removing and re-adding blocks yields consistent results
 290          block = index_node.getblockhash(99)
 291          index_node.invalidateblock(block)
 292          index_node.reconsiderblock(block)
 293          res3 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=112)
 294          assert_equal(res2, res3)
 295  
 296      def _test_index_rejects_hash_serialized(self):
 297          self.log.info("Test that the rpc raises if the legacy hash is passed with the index")
 298  
 299          msg = "hash_serialized_3 hash type cannot be queried for a specific block"
 300          assert_raises_rpc_error(-8, msg, self.nodes[1].gettxoutsetinfo, hash_type='hash_serialized_3', hash_or_height=111)
 301  
 302          for use_index in {True, False, None}:
 303              assert_raises_rpc_error(-8, msg, self.nodes[1].gettxoutsetinfo, hash_type='hash_serialized_3', hash_or_height=111, use_index=use_index)
 304  
 305      def _test_init_index_after_reorg(self):
 306          self.log.info("Test a reorg while the index is deactivated")
 307          index_node = self.nodes[1]
 308          block = self.nodes[0].getbestblockhash()
 309          self.generate(index_node, 2, sync_fun=self.no_op)
 310          self.sync_index_node()
 311  
 312          # Restart without index
 313          self.restart_node(1, extra_args=[])
 314          self.connect_nodes(0, 1)
 315          index_node.invalidateblock(block)
 316          self.generatetoaddress(index_node, 5, getnewdestination()[2])
 317          res = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
 318  
 319          # Restart with index that still has its best block on the old chain
 320          self.restart_node(1, extra_args=self.extra_args[1])
 321          self.sync_index_node()
 322          res1 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=True)
 323          assert_equal(res["muhash"], res1["muhash"])
 324  
 325          self.log.info("Test index with an unclean restart after a reorg")
 326          self.restart_node(1, extra_args=self.extra_args[1])
 327          committed_height = index_node.getblockcount()
 328          self.generate(index_node, 2, sync_fun=self.no_op)
 329          self.sync_index_node()
 330          block2 = index_node.getbestblockhash()
 331          index_node.invalidateblock(block2)
 332          self.generatetoaddress(index_node, 1, getnewdestination()[2], sync_fun=self.no_op)
 333          self.sync_index_node()
 334          index_node.kill_process()
 335          self.start_node(1, extra_args=self.extra_args[1])
 336          self.sync_index_node()
 337          # Because of the unclean shutdown above, indexes reset to the point we last committed them to disk.
 338          assert_equal(index_node.getindexinfo()['coinstatsindex']['best_block_height'], committed_height)
 339  
 340  
 341  if __name__ == '__main__':
 342      CoinStatsIndexTest(__file__).main()
 343