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