v29.4-roots.3 · Commit 3: [PATCH 03/30] 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. The two line-number columns refer to before and after.
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
|
+
|