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