Skip to content
Bitcoin Roots

v31.1-roots.1 · Commit 3: [PATCH 03/17] feat(policy): preserve cluster accounting and repair dynamic dust

test/functional/mempool_subdust_fee_penalty.py

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

File metadata
new file mode 100644
index 0000000000000000000000000000000000000000..5af44872f9dab968de0ab3874808c16275bff870

Download original patch · Release notes

Diff rows 1–365 of 365. 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/mempool_subdust_fee_penalty.py ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 The Bitcoin Roots 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 sub-dust output fee penalty (-subdustfeepenalty)."""
6
+
7
+ from decimal import Decimal
8
+ from math import ceil
9
+
10
+ from test_framework.messages import (
11
+ COIN,
12
+ COutPoint,
13
+ CTransaction,
14
+ CTxIn,
15
+ CTxOut,
16
+ )
17
+ from test_framework.script import (
18
+ CScript,
19
+ OP_RETURN,
20
+ )
21
+ from test_framework.script_util import PAY_TO_ANCHOR
22
+ from test_framework.test_framework import BitcoinTestFramework
23
+ from test_framework.util import assert_equal
24
+ from test_framework.wallet import MiniWallet
25
+
26
+
27
+ DUST_THRESHOLD = 330 # P2TR: (43 + 67) * 3000 / 1000 = 330 sats
28
+
29
+
30
+ class SubDustFeePenaltyTest(BitcoinTestFramework):
31
+ def set_test_params(self):
32
+ self.num_nodes = 1
33
+ self.extra_args = [["-acceptnonstdtxn=1", "-subdustfeepenalty=1"]]
34
+
35
+ def run_test(self):
36
+ self.wallet = MiniWallet(self.nodes[0])
37
+
38
+ info = self.nodes[0].getmempoolinfo()
39
+ self.minrelay_per_kvb = info['minrelaytxfee'] * COIN # sats per kvB
40
+
41
+ self.test_dust_output_increases_required_fee()
42
+ self.test_partial_dust_proportional_penalty()
43
+ self.test_multiple_dust_outputs_stack()
44
+ self.test_penalty_disabled()
45
+ self.test_op_return_not_penalized()
46
+ self.test_above_dust_not_penalized()
47
+ self.test_threshold_and_low_rate_boundaries()
48
+ self.test_anchor_and_standardness()
49
+ self.test_carrier_cost_interaction()
50
+ self.test_fee_delta_and_replacement()
51
+ self.test_package_and_truc()
52
+ self.test_mining_and_block_acceptance()
53
+
54
+ def build_tx_with_dust(self, dust_values, fee, *, utxo=None, dust_script=None, version=2, carrier=None):
55
+ utxo = utxo or self.wallet.get_utxo()
56
+ tx = CTransaction()
57
+ tx.version = version
58
+ tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
59
+ input_value = int(utxo["value"] * COIN)
60
+ change = input_value - sum(dust_values) - fee
61
+ assert change > 0, f"Not enough funds: input={input_value}, dust={dust_values}, fee={fee}"
62
+ tx.vout = [CTxOut(v, dust_script or self.wallet.get_output_script()) for v in dust_values]
63
+ if carrier is not None:
64
+ tx.vout.append(CTxOut(0, CScript([OP_RETURN, carrier])))
65
+ tx.vout.append(CTxOut(change, self.wallet.get_output_script()))
66
+ self.wallet.sign_tx(tx)
67
+ return tx
68
+
69
+ def get_min_relay_fee(self, tx):
70
+ """Calculate the minimum relay fee for a transaction."""
71
+ decoded = self.nodes[0].decoderawtransaction(tx.serialize().hex())
72
+ return int(ceil(self.minrelay_per_kvb * decoded['vsize'] / 1000))
73
+
74
+ def test_dust_output_increases_required_fee(self):
75
+ self.log.info("Test: sub-dust output penalizes effective fee")
76
+
77
+ # Build a probe tx to determine the exact min relay fee
78
+ probe = self.build_tx_with_dust([0], fee=1000)
79
+ min_fee = self.get_min_relay_fee(probe)
80
+
81
+ # fee just covers min relay + penalty - 1: rejected
82
+ reject_fee = DUST_THRESHOLD + min_fee - 1
83
+ tx_low = self.build_tx_with_dust([0], fee=reject_fee)
84
+ result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
85
+ assert_equal(result[0]["allowed"], False)
86
+ assert_equal(result[0]["reject-reason"], "min relay fee not met")
87
+
88
+ # fee covers min relay + penalty: accepted
89
+ accept_fee = DUST_THRESHOLD + min_fee
90
+ tx_high = self.build_tx_with_dust([0], fee=accept_fee)
91
+ result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
92
+ assert_equal(result[0]["allowed"], True)
93
+
94
+ def test_partial_dust_proportional_penalty(self):
95
+ self.log.info("Test: partial dust value gets proportional penalty")
96
+
97
+ partial_value = 100
98
+ penalty = DUST_THRESHOLD - partial_value # 230 sats
99
+
100
+ probe = self.build_tx_with_dust([partial_value], fee=1000)
101
+ min_fee = self.get_min_relay_fee(probe)
102
+
103
+ reject_fee = penalty + min_fee - 1
104
+ tx_low = self.build_tx_with_dust([partial_value], fee=reject_fee)
105
+ result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
106
+ assert_equal(result[0]["allowed"], False)
107
+ assert_equal(result[0]["reject-reason"], "min relay fee not met")
108
+
109
+ accept_fee = penalty + min_fee
110
+ tx_high = self.build_tx_with_dust([partial_value], fee=accept_fee)
111
+ result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
112
+ assert_equal(result[0]["allowed"], True)
113
+
114
+ def test_multiple_dust_outputs_stack(self):
115
+ self.log.info("Test: multiple dust outputs stack penalties")
116
+
117
+ total_penalty = DUST_THRESHOLD * 2 # 660 sats
118
+
119
+ probe = self.build_tx_with_dust([0, 0], fee=1000)
120
+ min_fee = self.get_min_relay_fee(probe)
121
+
122
+ reject_fee = total_penalty + min_fee - 1
123
+ tx_low = self.build_tx_with_dust([0, 0], fee=reject_fee)
124
+ result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
125
+ assert_equal(result[0]["allowed"], False)
126
+ assert_equal(result[0]["reject-reason"], "min relay fee not met")
127
+
128
+ accept_fee = total_penalty + min_fee
129
+ tx_high = self.build_tx_with_dust([0, 0], fee=accept_fee)
130
+ result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
131
+ assert_equal(result[0]["allowed"], True)
132
+
133
+ def test_penalty_disabled(self):
134
+ self.log.info("Test: penalty disabled with -subdustfeepenalty=0")
135
+ self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=0"])
136
+ self.wallet.rescan_utxos()
137
+
138
+ probe = self.build_tx_with_dust([0], fee=1000)
139
+ min_fee = self.get_min_relay_fee(probe)
140
+
141
+ # Without penalty, just the min relay fee suffices
142
+ tx = self.build_tx_with_dust([0], fee=min_fee)
143
+ result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
144
+ assert_equal(result[0]["allowed"], True)
145
+
146
+ def test_op_return_not_penalized(self):
147
+ self.log.info("Test: OP_RETURN outputs are not penalized (threshold=0)")
148
+ self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=1"])
149
+ self.wallet.rescan_utxos()
150
+
151
+ utxo = self.wallet.get_utxo()
152
+ tx = CTransaction()
153
+ tx.version = 2
154
+ tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
155
+ input_value = int(utxo["value"] * COIN)
156
+ fee = 400
157
+ tx.vout = [
158
+ CTxOut(0, CScript([OP_RETURN, b"test data"])),
159
+ CTxOut(input_value - fee, self.wallet.get_output_script()),
160
+ ]
161
+ self.wallet.sign_tx(tx)
162
+
163
+ result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
164
+ assert_equal(result[0]["allowed"], True)
165
+
166
+ def test_above_dust_not_penalized(self):
167
+ self.log.info("Test: outputs above dust threshold are not penalized")
168
+
169
+ probe = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=1000)
170
+ min_fee = self.get_min_relay_fee(probe)
171
+
172
+ tx = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=min_fee)
173
+ result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
174
+ assert_equal(result[0]["allowed"], True)
175
+
176
+ def reset_policy(self, *args):
177
+ # Confirm previous fixtures before reloading policy; avoid repricing saved entries.
178
+ self.generate(self.wallet, 1)
179
+ self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=1", *args])
180
+ self.wallet.rescan_utxos()
181
+
182
+ def check_boundary(self, values, penalty, **kwargs):
183
+ utxo = self.wallet.get_utxo()
184
+ probe = self.build_tx_with_dust(values, 1000, utxo=utxo, **kwargs)
185
+ min_fee = self.get_min_relay_fee(probe)
186
+ low = self.build_tx_with_dust(values, min_fee + penalty - 1, utxo=utxo, **kwargs)
187
+ high = self.build_tx_with_dust(values, min_fee + penalty, utxo=utxo, **kwargs)
188
+ rejection = self.nodes[0].testmempoolaccept([low.serialize().hex()])[0]
189
+ assert_equal(rejection["allowed"], False)
190
+ assert_equal(rejection["reject-reason"], "min relay fee not met")
191
+ assert_equal(self.nodes[0].testmempoolaccept([high.serialize().hex()])[0]["allowed"], True)
192
+
193
+ def test_threshold_and_low_rate_boundaries(self):
194
+ self.log.info("Exact threshold, per-output ceiling at one sat/kvB, and zero rate")
195
+ self.check_boundary([DUST_THRESHOLD - 1], 1)
196
+ self.check_boundary([DUST_THRESHOLD], 0)
197
+ self.reset_policy("-dustrelayfee=0.00000001")
198
+ # Each P2TR threshold rounds up separately, rather than rounding the sum.
199
+ self.check_boundary([0, 0], 2)
200
+ self.check_boundary([1], 0)
201
+ self.reset_policy("-dustrelayfee=0")
202
+ self.check_boundary([0, 0], 0)
203
+ self.reset_policy()
204
+
205
+ def test_anchor_and_standardness(self):
206
+ self.log.info("P2A permission does not exempt its zero-value output from the penalty")
207
+ # P2A is a witness program: 13-byte output + 67-byte spend estimate.
208
+ self.check_boundary([0], 240, dust_script=PAY_TO_ANCHOR)
209
+ self.reset_policy("-acceptnonstdtxn=0")
210
+ tx = self.build_tx_with_dust([0], 10000, dust_script=PAY_TO_ANCHOR)
211
+ rejection = self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]
212
+ assert_equal(rejection["allowed"], False)
213
+ assert_equal(rejection["reject-reason"], "dust")
214
+ # Paying the deficit cannot bypass standard dust rejection either.
215
+ tx = self.build_tx_with_dust([0], 10000)
216
+ assert_equal(self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]["reject-reason"], "dust-nonanchor")
217
+ self.reset_policy("-acceptnonstdtxn=0", "-subdustfeepenalty=0")
218
+ assert_equal(self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]["reject-reason"], "dust-nonanchor")
219
+ self.reset_policy()
220
+
221
+ def test_carrier_cost_interaction(self):
222
+ self.log.info("Carrier weight and dust deficit independently raise the required effective fee")
223
+ payload = b"x" * 16
224
+ self.reset_policy("-datacarriercost=1.01") # rounds to five weight units per byte
225
+ utxo = self.wallet.get_utxo()
226
+ probe = self.build_tx_with_dust([0], 1000, utxo=utxo, carrier=payload)
227
+ # Recognized carrier bytes include OP_RETURN and the push opcode.
228
+ policy_vsize = (probe.get_weight() + len(CScript([OP_RETURN, payload])) + 3) // 4
229
+ fee = policy_vsize + DUST_THRESHOLD
230
+ low = self.build_tx_with_dust([0], fee - 1, utxo=utxo, carrier=payload)
231
+ high = self.build_tx_with_dust([0], fee, utxo=utxo, carrier=payload)
232
+ assert_equal(self.nodes[0].testmempoolaccept([low.serialize().hex()])[0]["reject-reason"], "min relay fee not met")
233
+ txid = self.nodes[0].sendrawtransaction(high.serialize().hex())
234
+ entry = self.nodes[0].getmempoolentry(txid)
235
+ assert_equal(entry["vsize"], policy_vsize)
236
+ assert_equal(entry["fees"]["base"], Decimal(fee) / COIN)
237
+ assert_equal(entry["fees"]["modified"], Decimal(policy_vsize) / COIN)
238
+ self.reset_policy("-datacarriercost=0")
239
+ self.check_boundary([0], DUST_THRESHOLD, carrier=payload)
240
+ self.reset_policy("-datacarriersize=0")
241
+ tx = self.build_tx_with_dust([0], 10000, carrier=payload)
242
+ assert_equal(self.nodes[0].testmempoolaccept([tx.serialize().hex()])[0]["reject-reason"], "txn-datacarrier-exceeded")
243
+ self.reset_policy("-datacarriersize=0", "-datacarrierfullcount=0")
244
+ self.check_boundary([0], DUST_THRESHOLD, carrier=payload)
245
+ self.reset_policy()
246
+
247
+ def test_fee_delta_and_replacement(self):
248
+ self.log.info("Fee deltas compose with the deficit; RBF compares modified fees")
249
+ node = self.nodes[0]
250
+ utxo = self.wallet.get_utxo()
251
+ tx = self.build_tx_with_dust([0], 1000, utxo=utxo)
252
+ txid = node.sendrawtransaction(tx.serialize().hex())
253
+ assert_equal(node.getmempoolentry(txid)["fees"]["modified"], Decimal(670) / COIN)
254
+ node.prioritisetransaction(txid, 0, 100)
255
+ assert_equal(node.getmempoolentry(txid)["fees"]["base"], Decimal(1000) / COIN)
256
+ assert_equal(node.getmempoolentry(txid)["fees"]["modified"], Decimal(770) / COIN)
257
+ # A higher actual fee can still have a lower effective fee than the conflict.
258
+ low = self.build_tx_with_dust([0], 1050, utxo=utxo)
259
+ assert_equal(node.testmempoolaccept([low.serialize().hex()])[0]["reject-reason"], "insufficient fee")
260
+ high = self.build_tx_with_dust([0], 2000, utxo=utxo)
261
+ replacement = node.sendrawtransaction(high.serialize().hex())
262
+ assert_equal(node.getrawmempool(), [replacement])
263
+ assert_equal(node.getmempoolentry(replacement)["fees"]["modified"], Decimal(1670) / COIN)
264
+ self.reset_policy()
265
+ # A pre-admission delta raises policy fees without changing actual output values.
266
+ tx = self.build_tx_with_dust([0], 0)
267
+ delta = DUST_THRESHOLD + self.get_min_relay_fee(tx)
268
+ node.prioritisetransaction(tx.txid_hex, 0, delta - 1)
269
+ assert_equal(node.testmempoolaccept([tx.serialize().hex()])[0]["allowed"], False)
270
+ node.prioritisetransaction(tx.txid_hex, 0, 1)
271
+ txid = node.sendrawtransaction(tx.serialize().hex())
272
+ assert_equal(node.getmempoolentry(txid)["fees"]["base"], 0)
273
+ assert_equal(node.getmempoolentry(txid)["fees"]["modified"], Decimal(delta - DUST_THRESHOLD) / COIN)
274
+ self.reset_policy()
275
+
276
+ def test_package_and_truc(self):
277
+ self.log.info("TRUC sponsorship pays the dust deficit; ordinary packages retain the relay floor")
278
+ node = self.nodes[0]
279
+ for version in (2, 3):
280
+ parent = self.build_tx_with_dust([0], 0, version=version)
281
+ child = CTransaction()
282
+ child.version = version
283
+ child.vin = [CTxIn(COutPoint(parent.txid_int, 1))]
284
+ child.vout = [CTxOut(parent.vout[1].nValue - 1000, self.wallet.get_output_script())]
285
+ self.wallet.sign_tx(child)
286
+ assert_equal(node.testmempoolaccept([parent.serialize().hex()])[0]["allowed"], False)
287
+ result = node.submitpackage([parent.serialize().hex(), child.serialize().hex()])
288
+ if version == 2:
289
+ assert_equal(result["tx-results"][parent.wtxid_hex]["error"], f"min relay fee not met, -{DUST_THRESHOLD} < {parent.get_vsize()}")
290
+ assert_equal(node.getrawmempool(), [])
291
+ self.generateblock(node, output=self.wallet.get_address(), transactions=[parent.serialize().hex(), child.serialize().hex()])
292
+ else:
293
+ assert_equal(result["package_msg"], "success")
294
+ parent_entry = node.getmempoolentry(parent.txid_hex)
295
+ child_entry = node.getmempoolentry(child.txid_hex)
296
+ assert_equal(parent_entry["fees"]["base"], 0)
297
+ assert_equal(parent_entry["fees"]["modified"], -Decimal(DUST_THRESHOLD) / COIN)
298
+ assert_equal(parent_entry["fees"]["descendant"], Decimal(1000 - DUST_THRESHOLD) / COIN)
299
+ assert_equal(child_entry["fees"]["ancestor"], Decimal(1000 - DUST_THRESHOLD) / COIN)
300
+ self.reset_policy()
301
+
302
+ # Enforced v3 can be sponsored below its individual relay floor, but the
303
+ # combined modified fee must still reach the package's mempool minimum.
304
+ parent = self.build_tx_with_dust([0], 0, version=3)
305
+ child = CTransaction()
306
+ child.version = 3
307
+ child.vin = [CTxIn(COutPoint(parent.txid_int, 1))]
308
+ child.vout = [CTxOut(parent.vout[1].nValue, self.wallet.get_output_script())]
309
+ self.wallet.sign_tx(child)
310
+ package_size = parent.get_vsize() + child.get_vsize()
311
+ fee = DUST_THRESHOLD + package_size
312
+ child.vout[0].nValue -= fee - 1
313
+ result = node.submitpackage([parent.serialize().hex(), child.serialize().hex()])
314
+ assert_equal(result["package_msg"], "transaction failed")
315
+ assert_equal(result["tx-results"][child.wtxid_hex]["error"], f"min relay fee not met, {package_size - 1} < {package_size}")
316
+ assert_equal(node.getrawmempool(), [])
317
+ child.vout[0].nValue -= 1
318
+ result = node.submitpackage([parent.serialize().hex(), child.serialize().hex()])
319
+ assert_equal(result["package_msg"], "success")
320
+ assert_equal(node.getmempoolentry(child.txid_hex)["fees"]["ancestor"], Decimal(package_size) / COIN)
321
+ self.reset_policy("-mempooltruc=accept")
322
+ parent = self.build_tx_with_dust([0], 0, version=3)
323
+ assert_equal(node.testmempoolaccept([parent.serialize().hex()])[0]["reject-reason"], "min relay fee not met")
324
+ self.reset_policy()
325
+
326
+ def test_mining_and_block_acceptance(self):
327
+ self.log.info("Templates select modified fees, while blocks reward actual fees and accept local rejects")
328
+ node = self.nodes[0]
329
+ self.reset_policy("-blockmintxfee=0.00002")
330
+ probe = self.build_tx_with_dust([0], 1000)
331
+ min_fee = self.get_min_relay_fee(probe)
332
+ low = self.build_tx_with_dust([0], min_fee)
333
+ assert_equal(node.testmempoolaccept([low.serialize().hex()])[0]["reject-reason"], "min relay fee not met")
334
+ previous_height = node.getblockcount()
335
+ block = self.generateblock(node, output=self.wallet.get_address(), transactions=[low.serialize().hex()])
336
+ assert_equal(node.getblockcount(), previous_height + 1)
337
+ assert low.txid_hex in node.getblock(block["hash"])["tx"]
338
+ self.wallet.rescan_utxos()
339
+ # Actual fee exceeds template minimum, but effective fee is one sat below it.
340
+ fee = DUST_THRESHOLD + 2 * min_fee - 1
341
+ tx = self.build_tx_with_dust([0], fee)
342
+ txid = node.sendrawtransaction(tx.serialize().hex())
343
+ template = node.getblocktemplate({"rules": ["segwit"]})
344
+ assert txid not in [entry["txid"] for entry in template["transactions"]]
345
+ node.prioritisetransaction(txid, 0, 1)
346
+ # Changing mocktime expires the RPC's cached template without wall-clock sleeps.
347
+ node.setmocktime(node.getblockheader(node.getbestblockhash())["time"] + 10)
348
+ template = node.getblocktemplate({"rules": ["segwit"]})
349
+ entry = next(entry for entry in template["transactions"] if entry["txid"] == txid)
350
+ assert_equal(entry["fee"], fee)
351
+ self.generate(self.wallet, 1)
352
+ mined = node.getblock(node.getbestblockhash(), 2)
353
+ assert txid in [entry["txid"] for entry in mined["tx"]]
354
+ # Regtest at this height has a 25 BTC subsidy. Penalty/delta do not alter reward.
355
+ assert_equal(sum(output["value"] for output in mined["tx"][0]["vout"]), Decimal(25) + Decimal(fee) / COIN)
356
+ self.reset_policy("-blockmintxfee=0.00002", "-subdustfeepenalty=0")
357
+ tx = self.build_tx_with_dust([0], fee)
358
+ txid = node.sendrawtransaction(tx.serialize().hex())
359
+ assert txid in [entry["txid"] for entry in node.getblocktemplate({"rules": ["segwit"]})["transactions"]]
360
+ # Priority-area selection is verified with its native L9 implementation.
361
+
362
+
363
+
364
+ if __name__ == '__main__':
365
+ SubDustFeePenaltyTest(__file__).main()