Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,10 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
strprintf("Refuse to relay or mine transactions involving non-bitcoin tokens (default: %u)",
DEFAULT_REJECT_TOKENS),
ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-subdustfeepenalty",
strprintf("Reduce effective fee by the dust threshold for each sub-dust output, making dust-creating transactions require higher fees (default: %u)",
DEFAULT_SUBDUSTFEEPENALTY),
ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-spkreuse=<policy>", strprintf("Either \"allow\" to relay/mine transactions reusing addresses or other pubkey scripts, or \"conflict\" to treat them as exclusive prior to being mined (default: %s)", DEFAULT_SPKREUSE), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-whitelistforcerelay", strprintf("Add 'forcerelay' permission to whitelisted peers with default permissions. This will relay transactions even if the transactions were already in the mempool. (default: %d)", DEFAULT_WHITELISTFORCERELAY), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
argsman.AddArg("-whitelistrelay", strprintf("Add 'relay' permission to whitelisted peers with default permissions. This will accept relayed transactions even when not relaying transactions (default: %d)", DEFAULT_WHITELISTRELAY), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
Expand Down Expand Up @@ -858,6 +862,7 @@ void InitParameterInteraction(ArgsManager& args)
args.SoftSetArg("-permitbarepubkey", "1");
args.SoftSetArg("-permitbaremultisig", "1");
args.SoftSetArg("-rejectparasites", "0");
args.SoftSetArg("-subdustfeepenalty", "0");
Comment thread
kwsantiago marked this conversation as resolved.
args.SoftSetArg("-datacarriercost", "0.25");
args.SoftSetArg("-datacarrierfullcount", "0");
args.SoftSetArg("-maxtxlegacysigops", strprintf("%s", std::numeric_limits<unsigned int>::max()));
Expand Down
1 change: 1 addition & 0 deletions src/kernel/mempool_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ struct MemPoolOptions {
bool permit_bare_multisig{DEFAULT_PERMIT_BAREMULTISIG};
bool reject_parasites{DEFAULT_REJECT_PARASITES};
bool reject_tokens{DEFAULT_REJECT_TOKENS};
bool subdustfeepenalty{DEFAULT_SUBDUSTFEEPENALTY};
bool accept_non_std_datacarrier{DEFAULT_ACCEPT_NON_STD_DATACARRIER};
bool require_standard{true};
bool acceptunknownwitness{DEFAULT_ACCEPTUNKNOWNWITNESS};
Expand Down
2 changes: 2 additions & 0 deletions src/node/mempool_args.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ util::Result<void> ApplyArgsManOptions(const ArgsManager& argsman, const CChainP

mempool_opts.reject_tokens = argsman.GetBoolArg("-rejecttokens", DEFAULT_REJECT_TOKENS);

mempool_opts.subdustfeepenalty = argsman.GetBoolArg("-subdustfeepenalty", DEFAULT_SUBDUSTFEEPENALTY);

if (argsman.GetBoolArg("-datacarrier", DEFAULT_ACCEPT_DATACARRIER)) {
mempool_opts.max_datacarrier_bytes = argsman.GetIntArg("-datacarriersize", MAX_OP_RETURN_RELAY);
} else {
Expand Down
2 changes: 2 additions & 0 deletions src/policy/policy.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ static constexpr unsigned int DEFAULT_BYTES_PER_SIGOP_STRICT{20};
static constexpr unsigned int DEFAULT_WEIGHT_PER_DATA_BYTE{4};
/** Default for -rejecttokens */
static constexpr bool DEFAULT_REJECT_TOKENS{false};
/** Default for -subdustfeepenalty */
static constexpr bool DEFAULT_SUBDUSTFEEPENALTY{true};

// NOTE: Changes to these three require manually adjusting doc in init.cpp
/** Default for -permitephemeral=send */
Expand Down
7 changes: 7 additions & 0 deletions src/qt/optionsdialog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,12 @@ OptionsDialog::OptionsDialog(QWidget* parent, bool enableWallet)
verticalLayout_Spamfiltering->addWidget(rejecttokens);
FixTabOrder(rejecttokens);

subdustfeepenalty = new QCheckBox(groupBox_Spamfiltering);
subdustfeepenalty->setText(tr("Penalize effective fee for sub-dust outputs"));
subdustfeepenalty->setToolTip(tr("For each output below the dust threshold, reduce the transaction's effective fee by the difference between the dust threshold and the output value. This makes transactions creating dust outputs require higher fees to be relayed and mined."));
verticalLayout_Spamfiltering->addWidget(subdustfeepenalty);
FixTabOrder(subdustfeepenalty);

minrelaytxfee = new BitcoinAmountField(groupBox_Spamfiltering);
CreateOptionUI(verticalLayout_Spamfiltering, minrelaytxfee, tr("Ignore transactions offering miners less than %s per kvB in transaction fees."));

Expand Down Expand Up @@ -933,6 +939,7 @@ void OptionsDialog::setMapper()
mapper->addMapping(rejectunknownwitness, OptionsModel::rejectunknownwitness);
mapper->addMapping(rejectparasites, OptionsModel::rejectparasites);
mapper->addMapping(rejecttokens, OptionsModel::rejecttokens);
mapper->addMapping(subdustfeepenalty, OptionsModel::subdustfeepenalty);
mapper->addMapping(rejectspkreuse, OptionsModel::rejectspkreuse);
mapper->addMapping(minrelaytxfee, OptionsModel::minrelaytxfee);
mapper->addMapping(minrelaycoinblocks, OptionsModel::minrelaycoinblocks);
Expand Down
1 change: 1 addition & 0 deletions src/qt/optionsdialog.h
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ private Q_SLOTS:
QCheckBox *rejectunknownwitness;
QCheckBox *rejectparasites;
QCheckBox *rejecttokens;
QCheckBox *subdustfeepenalty;
QCheckBox *rejectspkreuse;
BitcoinAmountField *minrelaytxfee;
BitcoinAmountField *minrelaycoinblocks;
Expand Down
12 changes: 12 additions & 0 deletions src/qt/optionsmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ static const char* SettingName(OptionsModel::OptionID option)
case OptionsModel::rejectunknownwitness: return "rejectunknownwitness";
case OptionsModel::rejectparasites: return "rejectparasites";
case OptionsModel::rejecttokens: return "rejecttokens";
case OptionsModel::subdustfeepenalty: return "subdustfeepenalty";
case OptionsModel::rejectspkreuse: return "rejectspkreuse";
case OptionsModel::minrelaytxfee: return "minrelaytxfee";
case OptionsModel::minrelaycoinblocks: return "minrelaycoinblocks";
Expand Down Expand Up @@ -729,6 +730,8 @@ QVariant OptionsModel::getOption(OptionID option, const std::string& suffix) con
return node().mempool().m_opts.reject_parasites;
case rejecttokens:
return node().mempool().m_opts.reject_tokens;
case subdustfeepenalty:
return node().mempool().m_opts.subdustfeepenalty;
case rejectspkreuse:
return f_rejectspkreuse;
case minrelaytxfee:
Expand Down Expand Up @@ -1249,6 +1252,15 @@ bool OptionsModel::setOption(OptionID option, const QVariant& value, const std::
}
break;
}
case subdustfeepenalty:
{
if (changed()) {
const bool nv = value.toBool();
node().mempool().m_opts.subdustfeepenalty = nv;
node().updateRwSetting("subdustfeepenalty", nv);
}
break;
}
case rejectspkreuse:
if (changed()) {
const bool fNewValue = value.toBool();
Expand Down
1 change: 1 addition & 0 deletions src/qt/optionsmodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class OptionsModel : public QAbstractListModel
rejectunknownwitness, // bool
rejectparasites, // bool
rejecttokens, // bool
subdustfeepenalty, // bool
rejectspkreuse, // bool
minrelaytxfee,
minrelaycoinblocks,
Expand Down
18 changes: 18 additions & 0 deletions src/validation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
#include <util/ioprio.h>
#include <util/mempressure.h>
#include <util/moneystr.h>
#include <util/overflow.h>
#include <util/rbf.h>
#include <util/result.h>
#include <util/signalinterrupt.h>
Expand Down Expand Up @@ -1080,6 +1081,23 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)

ws.m_vsize = ws.m_tx_handle->GetTxSize();

// Reduce effective fee by dust threshold for each sub-dust output
if (m_pool.m_opts.subdustfeepenalty) {
CAmount dust_penalty{0};
for (const auto& txout : tx.vout) {
const CAmount dust_threshold = GetDustThreshold(txout, m_pool.m_opts.dust_relay_feerate);
if (txout.nValue < dust_threshold) {
dust_penalty = SaturatingAdd(dust_penalty, dust_threshold - txout.nValue);
}
}
if (dust_penalty > 0) {
m_subpackage.m_changeset->m_to_add.modify(ws.m_tx_handle, [&](CTxMemPoolEntry& e) {
e.UpdateModifiedFee(-dust_penalty);
});
ws.m_modified_fees = SaturatingAdd(ws.m_modified_fees, -dust_penalty);
}
}

// Enforces 0-fee for dust transactions, no incentive to be mined alone
if (m_pool.m_opts.require_standard && !ignore_rejects.count("dust")) {
if (!PreCheckEphemeralTx(*ptx, m_pool.m_opts.dust_relay_feerate, ws.m_base_fees, ws.m_modified_fees, state)) {
Expand Down
168 changes: 168 additions & 0 deletions test/functional/mempool_subdust_fee_penalty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# Copyright (c) 2026 The Bitcoin Knots developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test sub-dust output fee penalty (-subdustfeepenalty)."""

from math import ceil

from test_framework.messages import (
COIN,
COutPoint,
CTransaction,
CTxIn,
CTxOut,
)
from test_framework.script import (
CScript,
OP_RETURN,
)
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal
from test_framework.wallet import MiniWallet


DUST_THRESHOLD = 330 # P2TR: (43 + 67) * 3000 / 1000 = 330 sats


class SubDustFeePenaltyTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 1
self.extra_args = [["-acceptnonstdtxn=1", "-subdustfeepenalty=1"]]

def run_test(self):
self.wallet = MiniWallet(self.nodes[0])

info = self.nodes[0].getmempoolinfo()
self.minrelay_per_kvb = info['minrelaytxfee'] * COIN # sats per kvB

self.test_dust_output_increases_required_fee()
self.test_partial_dust_proportional_penalty()
self.test_multiple_dust_outputs_stack()
self.test_penalty_disabled()
self.test_op_return_not_penalized()
self.test_above_dust_not_penalized()

def build_tx_with_dust(self, dust_values, fee):
utxo = self.wallet.get_utxo()
tx = CTransaction()
tx.version = 2
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
input_value = int(utxo["value"] * COIN)
change = input_value - sum(dust_values) - fee
assert change > 0, f"Not enough funds: input={input_value}, dust={dust_values}, fee={fee}"
tx.vout = [CTxOut(v, self.wallet.get_output_script()) for v in dust_values]
tx.vout.append(CTxOut(change, self.wallet.get_output_script()))
self.wallet.sign_tx(tx)
return tx

def get_min_relay_fee(self, tx):
"""Calculate the minimum relay fee for a transaction."""
decoded = self.nodes[0].decoderawtransaction(tx.serialize().hex())
return int(ceil(self.minrelay_per_kvb * decoded['vsize'] / 1000))

def test_dust_output_increases_required_fee(self):
self.log.info("Test: sub-dust output penalizes effective fee")

# Build a probe tx to determine the exact min relay fee
probe = self.build_tx_with_dust([0], fee=1000)
min_fee = self.get_min_relay_fee(probe)

# fee just covers min relay + penalty - 1: rejected
reject_fee = DUST_THRESHOLD + min_fee - 1
tx_low = self.build_tx_with_dust([0], fee=reject_fee)
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
assert_equal(result[0]["allowed"], False)
assert_equal(result[0]["reject-reason"], "min relay fee not met")

# fee covers min relay + penalty: accepted
accept_fee = DUST_THRESHOLD + min_fee
tx_high = self.build_tx_with_dust([0], fee=accept_fee)
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
assert_equal(result[0]["allowed"], True)

def test_partial_dust_proportional_penalty(self):
self.log.info("Test: partial dust value gets proportional penalty")

partial_value = 100
penalty = DUST_THRESHOLD - partial_value # 230 sats

probe = self.build_tx_with_dust([partial_value], fee=1000)
min_fee = self.get_min_relay_fee(probe)

reject_fee = penalty + min_fee - 1
tx_low = self.build_tx_with_dust([partial_value], fee=reject_fee)
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
assert_equal(result[0]["allowed"], False)
assert_equal(result[0]["reject-reason"], "min relay fee not met")

accept_fee = penalty + min_fee
tx_high = self.build_tx_with_dust([partial_value], fee=accept_fee)
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
assert_equal(result[0]["allowed"], True)

def test_multiple_dust_outputs_stack(self):
self.log.info("Test: multiple dust outputs stack penalties")

total_penalty = DUST_THRESHOLD * 2 # 660 sats

probe = self.build_tx_with_dust([0, 0], fee=1000)
min_fee = self.get_min_relay_fee(probe)

reject_fee = total_penalty + min_fee - 1
tx_low = self.build_tx_with_dust([0, 0], fee=reject_fee)
result = self.nodes[0].testmempoolaccept([tx_low.serialize().hex()])
assert_equal(result[0]["allowed"], False)
assert_equal(result[0]["reject-reason"], "min relay fee not met")

accept_fee = total_penalty + min_fee
tx_high = self.build_tx_with_dust([0, 0], fee=accept_fee)
result = self.nodes[0].testmempoolaccept([tx_high.serialize().hex()])
assert_equal(result[0]["allowed"], True)

def test_penalty_disabled(self):
self.log.info("Test: penalty disabled with -subdustfeepenalty=0")
self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=0"])
self.wallet.rescan_utxos()

probe = self.build_tx_with_dust([0], fee=1000)
min_fee = self.get_min_relay_fee(probe)

# Without penalty, just the min relay fee suffices
tx = self.build_tx_with_dust([0], fee=min_fee)
result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
assert_equal(result[0]["allowed"], True)

def test_op_return_not_penalized(self):
self.log.info("Test: OP_RETURN outputs are not penalized (threshold=0)")
self.restart_node(0, extra_args=["-acceptnonstdtxn=1", "-subdustfeepenalty=1"])
self.wallet.rescan_utxos()

utxo = self.wallet.get_utxo()
tx = CTransaction()
tx.version = 2
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
input_value = int(utxo["value"] * COIN)
fee = 400
tx.vout = [
CTxOut(0, CScript([OP_RETURN, b"test data"])),
CTxOut(input_value - fee, self.wallet.get_output_script()),
]
self.wallet.sign_tx(tx)

result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
assert_equal(result[0]["allowed"], True)

def test_above_dust_not_penalized(self):
self.log.info("Test: outputs above dust threshold are not penalized")

probe = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=1000)
min_fee = self.get_min_relay_fee(probe)

tx = self.build_tx_with_dust([DUST_THRESHOLD + 100], fee=min_fee)
result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
assert_equal(result[0]["allowed"], True)


if __name__ == '__main__':
SubDustFeePenaltyTest(__file__).main()
1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@
'mempool_compatibility.py',
'mempool_accept_wtxid.py',
'mempool_dust.py',
'mempool_subdust_fee_penalty.py',
'mempool_sigoplimit.py',
'rpc_deriveaddresses.py',
'rpc_deriveaddresses.py --usecli',
Expand Down
Loading