feature_bip9_max_activation_height.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 max_activation_height for mandatory BIP9 activation.
   6  
   7  This test verifies that BIP9 deployments with max_activation_height properly
   8  activate at the specified height regardless of miner signaling, similar to BIP8.
   9  
  10  The test verifies four critical scenarios:
  11  1. Mandatory activation at max_height without signaling
  12  2. Normal deployment without max_height (requires signaling)
  13  3. Early activation via signaling before reaching max_height
  14  4. Max_height overrides timeout
  15  
  16  Expected behavior:
  17  - When max_activation_height is set and reached while in STARTED state,
  18    the deployment transitions to LOCKED_IN (then ACTIVE) regardless of signaling
  19  - Max_activation_height overrides timeout
  20  - Once ACTIVE, the deployment remains ACTIVE permanently (terminal state)
  21  - Without max_activation_height, activation requires sufficient signaling
  22  """
  23  
  24  from test_framework.blocktools import (
  25      create_block,
  26      create_coinbase,
  27      add_witness_commitment,
  28  )
  29  from test_framework.test_framework import LimenkaTestFramework
  30  from test_framework.util import assert_equal
  31  
  32  TESTDUMMY_BIT = 28
  33  VERSIONBITS_TOP_BITS = 0x20000000
  34  
  35  
  36  class MaxActivationHeightTest(LimenkaTestFramework):
  37      def set_test_params(self):
  38          self.num_nodes = 6  # 6 nodes for tests 1-6 (test 0 validation is done separately)
  39          self.setup_clean_chain = True
  40          # NO_TIMEOUT = std::numeric_limits<int64_t>::max() = 9223372036854775807
  41          NO_TIMEOUT = '9223372036854775807'
  42          # INT_MAX = std::numeric_limits<int>::max() = 2147483647
  43          INT_MAX = '2147483647'
  44          self.extra_args = [
  45              [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'],      # Test 1: max_height=576 (shows full flow)
  46              ['-vbparams=testdummy:0:999999999999'],              # Test 2: no max_height (uses timeout)
  47              [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:576'],      # Test 3: max_height=576 (early activation)
  48              [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432'],      # Test 4: verify permanent ACTIVE
  49              [f'-vbparams=testdummy:0:{NO_TIMEOUT}:0:432:144'],  # Test 5: max_height + active_duration
  50              [f'-vbparams=testdummy:0:999999999999:0:{INT_MAX}:{INT_MAX}:72'],  # Test 6: custom 50% threshold (72/144)
  51          ]
  52  
  53      def setup_network(self):
  54          """Keep nodes isolated - don't connect them to each other"""
  55          self.add_nodes(self.num_nodes)
  56          for i in range(self.num_nodes):
  57              self.start_node(i, extra_args=self.extra_args[i])
  58          # Nodes remain disconnected for independent blockchain testing
  59  
  60      def mine_blocks(self, node, count, signal=False):
  61          """Mine count blocks, optionally signaling for testdummy."""
  62          for i in range(count):
  63              tip = node.getbestblockhash()
  64              height = node.getblockcount() + 1
  65              tip_header = node.getblockheader(tip)
  66              block_time = tip_header['time'] + 1
  67              block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time)
  68              if signal:
  69                  block.nVersion = VERSIONBITS_TOP_BITS | (1 << TESTDUMMY_BIT)
  70              add_witness_commitment(block)
  71              block.solve()
  72              node.submitblock(block.serialize().hex())
  73  
  74              # Log every 20 blocks and at key heights for debugging
  75              if height % 20 == 0 or height == 143 or height == 144:
  76                  mtp = node.getblockheader(node.getbestblockhash())['mediantime']
  77                  self.log.info(f"  Block {height}: time={block_time}, MTP={mtp}")
  78  
  79      def get_status(self, node):
  80          """Get testdummy deployment status."""
  81          info = node.getdeploymentinfo()
  82          td = info['deployments']['testdummy']
  83          if 'bip9' in td:
  84              return td['bip9']['status'], td['bip9'].get('since', 0)
  85          return td.get('status', 'unknown'), 0
  86  
  87      def run_test(self):
  88          # Test 0: Verify validation rejects both timeout and max_activation_height
  89          self.log.info("=== TEST 0: Validation test - reject both timeout and max_activation_height ===")
  90          self.log.info("Attempting to start limenkad with both timeout and max_activation_height...")
  91  
  92          # Run limenkad directly with invalid config to test validation
  93          import subprocess
  94          import os
  95  
  96          # Get the limenkad binary path from the test framework
  97          limenkad_path = self.options.limenkad
  98  
  99          # Create a temporary datadir for this test
 100          import tempfile
 101          with tempfile.TemporaryDirectory() as tmpdir:
 102              try:
 103                  # Run limenkad with invalid vbparams (both timeout and max_activation_height)
 104                  result = subprocess.run(
 105                      [limenkad_path, f'-datadir={tmpdir}', '-regtest',
 106                       '-vbparams=testdummy:0:1:0:432'],  # timeout=1, max_activation_height=432
 107                      capture_output=True,
 108                      text=True,
 109                      timeout=5
 110                  )
 111  
 112                  # If we get here with exit code 0, the validation failed
 113                  if result.returncode == 0:
 114                      raise AssertionError("limenkad should have failed to start with both timeout and max_activation_height")
 115  
 116                  # Check that the error message contains the expected validation error
 117                  error_output = result.stderr
 118                  self.log.info(f"limenkad correctly failed with error: {error_output[:200]}")
 119  
 120                  assert "Cannot specify both timeout" in error_output and "max_activation_height" in error_output, \
 121                      f"Expected validation error about both parameters, got: {error_output}"
 122  
 123                  self.log.info("SUCCESS: Validation correctly rejected invalid configuration")
 124  
 125              except subprocess.TimeoutExpired:
 126                  raise AssertionError("limenkad timed out (should have failed immediately with validation error)")
 127  
 128          self.log.info("\n=== Test: max_activation_height=576 (full flow with non-mandatory period) ===")
 129          node = self.nodes[0]
 130  
 131          # Check deployment info to verify max_activation_height is set
 132          info = node.getdeploymentinfo()
 133          self.log.info(f"Deployment info: {info['deployments']['testdummy']}")
 134  
 135          # Period 0 (0-143): DEFINED
 136          self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---")
 137          self.mine_blocks(node, 143, signal=False)
 138          assert_equal(node.getblockcount(), 143)
 139          status, since = self.get_status(node)
 140          self.log.info(f"Block 143: Status={status}")
 141          assert_equal(status, 'defined')
 142  
 143          # Block 144: Transition to STARTED
 144          self.log.info("\n--- Block 144: Transition to STARTED ---")
 145          self.mine_blocks(node, 1, signal=False)
 146          status, since = self.get_status(node)
 147          self.log.info(f"Block 144: Status={status}, Since={since}")
 148          assert_equal(status, 'started')
 149          assert_equal(since, 144)
 150  
 151          # Period 1 (144-287): STARTED
 152          self.log.info("\n--- Period 1 (blocks 145-287): STARTED ---")
 153          self.mine_blocks(node, 143, signal=False)
 154          assert_equal(node.getblockcount(), 287)
 155          status, since = self.get_status(node)
 156          self.log.info(f"Block 287: Status={status}")
 157          assert_equal(status, 'started')
 158  
 159          # Period 2 (288-431): STARTED - forced lock-in will occur at end of this period
 160          self.log.info("\n--- Period 2 (blocks 288-431): STARTED ---")
 161          self.log.info("Forced lock-in will occur at block 432 (max_activation_height - nPeriod)")
 162  
 163          # Try to mine block 288 without signaling - should be REJECTED
 164          self.log.info("\nNEGATIVE TEST: Attempting to mine block 288 without signaling...")
 165          tip = node.getbestblockhash()
 166          height = node.getblockcount() + 1
 167          tip_header = node.getblockheader(tip)
 168          block_time = tip_header['time'] + 1
 169          block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time)
 170          block.nVersion = VERSIONBITS_TOP_BITS  # No signaling bit
 171          add_witness_commitment(block)
 172          block.solve()
 173          result = node.submitblock(block.serialize().hex())
 174          self.log.info(f"Submitblock result (should be rejected): {result}")
 175          # Block should be rejected - check we're still at block 287
 176          assert_equal(node.getblockcount(), 287)
 177          self.log.info("SUCCESS: Block without signaling was correctly REJECTED during enforcement window")
 178  
 179          # Now mine Period 2 with proper signaling
 180          self.log.info("\nMining Period 2 with proper signaling...")
 181          self.mine_blocks(node, 144, signal=True)
 182          assert_equal(node.getblockcount(), 431)
 183          status, since = self.get_status(node)
 184          self.log.info(f"Block 431: Status={status}")
 185          assert_equal(status, 'started')
 186  
 187          # Period 3 (432-575): LOCKED_IN (forced by max_activation_height)
 188          self.log.info("\n--- Period 3 (blocks 432-575): LOCKED_IN ---")
 189          self.mine_blocks(node, 1, signal=False)  # Mine block 432
 190          assert_equal(node.getblockcount(), 432)
 191          status, since = self.get_status(node)
 192          self.log.info(f"Block 432: Status={status}, Since={since}")
 193          assert_equal(status, 'locked_in')
 194          assert_equal(since, 432)
 195  
 196          # Mine through period 3 to activate at block 576
 197          self.log.info("Mining blocks 433-575...")
 198          self.mine_blocks(node, 143, signal=False)
 199          assert_equal(node.getblockcount(), 575)
 200          status, since = self.get_status(node)
 201          assert_equal(status, 'locked_in')
 202  
 203          # Period 4 (576+): ACTIVE
 204          self.log.info("\n--- Period 4 (block 576+): ACTIVE ---")
 205          self.mine_blocks(node, 1, signal=False)
 206          assert_equal(node.getblockcount(), 576)
 207          status, since = self.get_status(node)
 208          self.log.info(f"Block 576: Status={status}, Since={since}")
 209          assert_equal(status, 'active')
 210          assert_equal(since, 576)
 211  
 212          self.log.info("\n=== TEST 1 COMPLETE ===")
 213          self.log.info("Summary: max_activation_height=576 test passed")
 214          self.log.info("- Deployment activated at height 576 via forced lock-in at 432")
 215          self.log.info("- Mandatory signaling enforced during blocks 288-431 (BIP148-style)")
 216          self.log.info("- Non-signaling blocks rejected during enforcement window")
 217          self.log.info("- Non-signaling blocks accepted outside enforcement window")
 218  
 219          # Test 2: Deployment without max_height requires signaling
 220          self.log.info("\n\n=== TEST 2: Deployment without max_height requires signaling ===")
 221          node = self.nodes[1]
 222  
 223          # Period 0 (0-143): DEFINED
 224          self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---")
 225          self.mine_blocks(node, 143, signal=False)
 226          assert_equal(node.getblockcount(), 143)
 227          status, since = self.get_status(node)
 228          self.log.info(f"Block 143: Status={status}")
 229          assert_equal(status, 'defined')
 230  
 231          # Mine period 1 (blocks 144-287) without signaling - should transition to STARTED
 232          self.log.info("Mining period 1 (blocks 144-287) without signaling...")
 233          self.mine_blocks(node, 144, signal=False)
 234          assert_equal(node.getblockcount(), 287)
 235          status, since = self.get_status(node)
 236          self.log.info(f"Block 287: Status={status}")
 237          assert_equal(status, 'started')
 238  
 239          # Mine period 2 (blocks 288-431) without signaling - should remain STARTED
 240          self.log.info("Mining period 2 (blocks 288-431) without signaling...")
 241          self.mine_blocks(node, 144, signal=False)
 242          status, since = self.get_status(node)
 243          self.log.info(f"Block 431: Status={status}")
 244          assert_equal(status, 'started')  # Should NOT lock in without signaling
 245  
 246          # Mine period 3 (blocks 432-575) without signaling - should remain STARTED
 247          self.log.info("Mining period 3 (blocks 432-575) without signaling...")
 248          self.mine_blocks(node, 144, signal=False)
 249          status, since = self.get_status(node)
 250          self.log.info(f"Block 575: Status={status}")
 251          assert_equal(status, 'started')  # Still STARTED without signaling
 252  
 253          self.log.info("\n=== TEST 2 COMPLETE ===")
 254          self.log.info("SUCCESS: Deployment did NOT activate without signaling (no max_height)")
 255  
 256          # Test 3: Early activation via signaling before max_height
 257          self.log.info("\n\n=== TEST 3: Early activation via signaling before max_height ===")
 258          node = self.nodes[2]
 259  
 260          # Period 0 (0-143): DEFINED
 261          self.log.info("\n--- Period 0 (blocks 0-143): DEFINED ---")
 262          self.mine_blocks(node, 143, signal=False)
 263          assert_equal(node.getblockcount(), 143)
 264          status, since = self.get_status(node)
 265          self.log.info(f"Block 143: Status={status}")
 266          assert_equal(status, 'defined')
 267  
 268          # Mine period 1 (blocks 144-287) with 100% signaling
 269          self.log.info("Mining period 1 (blocks 144-287) with 100% signaling...")
 270          self.mine_blocks(node, 144, signal=True)
 271          assert_equal(node.getblockcount(), 287)
 272          status, since = self.get_status(node)
 273          self.log.info(f"Block 287: Status={status}")
 274          assert_equal(status, 'started')
 275  
 276          # Mine period 2 (blocks 288-431) with signaling - should lock in
 277          self.log.info("Mining period 2 (blocks 288-431) with signaling - should lock in...")
 278          self.mine_blocks(node, 144, signal=True)
 279          assert_equal(node.getblockcount(), 431)
 280          status, since = self.get_status(node)
 281          self.log.info(f"Block 431: Status={status}, Since={since}")
 282          assert_equal(status, 'locked_in')
 283          assert_equal(since, 288)  # Locked in at start of period 2 via signaling threshold
 284  
 285          # Mine block 432 - should activate via signaling (well before max_height 576)
 286          self.log.info("Mining block 432 - should activate via signaling (before max_height 576)...")
 287          self.mine_blocks(node, 1, signal=True)
 288          assert_equal(node.getblockcount(), 432)
 289          status, since = self.get_status(node)
 290          self.log.info(f"Block 432: Status={status}, Since={since}")
 291          assert_equal(status, 'active')
 292          assert_equal(since, 432)
 293  
 294          self.log.info("\n=== TEST 3 COMPLETE ===")
 295          self.log.info("SUCCESS: Deployment activated early via signaling (at 432, before max_height 576)")
 296  
 297          # Test 4: Verify ACTIVE state is permanent
 298          self.log.info("\n\n=== TEST 4: Verify ACTIVE state is permanent ===")
 299          node = self.nodes[3]
 300  
 301          # Activate via max_height (max_height=432)
 302          # Mine to block 143 (period 0) without signaling
 303          self.log.info("Mining period 0 (blocks 0-143) without signaling...")
 304          self.mine_blocks(node, 143, signal=False)
 305          assert_equal(node.getblockcount(), 143)
 306  
 307          # Mine through enforcement window (blocks 144-287) WITH signaling
 308          # Enforcement window for max_height=432 is [144, 288)
 309          self.log.info("Mining blocks 144-287 with signaling (enforcement window)...")
 310          self.mine_blocks(node, 144, signal=True)
 311          assert_equal(node.getblockcount(), 287)
 312          status, since = self.get_status(node)
 313          assert_equal(status, 'started')
 314  
 315          # Mine period 2 (blocks 288-431) - will force lock-in at 288 (432 - 144)
 316          self.log.info("Mining period 2 (blocks 288-431) - forced lock-in at end...")
 317          self.mine_blocks(node, 144, signal=False)
 318          assert_equal(node.getblockcount(), 431)
 319          status, since = self.get_status(node)
 320          assert_equal(status, 'locked_in')
 321  
 322          # Mine block 432 - should activate
 323          self.log.info("Mining block 432 - should activate via max_height...")
 324          self.mine_blocks(node, 1, signal=False)
 325          assert_equal(node.getblockcount(), 432)
 326          status, since = self.get_status(node)
 327          self.log.info(f"Block 432: Status={status}, Since={since}")
 328          assert_equal(status, 'active')
 329          assert_equal(since, 432)
 330  
 331          # Mine 300 more blocks to verify permanence
 332          self.log.info("Mining 300 more blocks to verify ACTIVE state persists...")
 333          self.mine_blocks(node, 300, signal=False)
 334          assert_equal(node.getblockcount(), 732)
 335          status, since = self.get_status(node)
 336          self.log.info(f"Block 732: Status={status}, Since={since}")
 337          assert_equal(status, 'active')
 338          assert_equal(since, 432)
 339  
 340          self.log.info("\n=== TEST 4 COMPLETE ===")
 341          self.log.info("SUCCESS: Deployment remains ACTIVE permanently")
 342  
 343          # Test 5: Combined temporary deployment with max_height
 344          self.log.info("\n\n=== TEST 5: Temporary deployment with max_height ===")
 345          node = self.nodes[4]
 346  
 347          # This node has max_activation_height=432 AND active_duration=144
 348          # Should activate at 432 via max_height, then expire at 432+144=576
 349          # Mine to block 143 (period 0) without signaling
 350          self.log.info("Mining period 0 (blocks 0-143) without signaling...")
 351          self.mine_blocks(node, 143, signal=False)
 352          assert_equal(node.getblockcount(), 143)
 353  
 354          # Mine through enforcement window (blocks 144-287) WITH signaling
 355          # Enforcement window for max_height=432 is [144, 288)
 356          self.log.info("Mining blocks 144-287 with signaling (enforcement window)...")
 357          self.mine_blocks(node, 144, signal=True)
 358          assert_equal(node.getblockcount(), 287)
 359          status, since = self.get_status(node)
 360          self.log.info(f"Block 287: Status={status}")
 361          assert_equal(status, 'started')
 362  
 363          # Mine period 2 (blocks 288-431) - will force lock-in at 288 (432 - 144)
 364          self.log.info("Mining period 2 (blocks 288-431) - forced lock-in at end...")
 365          self.mine_blocks(node, 144, signal=False)
 366          assert_equal(node.getblockcount(), 431)
 367          status, since = self.get_status(node)
 368          self.log.info(f"Block 431: Status={status}")
 369          assert_equal(status, 'locked_in')
 370  
 371          # Mine block 432 - should activate via max_height
 372          self.log.info("Mining block 432 - should activate via max_height...")
 373          self.mine_blocks(node, 1, signal=False)
 374          assert_equal(node.getblockcount(), 432)
 375          status, since = self.get_status(node)
 376          self.log.info(f"Block 432: Status={status}, Since={since}")
 377          assert_equal(status, 'active')
 378          assert_equal(since, 432)
 379  
 380          # Mine through active period to block 575 (432+144-1)
 381          self.log.info("Mining through active period to block 575 (432+144-1)...")
 382          self.mine_blocks(node, 143, signal=False)
 383          assert_equal(node.getblockcount(), 575)
 384          status, since = self.get_status(node)
 385          self.log.info(f"Block 575: Status={status}")
 386          assert_equal(status, 'active')
 387  
 388          # Mine block 576 (432+144) - deployment has expired
 389          # RPC status uses State(blockindex->pprev), so expired appears at tip 576
 390          self.log.info("Mining block 576 (432+144) - deployment should be expired...")
 391          self.mine_blocks(node, 1, signal=False)
 392          assert_equal(node.getblockcount(), 576)
 393          status, since = self.get_status(node)
 394          self.log.info(f"Block 576: Status={status}, Since={since}")
 395          assert_equal(status, 'expired')
 396          assert_equal(since, 576)
 397  
 398          # Mine block 577 - verify EXPIRED is terminal
 399          self.log.info("Mining block 577 - verify EXPIRED is terminal...")
 400          self.mine_blocks(node, 1, signal=False)
 401          assert_equal(node.getblockcount(), 577)
 402          status, since = self.get_status(node)
 403          self.log.info(f"Block 577: Status={status}")
 404          assert_equal(status, 'expired')
 405  
 406          self.log.info("\n=== TEST 5 COMPLETE ===")
 407          self.log.info("SUCCESS: Temporary deployment with max_height activated and expired correctly")
 408  
 409          # Test 6: Custom per-deployment threshold
 410          self.log.info("\n\n=== TEST 6: Custom per-deployment threshold (50% = 72/144 blocks) ===")
 411          node = self.nodes[5]
 412  
 413          # This node has threshold=72 (50% of 144 blocks)
 414          # Default regtest threshold is 108 (75%), but this deployment should activate at 72
 415  
 416          # Period 0 (0-143): DEFINED
 417          self.log.info("Mining period 0 (blocks 0-143) without signaling...")
 418          self.mine_blocks(node, 143, signal=False)
 419          assert_equal(node.getblockcount(), 143)
 420          status, _ = self.get_status(node)
 421          assert_equal(status, 'defined')
 422  
 423          # Block 144: Transition to STARTED
 424          self.log.info("Mining block 144 to transition to STARTED...")
 425          self.mine_blocks(node, 1, signal=False)
 426          assert_equal(node.getblockcount(), 144)
 427          status, since = self.get_status(node)
 428          self.log.info(f"Block 144: Status={status}, Since={since}")
 429          assert_equal(status, 'started')
 430          assert_equal(since, 144)
 431  
 432          # Period 1 (144-287): Mine exactly 72 signaling blocks (50%)
 433          # With custom threshold of 72, this should be enough to lock in
 434          self.log.info("Mining period 1 with exactly 72 signaling blocks (50%)...")
 435          self.mine_blocks(node, 72, signal=True)   # 72 signaling blocks
 436          self.mine_blocks(node, 71, signal=False)  # 71 non-signaling blocks
 437          assert_equal(node.getblockcount(), 287)
 438          status, since = self.get_status(node)
 439          self.log.info(f"Block 287: Status={status}")
 440          assert_equal(status, 'started')  # Still started until next period boundary
 441  
 442          # Block 288: Should transition to LOCKED_IN (threshold met in previous period)
 443          self.log.info("Mining block 288 to check lock-in...")
 444          self.mine_blocks(node, 1, signal=False)
 445          assert_equal(node.getblockcount(), 288)
 446          status, since = self.get_status(node)
 447          self.log.info(f"Block 288: Status={status}, Since={since}")
 448          assert_equal(status, 'locked_in')
 449          assert_equal(since, 288)
 450  
 451          # Mine through locked_in period to activate
 452          self.log.info("Mining through locked_in period (289-431)...")
 453          self.mine_blocks(node, 143, signal=False)
 454          assert_equal(node.getblockcount(), 431)
 455          status, since = self.get_status(node)
 456          assert_equal(status, 'locked_in')
 457  
 458          # Block 432: Should transition to ACTIVE
 459          self.log.info("Mining block 432 to activate...")
 460          self.mine_blocks(node, 1, signal=False)
 461          assert_equal(node.getblockcount(), 432)
 462          status, since = self.get_status(node)
 463          self.log.info(f"Block 432: Status={status}, Since={since}")
 464          assert_equal(status, 'active')
 465          assert_equal(since, 432)
 466  
 467          self.log.info("\n=== TEST 6 COMPLETE ===")
 468          self.log.info("SUCCESS: Deployment activated with custom 50% threshold (72/144 blocks)")
 469          self.log.info("- Custom threshold overrode default 75% threshold")
 470          self.log.info("- Lock-in occurred with only 50% signaling support")
 471  
 472  
 473  if __name__ == '__main__':
 474      MaxActivationHeightTest(__file__).main()
 475