mempool_dust.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 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 dust limit mempool policy (`-dustrelayfee` parameter)"""
   6  from decimal import Decimal
   7  
   8  from test_framework.messages import (
   9      COIN,
  10      CTxOut,
  11  )
  12  from test_framework.script import (
  13      CScript,
  14      OP_RETURN,
  15      OP_TRUE,
  16  )
  17  from test_framework.script_util import (
  18      key_to_p2pk_script,
  19      key_to_p2pkh_script,
  20      key_to_p2wpkh_script,
  21      keys_to_multisig_script,
  22      output_key_to_p2tr_script,
  23      program_to_witness_script,
  24      script_to_p2sh_script,
  25      script_to_p2wsh_script,
  26  )
  27  from test_framework.test_framework import LimenkaTestFramework
  28  from test_framework.test_node import TestNode
  29  from test_framework.util import (
  30      assert_equal,
  31      get_fee,
  32  )
  33  from test_framework.wallet import MiniWallet
  34  from test_framework.wallet_util import generate_keypair
  35  
  36  
  37  DUST_RELAY_TX_FEE = 3000  # default setting [sat/kvB]
  38  
  39  
  40  class DustRelayFeeTest(LimenkaTestFramework):
  41      def set_test_params(self):
  42          self.num_nodes = 1
  43          self.extra_args = [['-permitbaremultisig']]
  44  
  45      def test_dust_output(self, node: TestNode, dust_relay_fee: Decimal,
  46                           output_script: CScript, type_desc: str) -> None:
  47          # determine dust threshold (see `GetDustThreshold`)
  48          if output_script[0] == OP_RETURN:
  49              dust_threshold = 0
  50          else:
  51              tx_size = len(CTxOut(nValue=0, scriptPubKey=output_script).serialize())
  52              tx_size += 67 if output_script.IsWitnessProgram() else 148
  53              dust_threshold = int(get_fee(tx_size, dust_relay_fee) * COIN)
  54          self.log.info(f"-> Test {type_desc} output (size {len(output_script)}, limit {dust_threshold})")
  55  
  56          # amount right on the dust threshold should pass
  57          tx = self.wallet.create_self_transfer()["tx"]
  58          tx.vout.append(CTxOut(nValue=dust_threshold, scriptPubKey=output_script))
  59          tx.vout[0].nValue -= dust_threshold  # keep total output value constant
  60          tx_good_hex = tx.serialize().hex()
  61          res = node.testmempoolaccept([tx_good_hex])[0]
  62          assert_equal(res['allowed'], True)
  63  
  64          # amount just below the dust threshold should fail
  65          if dust_threshold > 0:
  66              tx.vout[1].nValue -= 1
  67              res = node.testmempoolaccept([tx.serialize().hex()])[0]
  68              assert_equal(res['allowed'], False)
  69              assert_equal(res['reject-reason'], 'dust')
  70  
  71          # finally send the transaction to avoid running out of MiniWallet UTXOs
  72          self.wallet.sendrawtransaction(from_node=node, tx_hex=tx_good_hex)
  73  
  74      def test_dustrelay(self):
  75          self.log.info("Test that small outputs are acceptable when dust relay rate is set to 0 that would otherwise trigger ephemeral dust rules")
  76  
  77          self.restart_node(0, extra_args=["-dustrelayfee=0"])
  78  
  79          assert_equal(self.nodes[0].getrawmempool(), [])
  80  
  81          # Create two dust outputs. Transaction has zero fees. both dust outputs are unspent, and would have failed individual checks.
  82          # The amount is 1 satoshi because create_self_transfer_multi disallows 0.
  83          dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=1000, amount_per_output=1, num_outputs=2)
  84          dust_txid = self.nodes[0].sendrawtransaction(hexstring=dusty_tx["hex"], maxfeerate=0)
  85  
  86          assert_equal(self.nodes[0].getrawmempool(), [dust_txid])
  87  
  88          # Spends one dust along with fee input, leave other dust unspent to check ephemeral dust checks aren't being enforced
  89          sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[self.wallet.get_utxo(), dusty_tx["new_utxos"][0]])
  90          sweep_txid = self.nodes[0].sendrawtransaction(sweep_tx["hex"])
  91  
  92          mempool_entries = self.nodes[0].getrawmempool()
  93          assert dust_txid in mempool_entries
  94          assert sweep_txid in mempool_entries
  95          assert_equal(len(mempool_entries), 2)
  96  
  97          # Wipe extra arg to reset dust relay
  98          self.restart_node(0, extra_args=[])
  99  
 100          assert_equal(self.nodes[0].getrawmempool(), [])
 101  
 102      def test_output_size_limit(self):
 103          """Test that outputs exceeding MAX_OUTPUT_SCRIPT_SIZE (34 bytes) are rejected"""
 104          self.log.info("Test MAX_OUTPUT_SCRIPT_SIZE limit (34 bytes)")
 105  
 106          node = self.nodes[0]
 107          _, pubkey = generate_keypair(compressed=True)
 108  
 109          # Test Case 1: Scripts at or under 34 bytes should be accepted
 110          self.log.info("-> Testing scripts at or under 34-byte limit (should pass)")
 111  
 112          passing_scripts = [
 113              (key_to_p2pkh_script(pubkey), "P2PKH", 25),
 114              (key_to_p2wpkh_script(pubkey), "P2WPKH", 22),
 115              (script_to_p2wsh_script(CScript([OP_TRUE])), "P2WSH", 34),
 116              (script_to_p2sh_script(CScript([OP_TRUE])), "P2SH", 23),
 117              (output_key_to_p2tr_script(pubkey[1:]), "P2TR", 34),
 118          ]
 119  
 120          for script, name, expected_size in passing_scripts:
 121              assert_equal(len(script), expected_size)
 122              tx = self.wallet.create_self_transfer()["tx"]
 123              tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script))
 124              res = node.testmempoolaccept([tx.serialize().hex()])[0]
 125              assert_equal(res['allowed'], True)
 126              self.log.info(f"   ✓ {name} ({expected_size} bytes) accepted")
 127  
 128          # Test Case 2: P2PK with compressed pubkey (35 bytes) should be rejected
 129          self.log.info("-> Testing P2PK compressed (35 bytes) - should be rejected")
 130          p2pk_script = key_to_p2pk_script(pubkey)
 131          assert_equal(len(p2pk_script), 35)
 132  
 133          tx = self.wallet.create_self_transfer()["tx"]
 134          tx.vout.append(CTxOut(nValue=1000, scriptPubKey=p2pk_script))
 135          res = node.testmempoolaccept([tx.serialize().hex()])[0]
 136          assert_equal(res['allowed'], False)
 137          assert 'output-script-size' in res['reject-reason'].lower() or \
 138                 'bad-txns' in res['reject-reason'].lower(), \
 139                 f"Expected output-script-size error, got: {res['reject-reason']}"
 140          self.log.info(f"   ✓ P2PK compressed (35 bytes) correctly rejected: {res['reject-reason']}")
 141  
 142          # Test Case 3: 1-of-1 bare multisig (37 bytes) should be rejected
 143          self.log.info("-> Testing 1-of-1 bare multisig (37 bytes) - should be rejected")
 144          multisig_script = keys_to_multisig_script([pubkey], k=1)
 145          assert_equal(len(multisig_script), 37)
 146  
 147          tx = self.wallet.create_self_transfer()["tx"]
 148          tx.vout.append(CTxOut(nValue=1000, scriptPubKey=multisig_script))
 149          res = node.testmempoolaccept([tx.serialize().hex()])[0]
 150          assert_equal(res['allowed'], False)
 151          assert 'output-script-size' in res['reject-reason'].lower() or \
 152                 'bad-txns' in res['reject-reason'].lower(), \
 153                 f"Expected output-script-size error, got: {res['reject-reason']}"
 154          self.log.info(f"   ✓ 1-of-1 bare multisig (37 bytes) correctly rejected: {res['reject-reason']}")
 155  
 156          # Test Case 4: Boundary testing (exactly 34 vs 35 bytes)
 157          self.log.info("-> Testing boundary conditions")
 158  
 159          # Exactly 34 bytes should pass (create a witness program v0 with 32-byte data)
 160          script_34 = CScript([0, bytes(32)])  # OP_0 + 32 bytes = 34 bytes
 161          assert_equal(len(script_34), 34)
 162          tx = self.wallet.create_self_transfer()["tx"]
 163          tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_34))
 164          res = node.testmempoolaccept([tx.serialize().hex()])[0]
 165          assert_equal(res['allowed'], True)
 166          self.log.info("   ✓ Exactly 34 bytes accepted (boundary)")
 167  
 168          # 35 bytes should fail (create a witness program v0 with 33-byte data - invalid but tests size)
 169          script_35 = CScript([0, bytes(33)])  # OP_0 + 33 bytes = 35 bytes
 170          assert_equal(len(script_35), 35)
 171          tx = self.wallet.create_self_transfer()["tx"]
 172          tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_35))
 173          res = node.testmempoolaccept([tx.serialize().hex()])[0]
 174          assert_equal(res['allowed'], False)
 175          self.log.info(f"   ✓ 35 bytes rejected (boundary): {res['reject-reason']}")
 176  
 177      def run_test(self):
 178          self.wallet = MiniWallet(self.nodes[0])
 179  
 180          self.test_dustrelay()
 181          self.test_output_size_limit()
 182  
 183          # prepare output scripts of each standard type
 184          _, uncompressed_pubkey = generate_keypair(compressed=False)
 185          _, pubkey = generate_keypair(compressed=True)
 186  
 187          output_scripts = (
 188              (key_to_p2pkh_script(pubkey),                      "P2PKH"),
 189              (script_to_p2sh_script(CScript([OP_TRUE])),        "P2SH"),
 190              (key_to_p2wpkh_script(pubkey),                     "P2WPKH"),
 191              (script_to_p2wsh_script(CScript([OP_TRUE])),       "P2WSH"),
 192              (output_key_to_p2tr_script(pubkey[1:]),            "P2TR"),
 193              # witness programs for segwitv2+ can be between 2 and 40 bytes
 194              (program_to_witness_script(2,  b'\x66' * 2),       "P2?? (future witness version 2)"),
 195              (program_to_witness_script(16, b'\x77' * 32),      "P2?? (future witness version 16)"),
 196              (CScript([OP_RETURN, b'superimportanthash']),      "null data (OP_RETURN)"),
 197          )
 198  
 199          # test default (no parameter), disabled (=0) and a bunch of arbitrary dust fee rates [sat/kvB]
 200          for dustfee_sat_kvb in (DUST_RELAY_TX_FEE, 0, 1, 66, 500, 1337, 12345, 21212, 333333):
 201              dustfee_btc_kvb = dustfee_sat_kvb / Decimal(COIN)
 202              if dustfee_sat_kvb == DUST_RELAY_TX_FEE:
 203                  self.log.info(f"Test default dust limit setting ({dustfee_sat_kvb} sat/kvB)...")
 204              else:
 205                  dust_parameter = f"-dustrelayfee={dustfee_btc_kvb:.8f}"
 206                  self.log.info(f"Test dust limit setting {dust_parameter} ({dustfee_sat_kvb} sat/kvB)...")
 207                  self.restart_node(0, extra_args=[dust_parameter, "-permitbaremultisig"])
 208  
 209              for output_script, description in output_scripts:
 210                  self.test_dust_output(self.nodes[0], dustfee_btc_kvb, output_script, description)
 211              self.generate(self.nodes[0], 1)
 212  
 213  
 214  if __name__ == '__main__':
 215      DustRelayFeeTest(__file__).main()
 216