feature_notifications.py
1 #!/usr/bin/env python3 2 # Copyright (c) 2014-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 """Test the -alertnotify, -blocknotify and -walletnotify options.""" 6 import os 7 import platform 8 9 from test_framework.address import ADDRESS_BCRT1_UNSPENDABLE 10 from test_framework.blocktools import ( 11 create_block, 12 create_coinbase, 13 ) 14 from test_framework.descriptors import descsum_create 15 from test_framework.test_framework import BitcoinTestFramework 16 from test_framework.util import ( 17 assert_equal, 18 ) 19 20 # Linux allow all characters other than \x00 21 # Windows disallow control characters (0-31) and /\?%:|"<> 22 FILE_CHAR_START = 32 if platform.system() == 'Windows' else 1 23 FILE_CHAR_END = 128 24 FILE_CHARS_DISALLOWED = '/\\?%*:|"<>' if platform.system() == 'Windows' else '/' 25 UNCONFIRMED_HASH_STRING = 'unconfirmed' 26 27 LARGE_WORK_INVALID_CHAIN_WARNING = ( 28 "Warning: Found invalid chain more than 6 blocks longer than our best chain. This could be due to database corruption or consensus incompatibility with peers." 29 ) 30 31 32 def notify_outputname(walletname, txid): 33 return txid if platform.system() == 'Windows' else f'{walletname}_{txid}' 34 35 36 class NotificationsTest(BitcoinTestFramework): 37 def set_test_params(self): 38 self.num_nodes = 2 39 self.setup_clean_chain = True 40 self.uses_wallet = None 41 42 def setup_network(self): 43 self.wallet = ''.join(chr(i) for i in range(FILE_CHAR_START, FILE_CHAR_END) if chr(i) not in FILE_CHARS_DISALLOWED) 44 self.alertnotify_dir = os.path.join(self.options.tmpdir, "alertnotify") 45 self.alertnotify_file = os.path.join(self.alertnotify_dir, "alertnotify.txt") 46 self.blocknotify_dir = os.path.join(self.options.tmpdir, "blocknotify") 47 self.walletnotify_dir = os.path.join(self.options.tmpdir, "walletnotify") 48 self.shutdownnotify_dir = os.path.join(self.options.tmpdir, "shutdownnotify") 49 self.shutdownnotify_file = os.path.join(self.shutdownnotify_dir, "shutdownnotify.txt") 50 os.mkdir(self.alertnotify_dir) 51 os.mkdir(self.blocknotify_dir) 52 os.mkdir(self.walletnotify_dir) 53 os.mkdir(self.shutdownnotify_dir) 54 55 # -alertnotify and -blocknotify on node0, walletnotify on node1 56 self.extra_args = [[ 57 f"-alertnotify=echo %s >> {self.alertnotify_file}", 58 f"-blocknotify=echo > {os.path.join(self.blocknotify_dir, '%s')}", 59 f"-shutdownnotify=echo > {self.shutdownnotify_file}", 60 ], [ 61 f"-walletnotify=echo %h_%b > {os.path.join(self.walletnotify_dir, notify_outputname('%w', '%s'))}", 62 ]] 63 self.wallet_names = [self.default_wallet_name, self.wallet] 64 super().setup_network() 65 66 def run_test(self): 67 if self.is_wallet_compiled(): 68 # Setup the descriptors to be imported to the wallet 69 xpriv = "tprv8ZgxMBicQKsPfHCsTwkiM1KT56RXbGGTqvc2hgqzycpwbHqqpcajQeMRZoBD35kW4RtyCemu6j34Ku5DEspmgjKdt2qe4SvRch5Kk8B8A2v" 70 desc_imports = [{ 71 "desc": descsum_create(f"wpkh({xpriv}/0/*)"), 72 "timestamp": 0, 73 "active": True, 74 "keypool": True, 75 },{ 76 "desc": descsum_create(f"wpkh({xpriv}/1/*)"), 77 "timestamp": 0, 78 "active": True, 79 "keypool": True, 80 "internal": True, 81 }] 82 # Make the wallets and import the descriptors 83 # Ensures that node 0 and node 1 share the same wallet for the conflicting transaction tests below. 84 for i, name in enumerate(self.wallet_names): 85 self.nodes[i].createwallet(wallet_name=name, blank=True, load_on_startup=True) 86 self.nodes[i].importdescriptors(desc_imports) 87 88 self.log.info("test -blocknotify") 89 block_count = 10 90 blocks = self.generatetoaddress(self.nodes[1], block_count, self.nodes[1].getnewaddress() if self.is_wallet_compiled() else ADDRESS_BCRT1_UNSPENDABLE) 91 92 # wait at most 10 seconds for expected number of files before reading the content 93 self.wait_until(lambda: len(os.listdir(self.blocknotify_dir)) == block_count, timeout=10) 94 95 # directory content should equal the generated blocks hashes 96 assert_equal(sorted(blocks), sorted(os.listdir(self.blocknotify_dir))) 97 98 if self.is_wallet_compiled(): 99 self.log.info("test -walletnotify") 100 # wait at most 10 seconds for expected number of files before reading the content 101 self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) == block_count, timeout=10) 102 103 # directory content should equal the generated transaction hashes 104 tx_details = list(map(lambda t: (t['txid'], t['blockheight'], t['blockhash']), self.nodes[1].listtransactions("*", block_count))) 105 self.expect_wallet_notify(tx_details) 106 107 self.log.info("test -walletnotify after rescan") 108 # rescan to force wallet notifications 109 self.nodes[1].rescanblockchain() 110 self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) == block_count, timeout=10) 111 112 self.connect_nodes(0, 1) 113 114 # directory content should equal the generated transaction hashes 115 tx_details = list(map(lambda t: (t['txid'], t['blockheight'], t['blockhash']), self.nodes[1].listtransactions("*", block_count))) 116 self.expect_wallet_notify(tx_details) 117 118 # Conflicting transactions tests. 119 # Generate spends from node 0, and check notifications 120 # triggered by node 1 121 self.log.info("test -walletnotify with conflicting transactions") 122 self.nodes[0].rescanblockchain() 123 self.generatetoaddress(self.nodes[0], 100, ADDRESS_BCRT1_UNSPENDABLE) 124 125 # Generate transaction on node 0, sync mempools, and check for 126 # notification on node 1. 127 tx1 = self.nodes[0].sendtoaddress(address=ADDRESS_BCRT1_UNSPENDABLE, amount=1, replaceable=True) 128 assert_equal(tx1 in self.nodes[0].getrawmempool(), True) 129 self.sync_mempools() 130 self.expect_wallet_notify([(tx1, -1, UNCONFIRMED_HASH_STRING)]) 131 132 # Generate bump transaction, sync mempools, and check for bump1 133 # notification. In the future, per 134 # https://github.com/bitcoin/bitcoin/pull/9371, it might be better 135 # to have notifications for both tx1 and bump1. 136 bump1 = self.nodes[0].bumpfee(tx1)["txid"] 137 assert_equal(bump1 in self.nodes[0].getrawmempool(), True) 138 self.sync_mempools() 139 self.expect_wallet_notify([(bump1, -1, UNCONFIRMED_HASH_STRING)]) 140 141 # Add bump1 transaction to new block, checking for a notification 142 # and the correct number of confirmations. 143 blockhash1 = self.generatetoaddress(self.nodes[0], 1, ADDRESS_BCRT1_UNSPENDABLE)[0] 144 blockheight1 = self.nodes[0].getblockcount() 145 self.sync_blocks() 146 self.expect_wallet_notify([(bump1, blockheight1, blockhash1)]) 147 assert_equal(self.nodes[1].gettransaction(bump1)["confirmations"], 1) 148 149 # Generate a second transaction to be bumped. 150 tx2 = self.nodes[0].sendtoaddress(address=ADDRESS_BCRT1_UNSPENDABLE, amount=1, replaceable=True) 151 assert_equal(tx2 in self.nodes[0].getrawmempool(), True) 152 self.sync_mempools() 153 self.expect_wallet_notify([(tx2, -1, UNCONFIRMED_HASH_STRING)]) 154 155 # Bump tx2 as bump2 and generate a block on node 0 while 156 # disconnected, then reconnect and check for notifications on node 1 157 # about newly confirmed bump2 and newly conflicted tx2. 158 self.disconnect_nodes(0, 1) 159 bump2 = self.nodes[0].bumpfee(tx2)["txid"] 160 blockhash2 = self.generatetoaddress(self.nodes[0], 1, ADDRESS_BCRT1_UNSPENDABLE, sync_fun=self.no_op)[0] 161 blockheight2 = self.nodes[0].getblockcount() 162 assert_equal(self.nodes[0].gettransaction(bump2)["confirmations"], 1) 163 assert_equal(tx2 in self.nodes[1].getrawmempool(), True) 164 self.connect_nodes(0, 1) 165 self.sync_blocks() 166 self.expect_wallet_notify([(bump2, blockheight2, blockhash2), (tx2, -1, UNCONFIRMED_HASH_STRING)]) 167 assert_equal(self.nodes[1].gettransaction(bump2)["confirmations"], 1) 168 169 self.log.info("test -alertnotify with large work invalid chain") 170 # create a bunch of invalid blocks 171 tip = self.nodes[0].getbestblockhash() 172 height = self.nodes[0].getblockcount() + 1 173 block_time = self.nodes[0].getblock(tip)['time'] + 1 174 175 invalid_blocks = [] 176 for _ in range(7): # invalid chain must be longer than 6 blocks to trigger warning 177 block = create_block(int(tip, 16), create_coinbase(height), block_time) 178 # make block invalid by exceeding block subsidy 179 block.vtx[0].vout[0].nValue += 1 180 block.hashMerkleRoot = block.calc_merkle_root() 181 block.solve() 182 invalid_blocks.append(block) 183 tip = block.hash_hex 184 height += 1 185 block_time += 1 186 187 # submit headers of invalid blocks 188 for invalid_block in invalid_blocks: 189 self.nodes[0].submitheader(invalid_block.serialize().hex()) 190 # submit invalid blocks in reverse order (tip first, to set m_best_invalid) 191 for invalid_block in reversed(invalid_blocks): 192 self.nodes[0].submitblock(invalid_block.serialize().hex()) 193 194 self.wait_until(lambda: os.path.isfile(self.alertnotify_file), timeout=10) 195 self.wait_until(self.large_work_invalid_chain_warning_in_alert_file, timeout=10) 196 197 self.log.info("test -shutdownnotify") 198 self.stop_nodes() 199 self.wait_until(lambda: os.path.isfile(self.shutdownnotify_file), timeout=10) 200 201 def large_work_invalid_chain_warning_in_alert_file(self): 202 with open(self.alertnotify_file, 'r') as f: 203 alert_text = f.read() 204 return LARGE_WORK_INVALID_CHAIN_WARNING in alert_text 205 206 def expect_wallet_notify(self, tx_details): 207 self.wait_until(lambda: len(os.listdir(self.walletnotify_dir)) >= len(tx_details), timeout=10) 208 # Should have no more and no less files than expected 209 assert_equal(sorted(notify_outputname(self.wallet, tx_id) for tx_id, _, _ in tx_details), sorted(os.listdir(self.walletnotify_dir))) 210 # Should now verify contents of each file 211 for tx_id, blockheight, blockhash in tx_details: 212 fname = os.path.join(self.walletnotify_dir, notify_outputname(self.wallet, tx_id)) 213 # Wait for the cached writes to hit storage 214 self.wait_until(lambda: os.path.getsize(fname) > 0, timeout=10) 215 with open(fname, 'rt') as f: 216 text = f.read() 217 # Universal newline ensures '\n' on 'nt' 218 assert_equal(text[-1], '\n') 219 text = text[:-1] 220 if platform.system() == 'Windows': 221 # On Windows, echo as above will append a whitespace 222 assert_equal(text[-1], ' ') 223 text = text[:-1] 224 expected = str(blockheight) + '_' + blockhash 225 assert_equal(text, expected) 226 227 for tx_file in os.listdir(self.walletnotify_dir): 228 os.remove(os.path.join(self.walletnotify_dir, tx_file)) 229 230 231 if __name__ == '__main__': 232 NotificationsTest(__file__).main()