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. Before is on the left; after is on the right. Unified view fits narrow screens more easily.
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()
|