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