feature_reduced_data_utxo_height.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2025 The Limenka Knots 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 REDUCED_DATA soft fork UTXO height checking.
   6  
   7  This test verifies that the REDUCED_DATA deployment correctly exempts UTXOs
   8  created before ReducedDataHeightBegin from reduced_data script validation rules,
   9  as implemented in validation.cpp.
  10  
  11  Test scenarios:
  12  1. Old UTXO (created before activation) spent during active period with violation - should be ACCEPTED (EXEMPT)
  13  2. New UTXO (created during active period) spent with violation - should be REJECTED
  14  3. Mixed inputs (old + new UTXOs) in same transaction
  15  4. Boundary test: UTXO created at exactly ReducedDataHeightBegin
  16  """
  17  
  18  from io import BytesIO
  19  
  20  from test_framework.blocktools import (
  21      COINBASE_MATURITY,
  22      create_block,
  23      create_coinbase,
  24      add_witness_commitment,
  25  )
  26  from test_framework.messages import (
  27      COIN,
  28      COutPoint,
  29      CTransaction,
  30      CTxIn,
  31      CTxInWitness,
  32      CTxOut,
  33  )
  34  from test_framework.p2p import P2PDataStore
  35  from test_framework.script import (
  36      CScript,
  37      OP_TRUE,
  38      OP_DROP,
  39      hash256,
  40  )
  41  from test_framework.script_util import (
  42      script_to_p2wsh_script,
  43  )
  44  from test_framework.test_framework import LimenkaTestFramework
  45  from test_framework.util import (
  46      assert_equal,
  47  )
  48  from test_framework.wallet import MiniWallet
  49  
  50  
  51  # BIP9 constants for regtest
  52  BIP9_PERIOD = 144  # blocks per period in regtest
  53  BIP9_THRESHOLD = 108  # 75% of 144
  54  VERSIONBITS_TOP_BITS = 0x20000000
  55  REDUCED_DATA_BIT = 4
  56  
  57  # REDUCED_DATA enforces MAX_SCRIPT_ELEMENT_SIZE_REDUCED (256) instead of MAX_SCRIPT_ELEMENT_SIZE (520)
  58  MAX_ELEMENT_SIZE_STANDARD = 520
  59  MAX_ELEMENT_SIZE_REDUCED = 256
  60  VIOLATION_SIZE = 300  # Violates reduced (256) but OK for standard (520)
  61  
  62  
  63  class ReducedDataUTXOHeightTest(LimenkaTestFramework):
  64      def set_test_params(self):
  65          self.num_nodes = 1
  66          self.setup_clean_chain = True
  67          # Activate REDUCED_DATA using BIP9 with min_activation_height=288
  68          # Due to BIP9 design, period 0 is always DEFINED, so signaling happens in period 1
  69          # This activates at height 432 (start of period 3)
  70          # Format: deployment:start:timeout:min_activation_height:max_activation_height:active_duration
  71          # start_time=0, timeout=999999999999 (never), min_activation_height=288, max=2147483647 (INT_MAX, disabled), active_duration=2147483647 (permanent)
  72          self.extra_args = [[
  73              '-vbparams=reduced_data:0:999999999999:288:2147483647:2147483647',
  74          ]]
  75  
  76      def create_p2wsh_funding_and_spending_tx(self, wallet, node, witness_element_size):
  77          """Create a P2WSH output, then a transaction spending it with custom witness size.
  78  
  79          Returns:
  80              tuple: (funding_tx, spending_tx) where funding_tx creates P2WSH output,
  81                     spending_tx spends it with witness element of specified size
  82          """
  83          # Create a simple witness script: <data> OP_DROP OP_TRUE
  84          # This allows us to put arbitrary data in the witness
  85          witness_script = CScript([OP_DROP, OP_TRUE])
  86          script_pubkey = script_to_p2wsh_script(witness_script)
  87  
  88          # Use MiniWallet to create funding transaction to P2WSH output
  89          funding_txid = wallet.send_to(from_node=node, scriptPubKey=script_pubkey, amount=100000)['txid']
  90          funding_tx_hex = node.getrawtransaction(funding_txid)
  91          funding_tx = CTransaction()
  92          funding_tx.deserialize(BytesIO(bytes.fromhex(funding_tx_hex)))
  93          funding_tx.rehash()  # Calculate sha256 hash after deserializing
  94  
  95          # Find the P2WSH output
  96          p2wsh_vout = None
  97          for i, vout in enumerate(funding_tx.vout):
  98              if vout.scriptPubKey == script_pubkey:
  99                  p2wsh_vout = i
 100                  break
 101          assert p2wsh_vout is not None, "P2WSH output not found"
 102  
 103          # Spending transaction: spend P2WSH output with custom witness
 104          spending_tx = CTransaction()
 105          spending_tx.vin = [CTxIn(COutPoint(funding_tx.sha256, p2wsh_vout))]
 106          spending_tx.vout = [CTxOut(funding_tx.vout[p2wsh_vout].nValue - 1000, CScript([OP_TRUE]))]
 107  
 108          # Create witness with element of specified size
 109          spending_tx.wit.vtxinwit.append(CTxInWitness())
 110          spending_tx.wit.vtxinwit[0].scriptWitness.stack = [
 111              b'\x42' * witness_element_size,  # Data element of specified size
 112              witness_script  # Witness script
 113          ]
 114          spending_tx.rehash()
 115  
 116          return funding_tx, spending_tx
 117  
 118      def create_test_block(self, txs, signal=False):
 119          """Create a block with the given transactions."""
 120          # Always get fresh tip and height to ensure blocks chain correctly
 121          tip = self.nodes[0].getbestblockhash()
 122          height = self.nodes[0].getblockcount() + 1
 123          tip_header = self.nodes[0].getblockheader(tip)
 124          block_time = tip_header['time'] + 1
 125          block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs)
 126          if signal:
 127              block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT)
 128          add_witness_commitment(block)
 129          block.solve()
 130          return block
 131  
 132      def mine_blocks(self, count, signal=False):
 133          """Mine blocks with optional BIP9 signaling for REDUCED_DATA."""
 134          for _ in range(count):
 135              block = self.create_test_block([], signal=signal)
 136              result = self.nodes[0].submitblock(block.serialize().hex())
 137              if result is not None:
 138                  raise AssertionError(f"submitblock failed: {result}")
 139              # Verify block was accepted
 140              assert self.nodes[0].getbestblockhash() == block.hash
 141  
 142      def run_test(self):
 143          node = self.nodes[0]
 144          self.peer = node.add_p2p_connection(P2PDataStore())
 145  
 146          # Use MiniWallet for easy UTXO management
 147          wallet = MiniWallet(node)
 148  
 149          self.log.info("Mining blocks to activate REDUCED_DATA via BIP9...")
 150  
 151          # BIP9 state timeline with start_time=0:
 152          # - Period 0 (blocks 0-143): DEFINED (cannot signal yet)
 153          # - Period 1 (blocks 144-287): STARTED (signal here with 108/144 threshold)
 154          # - Period 2 (blocks 288-431): LOCKED_IN (if threshold met in period 1)
 155          # - Period 3 (blocks 432-575): ACTIVE
 156  
 157          # Mine through period 0 (DEFINED state)
 158          self.log.info("Mining through period 0 (DEFINED)...")
 159          self.generate(wallet, 144)
 160          self.log.info(f"DEBUG: After period 0, height = {node.getblockcount()}")
 161  
 162          # Mine 108 signaling blocks in period 1 (STARTED state)
 163          self.log.info("Mining 108 signaling blocks in period 1 (blocks 144-251)...")
 164          self.mine_blocks(108, signal=True)
 165          self.log.info(f"DEBUG: After 108 signaling blocks, height = {node.getblockcount()}")
 166  
 167          # Mine to end of period 1 (block 287)
 168          self.log.info("Mining to end of period 1 (block 287)...")
 169          self.mine_blocks(287 - 144 - 108, signal=False)
 170          self.log.info(f"DEBUG: After period 1, height = {node.getblockcount()}")
 171  
 172          # Check that we're LOCKED_IN at start of period 2
 173          self.generate(wallet, 1)  # Mine block 288
 174          self.log.info(f"DEBUG: After mining block 288, height = {node.getblockcount()}")
 175          deployment_info = node.getdeploymentinfo()
 176          rd_info = deployment_info['deployments']['reduced_data']
 177          if 'bip9' in rd_info:
 178              status = rd_info['bip9']['status']
 179              self.log.info(f"At height {node.getblockcount()}, REDUCED_DATA status: {status}")
 180              assert status == 'locked_in', f"Expected LOCKED_IN at block 288, got {status}"
 181          else:
 182              raise AssertionError("REDUCED_DATA deployment not found")
 183  
 184          # Mine to block 432 (start of period 3) where activation occurs
 185          self.log.info("Mining to block 432 for activation...")
 186          self.generate(wallet, 432 - 288)
 187  
 188          current_height = node.getblockcount()
 189  
 190          # Check activation status
 191          deployment_info = node.getdeploymentinfo()
 192          rd_info = deployment_info['deployments']['reduced_data']
 193          if 'bip9' in rd_info:
 194              status = rd_info['bip9']['status']
 195              self.log.info(f"At height {current_height}, REDUCED_DATA status: {status}")
 196              if status == 'active':
 197                  ACTIVATION_HEIGHT = rd_info['bip9']['since']
 198              else:
 199                  raise AssertionError(f"REDUCED_DATA not active at height {current_height}, status: {status}")
 200          else:
 201              raise AssertionError("REDUCED_DATA deployment not found")
 202  
 203          self.log.info(f"✓ REDUCED_DATA activated at height {ACTIVATION_HEIGHT}")
 204          assert ACTIVATION_HEIGHT == 432, f"Expected activation at 432, got {ACTIVATION_HEIGHT}"
 205  
 206          # Initialize wallet with some coins
 207          self.generate(wallet, COINBASE_MATURITY + 10)
 208          current_height = node.getblockcount()
 209  
 210          # Now rewind to before activation to create test UTXOs
 211          # Save the tip so we can restore later
 212          activation_tip = node.getbestblockhash()
 213  
 214          # Rewind to 20 blocks before activation
 215          target_height = ACTIVATION_HEIGHT - 20
 216          blocks_to_invalidate = current_height - target_height
 217          self.log.info(f"Rewinding {blocks_to_invalidate} blocks to height {target_height}...")
 218          for _ in range(blocks_to_invalidate):
 219              node.invalidateblock(node.getbestblockhash())
 220  
 221          assert_equal(node.getblockcount(), target_height)
 222  
 223          # ======================================================================
 224          # Test 1: Create OLD UTXO before activation
 225          # ======================================================================
 226          self.log.info("Test 1: Creating P2WSH UTXO before activation height...")
 227  
 228          # Create P2WSH funding transaction for old UTXO
 229          old_funding_tx, old_spending_tx = self.create_p2wsh_funding_and_spending_tx(
 230              wallet, node, VIOLATION_SIZE
 231          )
 232  
 233          # Confirm the funding transaction in a block
 234          block = self.create_test_block([old_funding_tx], signal=False)
 235          node.submitblock(block.serialize().hex())
 236          old_utxo_height = node.getblockcount()
 237  
 238          self.log.info(f"Created old P2WSH UTXO at height {old_utxo_height} (< {ACTIVATION_HEIGHT})")
 239  
 240          # ======================================================================
 241          # Test 2: Mine to activation height
 242          # ======================================================================
 243          self.log.info("Test 2: Mining to activation height...")
 244  
 245          current_height = node.getblockcount()
 246          blocks_to_activation = ACTIVATION_HEIGHT - current_height
 247          if blocks_to_activation > 0:
 248              self.mine_blocks(blocks_to_activation, signal=False)
 249  
 250          current_height = node.getblockcount()
 251          assert_equal(current_height, ACTIVATION_HEIGHT)
 252          self.log.info(f"At activation height: {current_height}")
 253  
 254          # Verify REDUCED_DATA is active
 255          deployment_info = node.getdeploymentinfo()
 256          rd_info = deployment_info['deployments']['reduced_data']
 257          if 'bip9' in rd_info:
 258              status = rd_info['bip9']['status']
 259          else:
 260              status = 'active' if rd_info.get('active') else 'unknown'
 261          assert status == 'active', f"Expected 'active' at height {current_height}, got '{status}'"
 262  
 263          # ======================================================================
 264          # Test 3: Create NEW UTXO at/after activation
 265          # ======================================================================
 266          self.log.info("Test 3: Creating P2WSH UTXO at activation height...")
 267  
 268          # Create P2WSH funding transaction for new UTXO
 269          new_funding_tx, new_spending_tx = self.create_p2wsh_funding_and_spending_tx(
 270              wallet, node, VIOLATION_SIZE
 271          )
 272  
 273          # Confirm the funding transaction in a block
 274          block = self.create_test_block([new_funding_tx], signal=False)
 275          node.submitblock(block.serialize().hex())
 276          new_utxo_height = node.getblockcount()
 277  
 278          self.log.info(f"Created new P2WSH UTXO at height {new_utxo_height} (>= {ACTIVATION_HEIGHT})")
 279  
 280          # Mine a few more blocks
 281          self.mine_blocks(5, signal=False)
 282          current_height = node.getblockcount()
 283          self.log.info(f"Current height: {current_height}")
 284  
 285          # ======================================================================
 286          # Test 4: Spend OLD UTXO with oversized witness - should be ACCEPTED
 287          # ======================================================================
 288          self.log.info(f"Test 4: Spending old UTXO (height {old_utxo_height}) with {VIOLATION_SIZE}-byte witness element...")
 289          self.log.info(f"        This violates REDUCED_DATA ({MAX_ELEMENT_SIZE_REDUCED} limit) but old UTXOs should be EXEMPT")
 290  
 291          # Try to mine block with old_spending_tx (has 300-byte witness element)
 292          block = self.create_test_block([old_spending_tx], signal=False)
 293          result = node.submitblock(block.serialize().hex())
 294          assert result is None, f"Expected success, got: {result}"
 295  
 296          self.log.info(f"✓ SUCCESS: Old UTXO with {VIOLATION_SIZE}-byte witness element was ACCEPTED (correctly exempt)")
 297  
 298          # ======================================================================
 299          # Test 5: Spend NEW UTXO with oversized witness - should be REJECTED
 300          # ======================================================================
 301          self.log.info(f"Test 5: Spending new UTXO (height {new_utxo_height}) with {VIOLATION_SIZE}-byte witness element...")
 302          self.log.info(f"        This violates REDUCED_DATA ({MAX_ELEMENT_SIZE_REDUCED} limit) and should be REJECTED")
 303  
 304          # Try to mine block with new_spending_tx (has 300-byte witness element)
 305          block = self.create_test_block([new_spending_tx], signal=False)
 306          result = node.submitblock(block.serialize().hex())
 307          assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}"
 308  
 309          self.log.info(f"✓ SUCCESS: New UTXO with {VIOLATION_SIZE}-byte witness element was REJECTED (correctly enforced)")
 310  
 311          # ======================================================================
 312          # Test 6: Boundary test - UTXO at exactly ReducedDataHeightBegin
 313          # ======================================================================
 314          self.log.info(f"Test 6: Boundary test - verifying UTXO at activation height {ACTIVATION_HEIGHT}...")
 315  
 316          # The new_funding_tx was confirmed at height ACTIVATION_HEIGHT+1, but let's create one AT height ACTIVATION_HEIGHT
 317          # First, invalidate back to height ACTIVATION_HEIGHT-1
 318          current_tip = node.getbestblockhash()
 319          blocks_to_invalidate = node.getblockcount() - (ACTIVATION_HEIGHT - 1)
 320          for _ in range(blocks_to_invalidate):
 321              node.invalidateblock(node.getbestblockhash())
 322  
 323          assert_equal(node.getblockcount(), ACTIVATION_HEIGHT - 1)
 324          self.log.info(f"        Rewound to height {node.getblockcount()}")
 325  
 326          # Create UTXO exactly at activation height
 327          boundary_funding_tx, boundary_spending_tx = self.create_p2wsh_funding_and_spending_tx(
 328              wallet, node, VIOLATION_SIZE
 329          )
 330          block = self.create_test_block([boundary_funding_tx], signal=False)
 331          result = node.submitblock(block.serialize().hex())
 332          assert result is None, f"Expected success, got: {result}"
 333          boundary_height = node.getblockcount()
 334          assert_equal(boundary_height, ACTIVATION_HEIGHT)
 335  
 336          self.log.info(f"        Created boundary UTXO at height {boundary_height} (exactly at activation)")
 337  
 338          # Mine a few blocks past activation
 339          self.mine_blocks(5, signal=False)
 340  
 341          # Try to spend boundary UTXO - should be REJECTED (height ACTIVATION_HEIGHT >= ACTIVATION_HEIGHT)
 342          self.log.info(f"        Spending boundary UTXO with {VIOLATION_SIZE}-byte witness (should be REJECTED)")
 343          block = self.create_test_block([boundary_spending_tx], signal=False)
 344          result = node.submitblock(block.serialize().hex())
 345          assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}"
 346  
 347          self.log.info(f"✓ SUCCESS: UTXO at exactly activation height {ACTIVATION_HEIGHT} is SUBJECT to rules (not exempt)")
 348  
 349          # Restore chain to where we were
 350          node.reconsiderblock(current_tip)
 351  
 352          # ======================================================================
 353          # Test 7: Mixed inputs - one old (exempt) + one new (subject to rules)
 354          # ======================================================================
 355          self.log.info("Test 7: Creating transaction with mixed inputs (old + new UTXOs)...")
 356  
 357          # We need fresh old and new UTXOs. Rewind to before activation again
 358          current_tip2 = node.getbestblockhash()
 359          blocks_to_invalidate = node.getblockcount() - (ACTIVATION_HEIGHT - 20)
 360          for _ in range(blocks_to_invalidate):
 361              node.invalidateblock(node.getbestblockhash())
 362  
 363          # Create OLD UTXO at height before activation
 364          old_mixed_funding, old_mixed_spending = self.create_p2wsh_funding_and_spending_tx(
 365              wallet, node, VIOLATION_SIZE
 366          )
 367          block = self.create_test_block([old_mixed_funding], signal=False)
 368          node.submitblock(block.serialize().hex())
 369          old_mixed_height = node.getblockcount()
 370          self.log.info(f"        Created old UTXO at height {old_mixed_height}")
 371  
 372          # Mine to after activation
 373          blocks_to_mine = ACTIVATION_HEIGHT - node.getblockcount() + 5
 374          self.mine_blocks(blocks_to_mine, signal=False)
 375  
 376          # Create NEW UTXO at height after activation
 377          new_mixed_funding, new_mixed_spending = self.create_p2wsh_funding_and_spending_tx(
 378              wallet, node, VIOLATION_SIZE
 379          )
 380          block = self.create_test_block([new_mixed_funding], signal=False)
 381          node.submitblock(block.serialize().hex())
 382          new_mixed_height = node.getblockcount()
 383          self.log.info(f"        Created new UTXO at height {new_mixed_height}")
 384  
 385          # Find P2WSH outputs in funding transactions
 386          witness_script = CScript([OP_DROP, OP_TRUE])
 387          script_pubkey = script_to_p2wsh_script(witness_script)
 388  
 389          old_p2wsh_vout = None
 390          for i, vout in enumerate(old_mixed_funding.vout):
 391              if vout.scriptPubKey == script_pubkey:
 392                  old_p2wsh_vout = i
 393                  break
 394  
 395          new_p2wsh_vout = None
 396          for i, vout in enumerate(new_mixed_funding.vout):
 397              if vout.scriptPubKey == script_pubkey:
 398                  new_p2wsh_vout = i
 399                  break
 400  
 401          # Create transaction with BOTH inputs
 402          mixed_tx = CTransaction()
 403          mixed_tx.vin = [
 404              CTxIn(COutPoint(old_mixed_funding.sha256, old_p2wsh_vout)),  # Old UTXO (exempt)
 405              CTxIn(COutPoint(new_mixed_funding.sha256, new_p2wsh_vout)),  # New UTXO (subject to rules)
 406          ]
 407          total_value = (old_mixed_funding.vout[old_p2wsh_vout].nValue +
 408                        new_mixed_funding.vout[new_p2wsh_vout].nValue - 2000)
 409          mixed_tx.vout = [CTxOut(total_value, CScript([OP_TRUE]))]
 410  
 411          # Add witness for both inputs - both with 300-byte elements
 412          mixed_tx.wit.vtxinwit = []
 413  
 414          # Input 0: old UTXO (would pass alone)
 415          wit0 = CTxInWitness()
 416          wit0.scriptWitness.stack = [b'\x42' * VIOLATION_SIZE, witness_script]
 417          mixed_tx.wit.vtxinwit.append(wit0)
 418  
 419          # Input 1: new UTXO (would fail)
 420          wit1 = CTxInWitness()
 421          wit1.scriptWitness.stack = [b'\x42' * VIOLATION_SIZE, witness_script]
 422          mixed_tx.wit.vtxinwit.append(wit1)
 423  
 424          mixed_tx.rehash()
 425  
 426          self.log.info(f"        Mixed tx: old UTXO (height {old_mixed_height}, exempt) + new UTXO (height {new_mixed_height}, subject)")
 427          self.log.info(f"        Both inputs have {VIOLATION_SIZE}-byte witness elements")
 428  
 429          # Try to mine block - should REJECT because new input violates
 430          self.mine_blocks(2, signal=False)
 431          block = self.create_test_block([mixed_tx], signal=False)
 432          result = node.submitblock(block.serialize().hex())
 433          assert result is not None and 'mandatory-script-verify-flag-failed' in result, f"Expected rejection, got: {result}"
 434  
 435          self.log.info("✓ SUCCESS: Mixed transaction REJECTED (new input violated rules, even though old input was exempt)")
 436  
 437          # Restore chain
 438          node.reconsiderblock(current_tip2)
 439  
 440          # ======================================================================
 441          # Test 8: cache state must not survive activation-boundary reorg
 442          # ======================================================================
 443          self.log.info("Test 8: script-execution cache must not survive boundary-context flip")
 444  
 445          def rewind_to(height):
 446              # Height-based loop: invalidating one tip can switch to an alternate branch at same height.
 447              while node.getblockcount() > height:
 448                  node.invalidateblock(node.getbestblockhash())
 449              assert_equal(node.getblockcount(), height)
 450  
 451          branch_point = ACTIVATION_HEIGHT - 2  # 430
 452          rewind_to(branch_point)
 453  
 454          # spend_tx has a 300-byte witness element: valid only with pre-activation exemption.
 455          funding_tx, spend_tx = self.create_p2wsh_funding_and_spending_tx(wallet, node, VIOLATION_SIZE)
 456  
 457          # Branch A: funding at 431 (exempt).
 458          block = self.create_test_block([funding_tx], signal=False)
 459          assert_equal(node.submitblock(block.serialize().hex()), None)
 460          assert_equal(node.getblockcount(), ACTIVATION_HEIGHT - 1)
 461  
 462          self.restart_node(0, extra_args=['-vbparams=reduced_data:0:999999999999:288:2147483647:2147483647', '-par=1'])  # Use single-threaded validation to maximize chance of hitting cache-related issues.
 463  
 464          # Validate-only block at height 432. This calls TestBlockValidity(fJustCheck=true),
 465          # which populates the tx-wide script-execution cache under STRICT flags, even though
 466          # the spend is only valid here due to the per-input "pre-activation UTXO" exemption.
 467          self.generateblock(node, output=wallet.get_address(), transactions=[spend_tx.serialize().hex()], submit=False, sync_fun=self.no_op)
 468  
 469          assert_equal(node.getblockcount(), ACTIVATION_HEIGHT - 1)
 470  
 471          # Reorg to branch point; cache state is intentionally retained across reorg.
 472          rewind_to(branch_point)
 473  
 474          # Branch B: funding at 432 (non-exempt).
 475          # Make this empty block unique to avoid duplicate-invalid when rebuilding branch B.
 476          block = self.create_test_block([], signal=False)
 477          block.nTime += 1
 478          block.solve()
 479          assert_equal(node.submitblock(block.serialize().hex()), None)  # 431
 480          block = self.create_test_block([funding_tx], signal=False)
 481          assert_equal(node.submitblock(block.serialize().hex()), None)  # 432
 482  
 483          # Same spend is now non-exempt and must be rejected.
 484          attack_block = self.create_test_block([spend_tx], signal=False)  # 433
 485          result = node.submitblock(attack_block.serialize().hex())
 486          assert result is not None and 'Push value size limit exceeded' in result, \
 487              f"Expected rejection after boundary-crossing reorg, got: {result}"
 488  
 489          self.log.info("✓ SUCCESS: Cache poisoning via activation-boundary reorg correctly prevented")
 490  
 491          # ======================================================================
 492          # Summary
 493          # ======================================================================
 494          self.log.info(f"""
 495          ============================================================
 496          TEST SUMMARY - UTXO Height-Based REDUCED_DATA Enforcement
 497          ============================================================
 498  
 499          ✓ Test 1-3: Setup old and new UTXOs at correct heights
 500          ✓ Test 4: Old UTXO (height < {ACTIVATION_HEIGHT}) is EXEMPT - 300-byte witness ACCEPTED
 501          ✓ Test 5: New UTXO (height >= {ACTIVATION_HEIGHT}) is SUBJECT - 300-byte witness REJECTED
 502          ✓ Test 6: Boundary condition - UTXO at exactly height {ACTIVATION_HEIGHT} is SUBJECT
 503          ✓ Test 7: Mixed inputs - transaction rejected if ANY input violates
 504          ✓ Test 8: Cache poisoning via activation-boundary reorg prevented
 505  
 506          Key validations:
 507          • REDUCED_DATA activated via BIP9 signaling at height {ACTIVATION_HEIGHT}
 508          • UTXOs created before activation height are EXEMPT from rules
 509          • UTXOs created at/after activation height are SUBJECT to rules
 510          • Per-input validation flags work correctly (validation.cpp)
 511          • Boundary at activation height uses >= operator (not >)
 512  
 513          This confirms the implementation of UTXO height exemption:
 514          "Exempt inputs spending UTXOs prior to ReducedDataHeightBegin from
 515          reduced_data script validation rules"
 516  
 517          All 8 tests passed!
 518          ============================================================
 519          """)
 520  
 521  
 522  if __name__ == '__main__':
 523      ReducedDataUTXOHeightTest(__file__).main()
 524