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