mempool_sigoplimit.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2023 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 sigop limit mempool policy (`-bytespersigop` parameter)"""
   6  from copy import deepcopy
   7  from decimal import Decimal
   8  from math import ceil
   9  
  10  from test_framework.messages import (
  11      COutPoint,
  12      CTransaction,
  13      CTxIn,
  14      CTxInWitness,
  15      CTxOut,
  16      MAX_OP_RETURN_RELAY,
  17      WITNESS_SCALE_FACTOR,
  18      tx_from_hex,
  19  )
  20  from test_framework.script import (
  21      CScript,
  22      OP_1,
  23      OP_2DUP,
  24      OP_CHECKMULTISIG,
  25      OP_CHECKSIG,
  26      OP_DROP,
  27      OP_ENDIF,
  28      OP_FALSE,
  29      OP_IF,
  30      OP_NOT,
  31      OP_RETURN,
  32      OP_TRUE,
  33  )
  34  from test_framework.script_util import (
  35      keys_to_multisig_script,
  36      script_to_p2wsh_script,
  37      script_to_p2sh_script,
  38      MAX_STD_LEGACY_SIGOPS,
  39      MAX_STD_P2SH_SIGOPS,
  40  )
  41  from test_framework.test_framework import LimenkaTestFramework
  42  from test_framework.util import (
  43      assert_equal,
  44      assert_greater_than,
  45      assert_greater_than_or_equal,
  46      assert_raises_rpc_error,
  47  )
  48  from test_framework.wallet import MiniWallet
  49  from test_framework.wallet_util import generate_keypair
  50  
  51  DEFAULT_BYTES_PER_SIGOP = 20  # default setting
  52  MAX_PUBKEYS_PER_MULTISIG = 20
  53  
  54  class BytesPerSigOpTest(LimenkaTestFramework):
  55      def set_test_params(self):
  56          self.num_nodes = 1
  57          # allow large datacarrier output to pad transactions
  58          self.extra_args = [['-datacarriersize=100000']]
  59  
  60      def create_p2wsh_spending_tx(self, witness_script, output_script):
  61          """Create a 1-input-1-output P2WSH spending transaction with only the
  62             witness script in the witness stack and the given output script."""
  63          # create P2WSH address and fund it via MiniWallet first
  64          fund = self.wallet.send_to(
  65              from_node=self.nodes[0],
  66              scriptPubKey=script_to_p2wsh_script(witness_script),
  67              amount=1000000,
  68          )
  69  
  70          # create spending transaction
  71          tx = CTransaction()
  72          tx.vin = [CTxIn(COutPoint(int(fund["txid"], 16), fund["sent_vout"]))]
  73          tx.wit.vtxinwit = [CTxInWitness()]
  74          tx.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)]
  75          tx.vout = [CTxOut(500000, output_script)]
  76          return tx
  77  
  78      def test_sigops_limit(self, bytes_per_sigop, num_sigops):
  79          sigop_equivalent_vsize = ceil(num_sigops * bytes_per_sigop / WITNESS_SCALE_FACTOR)
  80          self.log.info(f"- {num_sigops} sigops (equivalent size of {sigop_equivalent_vsize} vbytes)")
  81  
  82          # create a template tx with the specified sigop cost in the witness script
  83          # (note that the sigops count even though being in a branch that's not executed)
  84          num_multisigops = num_sigops // 20
  85          num_singlesigops = num_sigops % 20
  86          witness_script = CScript(
  87              [OP_FALSE, OP_IF] +
  88              [OP_CHECKMULTISIG]*num_multisigops +
  89              [OP_CHECKSIG]*num_singlesigops +
  90              [OP_ENDIF, OP_TRUE]
  91          )
  92  
  93          # Create transaction ONCE with a small output
  94          # This creates ONE funding transaction in the mempool
  95          tx = self.create_p2wsh_spending_tx(witness_script, CScript([OP_RETURN, b'test123']))
  96  
  97          # Helper function to pad transaction to target vsize using multiple OP_RETURN outputs
  98          def pad_tx_to_vsize(tx, target_vsize):
  99              """Adjust transaction size by adding/removing multiple OP_RETURN outputs"""
 100              # Keep only the first output, remove all padding outputs
 101              while len(tx.vout) > 1:
 102                  tx.vout.pop()
 103  
 104              # MAX_OP_RETURN_RELAY = 83, so max script is: OP_RETURN + 82 bytes data
 105              max_script_size = MAX_OP_RETURN_RELAY
 106  
 107              # Iteratively add outputs until we reach or slightly exceed the target
 108              while True:
 109                  current_vsize = tx.get_vsize()
 110                  if current_vsize >= target_vsize:
 111                      break
 112  
 113                  vsize_needed = target_vsize - current_vsize
 114  
 115                  # CTxOut serialization: nValue (8) + compact_size(script_len) + script
 116                  # For script_len <= 252: compact_size = 1 byte
 117                  # So total = 8 + 1 + script_len = 9 + script_len
 118  
 119                  # Maximum output: 8 + 1 + 83 = 92 vbytes
 120                  if vsize_needed >= 92:
 121                      # Add a max-size output
 122                      tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * (max_script_size - 1))))
 123                  elif vsize_needed >= 10:
 124                      # Need to add exactly vsize_needed bytes
 125                      # 8 + 1 + script_len = vsize_needed
 126                      # script_len = vsize_needed - 9
 127                      script_len = vsize_needed - 9
 128                      # Script is [OP_RETURN] + data, so len = 1 + data_len
 129                      # data_len = script_len - 1
 130                      data_len = script_len - 1
 131                      if data_len >= 0:
 132                          tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len)))
 133                      else:
 134                          # Just add the minimum and overshoot slightly
 135                          tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN])))
 136                          break
 137                  else:
 138                      # vsize_needed < 10, can't add a new output
 139                      # Instead, adjust the first output's size by adding to its script
 140                      if vsize_needed > 0 and len(tx.vout[0].scriptPubKey) < max_script_size:
 141                          # Extend the first output's script
 142                          current_script = tx.vout[0].scriptPubKey
 143                          # Add vsize_needed more bytes to the script
 144                          new_script = bytes(current_script) + bytes([1] * vsize_needed)
 145                          # But cap at max_script_size
 146                          if len(new_script) <= max_script_size:
 147                              tx.vout[0].scriptPubKey = CScript(new_script)
 148                      break
 149  
 150              # If we overshot, try to trim the last output
 151              if tx.get_vsize() > target_vsize and len(tx.vout) > 1:
 152                  tx.vout.pop()
 153                  # Try again with a smaller output
 154                  current_vsize = tx.get_vsize()
 155                  vsize_needed = target_vsize - current_vsize
 156                  if vsize_needed >= 10:
 157                      script_len = vsize_needed - 9
 158                      data_len = script_len - 1
 159                      if data_len >= 0:
 160                          tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len)))
 161  
 162          # Pad to reach sigop-limit equivalent size
 163          pad_tx_to_vsize(tx, sigop_equivalent_vsize)
 164          if tx.get_vsize() != sigop_equivalent_vsize:
 165              self.log.error(f"Padding failed: got {tx.get_vsize()}, expected {sigop_equivalent_vsize}")
 166              self.log.error(f"Number of outputs: {len(tx.vout)}")
 167              for i, out in enumerate(tx.vout):
 168                  self.log.error(f"Output {i}: scriptPubKey len={len(out.scriptPubKey)}, vout entry size={8 + 1 + len(out.scriptPubKey)}")
 169          assert_equal(tx.get_vsize(), sigop_equivalent_vsize)
 170  
 171          res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
 172          assert_equal(res['allowed'], True)
 173          assert_equal(res['vsize'], sigop_equivalent_vsize)
 174  
 175          # increase the tx's vsize to be right above the sigop-limit equivalent size
 176          # => tx's vsize in mempool should also grow accordingly
 177          pad_tx_to_vsize(tx, sigop_equivalent_vsize + 1)
 178          res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
 179          assert_equal(res['allowed'], True)
 180          assert_equal(res['vsize'], sigop_equivalent_vsize+1)
 181  
 182          # decrease the tx's vsize to be right below the sigop-limit equivalent size
 183          # => tx's vsize in mempool should stick at the sigop-limit equivalent
 184          # bytes level, as it is higher than the tx's serialized vsize
 185          # (the maximum of both is taken)
 186          pad_tx_to_vsize(tx, sigop_equivalent_vsize - 1)
 187          res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
 188          assert_equal(res['allowed'], True)
 189          assert_equal(res['vsize'], sigop_equivalent_vsize)
 190  
 191          # check that the ancestor and descendant size calculations in the mempool
 192          # also use the same max(sigop_equivalent_vsize, serialized_vsize) logic
 193          # (to keep it simple, we only test the case here where the sigop vsize
 194          # is much larger than the serialized vsize, i.e. we create a small child
 195          # tx by getting rid of the large padding output)
 196          while len(tx.vout) > 1:
 197              tx.vout.pop()
 198          tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'test123'])
 199          assert_greater_than(sigop_equivalent_vsize, tx.get_vsize())
 200          self.nodes[0].sendrawtransaction(hexstring=tx.serialize().hex(), maxburnamount='1.0')
 201  
 202          # fetch parent tx, which doesn't contain any sigops
 203          parent_txid = tx.vin[0].prevout.hash.to_bytes(32, 'big').hex()
 204          parent_tx = tx_from_hex(self.nodes[0].getrawtransaction(txid=parent_txid))
 205  
 206          entry_child = self.nodes[0].getmempoolentry(tx.rehash())
 207          assert_equal(entry_child['descendantcount'], 1)
 208          assert_equal(entry_child['descendantsize'], sigop_equivalent_vsize)
 209          assert_equal(entry_child['ancestorcount'], 2)
 210          assert_equal(entry_child['ancestorsize'], sigop_equivalent_vsize + parent_tx.get_vsize())
 211  
 212          entry_parent = self.nodes[0].getmempoolentry(parent_tx.rehash())
 213          assert_equal(entry_parent['ancestorcount'], 1)
 214          assert_equal(entry_parent['ancestorsize'], parent_tx.get_vsize())
 215          assert_equal(entry_parent['descendantcount'], 2)
 216          assert_equal(entry_parent['descendantsize'], parent_tx.get_vsize() + sigop_equivalent_vsize)
 217  
 218      def test_sigops_package(self):
 219          self.log.info("Test a overly-large sigops-vbyte hits package limits")
 220          # Make a 2-transaction package which fails vbyte checks even though
 221          # separately they would work.
 222          #
 223          # Using P2WSH multisig instead of bare multisig to comply with REDUCED_DATA
 224          # output size limits (34 bytes max). Witness sigops are discounted by 4x,
 225          # so we use multiple CHECKMULTISIG ops to achieve sufficient sigop-adjusted vsize.
 226          self.restart_node(0, extra_args=["-bytespersigop=5000"] + self.extra_args[0])
 227  
 228          # With -bytespersigop=5000 and witness discount of 4:
 229          # - Each CHECKMULTISIG = 20 sigops
 230          # - Adjusted vsize per CHECKMULTISIG = 20 * 5000 / 4 = 25,000
 231          # - Need > 101,000 / 2 = 50,500 per tx to exceed limit as package
 232          # - Use 3 CHECKMULTISIG ops = 60 sigops = 75,000 adjusted vsize per tx
 233          # - Two txs together = 150,000 > 101,000 (fails package limit)
 234          # - Each tx alone = 75,000 < 101,000 (passes individually)
 235          NUM_CHECKMULTISIG_OPS = 3
 236          expected_sigops_per_tx = NUM_CHECKMULTISIG_OPS * MAX_PUBKEYS_PER_MULTISIG  # 60
 237          expected_vsize_per_tx = expected_sigops_per_tx * 5000 // WITNESS_SCALE_FACTOR  # 75,000
 238  
 239          # Create witness script with multiple CHECKMULTISIG ops (sigops counted even in unexecuted branches)
 240          witness_script = CScript(
 241              [OP_FALSE, OP_IF] +
 242              [OP_CHECKMULTISIG] * NUM_CHECKMULTISIG_OPS +
 243              [OP_ENDIF, OP_TRUE]
 244          )
 245          p2wsh_script = script_to_p2wsh_script(witness_script)
 246  
 247          # Pre-fund two P2WSH outputs that we'll spend as parent and child
 248          funding_amount = 1000000
 249          fund_parent = self.wallet.send_to(
 250              from_node=self.nodes[0],
 251              scriptPubKey=p2wsh_script,
 252              amount=funding_amount,
 253          )
 254          fund_child = self.wallet.send_to(
 255              from_node=self.nodes[0],
 256              scriptPubKey=p2wsh_script,
 257              amount=funding_amount,
 258          )
 259          self.generate(self.nodes[0], 1)
 260  
 261          # Parent tx: spends first P2WSH (high sigops), outputs to wallet
 262          tx_parent = CTransaction()
 263          tx_parent.vin = [CTxIn(COutPoint(int(fund_parent["txid"], 16), fund_parent["sent_vout"]))]
 264          tx_parent.wit.vtxinwit = [CTxInWitness()]
 265          tx_parent.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)]
 266          # Output back to a standard address (MiniWallet's default)
 267          tx_parent.vout = [CTxOut(funding_amount - 10000, self.wallet.get_output_script())]
 268          tx_parent.rehash()
 269  
 270          # Child tx: spends second P2WSH (high sigops) AND spends parent's output (to form package)
 271          tx_child = CTransaction()
 272          tx_child.vin = [
 273              CTxIn(COutPoint(int(fund_child["txid"], 16), fund_child["sent_vout"])),  # P2WSH input (sigops)
 274              CTxIn(COutPoint(tx_parent.sha256, 0)),  # Parent's output (links as child)
 275          ]
 276          tx_child.wit.vtxinwit = [CTxInWitness(), CTxInWitness()]
 277          tx_child.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)]  # For P2WSH input
 278          tx_child.wit.vtxinwit[1].scriptWitness.stack = [b'']  # Placeholder for wallet input
 279          tx_child.vout = [CTxOut(2 * funding_amount - 30000, self.wallet.get_output_script())]
 280          tx_child.rehash()
 281  
 282          # Separately, the parent tx is ok
 283          parent_individual_testres = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex()])[0]
 284          if not parent_individual_testres["allowed"]:
 285              self.log.error(f"Parent tx rejected: {parent_individual_testres}")
 286          assert parent_individual_testres["allowed"]
 287          assert_equal(parent_individual_testres["vsize"], expected_vsize_per_tx)
 288  
 289          # But together, it's exceeding limits in the *package* context. If sigops adjusted vsize wasn't being checked
 290          # here, it would get further in validation and give too-long-mempool-chain error instead.
 291          packet_test = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex(), tx_child.serialize().hex()])
 292          expected_package_error = f"package-mempool-limits, package size {2*expected_vsize_per_tx} exceeds ancestor size limit [limit: 101000]"
 293          assert_equal([x["package-error"] for x in packet_test], [expected_package_error] * 2)
 294  
 295          # When we actually try to submit, the parent makes it into the mempool, but the child would exceed ancestor vsize limits
 296          res = self.nodes[0].submitpackage([tx_parent.serialize().hex(), tx_child.serialize().hex()])
 297          assert "too-long-mempool-chain" in res["tx-results"][tx_child.getwtxid()]["error"]
 298          assert tx_parent.rehash() in self.nodes[0].getrawmempool()
 299  
 300          # Transactions are tiny in weight
 301          assert_greater_than(2000, tx_parent.get_weight() + tx_child.get_weight())
 302  
 303      def test_legacy_sigops_stdness(self):
 304          self.log.info("Test a transaction with too many legacy sigops in its inputs is non-standard.")
 305  
 306          # Restart with the test settings
 307          self.restart_node(0, extra_args=[f'-maxtxlegacysigops={MAX_STD_LEGACY_SIGOPS}'])
 308  
 309          # Create a P2SH script with 15 sigops.
 310          _, dummy_pubkey = generate_keypair()
 311          packed_redeem_script = [dummy_pubkey]
 312          for _ in range(MAX_STD_P2SH_SIGOPS - 1):
 313              packed_redeem_script += [OP_2DUP, OP_CHECKSIG, OP_DROP]
 314          packed_redeem_script = CScript(packed_redeem_script + [OP_CHECKSIG, OP_NOT])
 315          packed_p2sh_script = script_to_p2sh_script(packed_redeem_script)
 316  
 317          # Create enough outputs to reach the sigops limit when spending them all at once.
 318          outpoints = []
 319          for _ in range(int(MAX_STD_LEGACY_SIGOPS / MAX_STD_P2SH_SIGOPS) + 1):
 320              res = self.wallet.send_to(from_node=self.nodes[0], scriptPubKey=packed_p2sh_script, amount=1_000)
 321              txid = int.from_bytes(bytes.fromhex(res["txid"]), byteorder="big")
 322              outpoints.append(COutPoint(txid, res["sent_vout"]))
 323          self.generate(self.nodes[0], 1)
 324  
 325          # Spending all these outputs at once accounts for 2505 legacy sigops and is non-standard.
 326          nonstd_tx = CTransaction()
 327          nonstd_tx.vin = [CTxIn(op, CScript([b"", packed_redeem_script])) for op in outpoints]
 328          nonstd_tx.vout = [CTxOut(0, CScript([OP_RETURN, b""]))]
 329          assert_raises_rpc_error(-26, "bad-txns-input-sigops-toomany-overall", self.nodes[0].sendrawtransaction, nonstd_tx.serialize().hex())
 330  
 331          # Spending one less accounts for 2490 legacy sigops and is standard.
 332          std_tx = deepcopy(nonstd_tx)
 333          std_tx.vin.pop()
 334          self.nodes[0].sendrawtransaction(std_tx.serialize().hex())
 335  
 336          # Make sure the original, non-standard, transaction can be mined.
 337          self.generateblock(self.nodes[0], output="raw(42)", transactions=[nonstd_tx.serialize().hex()])
 338  
 339      def run_test(self):
 340          self.wallet = MiniWallet(self.nodes[0])
 341  
 342          for bytes_per_sigop in (DEFAULT_BYTES_PER_SIGOP, 43, 81, 165, 327, 649, 1072):
 343              if bytes_per_sigop == DEFAULT_BYTES_PER_SIGOP:
 344                  self.log.info(f"Test default sigops limit setting ({bytes_per_sigop} bytes per sigop)...")
 345              else:
 346                  bytespersigop_parameter = f"-bytespersigop={bytes_per_sigop}"
 347                  self.log.info(f"Test sigops limit setting {bytespersigop_parameter}...")
 348                  self.restart_node(0, extra_args=[bytespersigop_parameter] + self.extra_args[0])
 349  
 350              for num_sigops in (69, 101, 142, 183, 222):
 351                  self.test_sigops_limit(bytes_per_sigop, num_sigops)
 352  
 353              self.generate(self.wallet, 1)
 354  
 355          self.test_sigops_package()
 356          self.test_legacy_sigops_stdness()
 357  
 358  
 359  if __name__ == '__main__':
 360      BytesPerSigOpTest(__file__).main()
 361