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