Skip to content
Bitcoin Roots

v30.3-roots.1 · Commit 3: [PATCH 03/68] feat(policy): preserve Roots relay and network controls on Core 30.3

test/functional/p2p_compactblocks_extratxs.py

Before: /dev/null · After: test/functional/p2p_compactblocks_extratxs.py

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..341071229b41da1f0a56c1a7bdb375c52bfd2f81

Download original patch · Release notes

Diff rows 1–400 of 509. Line numbers refer to the original before/after files.

+ Added− RemovedUnmarked lines are context.

Scroll the code area horizontally to read long lines. Before is on the left; after is on the right. Unified view fits narrow screens more easily.

test/functional/p2p_compactblocks_extratxs.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2025 The Bitcoin Core 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 blockreconstructionextratxn and blockreconstructionextratxnsize options with compact blocks."""
6
+
7
+ from test_framework.blocktools import (
8
+ COINBASE_MATURITY,
9
+ NORMAL_GBT_REQUEST_PARAMS,
10
+ create_block,
11
+ add_witness_commitment,
12
+ )
13
+ from test_framework.messages import (
14
+ CTxOut,
15
+ HeaderAndShortIDs,
16
+ MSG_BLOCK,
17
+ msg_cmpctblock,
18
+ msg_sendcmpct,
19
+ msg_tx,
20
+ tx_from_hex,
21
+ )
22
+ from test_framework.p2p import (
23
+ P2PInterface,
24
+ p2p_lock,
25
+ )
26
+ from test_framework.script import (
27
+ CScript,
28
+ OP_DROP,
29
+ OP_TRUE,
30
+ OP_RETURN,
31
+ )
32
+ from test_framework.script_util import (
33
+ keys_to_multisig_script,
34
+ )
35
+ from test_framework.test_framework import BitcoinTestFramework
36
+ from test_framework.util import (
37
+ assert_equal,
38
+ softfork_active,
39
+ )
40
+ from decimal import Decimal
41
+ from test_framework.wallet import MiniWallet
42
+
43
+
44
+ # TestP2PConn: A peer we use to send messages to bitcoind, and store responses.
45
+ class TestP2PConn(P2PInterface):
46
+ def __init__(self):
47
+ super().__init__()
48
+ self.last_sendcmpct = []
49
+ self.block_announced = False
50
+ # Store the hashes of blocks we've seen announced.
51
+ # This is for synchronizing the p2p message traffic,
52
+ # so we can eg wait until a particular block is announced.
53
+ self.announced_blockhashes = set()
54
+
55
+ def on_sendcmpct(self, message):
56
+ self.last_sendcmpct.append(message)
57
+
58
+ def on_cmpctblock(self, message):
59
+ self.block_announced = True
60
+ self.announced_blockhashes.add(self.last_message["cmpctblock"].header_and_shortids.header.hash_int)
61
+
62
+ def on_headers(self, message):
63
+ self.block_announced = True
64
+ for x in self.last_message["headers"].headers:
65
+ self.announced_blockhashes.add(x.hash_int)
66
+
67
+ def on_inv(self, message):
68
+ for x in self.last_message["inv"].inv:
69
+ if x.type == MSG_BLOCK:
70
+ self.block_announced = True
71
+ self.announced_blockhashes.add(x.hash)
72
+
73
+ # Requires caller to hold p2p_lock
74
+ def received_block_announcement(self):
75
+ return self.block_announced
76
+
77
+ def clear_block_announcement(self):
78
+ with p2p_lock:
79
+ self.block_announced = False
80
+ self.last_message.pop("inv", None)
81
+ self.last_message.pop("headers", None)
82
+ self.last_message.pop("cmpctblock", None)
83
+
84
+ def clear_getblocktxn(self):
85
+ with p2p_lock:
86
+ self.last_message.pop("getblocktxn", None)
87
+
88
+
89
+ class CompactBlocksBlockReconstructionLimitTest(BitcoinTestFramework):
90
+ def set_test_params(self):
91
+ self.setup_clean_chain = True
92
+ self.num_nodes = 1
93
+ self.extra_args = [[
94
+ "-acceptnonstdtxn=0",
95
+ "-incrementalrelayfee=0.00001",
96
+ "-debug=net",
97
+ ]]
98
+ self.utxos = []
99
+
100
+ def build_block_on_tip(self, node):
101
+ """Build a block on top of the current tip."""
102
+ block = create_block(tmpl=node.getblocktemplate(NORMAL_GBT_REQUEST_PARAMS))
103
+ block.solve()
104
+ return block
105
+
106
+ def make_utxos(self):
107
+ """Generate blocks to create UTXOs for the wallet."""
108
+ self.generate(self.wallet, COINBASE_MATURITY + 1000)
109
+
110
+ def restart_node_with_limit(self, *, memory_mb=None, count=None):
111
+ """Restart node with specific size and/or count limits."""
112
+ extra_args = self.extra_args[0] + [
113
+ "-datacarriersize=83",
114
+ ]
115
+
116
+ if memory_mb is not None:
117
+ self.log.info(f"Setting size limit: {memory_mb} MB")
118
+ extra_args.append(f"-blockreconstructionextratxnsize={memory_mb}")
119
+
120
+ if count is not None:
121
+ self.log.info(f"Setting transaction count limit: {count}")
122
+ extra_args.append(f"-blockreconstructionextratxn={count}")
123
+
124
+ self.log.info(f"Restarting node with args: {extra_args}")
125
+ self.restart_node(0, extra_args=extra_args)
126
+ self.segwit_node = self.nodes[0].add_p2p_connection(TestP2PConn())
127
+ self.segwit_node.send_and_ping(msg_sendcmpct(announce=True, version=2))
128
+
129
+ def create_policy_rejected_tx(self, rejection_type="dust", target_size=None):
130
+ """Create a transaction that will be rejected for policy reasons but added to extra pool."""
131
+
132
+ if rejection_type == "dust":
133
+ tx_info = self.wallet.create_self_transfer()
134
+ dust_amount = 100
135
+ dust_script = CScript([OP_TRUE])
136
+ tx_info['tx'].vout.append(CTxOut(dust_amount, dust_script))
137
+ tx_info['tx'].vout[0].nValue -= dust_amount
138
+
139
+ elif rejection_type == "low_fee":
140
+ tx_info = self.wallet.create_self_transfer(fee_rate=Decimal('0.00000100'))
141
+
142
+ elif rejection_type == "op_return_size":
143
+ tx_info = self.wallet.create_self_transfer()
144
+ data = b'x' * 85
145
+ tx_info['tx'].vout.append(CTxOut(0, CScript([OP_RETURN, data])))
146
+
147
+ elif rejection_type == "nonstandard_script":
148
+ tx_info = self.wallet.create_self_transfer()
149
+ pubkeys = []
150
+ for _ in range(4):
151
+ pubkeys.append(bytes([0x02] + [0x00] * 32))
152
+ multisig_script = keys_to_multisig_script(pubkeys, k=4)
153
+ tx_info['tx'].vout.append(CTxOut(10000, multisig_script))
154
+ tx_info['tx'].vout[0].nValue -= 10000
155
+
156
+ else:
157
+ raise ValueError(f"Unknown rejection type: {rejection_type}")
158
+
159
+ # Add padding outputs to reach target size
160
+ if target_size:
161
+ # Estimate current transaction size
162
+ base_size = len(tx_info['tx'].serialize())
163
+
164
+ if base_size < target_size:
165
+ # Each padded output approximately 200 bytes
166
+ bytes_per_output = 200
167
+ num_outputs = (target_size - base_size) // bytes_per_output
168
+
169
+ for _ in range(num_outputs):
170
+ padding_data = b'x' * 190
171
+ script = CScript([padding_data, OP_DROP, OP_TRUE])
172
+ tx_info['tx'].vout.append(CTxOut(100, script))
173
+ tx_info['tx'].vout[0].nValue -= 100
174
+
175
+ tx_info['hex'] = tx_info['tx'].serialize().hex()
176
+ return tx_info
177
+
178
+ def populate_extra_pool(self, num_txs, rejection_type="dust", target_size=None):
179
+ """Populate the extra transaction pool using policy-rejected transactions."""
180
+ rejected_txs = []
181
+
182
+ for i in range(num_txs):
183
+ tx_info = self.create_policy_rejected_tx(rejection_type, target_size=target_size)
184
+ tx_obj = tx_from_hex(tx_info['hex'])
185
+ self.segwit_node.send_without_ping(msg_tx(tx_obj))
186
+ rejected_txs.append(tx_info)
187
+
188
+ self.segwit_node.sync_with_ping()
189
+
190
+ return rejected_txs
191
+
192
+ def send_compact_block(self, transactions, indices):
193
+ """Send a compact block and check which transactions are requested for reconstruction."""
194
+ node = self.nodes[0]
195
+
196
+ # Build block
197
+ block = self.build_block_on_tip(node)
198
+
199
+ for i in indices:
200
+ tx_obj = tx_from_hex(transactions[i]['hex'])
201
+ block.vtx.append(tx_obj)
202
+
203
+ # Add witness commitment for blocks with witness transactions
204
+ add_witness_commitment(block)
205
+ block.solve()
206
+
207
+ # Send as compact block
208
+ cmpct_block = HeaderAndShortIDs()
209
+ cmpct_block.initialize_from_block(block, use_witness=True)
210
+ self.segwit_node.send_and_ping(msg_cmpctblock(cmpct_block.to_p2p()))
211
+
212
+ # Check if node requested missing transactions
213
+ with p2p_lock:
214
+ getblocktxn = self.segwit_node.last_message.get("getblocktxn")
215
+
216
+ num_tx_requested = len(getblocktxn.block_txn_request.indexes) if getblocktxn else 0
217
+ self.segwit_node.clear_getblocktxn()
218
+
219
+ # Convert differential encoding to absolute indices (from BlockTransactionRequest)
220
+ missing_indices = []
221
+ if getblocktxn:
222
+ absolute_block_indices = getblocktxn.block_txn_request.to_absolute()
223
+ # Convert from block positions to transaction indices (subtract 1 for coinbase)
224
+ missing_indices = [idx - 1 for idx in absolute_block_indices]
225
+
226
+ return {
227
+ "block": block,
228
+ "getblocktxn": getblocktxn,
229
+ "num_tx_requested": num_tx_requested,
230
+ "missing_indices": missing_indices
231
+ }
232
+
233
+ # TEST: policy-rejected transactions
234
+
235
+ def test_policy_rejection_types(self):
236
+ """Test that each policy rejection type adds transactions to extra pool."""
237
+ self.log.info("Testing policy rejection types for extra pool...")
238
+
239
+ self.restart_node_with_limit(count=100)
240
+
241
+ rejection_types = ["dust", "low_fee", "op_return_size", "nonstandard_script"]
242
+ rejected_txs = []
243
+
244
+ for rejection_type in rejection_types:
245
+ self.log.info(f"Testing {rejection_type} rejection...")
246
+ tx_info = self.create_policy_rejected_tx(rejection_type)
247
+
248
+ tx_obj = tx_from_hex(tx_info['hex'])
249
+ self.segwit_node.send_without_ping(msg_tx(tx_obj))
250
+
251
+ rejected_txs.append({
252
+ 'type': rejection_type,
253
+ 'tx_info': tx_info,
254
+ 'txid': tx_info['tx'].txid_hex,
255
+ 'wtxid': tx_info['tx'].wtxid_hex
256
+ })
257
+
258
+ self.segwit_node.sync_with_ping()
259
+
260
+ mempool = self.nodes[0].getrawmempool()
261
+ for rejected in rejected_txs:
262
+ assert_equal(rejected['txid'] in mempool, False)
263
+ self.log.info(f"✓ {rejected['type']} transaction rejected from mempool")
264
+
265
+ indices = list(range(len(rejected_txs)))
266
+ tx_list = [r['tx_info'] for r in rejected_txs]
267
+
268
+ result = self.send_compact_block(tx_list, indices)
269
+
270
+ assert_equal(result["missing_indices"], [])
271
+ self.log.info("✓ All rejected transactions are available in extra pool")
272
+
273
+ # TEST: blockreconstructionextratxn
274
+
275
+ def test_extratxnpool_disabled(self):
276
+ """Test that setting count to 0 disables the extra transaction pool."""
277
+ self.log.info("Testing disabled extra transaction pool (0 capacity)...")
278
+
279
+ self.restart_node_with_limit(count=0)
280
+ buffersize = 5
281
+ rejected_txs = self.populate_extra_pool(buffersize)
282
+
283
+ indices = list(range(buffersize))
284
+ result = self.send_compact_block(rejected_txs, indices)
285
+ assert_equal(result["missing_indices"], indices)
286
+ self.log.info(f"✓ All {buffersize} transactions are missing (extra txn pool disabled)")
287
+
288
+ def test_extratxnpool_capacity_and_wraparound(self):
289
+ """Test extra transaction pool capacity and wraparound behavior."""
290
+ self.log.info("Testing extra transaction pool capacity (400 transactions)...")
291
+
292
+ buffersize = 400
293
+ self.restart_node_with_limit(count=buffersize)
294
+
295
+ rejected_txs = self.populate_extra_pool(buffersize)
296
+
297
+ indices = list(range(buffersize))
298
+ result = self.send_compact_block(rejected_txs, indices)
299
+
300
+ assert_equal(result["missing_indices"], [])
301
+ self.log.info("✓ All rejected transactions are in the extra txn pool")
302
+
303
+ # Test that adding a 401st transaction causes eviction
304
+ self.log.info("Adding transaction to test eviction...")
305
+ self.populate_extra_pool(1)
306
+
307
+ # Check original transactions again - first one should be evicted
308
+ result2 = self.send_compact_block(rejected_txs, indices)
309
+ assert_equal(result2["missing_indices"], [0])
310
+ self.log.info("✓ Transaction 0 was evicted as expected (wraparound)")
311
+
312
+ def test_single_extratxnpool_capacity(self):
313
+ """Test edge case of single capacity extra transaction pool."""
314
+ self.log.info("Testing single capacity extra transaction pool...")
315
+
316
+ self.restart_node_with_limit(count=1)
317
+ tx_count = 5
318
+
319
+ rejected_txs = self.populate_extra_pool(tx_count)
320
+
321
+ indices = list(range(tx_count))
322
+ result = self.send_compact_block(rejected_txs, indices)
323
+
324
+ expected_missing = list(range(tx_count - 1))
325
+ assert_equal(result["missing_indices"], expected_missing)
326
+
327
+ def test_extratxn_invalid_parameters(self):
328
+ """Test handling of invalid blockreconstructionextratxn values."""
329
+ self.log.info("Testing invalid parameter values...")
330
+
331
+ # Test negative value - should be clamped to 0 (disabled)
332
+ self.log.info("Testing negative value (-1)...")
333
+ self.restart_node_with_limit(count=-1)
334
+
335
+ # Add a transaction and verify pool is disabled
336
+ rejected_txs = self.populate_extra_pool(1)
337
+ result = self.send_compact_block(rejected_txs, [0])
338
+ assert_equal(result["missing_indices"], [0])
339
+ self.log.info("✓ Negative value correctly treated as disabled")
340
+
341
+ # TEST: blockreconstructionextratxnsize
342
+
343
+ def test_extratxnsize_zero_limit(self):
344
+ """Test extra transaction pool zero size limit prevents extra txn pool."""
345
+ self.log.info("Testing extra transaction pool zero size limit prevents extra txn pool...")
346
+ self.restart_node_with_limit(memory_mb=0)
347
+
348
+ rejected_txs = self.populate_extra_pool(1)
349
+ result = self.send_compact_block(rejected_txs, [0])
350
+
351
+ # Should fail - no size limit for extra pool
352
+ assert result["getblocktxn"] is not None, "Node should try to request when zero size limit"
353
+ assert_equal(int(self.nodes[0].getbestblockhash(), 16), result["block"].hashPrevBlock)
354
+
355
+ def test_extratxnsize_eviction(self):
356
+ """Test extra transaction pool size limit eviction behavior."""
357
+ self.log.info("Testing extra transaction pool size limit eviction behavior...")
358
+
359
+ buffersize = 60
360
+
361
+ # First, test with 1MB limit - should fail
362
+ self.log.info(f"Step 1: Testing with 1MB limit for {buffersize} large transactions")
363
+ self.restart_node_with_limit(memory_mb=1, count=buffersize)
364
+
365
+ # Create 60 large transactions (~20KB each = ~1.2MB total)
366
+ # This exceeds the 1MB limit
367
+ self.log.info(f"Creating {buffersize} large transactions (~20KB each, ~1.2MB total)")
368
+ rejected_txs = self.populate_extra_pool(buffersize, target_size=20000)
369
+
370
+ indices = list(range(buffersize))
371
+ result_small = self.send_compact_block(rejected_txs, indices)
372
+
373
+ # Should have evictions - can't fit 1.2MB in 1MB limit
374
+ assert len(result_small["missing_indices"]) > 0, "1MB limit should cause evictions for 1.2MB of transactions"
375
+ evicted_count = len(result_small["missing_indices"])
376
+ self.log.info(f"✓ 1MB limit caused {evicted_count} evictions (can't fit ~1.2MB of transactions)")
377
+
378
+ # Now test with larger size limit to show it succeeds
379
+ self.log.info(f"Step 2: Testing with 2MB limit for same {buffersize} large transactions")
380
+ self.restart_node_with_limit(memory_mb=2, count=buffersize)
381
+
382
+ rejected_txs = self.populate_extra_pool(buffersize, target_size=20000)
383
+
384
+ result_large = self.send_compact_block(rejected_txs, indices)
385
+
386
+ # Should have NO evictions with 2MB limit
387
+ assert result_large["missing_indices"] == [], "2MB limit should store all transactions"
388
+ self.log.info(f"✓ 2MB limit successfully stores all {buffersize} large transactions (~1.2MB)")
389
+
390
+ def test_extratxnsize_boundary(self):
391
+ """Test extra transaction pool at exact size limit boundary."""
392
+ self.log.info("Testing extra transaction pool exact size limit boundary...")
393
+
394
+ limit_mb = 1
395
+ self.restart_node_with_limit(memory_mb=limit_mb)
396
+
397
+ test_count = 100
398
+ rejected_txs = self.populate_extra_pool(test_count, target_size=20000)
399
+
400
+ indices = list(range(test_count))