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