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
4 changes: 4 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("-rejectnetnegativeinputs",
strprintf("Reject transactions containing inputs whose spending cost (at the transaction's feerate) exceeds the value of the input (default: %u)",
DEFAULT_REJECT_NET_NEGATIVE_INPUTS),
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
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 reject_net_negative_inputs{DEFAULT_REJECT_NET_NEGATIVE_INPUTS};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we lose the underscores in the option name please? :)

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.reject_net_negative_inputs = argsman.GetBoolArg("-rejectnetnegativeinputs", DEFAULT_REJECT_NET_NEGATIVE_INPUTS);

if (argsman.GetBoolArg("-datacarrier", DEFAULT_ACCEPT_DATACARRIER)) {
mempool_opts.max_datacarrier_bytes = argsman.GetIntArg("-datacarriersize", MAX_OP_RETURN_RELAY);
} else {
Expand Down
13 changes: 12 additions & 1 deletion src/policy/policy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ static bool CheckSigopsBIP54(const CTransaction& tx, const CCoinsViewCache& inpu
*
* We also check the total number of non-witness sigops across the whole transaction, as per BIP54.
*/
bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts, const std::string& reason_prefix, std::string& out_reason, const ignore_rejects_type& ignore_rejects)
bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts, const CFeeRate& tx_feerate, const std::string& reason_prefix, std::string& out_reason, const ignore_rejects_type& ignore_rejects)
{
if (tx.IsCoinBase()) {
return true; // Coinbases don't use vin normally
Expand Down Expand Up @@ -343,6 +343,17 @@ bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs,
MaybeReject("scriptcheck-sigops");
}
}

if (opts.reject_net_negative_inputs && whichType == TxoutType::WITNESS_V1_TAPROOT) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else if

const auto& stack = tx.vin[i].scriptWitness.stack;
if (stack.size() >= 2) {
int32_t input_vsize = (GetTransactionInputWeight(tx.vin[i]) + WITNESS_SCALE_FACTOR - 1) / WITNESS_SCALE_FACTOR;
CAmount input_cost = tx_feerate.GetFee(input_vsize);
if (input_cost > prev.nValue) {
MaybeReject("netnegative");
}
}
}
}

return true;
Expand Down
4 changes: 3 additions & 1 deletion 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 -rejectnetnegativeinputs */
static constexpr bool DEFAULT_REJECT_NET_NEGATIVE_INPUTS{false};

// NOTE: Changes to these three require manually adjusting doc in init.cpp
/** Default for -permitephemeral=send */
Expand Down Expand Up @@ -205,7 +207,7 @@ bool IsStandardTx(const CTransaction& tx, const kernel::MemPoolOptions& opts, st
* @param[in] mapInputs Map of previous transactions that have outputs we're spending
* @return True if all inputs (scriptSigs) use only standard transaction forms
*/
bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts, const std::string& reason_prefix, std::string& out_reason, const ignore_rejects_type& ignore_rejects=empty_ignore_rejects);
bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts, const CFeeRate& tx_feerate, const std::string& reason_prefix, std::string& out_reason, const ignore_rejects_type& ignore_rejects=empty_ignore_rejects);

/**
* Check if the transaction is over standard P2WSH resources limit:
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);

rejectnetnegativeinputs = new QCheckBox(groupBox_Spamfiltering);
rejectnetnegativeinputs->setText(tr("Reject overly net-negative value inputs"));
rejectnetnegativeinputs->setToolTip(tr("Reject transactions containing inputs whose spending cost (at the transaction's feerate) exceeds the value of the input being spent. Key-path spends are always allowed."));
verticalLayout_Spamfiltering->addWidget(rejectnetnegativeinputs);
FixTabOrder(rejectnetnegativeinputs);

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(rejectnetnegativeinputs, OptionsModel::rejectnetnegativeinputs);
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 *rejectnetnegativeinputs;
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::rejectnetnegativeinputs: return "rejectnetnegativeinputs";
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 rejectnetnegativeinputs:
return node().mempool().m_opts.reject_net_negative_inputs;
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 rejectnetnegativeinputs:
{
if (changed()) {
const bool nv = value.toBool();
node().mempool().m_opts.reject_net_negative_inputs = nv;
node().updateRwSetting("rejectnetnegativeinputs", 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
rejectnetnegativeinputs, // bool
rejectspkreuse, // bool
minrelaytxfee,
minrelaycoinblocks,
Expand Down
5 changes: 3 additions & 2 deletions src/test/util/transaction_utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#define BITCOIN_TEST_UTIL_TRANSACTION_UTILS_H

#include <kernel/mempool_options.h>
#include <policy/feerate.h>
#include <policy/policy.h>
#include <primitives/transaction.h>
#include <script/sign.h>
Expand Down Expand Up @@ -52,9 +53,9 @@ bool SignSignature(const SigningProvider &provider, const CScript& fromPubKey, C
bool SignSignature(const SigningProvider &provider, const CTransaction& txFrom, CMutableTransaction& txTo,
unsigned int nIn, int nHashType, SignatureData& sig_data);

inline bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts={}) {
inline bool AreInputsStandard(const CTransaction& tx, const CCoinsViewCache& mapInputs, const kernel::MemPoolOptions& opts={}, const CFeeRate& tx_feerate={}) {
std::string reason;
return AreInputsStandard(tx, mapInputs, opts, reason, reason);
return AreInputsStandard(tx, mapInputs, opts, tx_feerate, reason, reason);
}

#endif // BITCOIN_TEST_UTIL_TRANSACTION_UTILS_H
3 changes: 2 additions & 1 deletion src/validation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1021,7 +1021,8 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws)
}
}

if (m_pool.m_opts.require_standard && !AreInputsStandard(tx, m_view, m_pool.m_opts, "bad-txns-input-", reason, ignore_rejects)) {
const CFeeRate tx_feerate(ws.m_base_fees, GetVirtualTransactionSize(tx));
if (m_pool.m_opts.require_standard && !AreInputsStandard(tx, m_view, m_pool.m_opts, tx_feerate, "bad-txns-input-", reason, ignore_rejects)) {
return state.Invalid(TxValidationResult::TX_INPUTS_NOT_STANDARD, reason);
}

Expand Down
177 changes: 177 additions & 0 deletions test/functional/mempool_net_negative_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/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 net-negative input policy (-rejectnetnegativeinputs)."""

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


class TapscriptDustTest(BitcoinTestFramework):
def set_test_params(self):
self.num_nodes = 1
self.extra_args = [["-rejectnetnegativeinputs=1"]]

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

self.test_script_path_dust_rejected()
self.test_script_path_adequate_value_accepted()
self.test_key_path_exempt()
self.test_disabled()

def sign_tapscript(self, tx):
"""Add MiniWallet tapscript witness to all inputs."""
tx.wit.vtxinwit = [CTxInWitness() for _ in tx.vin]
leaf_info = list(self.wallet._taproot_info.leaves.values())[0]
for w in tx.wit.vtxinwit:
w.scriptWitness.stack = [
leaf_info.script,
bytes([leaf_info.version | self.wallet._taproot_info.negflag]) + self.wallet._taproot_info.internal_pubkey,
]

def fund_tiny_utxo(self, tiny_value=600):
"""Create a confirmed tiny taproot UTXO. Returns (txid, vout)."""
utxo = self.wallet.get_utxo()
tx = CTransaction()
tx.version = 2
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
change = int(utxo["value"] * COIN) - tiny_value - 1000
tx.vout = [
CTxOut(tiny_value, self.wallet.get_output_script()),
CTxOut(change, self.wallet.get_output_script()),
]
self.wallet.sign_tx(tx)
self.wallet.sendrawtransaction(from_node=self.nodes[0], tx_hex=tx.serialize().hex())
self.generate(self.nodes[0], 1)
txid = tx.rehash()
return {"txid": txid, "vout": 0, "value": tiny_value / COIN}

def test_script_path_dust_rejected(self):
self.log.info("Test: script-path spend where input cost > input value is rejected")
# Create a tiny UTXO (600 sats) and a large UTXO
tiny_utxo = self.fund_tiny_utxo(tiny_value=600)
large_utxo = self.wallet.get_utxo()

# Spend both: large input pays a high fee so the per-input cost
# of the tiny input's witness exceeds 600 sats.
# Witness per input ~35 bytes, non-witness overhead 41 bytes.
# Input weight ~199 wu = ~50 vbytes.
# Need feerate > 600/50 = 12 sat/vB.
# Use ~100 sat/vB to be safe → fee ~15000 for a ~150 vB tx.
tx = CTransaction()
tx.version = 2
tx.vin = [
CTxIn(COutPoint(int(tiny_utxo["txid"], 16), tiny_utxo["vout"])),
CTxIn(COutPoint(int(large_utxo["txid"], 16), large_utxo["vout"])),
]
large_value = int(large_utxo["value"] * COIN)
total_in = large_value + 600
fee = 15000 # ~100 sat/vB for a ~150 vB tx
tx.vout = [CTxOut(total_in - fee, self.wallet.get_output_script())]
self.sign_tapscript(tx)

result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
assert_equal(result[0]["allowed"], False)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Difficult to follow. Where/how is the witness size expressed here? Why are two inputs needed to test this? Why is the fee treated as per-input, instead of calculating the actual cost to spend each (especially the dust) input? Why large margins and ~approximate values instead of testing exact boundaries?

assert_equal(result[0]["reject-reason"], "bad-txns-input-netnegative")

def test_script_path_adequate_value_accepted(self):
self.log.info("Test: script-path spend with adequate value is accepted")
utxo = self.wallet.get_utxo()
tx = CTransaction()
tx.version = 2
tx.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
tx.vout = [CTxOut(int(utxo["value"] * COIN) - 1000, self.wallet.get_output_script())]
self.sign_tapscript(tx)

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

def test_key_path_exempt(self):
self.log.info("Test: key-path spend is exempt from net-negative check")
from test_framework.key import compute_xonly_pubkey, ECKey
from test_framework.script_util import output_key_to_p2tr_script

privkey = ECKey()
privkey.set((1).to_bytes(32, 'big'), True)
xonly_pubkey, _ = compute_xonly_pubkey(privkey.get_pubkey().get_bytes()[1:])
spk = output_key_to_p2tr_script(xonly_pubkey)

# Fund a tiny P2TR output
utxo = self.wallet.get_utxo()
tx_fund = CTransaction()
tx_fund.version = 2
tx_fund.vin = [CTxIn(COutPoint(int(utxo["txid"], 16), utxo["vout"]))]
tiny_value = 600
tx_fund.vout = [
CTxOut(tiny_value, spk),
CTxOut(int(utxo["value"] * COIN) - tiny_value - 1000, self.wallet.get_output_script()),
]
self.wallet.sign_tx(tx_fund)
self.wallet.sendrawtransaction(from_node=self.nodes[0], tx_hex=tx_fund.serialize().hex())
self.generate(self.nodes[0], 1)

# Key-path spend: 1 witness element (dummy signature)
large_utxo = self.wallet.get_utxo()
tx = CTransaction()
tx.version = 2
tx.vin = [
CTxIn(COutPoint(int(tx_fund.rehash(), 16), 0)),
CTxIn(COutPoint(int(large_utxo["txid"], 16), large_utxo["vout"])),
]
large_value = int(large_utxo["value"] * COIN)
fee = 15000
tx.vout = [CTxOut(large_value + tiny_value - fee, self.wallet.get_output_script())]
tx.wit.vtxinwit = [CTxInWitness(), CTxInWitness()]
tx.wit.vtxinwit[0].scriptWitness.stack = [bytes(64)] # key-path: 1 element
leaf_info = list(self.wallet._taproot_info.leaves.values())[0]
tx.wit.vtxinwit[1].scriptWitness.stack = [
leaf_info.script,
bytes([leaf_info.version | self.wallet._taproot_info.negflag]) + self.wallet._taproot_info.internal_pubkey,
]

result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
# May fail for bad signature, but NOT for netnegative on the key-path input
if not result[0]["allowed"]:
assert result[0]["reject-reason"] != "bad-txns-input-netnegative", \
f"Key-path input incorrectly rejected as net-negative: {result}"

def test_disabled(self):
self.log.info("Test: check disabled with -rejectnetnegativeinputs=0")
self.restart_node(0, extra_args=["-rejectnetnegativeinputs=0"])
self.wallet.rescan_utxos()

tiny_utxo = self.fund_tiny_utxo(tiny_value=600)
large_utxo = self.wallet.get_utxo()

tx = CTransaction()
tx.version = 2
tx.vin = [
CTxIn(COutPoint(int(tiny_utxo["txid"], 16), tiny_utxo["vout"])),
CTxIn(COutPoint(int(large_utxo["txid"], 16), large_utxo["vout"])),
]
large_value = int(large_utxo["value"] * COIN)
fee = 15000
output_value = large_value + 600 - fee
tx.vout = [CTxOut(output_value, self.wallet.get_output_script())]
self.sign_tapscript(tx)

result = self.nodes[0].testmempoolaccept([tx.serialize().hex()])
# Should not be rejected for tapscript dust when disabled
if not result[0]["allowed"]:
assert result[0]["reject-reason"] != "bad-txns-input-netnegative", \
f"Rejected despite -rejectnetnegativeinputs=0: {result}"


if __name__ == '__main__':
TapscriptDustTest(__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_net_negative_inputs.py',
'mempool_sigoplimit.py',
'rpc_deriveaddresses.py',
'rpc_deriveaddresses.py --usecli',
Expand Down
Loading