v29.4-roots.2 · Commit 6: [PATCH 06/21] test: align initial Roots policy coverage
test/functional/mempool_subdust_fee_penalty.py
Before: /dev/null · After: test/functional/mempool_subdust_fee_penalty.py
File metadata
new file mode 100755 index 0000000000000000000000000000000000000000..c7a1131490e0fa08059eeaad90286561f4ded9a9
Download original patch · Release notes
Diff rows 1–168 of 168. 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,168 @@
|
|
|
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 math import ceil
|
|
8
|
+
|
|
9
|
+
from test_framework.messages import (
|
|
10
|
+
COIN,
|
|
11
|
+
COutPoint,
|
|
12
|
+
CTransaction,
|
|
13
|
+
CTxIn,
|
|
14
|
+
CTxOut,
|
|
15
|
+
)
|
|
16
|
+
from test_framework.script import (
|
|
17
|
+
CScript,
|
|
18
|
+
OP_RETURN,
|
|
19
|
+
)
|
|
20
|
+
from test_framework.test_framework import BitcoinTestFramework
|
|
21
|
+
from test_framework.util import assert_equal
|
|
22
|
+
from test_framework.wallet import MiniWallet
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
DUST_THRESHOLD = 330 # P2TR: (43 + 67) * 3000 / 1000 = 330 sats
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubDustFeePenaltyTest(BitcoinTestFramework):
|
|
29
|
+
def set_test_params(self):
|
|
30
|
+
self.num_nodes = 1
|
|
31
|
+
self.extra_args = [["-acceptnonstdtxn=1", "-subdustfeepenalty=1"]]
|
|
32
|
+
|
|
33
|
+
def run_test(self):
|
|
34
|
+
self.wallet = MiniWallet(self.nodes[0])
|
|
35
|
+
|
|
36
|
+
info = self.nodes[0].getmempoolinfo()
|
|
37
|
+
self.minrelay_per_kvb = info['minrelaytxfee'] * COIN # sats per kvB
|
|
38
|
+
|
|
39
|
+
self.test_dust_output_increases_required_fee()
|
|
40
|
+
self.test_partial_dust_proportional_penalty()
|
|
41
|
+
self.test_multiple_dust_outputs_stack()
|
|
42
|
+
self.test_penalty_disabled()
|
|
43
|
+
self.test_op_return_not_penalized()
|
|
44
|
+
self.test_above_dust_not_penalized()
|
|
45
|
+
|
|
46
|
+
def build_tx_with_dust(self, dust_values, fee):
|
|
47
|
+
utxo = self.wallet.get_utxo()
|
|
48
|
+
tx = CTransaction()
|
|
49
|
+
tx.version = 2
|
|
50
|
+
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
|
|
51
|
+
input_value = int(utxo["value"] * COIN)
|
|
52
|
+
change = input_value - sum(dust_values) - fee
|
|
53
|
+
assert change > 0, f"Not enough funds: input={input_value}, dust={dust_values}, fee={fee}"
|
|
54
|
+
tx.vout = [CTxOut(v, self.wallet.get_output_script()) for v in dust_values]
|
|
55
|
+
tx.vout.append(CTxOut(change, self.wallet.get_output_script()))
|
|
56
|
+
self.wallet.sign_tx(tx)
|
|
57
|
+
return tx
|
|
58
|
+
|
|
59
|
+
def get_min_relay_fee(self, tx):
|
|
60
|
+
"""Calculate the minimum relay fee for a transaction."""
|
|
61
|
+
decoded = self.nodes[0].decoderawtransaction(tx.serialize().hex())
|
|
62
|
+
return int(ceil(self.minrelay_per_kvb * decoded['vsize'] / 1000))
|
|
63
|
+
|
|
64
|
+
def test_dust_output_increases_required_fee(self):
|
|
65
|
+
self.log.info("Test: sub-dust output penalizes effective fee")
|
|
66
|
+
|
|
67
|
+
# Build a probe tx to determine the exact min relay fee
|
|
68
|
+
probe = self.build_tx_with_dust([0], fee=1000)
|
|
69
|
+
min_fee = self.get_min_relay_fee(probe)
|
|
70
|
+
|
|
71
|
+
# fee just covers min relay + penalty - 1: rejected
|
|
72
|
+
reject_fee = DUST_THRESHOLD + min_fee - 1
|
|
73
|
+
tx_low = self.build_tx_with_dust([0], fee=reject_fee)
|
|
74
|
+
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
|
|
75
|
+
assert_equal(result[0]["allowed"], False)
|
|
76
|
+
assert_equal(result[0]["reject-reason"], "min relay fee not met")
|
|
77
|
+
|
|
78
|
+
# fee covers min relay + penalty: accepted
|
|
79
|
+
accept_fee = DUST_THRESHOLD + min_fee
|
|
80
|
+
tx_high = self.build_tx_with_dust([0], fee=accept_fee)
|
|
81
|
+
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
|
|
82
|
+
assert_equal(result[0]["allowed"], True)
|
|
83
|
+
|
|
84
|
+
def test_partial_dust_proportional_penalty(self):
|
|
85
|
+
self.log.info("Test: partial dust value gets proportional penalty")
|
|
86
|
+
|
|
87
|
+
partial_value = 100
|
|
88
|
+
penalty = DUST_THRESHOLD - partial_value # 230 sats
|
|
89
|
+
|
|
90
|
+
probe = self.build_tx_with_dust([partial_value], fee=1000)
|
|
91
|
+
min_fee = self.get_min_relay_fee(probe)
|
|
92
|
+
|
|
93
|
+
reject_fee = penalty + min_fee - 1
|
|
94
|
+
tx_low = self.build_tx_with_dust([partial_value], fee=reject_fee)
|
|
95
|
+
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
|
|
96
|
+
assert_equal(result[0]["allowed"], False)
|
|
97
|
+
assert_equal(result[0]["reject-reason"], "min relay fee not met")
|
|
98
|
+
|
|
99
|
+
accept_fee = penalty + min_fee
|
|
100
|
+
tx_high = self.build_tx_with_dust([partial_value], fee=accept_fee)
|
|
101
|
+
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
|
|
102
|
+
assert_equal(result[0]["allowed"], True)
|
|
103
|
+
|
|
104
|
+
def test_multiple_dust_outputs_stack(self):
|
|
105
|
+
self.log.info("Test: multiple dust outputs stack penalties")
|
|
106
|
+
|
|
107
|
+
total_penalty = DUST_THRESHOLD * 2 # 660 sats
|
|
108
|
+
|
|
109
|
+
probe = self.build_tx_with_dust([0, 0], fee=1000)
|
|
110
|
+
min_fee = self.get_min_relay_fee(probe)
|
|
111
|
+
|
|
112
|
+
reject_fee = total_penalty + min_fee - 1
|
|
113
|
+
tx_low = self.build_tx_with_dust([0, 0], fee=reject_fee)
|
|
114
|
+
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
|
|
115
|
+
assert_equal(result[0]["allowed"], False)
|
|
116
|
+
assert_equal(result[0]["reject-reason"], "min relay fee not met")
|
|
117
|
+
|
|
118
|
+
accept_fee = total_penalty + min_fee
|
|
119
|
+
tx_high = self.build_tx_with_dust([0, 0], fee=accept_fee)
|
|
120
|
+
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
|
|
121
|
+
assert_equal(result[0]["allowed"], True)
|
|
122
|
+
|
|
123
|
+
def test_penalty_disabled(self):
|
|
124
|
+
self.log.info("Test: penalty disabled with -subdustfeepenalty=0")
|
|
125
|
+
self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=0"])
|
|
126
|
+
self.wallet.rescan_utxos()
|
|
127
|
+
|
|
128
|
+
probe = self.build_tx_with_dust([0], fee=1000)
|
|
129
|
+
min_fee = self.get_min_relay_fee(probe)
|
|
130
|
+
|
|
131
|
+
# Without penalty, just the min relay fee suffices
|
|
132
|
+
tx = self.build_tx_with_dust([0], fee=min_fee)
|
|
133
|
+
result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
|
|
134
|
+
assert_equal(result[0]["allowed"], True)
|
|
135
|
+
|
|
136
|
+
def test_op_return_not_penalized(self):
|
|
137
|
+
self.log.info("Test: OP_RETURN outputs are not penalized (threshold=0)")
|
|
138
|
+
self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=1"])
|
|
139
|
+
self.wallet.rescan_utxos()
|
|
140
|
+
|
|
141
|
+
utxo = self.wallet.get_utxo()
|
|
142
|
+
tx = CTransaction()
|
|
143
|
+
tx.version = 2
|
|
144
|
+
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
|
|
145
|
+
input_value = int(utxo["value"] * COIN)
|
|
146
|
+
fee = 400
|
|
147
|
+
tx.vout = [
|
|
148
|
+
CTxOut(0, CScript([OP_RETURN, b"test data"])),
|
|
149
|
+
CTxOut(input_value - fee, self.wallet.get_output_script()),
|
|
150
|
+
]
|
|
151
|
+
self.wallet.sign_tx(tx)
|
|
152
|
+
|
|
153
|
+
result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
|
|
154
|
+
assert_equal(result[0]["allowed"], True)
|
|
155
|
+
|
|
156
|
+
def test_above_dust_not_penalized(self):
|
|
157
|
+
self.log.info("Test: outputs above dust threshold are not penalized")
|
|
158
|
+
|
|
159
|
+
probe = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=1000)
|
|
160
|
+
min_fee = self.get_min_relay_fee(probe)
|
|
161
|
+
|
|
162
|
+
tx = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=min_fee)
|
|
163
|
+
result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
|
|
164
|
+
assert_equal(result[0]["allowed"], True)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == '__main__':
|
|
168
|
+
SubDustFeePenaltyTest(__file__).main()
|