feature_reduced_data_temporary_deployment.py raw

   1  #!/usr/bin/env python3
   2  # Copyright (c) 2025 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 temporary BIP9 deployment with active_duration parameter.
   6  
   7  This test verifies that a BIP9 deployment with active_duration properly expires
   8  after the specified number of blocks. We use REDUCED_DATA as the test deployment
   9  with active_duration=144 blocks.
  10  
  11  The test uses two nodes:
  12  - Node 0: BIP-110 enforcing (active_duration=144)
  13  - Node 1: Non-BIP-110 (never active, simulates Limenka)
  14  
  15  The test verifies:
  16  1. BIP9 state transitions: DEFINED -> STARTED -> LOCKED_IN -> ACTIVE
  17  2. Consensus rules ARE enforced during the active period (blocks 432-575)
  18  3. Chain split: BIP-110 node rejects invalid blocks, non-BIP-110 accepts
  19  4. Reorg: Longer valid chain wins when nodes reconnect
  20  5. Consensus rules STOP being enforced after expiry (block 576+)
  21  6. Post-expiry convergence: Both nodes accept the same blocks
  22  
  23  Expected timeline:
  24  - Period 0 (blocks 0-143): DEFINED
  25  - Period 1 (blocks 144-287): STARTED (signaling happens here)
  26  - Period 2 (blocks 288-431): LOCKED_IN
  27  - Period 3 (blocks 432-575): ACTIVE (144 blocks, rules enforced on node0 only)
  28  - Block 576+: EXPIRED (rules no longer enforced, nodes converge)
  29  """
  30  
  31  from test_framework.blocktools import (
  32      create_block,
  33      create_coinbase,
  34      add_witness_commitment,
  35  )
  36  from test_framework.messages import (
  37      CTxOut,
  38  )
  39  from test_framework.script import (
  40      CScript,
  41      OP_RETURN,
  42  )
  43  from test_framework.test_framework import LimenkaTestFramework
  44  from test_framework.util import assert_equal
  45  from test_framework.wallet import MiniWallet
  46  
  47  REDUCED_DATA_BIT = 4
  48  VERSIONBITS_TOP_BITS = 0x20000000
  49  
  50  
  51  class TemporaryDeploymentTest(LimenkaTestFramework):
  52      def set_test_params(self):
  53          self.num_nodes = 2
  54          self.setup_clean_chain = True
  55          # Node 0: BIP-110 with active_duration=144 blocks
  56          # Node 1: BIP-110 never active (simulates Limenka)
  57          # NEVER_ACTIVE = -2 for start_time prevents deployment from ever leaving DEFINED state
  58          self.extra_args = [
  59              ['-vbparams=reduced_data:0:999999999999:0:2147483647:144', '-acceptnonstdtxn=1'],
  60              ['-vbparams=reduced_data:-2:-1', '-acceptnonstdtxn=1'],
  61          ]
  62  
  63      def setup_network(self):
  64          self.setup_nodes()
  65          self.connect_nodes(0, 1)
  66  
  67      def create_block_for_node(self, node, txs=None, signal=False, time_offset=0):
  68          """Create a block for a specific node."""
  69          if txs is None:
  70              txs = []
  71          tip = node.getbestblockhash()
  72          height = node.getblockcount() + 1
  73          tip_header = node.getblockheader(tip)
  74          block_time = tip_header['time'] + 1 + time_offset
  75          block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs)
  76          if signal:
  77              block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT)
  78          add_witness_commitment(block)
  79          block.solve()
  80          return block
  81  
  82      def mine_blocks_on_node(self, node, count, signal=False):
  83          """Mine count blocks on a specific node."""
  84          for _ in range(count):
  85              block = self.create_block_for_node(node, signal=signal)
  86              node.submitblock(block.serialize().hex())
  87  
  88      def create_tx_with_large_output(self, wallet):
  89          """Create a transaction with 84-byte OP_RETURN (violates BIP-110's 83-byte limit)."""
  90          tx_dict = wallet.create_self_transfer()
  91          tx = tx_dict['tx']
  92          # 81 bytes data = 84-byte script (OP_RETURN + OP_PUSHDATA1 + len + data)
  93          tx.vout.append(CTxOut(0, CScript([OP_RETURN, b'x' * 81])))
  94          tx.rehash()
  95          return tx
  96  
  97      def get_deployment_status(self, node):
  98          """Get reduced_data deployment status."""
  99          info = node.getdeploymentinfo()
 100          rd = info['deployments']['reduced_data']
 101          if 'bip9' in rd:
 102              return rd['bip9']['status'], rd['bip9'].get('since', 'N/A')
 103          return rd.get('status'), rd.get('since', 'N/A')
 104  
 105      def run_test(self):
 106          node_bip110 = self.nodes[0]
 107          node_core = self.nodes[1]
 108  
 109          wallet = MiniWallet(node_bip110)
 110  
 111          # =====================================================================
 112          # Phase 1: Build common chain through BIP9 state transitions
 113          # =====================================================================
 114          self.log.info("Phase 1: Building common chain through BIP9 states")
 115  
 116          self.log.info("Mining initial blocks for spendable coins...")
 117          self.generate(wallet, 101)
 118          self.sync_all()
 119  
 120          status, _ = self.get_deployment_status(node_bip110)
 121          assert_equal(status, 'defined')
 122  
 123          # Mine to end of period 0
 124          self.log.info("Mining through period 0 (DEFINED)...")
 125          self.generate(node_bip110, 42)
 126          self.sync_all()
 127          assert_equal(node_bip110.getblockcount(), 143)
 128  
 129          # Period 1: Signal for activation
 130          self.log.info("Mining period 1 with signaling (STARTED)...")
 131          self.mine_blocks_on_node(node_bip110, 144, signal=True)
 132          self.sync_all()
 133          assert_equal(node_bip110.getblockcount(), 287)
 134          status, _ = self.get_deployment_status(node_bip110)
 135          assert_equal(status, 'started')
 136  
 137          # Period 2: Lock in
 138          self.log.info("Mining period 2 (LOCKED_IN)...")
 139          self.mine_blocks_on_node(node_bip110, 144, signal=True)
 140          self.sync_all()
 141          assert_equal(node_bip110.getblockcount(), 431)
 142          status, since = self.get_deployment_status(node_bip110)
 143          assert_equal(status, 'locked_in')
 144          assert_equal(since, 288)
 145  
 146          # =====================================================================
 147          # Phase 2: Test activation and chain split
 148          # =====================================================================
 149          self.log.info("Phase 2: Testing activation and chain split behavior")
 150  
 151          # Mine block 432 (activation)
 152          self.mine_blocks_on_node(node_bip110, 1)
 153          self.sync_all()
 154          assert_equal(node_bip110.getblockcount(), 432)
 155          status, since = self.get_deployment_status(node_bip110)
 156          self.log.info(f"Block 432 - Status: {status}, Since: {since}")
 157          assert_equal(status, 'active')
 158          assert_equal(since, 432)
 159  
 160          # Disconnect nodes BEFORE creating invalid block to prevent P2P relay
 161          # (Limenka relays blocks via compact blocks before full validation completes)
 162          self.log.info("Disconnecting nodes for chain split test...")
 163          self.disconnect_nodes(0, 1)
 164  
 165          # Create the invalid block (84-byte OP_RETURN violates BIP-110's 83-byte limit)
 166          self.log.info("Test: BIP-110 node rejects block with 84-byte OP_RETURN output")
 167          tx_invalid = self.create_tx_with_large_output(wallet)
 168          block_invalid = self.create_block_for_node(node_bip110, [tx_invalid])
 169  
 170          # Submit to BIP-110 node - should be rejected
 171          result_bip110 = node_bip110.submitblock(block_invalid.serialize().hex())
 172          assert_equal(result_bip110, 'bad-txns-vout-script-toolarge')
 173          assert_equal(node_bip110.getblockcount(), 432)
 174  
 175          # Submit to non-BIP-110 node - should be accepted
 176          self.log.info("Test: Non-BIP-110 node accepts the same block")
 177          result_core = node_core.submitblock(block_invalid.serialize().hex())
 178          assert_equal(result_core, None)
 179          assert_equal(node_core.getblockcount(), 433)
 180  
 181          # Chain split confirmed
 182          self.log.info(f"Chain split: BIP-110={node_bip110.getblockcount()}, Core={node_core.getblockcount()}")
 183  
 184          # =====================================================================
 185          # Phase 3: Test reorg behavior
 186          # =====================================================================
 187          self.log.info("Phase 3: Testing reorg behavior")
 188  
 189          # Non-BIP-110 extends its chain
 190          self.log.info("Non-BIP-110 node extends chain with 3 more blocks...")
 191          for i in range(3):
 192              block = self.create_block_for_node(node_core, time_offset=i)
 193              node_core.submitblock(block.serialize().hex())
 194          assert_equal(node_core.getblockcount(), 436)
 195  
 196          # BIP-110 node builds longer valid chain
 197          self.log.info("BIP-110 node builds longer valid chain (5 blocks)...")
 198          for i in range(5):
 199              block = self.create_block_for_node(node_bip110, time_offset=i+10)
 200              node_bip110.submitblock(block.serialize().hex())
 201          assert_equal(node_bip110.getblockcount(), 437)
 202  
 203          # Reconnect - non-BIP-110 should reorg to BIP-110's chain
 204          self.log.info("Reconnecting nodes - expecting reorg...")
 205          self.connect_nodes(0, 1)
 206          self.sync_blocks()
 207  
 208          assert_equal(node_core.getbestblockhash(), node_bip110.getbestblockhash())
 209          assert_equal(node_core.getblockcount(), 437)
 210          self.log.info(f"Reorg complete: both nodes at height {node_core.getblockcount()}")
 211  
 212          # =====================================================================
 213          # Phase 4: Test rules enforced until expiry
 214          # =====================================================================
 215          self.log.info("Phase 4: Testing rules enforced until expiry")
 216  
 217          # Mine to block 574 (one before last active block)
 218          # active_duration=144, activation at 432, so last active block is 432+144-1=575
 219          blocks_to_574 = 574 - node_bip110.getblockcount()
 220          self.log.info(f"Mining {blocks_to_574} blocks to reach block 574...")
 221          self.generate(node_bip110, blocks_to_574)
 222          self.sync_all()
 223          assert_equal(node_bip110.getblockcount(), 574)
 224  
 225          # Disconnect nodes to prevent compact block relay of invalid block
 226          self.disconnect_nodes(0, 1)
 227  
 228          # Verify rules still enforced at block 575 (last active block)
 229          self.log.info("Test: Rules still enforced at block 575 (last active block)")
 230          tx_invalid = self.create_tx_with_large_output(wallet)
 231          block_invalid = self.create_block_for_node(node_bip110, [tx_invalid])
 232          result = node_bip110.submitblock(block_invalid.serialize().hex())
 233          assert_equal(result, 'bad-txns-vout-script-toolarge')
 234  
 235          # Mine valid block 575 (last active block)
 236          block_valid = self.create_block_for_node(node_bip110)
 237          node_bip110.submitblock(block_valid.serialize().hex())
 238          assert_equal(node_bip110.getblockcount(), 575)
 239  
 240          # Reconnect and sync
 241          self.connect_nodes(0, 1)
 242          self.sync_all()
 243  
 244          # =====================================================================
 245          # Phase 5: Test expiry - rules no longer enforced
 246          # =====================================================================
 247          self.log.info("Phase 5: Testing expiry - rules no longer enforced")
 248  
 249          # At block 576, deployment has expired (first expired block = 432 + 144)
 250          self.log.info("Test: BIP-110 node accepts 'invalid' block at height 576 (expired)")
 251          tx_invalid = self.create_tx_with_large_output(wallet)
 252          block_after_expiry = self.create_block_for_node(node_bip110, [tx_invalid])
 253          result = node_bip110.submitblock(block_after_expiry.serialize().hex())
 254          assert_equal(result, None)
 255          self.sync_all()
 256          assert_equal(node_bip110.getblockcount(), 576)
 257  
 258          # Verify state machine reports EXPIRED
 259          status, since = self.get_deployment_status(node_bip110)
 260          self.log.info(f"Block 576: Status={status}, Since={since}")
 261          assert_equal(status, 'expired')
 262          assert_equal(since, 576)
 263  
 264          # =====================================================================
 265          # Phase 6: Test post-expiry convergence
 266          # =====================================================================
 267          self.log.info("Phase 6: Testing post-expiry convergence")
 268  
 269          # Both nodes should accept the same "invalid" blocks now
 270          self.log.info("Test: Both nodes accept 'invalid' blocks after expiry")
 271          for i in range(5):
 272              tx = self.create_tx_with_large_output(wallet)
 273              block = self.create_block_for_node(node_bip110, [tx], time_offset=i)
 274              result_bip110 = node_bip110.submitblock(block.serialize().hex())
 275              assert_equal(result_bip110, None)
 276              self.sync_all()
 277              assert_equal(node_core.getbestblockhash(), node_bip110.getbestblockhash())
 278  
 279          final_height = node_bip110.getblockcount()
 280          self.log.info(f"Final height: {final_height}, both nodes synced")
 281  
 282          # =====================================================================
 283          # Summary
 284          # =====================================================================
 285          self.log.info("All tests passed:")
 286          self.log.info("  - BIP9 state transitions (DEFINED -> STARTED -> LOCKED_IN -> ACTIVE -> EXPIRED)")
 287          self.log.info("  - Chain split at activation (BIP-110 rejects, Core accepts)")
 288          self.log.info("  - Reorg to longer valid chain on reconnect")
 289          self.log.info("  - Rules enforced during active period (432-575)")
 290          self.log.info("  - Rules not enforced after expiry (576+)")
 291          self.log.info("  - Post-expiry convergence (both nodes accept same blocks)")
 292  
 293  
 294  if __name__ == '__main__':
 295      TemporaryDeploymentTest(__file__).main()
 296