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