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()