wallet_reorgsrestore.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2019-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  
   6  """Test tx status in case of reorgs while wallet being shutdown.
   7  
   8  Wallet txn status rely on block connection/disconnection for its
   9  accuracy. In case of reorgs happening while wallet being shutdown
  10  block updates are not going to be received. At wallet loading, we
  11  check against chain if confirmed txn are still in chain and change
  12  their status if block in which they have been included has been
  13  disconnected.
  14  """
  15  
  16  from decimal import Decimal
  17  import shutil
  18  
  19  from test_framework.test_framework import LimenkaTestFramework
  20  from test_framework.util import (
  21          assert_equal,
  22          assert_greater_than,
  23          assert_raises_rpc_error
  24  )
  25  
  26  class ReorgsRestoreTest(LimenkaTestFramework):
  27      def add_options(self, parser):
  28          self.add_wallet_options(parser)
  29  
  30      def set_test_params(self):
  31          self.num_nodes = 3
  32  
  33      def skip_test_if_missing_module(self):
  34          self.skip_if_no_wallet()
  35  
  36      def test_coinbase_automatic_abandon_during_startup(self):
  37          ##########################################################################################################
  38          # Verify the wallet marks coinbase transactions, and their descendants, as abandoned during startup when #
  39          # the block is no longer part of the best chain.                                                         #
  40          ##########################################################################################################
  41          self.log.info("Test automatic coinbase abandonment during startup")
  42          # Test setup: Sync nodes for the coming test, ensuring both are at the same block, then disconnect them to
  43          # generate two competing chains. After disconnection, verify no other peer connection exists.
  44          self.connect_nodes(1, 0)
  45          self.sync_blocks(self.nodes[:2])
  46          self.disconnect_nodes(1, 0)
  47          assert all(len(node.getpeerinfo()) == 0 for node in self.nodes[:2])
  48  
  49          # Create a new block in node0, coinbase going to wallet0
  50          self.nodes[0].createwallet(wallet_name="w0", load_on_startup=True)
  51          wallet0 = self.nodes[0].get_wallet_rpc("w0")
  52          self.generatetoaddress(self.nodes[0], 1, wallet0.getnewaddress(), sync_fun=self.no_op)
  53          node0_coinbase_tx_hash = wallet0.getblock(wallet0.getbestblockhash(), verbose=1)['tx'][0]
  54  
  55          # Mine 100 blocks on top to mature the coinbase and create a descendant
  56          self.generate(self.nodes[0], 101, sync_fun=self.no_op)
  57          # Make descendant, send-to-self
  58          descendant_tx_id = wallet0.sendtoaddress(wallet0.getnewaddress(), 1)
  59  
  60          # Verify balance
  61          wallet0.syncwithvalidationinterfacequeue()
  62          assert(wallet0.getbalances()['mine']['trusted'] > 0)
  63  
  64          # Now create a fork in node1. This will be used to replace node0's chain later.
  65          self.nodes[1].createwallet(wallet_name="w1", load_on_startup=True)
  66          wallet1 = self.nodes[1].get_wallet_rpc("w1")
  67          self.generatetoaddress(self.nodes[1], 1, wallet1.getnewaddress(), sync_fun=self.no_op)
  68          wallet1.syncwithvalidationinterfacequeue()
  69  
  70          # Verify both nodes are on a different chain
  71          block0_best_hash, block1_best_hash = wallet0.getbestblockhash(), wallet1.getbestblockhash()
  72          assert(block0_best_hash != block1_best_hash)
  73  
  74          # Stop both nodes and replace node0 chain entirely for the node1 chain
  75          self.stop_nodes()
  76          for path in ["chainstate", "blocks"]:
  77              shutil.rmtree(self.nodes[0].chain_path / path)
  78              shutil.copytree(self.nodes[1].chain_path / path, self.nodes[0].chain_path / path)
  79  
  80          # Start node0 and verify that now it has node1 chain and no info about its previous best block
  81          self.start_node(0)
  82          wallet0 = self.nodes[0].get_wallet_rpc("w0")
  83          assert_equal(wallet0.getbestblockhash(), block1_best_hash)
  84          assert_raises_rpc_error(-5, "Block not found", wallet0.getblock, block0_best_hash)
  85  
  86          # Verify the coinbase tx was marked as abandoned and balance correctly computed
  87          tx_info = wallet0.gettransaction(node0_coinbase_tx_hash)['details'][0]
  88          assert_equal(tx_info['abandoned'], True)
  89          assert_equal(tx_info['category'], 'orphan')
  90          assert(wallet0.getbalances()['mine']['trusted'] == 0)
  91          # Verify the coinbase descendant was also marked as abandoned
  92          assert_equal(wallet0.gettransaction(descendant_tx_id)['details'][0]['abandoned'], True)
  93  
  94      def test_reorg_handling_during_unclean_shutdown(self):
  95          self.log.info("Test that wallet transactions are un-abandoned in case of temporarily invalidated blocks and wallet doesn't crash due to a duplicate block disconnection event after an unclean shutdown")
  96          node = self.nodes[0]
  97          # Receive coinbase reward on a new wallet
  98          node.createwallet(wallet_name="reorg_crash", load_on_startup=True)
  99          wallet = node.get_wallet_rpc("reorg_crash")
 100          self.generatetoaddress(node, 1, wallet.getnewaddress(), sync_fun=self.no_op)
 101  
 102          # Restart to ensure node and wallet are flushed
 103          self.restart_node(0)
 104          wallet = node.get_wallet_rpc("reorg_crash")
 105          assert_greater_than(wallet.getwalletinfo()['immature_balance'], 0)
 106  
 107          # Disconnect tip and sync wallet state
 108          tip = wallet.getbestblockhash()
 109          tip_height = wallet.getblockstats(tip)["height"]
 110          wallet.invalidateblock(tip)
 111          wallet.syncwithvalidationinterfacequeue()
 112  
 113          # Tip was disconnected, ensure coinbase has been abandoned
 114          assert_equal(wallet.getwalletinfo()['immature_balance'], 0)
 115          coinbase_tx_id = wallet.getblock(tip, verbose=1)["tx"][0]
 116          assert_equal(wallet.gettransaction(coinbase_tx_id)['details'][0]['abandoned'], True)
 117  
 118          # Abort process abruptly to mimic an unclean shutdown (no chain state flush to disk)
 119          node.kill_process()
 120  
 121          # Restart the node and confirm that it has not persisted the last chain state changes to disk
 122          # that leads to a rescan by the wallet
 123          with self.nodes[0].assert_debug_log(expected_msgs=[f"Rescanning last 1 blocks (from block {tip_height - 1})...\n"]):
 124              self.start_node(0)
 125          assert_equal(node.getbestblockhash(), tip)
 126  
 127          # After disconnecting the block, the wallet should record the new best block.
 128          # Upon reload after the crash, since the chainstate was not flushed, the tip contains the previously abandoned
 129          # coinbase. This was rescanned and now un-abandoned.
 130          wallet = node.get_wallet_rpc("reorg_crash")
 131          assert_equal(wallet.gettransaction(coinbase_tx_id)['details'][0]['abandoned'], False)
 132          assert_greater_than(wallet.getbalances()["mine"]["immature"], 0)
 133  
 134          # Previously, a bug caused the node to crash if two block disconnection events occurred consecutively.
 135          # Ensure this is no longer the case by simulating a new reorg.
 136          node.invalidateblock(tip)
 137          assert(node.getbestblockhash() != tip)
 138          # Ensure wallet state is consistent now
 139          assert_equal(wallet.gettransaction(coinbase_tx_id)['details'][0]['abandoned'], True)
 140          assert_equal(wallet.getwalletinfo()['immature_balance'], 0)
 141  
 142          # And finally, verify the state if the block ends up being into the best chain again
 143          node.reconsiderblock(tip)
 144          assert_equal(wallet.gettransaction(coinbase_tx_id)['details'][0]['abandoned'], False)
 145          assert_greater_than(wallet.getwalletinfo()['immature_balance'], 0)
 146  
 147      def run_test(self):
 148          # Send a tx from which to conflict outputs later
 149          txid_conflict_from = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), Decimal("10"))
 150          self.generate(self.nodes[0], 1)
 151  
 152          # Disconnect node1 from others to reorg its chain later
 153          self.disconnect_nodes(0, 1)
 154          self.disconnect_nodes(1, 2)
 155          self.connect_nodes(0, 2)
 156  
 157          # Send a tx to be unconfirmed later
 158          txid = self.nodes[0].sendtoaddress(self.nodes[0].getnewaddress(), Decimal("10"))
 159          tx = self.nodes[0].gettransaction(txid)
 160          self.generate(self.nodes[0], 4, sync_fun=self.no_op)
 161          self.sync_blocks([self.nodes[0], self.nodes[2]])
 162          tx_before_reorg = self.nodes[0].gettransaction(txid)
 163          assert_equal(tx_before_reorg["confirmations"], 4)
 164  
 165          # Disconnect node0 from node2 to broadcast a conflict on their respective chains
 166          self.disconnect_nodes(0, 2)
 167          nA = next(tx_out["vout"] for tx_out in self.nodes[0].gettransaction(txid_conflict_from)["details"] if tx_out["amount"] == Decimal("10"))
 168          inputs = []
 169          inputs.append({"txid": txid_conflict_from, "vout": nA})
 170          outputs_1 = {}
 171          outputs_2 = {}
 172  
 173          # Create a conflicted tx broadcast on node0 chain and conflicting tx broadcast on node1 chain. Both spend from txid_conflict_from
 174          outputs_1[self.nodes[0].getnewaddress()] = Decimal("9.99998")
 175          outputs_2[self.nodes[0].getnewaddress()] = Decimal("9.99998")
 176          conflicted = self.nodes[0].signrawtransactionwithwallet(self.nodes[0].createrawtransaction(inputs, outputs_1))
 177          conflicting = self.nodes[0].signrawtransactionwithwallet(self.nodes[0].createrawtransaction(inputs, outputs_2))
 178  
 179          conflicted_txid = self.nodes[0].sendrawtransaction(conflicted["hex"])
 180          self.generate(self.nodes[0], 1, sync_fun=self.no_op)
 181          conflicting_txid = self.nodes[2].sendrawtransaction(conflicting["hex"])
 182          self.generate(self.nodes[2], 9, sync_fun=self.no_op)
 183  
 184          # Reconnect node0 and node2 and check that conflicted_txid is effectively conflicted
 185          self.connect_nodes(0, 2)
 186          self.sync_blocks([self.nodes[0], self.nodes[2]])
 187          conflicted = self.nodes[0].gettransaction(conflicted_txid)
 188          conflicting = self.nodes[0].gettransaction(conflicting_txid)
 189          assert_equal(conflicted["confirmations"], -9)
 190          assert_equal(conflicted["walletconflicts"][0], conflicting["txid"])
 191  
 192          # Node0 wallet is shutdown
 193          self.restart_node(0)
 194  
 195          # The block chain re-orgs and the tx is included in a different block
 196          self.generate(self.nodes[1], 9, sync_fun=self.no_op)
 197          self.nodes[1].sendrawtransaction(tx["hex"])
 198          self.generate(self.nodes[1], 1, sync_fun=self.no_op)
 199          self.nodes[1].sendrawtransaction(conflicted["hex"])
 200          self.generate(self.nodes[1], 1, sync_fun=self.no_op)
 201  
 202          # Node0 wallet file is loaded on longest sync'ed node1
 203          self.stop_node(1)
 204          self.nodes[0].backupwallet(self.nodes[0].datadir_path / 'wallet.bak')
 205          shutil.copyfile(self.nodes[0].datadir_path / 'wallet.bak', self.nodes[1].chain_path / self.default_wallet_name / self.wallet_data_filename)
 206          self.start_node(1)
 207          tx_after_reorg = self.nodes[1].gettransaction(txid)
 208          # Check that normal confirmed tx is confirmed again but with different blockhash
 209          assert_equal(tx_after_reorg["confirmations"], 2)
 210          assert tx_before_reorg["blockhash"] != tx_after_reorg["blockhash"]
 211          conflicted_after_reorg = self.nodes[1].gettransaction(conflicted_txid)
 212          # Check that conflicted tx is confirmed again with blockhash different than previously conflicting tx
 213          assert_equal(conflicted_after_reorg["confirmations"], 1)
 214          assert conflicting["blockhash"] != conflicted_after_reorg["blockhash"]
 215  
 216          # Verify we mark coinbase txs, and their descendants, as abandoned during startup
 217          self.test_coinbase_automatic_abandon_during_startup()
 218  
 219          # Verify reorg behavior during an unclean shutdown
 220          self.test_reorg_handling_during_unclean_shutdown()
 221  
 222  
 223  if __name__ == '__main__':
 224      ReorgsRestoreTest(__file__).main()
 225