mempool_dust.py raw
1 #!/usr/bin/env python3
2 # Copyright (c) 2022 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 dust limit mempool policy (`-dustrelayfee` parameter)"""
6 from decimal import Decimal
7
8 from test_framework.messages import (
9 COIN,
10 CTxOut,
11 )
12 from test_framework.script import (
13 CScript,
14 OP_RETURN,
15 OP_TRUE,
16 )
17 from test_framework.script_util import (
18 key_to_p2pk_script,
19 key_to_p2pkh_script,
20 key_to_p2wpkh_script,
21 keys_to_multisig_script,
22 output_key_to_p2tr_script,
23 program_to_witness_script,
24 script_to_p2sh_script,
25 script_to_p2wsh_script,
26 )
27 from test_framework.test_framework import LimenkaTestFramework
28 from test_framework.test_node import TestNode
29 from test_framework.util import (
30 assert_equal,
31 get_fee,
32 )
33 from test_framework.wallet import MiniWallet
34 from test_framework.wallet_util import generate_keypair
35
36
37 DUST_RELAY_TX_FEE = 3000 # default setting [sat/kvB]
38
39
40 class DustRelayFeeTest(LimenkaTestFramework):
41 def set_test_params(self):
42 self.num_nodes = 1
43 self.extra_args = [['-permitbaremultisig']]
44
45 def test_dust_output(self, node: TestNode, dust_relay_fee: Decimal,
46 output_script: CScript, type_desc: str) -> None:
47 # determine dust threshold (see `GetDustThreshold`)
48 if output_script[0] == OP_RETURN:
49 dust_threshold = 0
50 else:
51 tx_size = len(CTxOut(nValue=0, scriptPubKey=output_script).serialize())
52 tx_size += 67 if output_script.IsWitnessProgram() else 148
53 dust_threshold = int(get_fee(tx_size, dust_relay_fee) * COIN)
54 self.log.info(f"-> Test {type_desc} output (size {len(output_script)}, limit {dust_threshold})")
55
56 # amount right on the dust threshold should pass
57 tx = self.wallet.create_self_transfer()["tx"]
58 tx.vout.append(CTxOut(nValue=dust_threshold, scriptPubKey=output_script))
59 tx.vout[0].nValue -= dust_threshold # keep total output value constant
60 tx_good_hex = tx.serialize().hex()
61 res = node.testmempoolaccept([tx_good_hex])[0]
62 assert_equal(res['allowed'], True)
63
64 # amount just below the dust threshold should fail
65 if dust_threshold > 0:
66 tx.vout[1].nValue -= 1
67 res = node.testmempoolaccept([tx.serialize().hex()])[0]
68 assert_equal(res['allowed'], False)
69 assert_equal(res['reject-reason'], 'dust')
70
71 # finally send the transaction to avoid running out of MiniWallet UTXOs
72 self.wallet.sendrawtransaction(from_node=node, tx_hex=tx_good_hex)
73
74 def test_dustrelay(self):
75 self.log.info("Test that small outputs are acceptable when dust relay rate is set to 0 that would otherwise trigger ephemeral dust rules")
76
77 self.restart_node(0, extra_args=["-dustrelayfee=0"])
78
79 assert_equal(self.nodes[0].getrawmempool(), [])
80
81 # Create two dust outputs. Transaction has zero fees. both dust outputs are unspent, and would have failed individual checks.
82 # The amount is 1 satoshi because create_self_transfer_multi disallows 0.
83 dusty_tx = self.wallet.create_self_transfer_multi(fee_per_output=1000, amount_per_output=1, num_outputs=2)
84 dust_txid = self.nodes[0].sendrawtransaction(hexstring=dusty_tx["hex"], maxfeerate=0)
85
86 assert_equal(self.nodes[0].getrawmempool(), [dust_txid])
87
88 # Spends one dust along with fee input, leave other dust unspent to check ephemeral dust checks aren't being enforced
89 sweep_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=[self.wallet.get_utxo(), dusty_tx["new_utxos"][0]])
90 sweep_txid = self.nodes[0].sendrawtransaction(sweep_tx["hex"])
91
92 mempool_entries = self.nodes[0].getrawmempool()
93 assert dust_txid in mempool_entries
94 assert sweep_txid in mempool_entries
95 assert_equal(len(mempool_entries), 2)
96
97 # Wipe extra arg to reset dust relay
98 self.restart_node(0, extra_args=[])
99
100 assert_equal(self.nodes[0].getrawmempool(), [])
101
102 def test_output_size_limit(self):
103 """Test that outputs exceeding MAX_OUTPUT_SCRIPT_SIZE (34 bytes) are rejected"""
104 self.log.info("Test MAX_OUTPUT_SCRIPT_SIZE limit (34 bytes)")
105
106 node = self.nodes[0]
107 _, pubkey = generate_keypair(compressed=True)
108
109 # Test Case 1: Scripts at or under 34 bytes should be accepted
110 self.log.info("-> Testing scripts at or under 34-byte limit (should pass)")
111
112 passing_scripts = [
113 (key_to_p2pkh_script(pubkey), "P2PKH", 25),
114 (key_to_p2wpkh_script(pubkey), "P2WPKH", 22),
115 (script_to_p2wsh_script(CScript([OP_TRUE])), "P2WSH", 34),
116 (script_to_p2sh_script(CScript([OP_TRUE])), "P2SH", 23),
117 (output_key_to_p2tr_script(pubkey[1:]), "P2TR", 34),
118 ]
119
120 for script, name, expected_size in passing_scripts:
121 assert_equal(len(script), expected_size)
122 tx = self.wallet.create_self_transfer()["tx"]
123 tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script))
124 res = node.testmempoolaccept([tx.serialize().hex()])[0]
125 assert_equal(res['allowed'], True)
126 self.log.info(f" ✓ {name} ({expected_size} bytes) accepted")
127
128 # Test Case 2: P2PK with compressed pubkey (35 bytes) should be rejected
129 self.log.info("-> Testing P2PK compressed (35 bytes) - should be rejected")
130 p2pk_script = key_to_p2pk_script(pubkey)
131 assert_equal(len(p2pk_script), 35)
132
133 tx = self.wallet.create_self_transfer()["tx"]
134 tx.vout.append(CTxOut(nValue=1000, scriptPubKey=p2pk_script))
135 res = node.testmempoolaccept([tx.serialize().hex()])[0]
136 assert_equal(res['allowed'], False)
137 assert 'output-script-size' in res['reject-reason'].lower() or \
138 'bad-txns' in res['reject-reason'].lower(), \
139 f"Expected output-script-size error, got: {res['reject-reason']}"
140 self.log.info(f" ✓ P2PK compressed (35 bytes) correctly rejected: {res['reject-reason']}")
141
142 # Test Case 3: 1-of-1 bare multisig (37 bytes) should be rejected
143 self.log.info("-> Testing 1-of-1 bare multisig (37 bytes) - should be rejected")
144 multisig_script = keys_to_multisig_script([pubkey], k=1)
145 assert_equal(len(multisig_script), 37)
146
147 tx = self.wallet.create_self_transfer()["tx"]
148 tx.vout.append(CTxOut(nValue=1000, scriptPubKey=multisig_script))
149 res = node.testmempoolaccept([tx.serialize().hex()])[0]
150 assert_equal(res['allowed'], False)
151 assert 'output-script-size' in res['reject-reason'].lower() or \
152 'bad-txns' in res['reject-reason'].lower(), \
153 f"Expected output-script-size error, got: {res['reject-reason']}"
154 self.log.info(f" ✓ 1-of-1 bare multisig (37 bytes) correctly rejected: {res['reject-reason']}")
155
156 # Test Case 4: Boundary testing (exactly 34 vs 35 bytes)
157 self.log.info("-> Testing boundary conditions")
158
159 # Exactly 34 bytes should pass (create a witness program v0 with 32-byte data)
160 script_34 = CScript([0, bytes(32)]) # OP_0 + 32 bytes = 34 bytes
161 assert_equal(len(script_34), 34)
162 tx = self.wallet.create_self_transfer()["tx"]
163 tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_34))
164 res = node.testmempoolaccept([tx.serialize().hex()])[0]
165 assert_equal(res['allowed'], True)
166 self.log.info(" ✓ Exactly 34 bytes accepted (boundary)")
167
168 # 35 bytes should fail (create a witness program v0 with 33-byte data - invalid but tests size)
169 script_35 = CScript([0, bytes(33)]) # OP_0 + 33 bytes = 35 bytes
170 assert_equal(len(script_35), 35)
171 tx = self.wallet.create_self_transfer()["tx"]
172 tx.vout.append(CTxOut(nValue=1000, scriptPubKey=script_35))
173 res = node.testmempoolaccept([tx.serialize().hex()])[0]
174 assert_equal(res['allowed'], False)
175 self.log.info(f" ✓ 35 bytes rejected (boundary): {res['reject-reason']}")
176
177 def run_test(self):
178 self.wallet = MiniWallet(self.nodes[0])
179
180 self.test_dustrelay()
181 self.test_output_size_limit()
182
183 # prepare output scripts of each standard type
184 _, uncompressed_pubkey = generate_keypair(compressed=False)
185 _, pubkey = generate_keypair(compressed=True)
186
187 output_scripts = (
188 (key_to_p2pkh_script(pubkey), "P2PKH"),
189 (script_to_p2sh_script(CScript([OP_TRUE])), "P2SH"),
190 (key_to_p2wpkh_script(pubkey), "P2WPKH"),
191 (script_to_p2wsh_script(CScript([OP_TRUE])), "P2WSH"),
192 (output_key_to_p2tr_script(pubkey[1:]), "P2TR"),
193 # witness programs for segwitv2+ can be between 2 and 40 bytes
194 (program_to_witness_script(2, b'\x66' * 2), "P2?? (future witness version 2)"),
195 (program_to_witness_script(16, b'\x77' * 32), "P2?? (future witness version 16)"),
196 (CScript([OP_RETURN, b'superimportanthash']), "null data (OP_RETURN)"),
197 )
198
199 # test default (no parameter), disabled (=0) and a bunch of arbitrary dust fee rates [sat/kvB]
200 for dustfee_sat_kvb in (DUST_RELAY_TX_FEE, 0, 1, 66, 500, 1337, 12345, 21212, 333333):
201 dustfee_btc_kvb = dustfee_sat_kvb / Decimal(COIN)
202 if dustfee_sat_kvb == DUST_RELAY_TX_FEE:
203 self.log.info(f"Test default dust limit setting ({dustfee_sat_kvb} sat/kvB)...")
204 else:
205 dust_parameter = f"-dustrelayfee={dustfee_btc_kvb:.8f}"
206 self.log.info(f"Test dust limit setting {dust_parameter} ({dustfee_sat_kvb} sat/kvB)...")
207 self.restart_node(0, extra_args=[dust_parameter, "-permitbaremultisig"])
208
209 for output_script, description in output_scripts:
210 self.test_dust_output(self.nodes[0], dustfee_btc_kvb, output_script, description)
211 self.generate(self.nodes[0], 1)
212
213
214 if __name__ == '__main__':
215 DustRelayFeeTest(__file__).main()
216