feature_notifications.py raw

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