mempool_sigoplimit.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2023 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 sigop limit mempool policy (`-bytespersigop` parameter)"""
6 from copy import deepcopy
7 from decimal import Decimal
8 from math import ceil
9
10 from test_framework.messages import (
11 COutPoint,
12 CTransaction,
13 CTxIn,
14 CTxInWitness,
15 CTxOut,
16 MAX_OP_RETURN_RELAY,
17 WITNESS_SCALE_FACTOR,
18 tx_from_hex,
19 )
20 from test_framework.script import (
21 CScript,
22 OP_1,
23 OP_2DUP,
24 OP_CHECKMULTISIG,
25 OP_CHECKSIG,
26 OP_DROP,
27 OP_ENDIF,
28 OP_FALSE,
29 OP_IF,
30 OP_NOT,
31 OP_RETURN,
32 OP_TRUE,
33 )
34 from test_framework.script_util import (
35 keys_to_multisig_script,
36 script_to_p2wsh_script,
37 script_to_p2sh_script,
38 MAX_STD_LEGACY_SIGOPS,
39 MAX_STD_P2SH_SIGOPS,
40 )
41 from test_framework.test_framework import LimenkaTestFramework
42 from test_framework.util import (
43 assert_equal,
44 assert_greater_than,
45 assert_greater_than_or_equal,
46 assert_raises_rpc_error,
47 )
48 from test_framework.wallet import MiniWallet
49 from test_framework.wallet_util import generate_keypair
50
51 DEFAULT_BYTES_PER_SIGOP = 20 # default setting
52 MAX_PUBKEYS_PER_MULTISIG = 20
53
54 class BytesPerSigOpTest(LimenkaTestFramework):
55 def set_test_params(self):
56 self.num_nodes = 1
57 # allow large datacarrier output to pad transactions
58 self.extra_args = [['-datacarriersize=100000']]
59
60 def create_p2wsh_spending_tx(self, witness_script, output_script):
61 """Create a 1-input-1-output P2WSH spending transaction with only the
62 witness script in the witness stack and the given output script."""
63 # create P2WSH address and fund it via MiniWallet first
64 fund = self.wallet.send_to(
65 from_node=self.nodes[0],
66 scriptPubKey=script_to_p2wsh_script(witness_script),
67 amount=1000000,
68 )
69
70 # create spending transaction
71 tx = CTransaction()
72 tx.vin = [CTxIn(COutPoint(int(fund["txid"], 16), fund["sent_vout"]))]
73 tx.wit.vtxinwit = [CTxInWitness()]
74 tx.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)]
75 tx.vout = [CTxOut(500000, output_script)]
76 return tx
77
78 def test_sigops_limit(self, bytes_per_sigop, num_sigops):
79 sigop_equivalent_vsize = ceil(num_sigops * bytes_per_sigop / WITNESS_SCALE_FACTOR)
80 self.log.info(f"- {num_sigops} sigops (equivalent size of {sigop_equivalent_vsize} vbytes)")
81
82 # create a template tx with the specified sigop cost in the witness script
83 # (note that the sigops count even though being in a branch that's not executed)
84 num_multisigops = num_sigops // 20
85 num_singlesigops = num_sigops % 20
86 witness_script = CScript(
87 [OP_FALSE, OP_IF] +
88 [OP_CHECKMULTISIG]*num_multisigops +
89 [OP_CHECKSIG]*num_singlesigops +
90 [OP_ENDIF, OP_TRUE]
91 )
92
93 # Create transaction ONCE with a small output
94 # This creates ONE funding transaction in the mempool
95 tx = self.create_p2wsh_spending_tx(witness_script, CScript([OP_RETURN, b'test123']))
96
97 # Helper function to pad transaction to target vsize using multiple OP_RETURN outputs
98 def pad_tx_to_vsize(tx, target_vsize):
99 """Adjust transaction size by adding/removing multiple OP_RETURN outputs"""
100 # Keep only the first output, remove all padding outputs
101 while len(tx.vout) > 1:
102 tx.vout.pop()
103
104 # MAX_OP_RETURN_RELAY = 83, so max script is: OP_RETURN + 82 bytes data
105 max_script_size = MAX_OP_RETURN_RELAY
106
107 # Iteratively add outputs until we reach or slightly exceed the target
108 while True:
109 current_vsize = tx.get_vsize()
110 if current_vsize >= target_vsize:
111 break
112
113 vsize_needed = target_vsize - current_vsize
114
115 # CTxOut serialization: nValue (8) + compact_size(script_len) + script
116 # For script_len <= 252: compact_size = 1 byte
117 # So total = 8 + 1 + script_len = 9 + script_len
118
119 # Maximum output: 8 + 1 + 83 = 92 vbytes
120 if vsize_needed >= 92:
121 # Add a max-size output
122 tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * (max_script_size - 1))))
123 elif vsize_needed >= 10:
124 # Need to add exactly vsize_needed bytes
125 # 8 + 1 + script_len = vsize_needed
126 # script_len = vsize_needed - 9
127 script_len = vsize_needed - 9
128 # Script is [OP_RETURN] + data, so len = 1 + data_len
129 # data_len = script_len - 1
130 data_len = script_len - 1
131 if data_len >= 0:
132 tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len)))
133 else:
134 # Just add the minimum and overshoot slightly
135 tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN])))
136 break
137 else:
138 # vsize_needed < 10, can't add a new output
139 # Instead, adjust the first output's size by adding to its script
140 if vsize_needed > 0 and len(tx.vout[0].scriptPubKey) < max_script_size:
141 # Extend the first output's script
142 current_script = tx.vout[0].scriptPubKey
143 # Add vsize_needed more bytes to the script
144 new_script = bytes(current_script) + bytes([1] * vsize_needed)
145 # But cap at max_script_size
146 if len(new_script) <= max_script_size:
147 tx.vout[0].scriptPubKey = CScript(new_script)
148 break
149
150 # If we overshot, try to trim the last output
151 if tx.get_vsize() > target_vsize and len(tx.vout) > 1:
152 tx.vout.pop()
153 # Try again with a smaller output
154 current_vsize = tx.get_vsize()
155 vsize_needed = target_vsize - current_vsize
156 if vsize_needed >= 10:
157 script_len = vsize_needed - 9
158 data_len = script_len - 1
159 if data_len >= 0:
160 tx.vout.append(CTxOut(nValue=0, scriptPubKey=CScript([OP_RETURN] + [OP_1] * data_len)))
161
162 # Pad to reach sigop-limit equivalent size
163 pad_tx_to_vsize(tx, sigop_equivalent_vsize)
164 if tx.get_vsize() != sigop_equivalent_vsize:
165 self.log.error(f"Padding failed: got {tx.get_vsize()}, expected {sigop_equivalent_vsize}")
166 self.log.error(f"Number of outputs: {len(tx.vout)}")
167 for i, out in enumerate(tx.vout):
168 self.log.error(f"Output {i}: scriptPubKey len={len(out.scriptPubKey)}, vout entry size={8 + 1 + len(out.scriptPubKey)}")
169 assert_equal(tx.get_vsize(), sigop_equivalent_vsize)
170
171 res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
172 assert_equal(res['allowed'], True)
173 assert_equal(res['vsize'], sigop_equivalent_vsize)
174
175 # increase the tx's vsize to be right above the sigop-limit equivalent size
176 # => tx's vsize in mempool should also grow accordingly
177 pad_tx_to_vsize(tx, sigop_equivalent_vsize + 1)
178 res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
179 assert_equal(res['allowed'], True)
180 assert_equal(res['vsize'], sigop_equivalent_vsize+1)
181
182 # decrease the tx's vsize to be right below the sigop-limit equivalent size
183 # => tx's vsize in mempool should stick at the sigop-limit equivalent
184 # bytes level, as it is higher than the tx's serialized vsize
185 # (the maximum of both is taken)
186 pad_tx_to_vsize(tx, sigop_equivalent_vsize - 1)
187 res = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
188 assert_equal(res['allowed'], True)
189 assert_equal(res['vsize'], sigop_equivalent_vsize)
190
191 # check that the ancestor and descendant size calculations in the mempool
192 # also use the same max(sigop_equivalent_vsize, serialized_vsize) logic
193 # (to keep it simple, we only test the case here where the sigop vsize
194 # is much larger than the serialized vsize, i.e. we create a small child
195 # tx by getting rid of the large padding output)
196 while len(tx.vout) > 1:
197 tx.vout.pop()
198 tx.vout[0].scriptPubKey = CScript([OP_RETURN, b'test123'])
199 assert_greater_than(sigop_equivalent_vsize, tx.get_vsize())
200 self.nodes[0].sendrawtransaction(hexstring=tx.serialize().hex(), maxburnamount='1.0')
201
202 # fetch parent tx, which doesn't contain any sigops
203 parent_txid = tx.vin[0].prevout.hash.to_bytes(32, 'big').hex()
204 parent_tx = tx_from_hex(self.nodes[0].getrawtransaction(txid=parent_txid))
205
206 entry_child = self.nodes[0].getmempoolentry(tx.rehash())
207 assert_equal(entry_child['descendantcount'], 1)
208 assert_equal(entry_child['descendantsize'], sigop_equivalent_vsize)
209 assert_equal(entry_child['ancestorcount'], 2)
210 assert_equal(entry_child['ancestorsize'], sigop_equivalent_vsize + parent_tx.get_vsize())
211
212 entry_parent = self.nodes[0].getmempoolentry(parent_tx.rehash())
213 assert_equal(entry_parent['ancestorcount'], 1)
214 assert_equal(entry_parent['ancestorsize'], parent_tx.get_vsize())
215 assert_equal(entry_parent['descendantcount'], 2)
216 assert_equal(entry_parent['descendantsize'], parent_tx.get_vsize() + sigop_equivalent_vsize)
217
218 def test_sigops_package(self):
219 self.log.info("Test a overly-large sigops-vbyte hits package limits")
220 # Make a 2-transaction package which fails vbyte checks even though
221 # separately they would work.
222 #
223 # Using P2WSH multisig instead of bare multisig to comply with REDUCED_DATA
224 # output size limits (34 bytes max). Witness sigops are discounted by 4x,
225 # so we use multiple CHECKMULTISIG ops to achieve sufficient sigop-adjusted vsize.
226 self.restart_node(0, extra_args=["-bytespersigop=5000"] + self.extra_args[0])
227
228 # With -bytespersigop=5000 and witness discount of 4:
229 # - Each CHECKMULTISIG = 20 sigops
230 # - Adjusted vsize per CHECKMULTISIG = 20 * 5000 / 4 = 25,000
231 # - Need > 101,000 / 2 = 50,500 per tx to exceed limit as package
232 # - Use 3 CHECKMULTISIG ops = 60 sigops = 75,000 adjusted vsize per tx
233 # - Two txs together = 150,000 > 101,000 (fails package limit)
234 # - Each tx alone = 75,000 < 101,000 (passes individually)
235 NUM_CHECKMULTISIG_OPS = 3
236 expected_sigops_per_tx = NUM_CHECKMULTISIG_OPS * MAX_PUBKEYS_PER_MULTISIG # 60
237 expected_vsize_per_tx = expected_sigops_per_tx * 5000 // WITNESS_SCALE_FACTOR # 75,000
238
239 # Create witness script with multiple CHECKMULTISIG ops (sigops counted even in unexecuted branches)
240 witness_script = CScript(
241 [OP_FALSE, OP_IF] +
242 [OP_CHECKMULTISIG] * NUM_CHECKMULTISIG_OPS +
243 [OP_ENDIF, OP_TRUE]
244 )
245 p2wsh_script = script_to_p2wsh_script(witness_script)
246
247 # Pre-fund two P2WSH outputs that we'll spend as parent and child
248 funding_amount = 1000000
249 fund_parent = self.wallet.send_to(
250 from_node=self.nodes[0],
251 scriptPubKey=p2wsh_script,
252 amount=funding_amount,
253 )
254 fund_child = self.wallet.send_to(
255 from_node=self.nodes[0],
256 scriptPubKey=p2wsh_script,
257 amount=funding_amount,
258 )
259 self.generate(self.nodes[0], 1)
260
261 # Parent tx: spends first P2WSH (high sigops), outputs to wallet
262 tx_parent = CTransaction()
263 tx_parent.vin = [CTxIn(COutPoint(int(fund_parent["txid"], 16), fund_parent["sent_vout"]))]
264 tx_parent.wit.vtxinwit = [CTxInWitness()]
265 tx_parent.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)]
266 # Output back to a standard address (MiniWallet's default)
267 tx_parent.vout = [CTxOut(funding_amount - 10000, self.wallet.get_output_script())]
268 tx_parent.rehash()
269
270 # Child tx: spends second P2WSH (high sigops) AND spends parent's output (to form package)
271 tx_child = CTransaction()
272 tx_child.vin = [
273 CTxIn(COutPoint(int(fund_child["txid"], 16), fund_child["sent_vout"])), # P2WSH input (sigops)
274 CTxIn(COutPoint(tx_parent.sha256, 0)), # Parent's output (links as child)
275 ]
276 tx_child.wit.vtxinwit = [CTxInWitness(), CTxInWitness()]
277 tx_child.wit.vtxinwit[0].scriptWitness.stack = [bytes(witness_script)] # For P2WSH input
278 tx_child.wit.vtxinwit[1].scriptWitness.stack = [b''] # Placeholder for wallet input
279 tx_child.vout = [CTxOut(2 * funding_amount - 30000, self.wallet.get_output_script())]
280 tx_child.rehash()
281
282 # Separately, the parent tx is ok
283 parent_individual_testres = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex()])[0]
284 if not parent_individual_testres["allowed"]:
285 self.log.error(f"Parent tx rejected: {parent_individual_testres}")
286 assert parent_individual_testres["allowed"]
287 assert_equal(parent_individual_testres["vsize"], expected_vsize_per_tx)
288
289 # But together, it's exceeding limits in the *package* context. If sigops adjusted vsize wasn't being checked
290 # here, it would get further in validation and give too-long-mempool-chain error instead.
291 packet_test = self.nodes[0].testmempoolaccept([tx_parent.serialize().hex(), tx_child.serialize().hex()])
292 expected_package_error = f"package-mempool-limits, package size {2*expected_vsize_per_tx} exceeds ancestor size limit [limit: 101000]"
293 assert_equal([x["package-error"] for x in packet_test], [expected_package_error] * 2)
294
295 # When we actually try to submit, the parent makes it into the mempool, but the child would exceed ancestor vsize limits
296 res = self.nodes[0].submitpackage([tx_parent.serialize().hex(), tx_child.serialize().hex()])
297 assert "too-long-mempool-chain" in res["tx-results"][tx_child.getwtxid()]["error"]
298 assert tx_parent.rehash() in self.nodes[0].getrawmempool()
299
300 # Transactions are tiny in weight
301 assert_greater_than(2000, tx_parent.get_weight() + tx_child.get_weight())
302
303 def test_legacy_sigops_stdness(self):
304 self.log.info("Test a transaction with too many legacy sigops in its inputs is non-standard.")
305
306 # Restart with the test settings
307 self.restart_node(0, extra_args=[f'-maxtxlegacysigops={MAX_STD_LEGACY_SIGOPS}'])
308
309 # Create a P2SH script with 15 sigops.
310 _, dummy_pubkey = generate_keypair()
311 packed_redeem_script = [dummy_pubkey]
312 for _ in range(MAX_STD_P2SH_SIGOPS - 1):
313 packed_redeem_script += [OP_2DUP, OP_CHECKSIG, OP_DROP]
314 packed_redeem_script = CScript(packed_redeem_script + [OP_CHECKSIG, OP_NOT])
315 packed_p2sh_script = script_to_p2sh_script(packed_redeem_script)
316
317 # Create enough outputs to reach the sigops limit when spending them all at once.
318 outpoints = []
319 for _ in range(int(MAX_STD_LEGACY_SIGOPS / MAX_STD_P2SH_SIGOPS) + 1):
320 res = self.wallet.send_to(from_node=self.nodes[0], scriptPubKey=packed_p2sh_script, amount=1_000)
321 txid = int.from_bytes(bytes.fromhex(res["txid"]), byteorder="big")
322 outpoints.append(COutPoint(txid, res["sent_vout"]))
323 self.generate(self.nodes[0], 1)
324
325 # Spending all these outputs at once accounts for 2505 legacy sigops and is non-standard.
326 nonstd_tx = CTransaction()
327 nonstd_tx.vin = [CTxIn(op, CScript([b"", packed_redeem_script])) for op in outpoints]
328 nonstd_tx.vout = [CTxOut(0, CScript([OP_RETURN, b""]))]
329 assert_raises_rpc_error(-26, "bad-txns-input-sigops-toomany-overall", self.nodes[0].sendrawtransaction, nonstd_tx.serialize().hex())
330
331 # Spending one less accounts for 2490 legacy sigops and is standard.
332 std_tx = deepcopy(nonstd_tx)
333 std_tx.vin.pop()
334 self.nodes[0].sendrawtransaction(std_tx.serialize().hex())
335
336 # Make sure the original, non-standard, transaction can be mined.
337 self.generateblock(self.nodes[0], output="raw(42)", transactions=[nonstd_tx.serialize().hex()])
338
339 def run_test(self):
340 self.wallet = MiniWallet(self.nodes[0])
341
342 for bytes_per_sigop in (DEFAULT_BYTES_PER_SIGOP, 43, 81, 165, 327, 649, 1072):
343 if bytes_per_sigop == DEFAULT_BYTES_PER_SIGOP:
344 self.log.info(f"Test default sigops limit setting ({bytes_per_sigop} bytes per sigop)...")
345 else:
346 bytespersigop_parameter = f"-bytespersigop={bytes_per_sigop}"
347 self.log.info(f"Test sigops limit setting {bytespersigop_parameter}...")
348 self.restart_node(0, extra_args=[bytespersigop_parameter] + self.extra_args[0])
349
350 for num_sigops in (69, 101, 142, 183, 222):
351 self.test_sigops_limit(bytes_per_sigop, num_sigops)
352
353 self.generate(self.wallet, 1)
354
355 self.test_sigops_package()
356 self.test_legacy_sigops_stdness()
357
358
359 if __name__ == '__main__':
360 BytesPerSigOpTest(__file__).main()
361