From 30d3ee5024c2eb74cb708ad0cc5b39990df7e5c1 Mon Sep 17 00:00:00 2001 From: connorslab <82235496+connorslab@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:48:54 -0600 Subject: [PATCH] policy: Allow negative modified fees for ephemeral dust --- doc/release-notes-438.md | 9 +++ src/validation.cpp | 15 ++--- test/functional/mempool_ephemeral_dust.py | 69 +++++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 doc/release-notes-438.md diff --git a/doc/release-notes-438.md b/doc/release-notes-438.md new file mode 100644 index 000000000000..25af64d1006b --- /dev/null +++ b/doc/release-notes-438.md @@ -0,0 +1,9 @@ +Transaction relay and mining policy +---------------------------------- + +- Ephemeral-dust transactions may have negative modified fees from the + calculated sub-dust fee penalty. Actual fees and manual fee deltas must + remain zero; the child must cover the penalty in package fee evaluation. + The penalty follows the configured dust relay rate and output values. + Policy defaults, `corepolicy` overrides, and existing dust and package + restrictions are unchanged. (#438) diff --git a/src/validation.cpp b/src/validation.cpp index db17c58d5648..c362ad2d0819 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -1064,6 +1064,14 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) ws.m_vsize = ws.m_tx_handle->GetTxSize(); + // Check actual fees and manual fee deltas before applying the automatic + // dust penalty. StageAddition already applied mapDeltas to the entry. + 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)) { + return false; // state filled in by PreCheckEphemeralTx + } + } + // Reduce effective fee by dust threshold for each sub-dust output if (m_pool.m_opts.subdustfeepenalty) { CAmount dust_penalty{0}; @@ -1081,13 +1089,6 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) } } - // 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)) { - return false; // state filled in by PreCheckEphemeralTx - } - } - // To avoid rejecting low-sigop bare-multisig transactions, the sigops // are counted a second time more accurately. if ((nSigOpsCost > MAX_STANDARD_TX_SIGOPS_COST) || (nBytesPerSigOpStrict && GetAccurateTransactionSigOpCost(tx, m_view, STANDARD_SCRIPT_VERIFY_FLAGS) > ws.m_vsize * WITNESS_SCALE_FACTOR / nBytesPerSigOpStrict)) { diff --git a/test/functional/mempool_ephemeral_dust.py b/test/functional/mempool_ephemeral_dust.py index 9de7b85e9810..7972e9554687 100755 --- a/test/functional/mempool_ephemeral_dust.py +++ b/test/functional/mempool_ephemeral_dust.py @@ -3,12 +3,17 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. +from copy import deepcopy from decimal import Decimal from test_framework.messages import ( COIN, + COutPoint, + CTxIn, + CTxInWitness, CTxOut, ) +from test_framework.script_util import PAY_TO_ANCHOR from test_framework.test_framework import BitcoinTestFramework from test_framework.mempool_util import assert_mempool_contents from test_framework.util import ( @@ -78,6 +83,70 @@ def run_test(self): self.test_unspent_ephemeral() self.test_reorgs() self.test_no_minrelay_fee() + self.test_negative_modified_fee(3) + self.test_negative_modified_fee(6) + self.test_negative_modified_fee(3, penalty_enabled=False) + + def test_negative_modified_fee(self, dust_rate, penalty_enabled=True): + self.log.info("Test ephemeral anchor package fees with the native sub-dust penalty") + # Retain native dust/anchor policy. Only enable TRUC; pin fee rates to + # check the exact package boundary. No policy defaults are changed. + self.restart_node(0, extra_args=["-acceptnonstdtxn=0", "-persistmempool=0", + "-mempooltruc=enforce", "-minrelaytxfee=0.00001000", + f"-dustrelayfee={Decimal(dust_rate) / 100000:.8f}", + f"-subdustfeepenalty={int(penalty_enabled)}"]) + node = self.nodes[0] + self.wallet.rescan_utxos() + parent = self.wallet.create_self_transfer(version=3, fee_rate=0, confirmed_only=True)["tx"] + parent.vout.append(CTxOut(0, PAY_TO_ANCHOR)) + parent.rehash() + child = self.wallet.create_self_transfer_multi(version=3, fee_per_output=1000, confirmed_only=True)["tx"] + child.vin.append(CTxIn(COutPoint(parent.sha256, 1))) + child.wit.vtxinwit.append(CTxInWitness()) + child.rehash() + penalty = (len(parent.vout[1].serialize()) + 67) * dust_rate + assert_equal(penalty, 80 * dust_rate) + if not penalty_enabled: + penalty = 0 + assert_equal(node.testmempoolaccept([parent.serialize().hex()])[0]["reject-reason"], "min relay fee not met") + + # Reject manual fee deltas independently of the automatic penalty, + # including when the penalty is disabled. The child has ample fees. + for delta in [-1, 1]: + node.prioritisetransaction(txid=parent.hash, fee_delta=delta) + assert node.submitpackage([parent.serialize().hex(), child.serialize().hex()])["package_msg"] != "success" + assert_equal(node.getrawmempool(), []) + node.prioritisetransaction(txid=parent.hash, fee_delta=-delta) + + required = parent.get_vsize() + child.get_vsize() + penalty + low = deepcopy(child) + low.vout[0].nValue += 1000 - (required - 1) + low.rehash() + assert node.submitpackage([parent.serialize().hex(), low.serialize().hex()])["package_msg"] != "success" + assert_equal(node.getrawmempool(), []) + + child.vout[0].nValue += 1000 - required + child.rehash() + assert_equal(node.submitpackage([parent.serialize().hex(), child.serialize().hex()])["package_msg"], "success") + fees = node.getmempoolentry(parent.hash)["fees"] + assert_equal(fees["base"], 0) + assert_equal(fees["modified"], -Decimal(penalty) / COIN) + + # Once accepted, existing RPC policy also forbids manual fee changes. + assert_raises_rpc_error(-8, "Priority is not supported for transactions with dust outputs.", + node.prioritisetransaction, parent.hash, 0, -1) + + # A higher-fee child remains replaceable and the package is mineable. + replacement = deepcopy(child) + replacement.vout[0].nValue -= 2000 + replacement.rehash() + node.sendrawtransaction(replacement.serialize().hex()) + assert_equal(set(node.getrawmempool()), {parent.hash, replacement.hash}) + template = node.getblocktemplate({"rules": ["segwit"]}) + assert {parent.hash, replacement.hash}.issubset({tx["txid"] for tx in template["transactions"]}) + block = node.getblock(self.generate(node, 1, sync_fun=self.no_op)[0]) + assert {parent.hash, replacement.hash}.issubset(set(block["tx"])) + assert_equal(node.gettxout(parent.hash, 1), None) def test_normal_dust(self): self.log.info("Create 0-value dusty output, show that it works inside truc when spent in package")