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