Skip to content
Bitcoin Roots

v29.4-roots.1 · Commit 3: [PATCH 03/13] policy: restore conservative relay and network controls

test/functional/p2p_compactblocks_extratxs.py

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

File metadata
new file mode 100755
index 0000000000000000000000000000000000000000..156c0785e9460c176b4e77e5d790ff5efb8fd8b0

Download original patch · Release notes

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