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