feature_coinstatsindex.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2020-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 """Test coinstatsindex across nodes.
6
7 Test that the values returned by gettxoutsetinfo are consistent
8 between a node running the coinstatsindex and a node without
9 the index.
10 """
11
12 from decimal import Decimal
13
14 from test_framework.blocktools import (
15 COINBASE_MATURITY,
16 create_block,
17 create_coinbase,
18 )
19 from test_framework.messages import (
20 COIN,
21 CTxOut,
22 )
23 from test_framework.script import (
24 CScript,
25 OP_FALSE,
26 OP_RETURN,
27 )
28 from test_framework.test_framework import LimenkaTestFramework
29 from test_framework.util import (
30 assert_equal,
31 assert_raises_rpc_error,
32 )
33 from test_framework.wallet import (
34 MiniWallet,
35 getnewdestination,
36 )
37
38
39 class CoinStatsIndexTest(LimenkaTestFramework):
40 def set_test_params(self):
41 self.setup_clean_chain = True
42 self.num_nodes = 2
43 self.supports_cli = False
44 self.extra_args = [
45 [],
46 ["-coinstatsindex"]
47 ]
48
49 def run_test(self):
50 self.wallet = MiniWallet(self.nodes[0])
51 self._test_coin_stats_index()
52 self._test_use_index_option()
53 self._test_reorg_index()
54 self._test_index_rejects_hash_serialized()
55 self._test_init_index_after_reorg()
56
57 def block_sanity_check(self, block_info):
58 block_subsidy = 50
59 assert_equal(
60 block_info['prevout_spent'] + block_subsidy,
61 block_info['new_outputs_ex_coinbase'] + block_info['coinbase'] + block_info['unspendable']
62 )
63
64 def sync_index_node(self):
65 self.wait_until(lambda: self.nodes[1].getindexinfo()['coinstatsindex']['synced'] is True)
66
67 def _test_coin_stats_index(self):
68 node = self.nodes[0]
69 index_node = self.nodes[1]
70 # Both none and muhash options allow the usage of the index
71 index_hash_options = ['none', 'muhash']
72
73 # Generate a normal transaction and mine it
74 self.generate(self.wallet, COINBASE_MATURITY + 1)
75 self.wallet.send_self_transfer(from_node=node)
76 self.generate(node, 1)
77
78 self.log.info("Test that gettxoutsetinfo() output is consistent with or without coinstatsindex option")
79 res0 = node.gettxoutsetinfo('none')
80
81 # The fields 'disk_size' and 'transactions' do not exist on the index
82 del res0['disk_size'], res0['transactions']
83
84 for hash_option in index_hash_options:
85 res1 = index_node.gettxoutsetinfo(hash_option)
86 # The fields 'block_info' and 'total_unspendable_amount' only exist on the index
87 del res1['block_info'], res1['total_unspendable_amount']
88 res1.pop('muhash', None)
89
90 # Everything left should be the same
91 assert_equal(res1, res0)
92
93 self.log.info("Test that gettxoutsetinfo() can get fetch data on specific heights with index")
94
95 # Generate a new tip
96 self.generate(node, 5)
97
98 for hash_option in index_hash_options:
99 # Fetch old stats by height
100 res2 = index_node.gettxoutsetinfo(hash_option, 102)
101 del res2['block_info'], res2['total_unspendable_amount']
102 res2.pop('muhash', None)
103 assert_equal(res0, res2)
104
105 # Fetch old stats by hash
106 res3 = index_node.gettxoutsetinfo(hash_option, res0['bestblock'])
107 del res3['block_info'], res3['total_unspendable_amount']
108 res3.pop('muhash', None)
109 assert_equal(res0, res3)
110
111 # It does not work without coinstatsindex
112 assert_raises_rpc_error(-8, "Querying specific block heights requires coinstatsindex", node.gettxoutsetinfo, hash_option, 102)
113
114 self.log.info("Test gettxoutsetinfo() with index and verbose flag")
115
116 for hash_option in index_hash_options:
117 # Genesis block is unspendable
118 res4 = index_node.gettxoutsetinfo(hash_option, 0)
119 assert_equal(res4['total_unspendable_amount'], 50)
120 assert_equal(res4['block_info'], {
121 'unspendable': 50,
122 'prevout_spent': 0,
123 'new_outputs_ex_coinbase': 0,
124 'coinbase': 0,
125 'unspendables': {
126 'genesis_block': 50,
127 'bip30': 0,
128 'scripts': 0,
129 'unclaimed_rewards': 0
130 }
131 })
132 self.block_sanity_check(res4['block_info'])
133
134 # Test an older block height that included a normal tx
135 res5 = index_node.gettxoutsetinfo(hash_option, 102)
136 assert_equal(res5['total_unspendable_amount'], 50)
137 assert_equal(res5['block_info'], {
138 'unspendable': 0,
139 'prevout_spent': 50,
140 'new_outputs_ex_coinbase': Decimal('49.99968800'),
141 'coinbase': Decimal('50.00031200'),
142 'unspendables': {
143 'genesis_block': 0,
144 'bip30': 0,
145 'scripts': 0,
146 'unclaimed_rewards': 0,
147 }
148 })
149 self.block_sanity_check(res5['block_info'])
150
151 # Generate and send a normal tx with two outputs
152 tx1 = self.wallet.send_to(
153 from_node=node,
154 scriptPubKey=self.wallet.get_output_script(),
155 amount=21 * COIN,
156 )
157
158 # Find the right position of the 21 BTC output
159 tx1_out_21 = self.wallet.get_utxo(txid=tx1["txid"], vout=tx1["sent_vout"])
160
161 # Generate and send another tx with an OP_RETURN output (which is unspendable)
162 tx2 = self.wallet.create_self_transfer(utxo_to_spend=tx1_out_21)['tx']
163 tx2_val = '20.99'
164 tx2.vout = [CTxOut(int(Decimal(tx2_val) * COIN), CScript([OP_RETURN] + [OP_FALSE] * 30))]
165 tx2_hex = tx2.serialize().hex()
166 self.nodes[0].sendrawtransaction(tx2_hex, 0, tx2_val)
167
168 # Include both txs in a block
169 self.generate(self.nodes[0], 1)
170
171 for hash_option in index_hash_options:
172 # Check all amounts were registered correctly
173 res6 = index_node.gettxoutsetinfo(hash_option, 108)
174 assert_equal(res6['total_unspendable_amount'], Decimal('70.99000000'))
175 assert_equal(res6['block_info'], {
176 'unspendable': Decimal('20.99000000'),
177 'prevout_spent': 71,
178 'new_outputs_ex_coinbase': Decimal('49.99999000'),
179 'coinbase': Decimal('50.01001000'),
180 'unspendables': {
181 'genesis_block': 0,
182 'bip30': 0,
183 'scripts': Decimal('20.99000000'),
184 'unclaimed_rewards': 0,
185 }
186 })
187 self.block_sanity_check(res6['block_info'])
188
189 # Create a coinbase that does not claim full subsidy and also
190 # has two outputs
191 cb = create_coinbase(109, nValue=35)
192 cb.vout.append(CTxOut(5 * COIN, CScript([OP_FALSE])))
193 cb.rehash()
194
195 # Generate a block that includes previous coinbase
196 tip = self.nodes[0].getbestblockhash()
197 block_time = self.nodes[0].getblock(tip)['time'] + 1
198 block = create_block(int(tip, 16), cb, block_time)
199 block.solve()
200 self.nodes[0].submitblock(block.serialize().hex())
201 self.sync_all()
202
203 for hash_option in index_hash_options:
204 res7 = index_node.gettxoutsetinfo(hash_option, 109)
205 assert_equal(res7['total_unspendable_amount'], Decimal('80.99000000'))
206 assert_equal(res7['block_info'], {
207 'unspendable': 10,
208 'prevout_spent': 0,
209 'new_outputs_ex_coinbase': 0,
210 'coinbase': 40,
211 'unspendables': {
212 'genesis_block': 0,
213 'bip30': 0,
214 'scripts': 0,
215 'unclaimed_rewards': 10
216 }
217 })
218 self.block_sanity_check(res7['block_info'])
219
220 self.log.info("Test that the index is robust across restarts")
221
222 res8 = index_node.gettxoutsetinfo('muhash')
223 self.restart_node(1, extra_args=self.extra_args[1])
224 res9 = index_node.gettxoutsetinfo('muhash')
225 assert_equal(res8, res9)
226
227 self.generate(index_node, 1, sync_fun=self.no_op)
228 res10 = index_node.gettxoutsetinfo('muhash')
229 assert res8['txouts'] < res10['txouts']
230
231 self.log.info("Test that the index works with -reindex")
232
233 self.restart_node(1, extra_args=["-coinstatsindex", "-reindex"])
234 self.sync_index_node()
235 res11 = index_node.gettxoutsetinfo('muhash')
236 assert_equal(res11, res10)
237
238 self.log.info("Test that the index works with -reindex-chainstate")
239
240 self.restart_node(1, extra_args=["-coinstatsindex", "-reindex-chainstate"])
241 self.sync_index_node()
242 res12 = index_node.gettxoutsetinfo('muhash')
243 assert_equal(res12, res10)
244
245 self.log.info("Test obtaining info for a non-existent block hash")
246 assert_raises_rpc_error(-5, "Block not found", index_node.gettxoutsetinfo, hash_type="none", hash_or_height="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", use_index=True)
247
248 def _test_use_index_option(self):
249 self.log.info("Test use_index option for nodes running the index")
250
251 self.connect_nodes(0, 1)
252 self.nodes[0].waitforblockheight(110)
253 res = self.nodes[0].gettxoutsetinfo('muhash')
254 option_res = self.nodes[1].gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
255 del res['disk_size'], option_res['disk_size']
256 assert_equal(res, option_res)
257
258 def _test_reorg_index(self):
259 self.log.info("Test that index can handle reorgs")
260
261 # Generate two block, let the index catch up, then invalidate the blocks
262 index_node = self.nodes[1]
263 reorg_blocks = self.generatetoaddress(index_node, 2, getnewdestination()[2])
264 reorg_block = reorg_blocks[1]
265 self.sync_index_node()
266 res_invalid = index_node.gettxoutsetinfo('muhash')
267 index_node.invalidateblock(reorg_blocks[0])
268 assert_equal(index_node.gettxoutsetinfo('muhash')['height'], 110)
269
270 # Add two new blocks
271 block = self.generate(index_node, 2, sync_fun=self.no_op)[1]
272 res = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
273
274 # Test that the result of the reorged block is not returned for its old block height
275 res2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=112)
276 assert_equal(res["bestblock"], block)
277 assert_equal(res["muhash"], res2["muhash"])
278 assert res["muhash"] != res_invalid["muhash"]
279
280 # Test that requesting reorged out block by hash is still returning correct results
281 res_invalid2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=reorg_block)
282 assert_equal(res_invalid2["muhash"], res_invalid["muhash"])
283 assert res["muhash"] != res_invalid2["muhash"]
284
285 # Add another block, so we don't depend on reconsiderblock remembering which
286 # blocks were touched by invalidateblock
287 self.generate(index_node, 1)
288
289 # Ensure that removing and re-adding blocks yields consistent results
290 block = index_node.getblockhash(99)
291 index_node.invalidateblock(block)
292 index_node.reconsiderblock(block)
293 res3 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=112)
294 assert_equal(res2, res3)
295
296 def _test_index_rejects_hash_serialized(self):
297 self.log.info("Test that the rpc raises if the legacy hash is passed with the index")
298
299 msg = "hash_serialized_3 hash type cannot be queried for a specific block"
300 assert_raises_rpc_error(-8, msg, self.nodes[1].gettxoutsetinfo, hash_type='hash_serialized_3', hash_or_height=111)
301
302 for use_index in {True, False, None}:
303 assert_raises_rpc_error(-8, msg, self.nodes[1].gettxoutsetinfo, hash_type='hash_serialized_3', hash_or_height=111, use_index=use_index)
304
305 def _test_init_index_after_reorg(self):
306 self.log.info("Test a reorg while the index is deactivated")
307 index_node = self.nodes[1]
308 block = self.nodes[0].getbestblockhash()
309 self.generate(index_node, 2, sync_fun=self.no_op)
310 self.sync_index_node()
311
312 # Restart without index
313 self.restart_node(1, extra_args=[])
314 self.connect_nodes(0, 1)
315 index_node.invalidateblock(block)
316 self.generatetoaddress(index_node, 5, getnewdestination()[2])
317 res = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=False)
318
319 # Restart with index that still has its best block on the old chain
320 self.restart_node(1, extra_args=self.extra_args[1])
321 self.sync_index_node()
322 res1 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=None, use_index=True)
323 assert_equal(res["muhash"], res1["muhash"])
324
325 self.log.info("Test index with an unclean restart after a reorg")
326 self.restart_node(1, extra_args=self.extra_args[1])
327 committed_height = index_node.getblockcount()
328 self.generate(index_node, 2, sync_fun=self.no_op)
329 self.sync_index_node()
330 block2 = index_node.getbestblockhash()
331 index_node.invalidateblock(block2)
332 self.generatetoaddress(index_node, 1, getnewdestination()[2], sync_fun=self.no_op)
333 self.sync_index_node()
334 index_node.kill_process()
335 self.start_node(1, extra_args=self.extra_args[1])
336 self.sync_index_node()
337 # Because of the unclean shutdown above, indexes reset to the point we last committed them to disk.
338 assert_equal(index_node.getindexinfo()['coinstatsindex']['best_block_height'], committed_height)
339
340
341 if __name__ == '__main__':
342 CoinStatsIndexTest(__file__).main()
343