From d896460b810ca3a88dd305bc67c985b6ddacba2e Mon Sep 17 00:00:00 2001 From: connorslab <82235496+connorslab@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:19:15 -0600 Subject: [PATCH] policy: Enforce TRUC by default and guard priority mining --- doc/man/bitcoin-qt.1 | 2 +- doc/man/bitcoind.1 | 2 +- doc/release-notes-439.md | 14 ++++ share/examples/bitcoin.conf | 2 +- src/init.cpp | 3 +- src/kernel/mempool_options.h | 2 +- src/policy/coin_age_priority.cpp | 6 ++ test/functional/mempool_truc.py | 26 +++++++ test/functional/mining_ephemeral_priority.py | 82 ++++++++++++++++++++ test/functional/test_runner.py | 1 + 10 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 doc/release-notes-439.md create mode 100644 test/functional/mining_ephemeral_priority.py diff --git a/doc/man/bitcoin-qt.1 b/doc/man/bitcoin-qt.1 index 16e337f19423..86646188bda1 100644 --- a/doc/man/bitcoin-qt.1 +++ b/doc/man/bitcoin-qt.1 @@ -858,7 +858,7 @@ fee,\-optin) Behaviour for transactions requesting TRUC limits: "reject" the transactions entirely, "accept" them just like any other, or "enforce" to impose their requested restrictions (default: -accept) +enforce) .HP \fB\-minrelaycoinblocks=\fR .IP diff --git a/doc/man/bitcoind.1 b/doc/man/bitcoind.1 index 8a0ed9a43795..9fc84d674db4 100644 --- a/doc/man/bitcoind.1 +++ b/doc/man/bitcoind.1 @@ -858,7 +858,7 @@ fee,\-optin) Behaviour for transactions requesting TRUC limits: "reject" the transactions entirely, "accept" them just like any other, or "enforce" to impose their requested restrictions (default: -accept) +enforce) .HP \fB\-minrelaycoinblocks=\fR .IP diff --git a/doc/release-notes-439.md b/doc/release-notes-439.md new file mode 100644 index 000000000000..6b0075114b12 --- /dev/null +++ b/doc/release-notes-439.md @@ -0,0 +1,14 @@ +Transaction relay and mining policy +---------------------------------- + +- The default `mempooltruc` setting is now `enforce`, applying the requested + TRUC topology and package-fee rules to version-3 transactions. Explicit + `mempooltruc=accept` and `mempooltruc=reject` settings remain available. + The redundant TRUC override in `corepolicy` has been removed; `corepolicy` + itself and its other overrides are unchanged. The sub-dust fee penalty + remains enabled by default. This change does not alter ephemeral-dust fee + eligibility. + +- Transactions with dust outputs are excluded from individual priority-space + mining selection. They remain eligible for fee-based package selection, + preventing priority space from mining an ephemeral-dust parent alone. diff --git a/share/examples/bitcoin.conf b/share/examples/bitcoin.conf index f09c115e41b5..1a65515f243e 100644 --- a/share/examples/bitcoin.conf +++ b/share/examples/bitcoin.conf @@ -709,7 +709,7 @@ # Behaviour for transactions requesting TRUC limits: "reject" the # transactions entirely, "accept" them just like any other, or # "enforce" to impose their requested restrictions (default: -# accept) +# enforce) #mempooltruc=1 # Minimum "coin blocks" (measured in sat per block) that a transaction diff --git a/src/init.cpp b/src/init.cpp index 50c4d691bf67..5eac81aae0e8 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -738,7 +738,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-mempoolfullrbf", strprintf("Accept transaction replace-by-fee without requiring replaceability signaling (default: %u)", (DEFAULT_MEMPOOL_RBF_POLICY == RBFPolicy::Always)), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-mempoolreplacement", strprintf("Set to 0 to disable RBF entirely, \"fee,optin\" to honour RBF opt-out signal, or \"fee,-optin\" to always RBF aka full RBF (default: %s)", "fee,-optin"), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); - argsman.AddArg("-mempooltruc", strprintf("Behaviour for transactions requesting TRUC limits: \"reject\" the transactions entirely, \"accept\" them just like any other, or \"enforce\" to impose their requested restrictions (default: %s)", "accept"), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); + argsman.AddArg("-mempooltruc", strprintf("Behaviour for transactions requesting TRUC limits: \"reject\" the transactions entirely, \"accept\" them just like any other, or \"enforce\" to impose their requested restrictions (default: %s)", "enforce"), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY); argsman.AddArg("-permitbareanchor", strprintf("Relay transactions that only have ephemeral anchor outputs (default: %u)", DEFAULT_PERMITBAREANCHOR), ArgsManager::ALLOW_ANY, @@ -880,7 +880,6 @@ void InitParameterInteraction(ArgsManager& args) args.SoftSetArg("-datacarrierfullcount", "0"); args.SoftSetArg("-maxtxlegacysigops", strprintf("%s", std::numeric_limits::max())); args.SoftSetArg("-maxscriptsize", strprintf("%s", std::numeric_limits::max())); - args.SoftSetArg("-mempooltruc", "enforce"); args.SoftSetArg("-permitephemeral", "anchor,send,dust"); args.SoftSetArg("-spkreuse", "allow"); args.SoftSetArg("-blockprioritysize", "0"); diff --git a/src/kernel/mempool_options.h b/src/kernel/mempool_options.h index 0ce9ac96b1e5..30c815ec1279 100644 --- a/src/kernel/mempool_options.h +++ b/src/kernel/mempool_options.h @@ -33,7 +33,7 @@ static constexpr int DEFAULT_MINRELAYMATURITY{0}; /** Default for -mempoolreplacement; must update docs in init.cpp manually */ static constexpr RBFPolicy DEFAULT_MEMPOOL_RBF_POLICY{RBFPolicy::Always}; /** Default for -mempooltruc; must update docs in init.cpp manually */ -static constexpr TRUCPolicy DEFAULT_MEMPOOL_TRUC_POLICY{TRUCPolicy::Accept}; +static constexpr TRUCPolicy DEFAULT_MEMPOOL_TRUC_POLICY{TRUCPolicy::Enforce}; /** Whether to fall back to legacy V1 serialization when writing mempool.dat */ static constexpr bool DEFAULT_PERSIST_V1_DAT{false}; /** Default for -acceptnonstddatacarrier */ diff --git a/src/policy/coin_age_priority.cpp b/src/policy/coin_age_priority.cpp index 49a6efb6b7ff..1bfc0039eab4 100644 --- a/src/policy/coin_age_priority.cpp +++ b/src/policy/coin_age_priority.cpp @@ -209,6 +209,12 @@ void BlockAssembler::addPriorityTxs(const CTxMemPool& mempool, int &nPackagesSel vecPriority.reserve(mempool.mapTx.size()); for (auto mi = mempool.mapTx.begin(); mi != mempool.mapTx.end(); ++mi) { + // Priority selection considers transactions individually, so it cannot + // guarantee that ephemeral dust is spent in the same block. Leave + // these transactions to fee-based package selection instead. + if (!GetDust(mi->GetTx(), mempool.m_opts.dust_relay_feerate).empty()) { + continue; + } double dPriority = mi->GetPriority(nHeight); CAmount dummy; mempool.ApplyDeltas(mi->GetTx().GetHash(), dPriority, dummy); diff --git a/test/functional/mempool_truc.py b/test/functional/mempool_truc.py index 28cb32a29d42..a0b222542418 100755 --- a/test/functional/mempool_truc.py +++ b/test/functional/mempool_truc.py @@ -682,6 +682,31 @@ def test_minrelay_in_package_combos(self): self.check_mempool([tx_v2_0fee_parent["txid"], tx_v2_child["txid"], tx_v3_0fee_parent["txid"], tx_v3_child["txid"]]) + def test_default_and_overrides(self): + self.log.info("Test native TRUC default and explicit overrides with both corepolicy settings") + node = self.nodes[0] + for corepolicy in (0, 1): + for mode in (None, "accept", "reject", "enforce"): + args = [f"-corepolicy={corepolicy}", "-acceptnonstdtxn=0", "-persistmempool=0"] + if mode is not None: + args.append(f"-mempooltruc={mode}") + self.restart_node(0, extra_args=args) + assert_equal(node.getmempoolinfo()["truc_policy"], mode or "enforce") + self.wallet.rescan_utxos() + parent = self.wallet.create_self_transfer(version=3, confirmed_only=True) + child = self.wallet.create_self_transfer(utxo_to_spend=parent["new_utxo"], version=2) + if mode == "reject": + result = node.testmempoolaccept([parent["hex"]])[0] + assert_equal(result["allowed"], False) + assert_equal(result["reject-reason"], "version") + continue + node.sendrawtransaction(parent["hex"]) + result = node.testmempoolaccept([child["hex"]])[0] + assert_equal(result["allowed"], mode == "accept") + if mode != "accept": + assert_equal(result["reject-reason"], "truc-spent-by-nontruc") + self.restart_node(0) + def run_test(self): self.log.info("Generate blocks to create UTXOs") node = self.nodes[0] @@ -702,6 +727,7 @@ def run_test(self): self.test_truc_sibling_eviction() self.test_reorg_sibling_eviction_1p2c() self.test_minrelay_in_package_combos() + self.test_default_and_overrides() if __name__ == "__main__": diff --git a/test/functional/mining_ephemeral_priority.py b/test/functional/mining_ephemeral_priority.py new file mode 100644 index 000000000000..40011846db5f --- /dev/null +++ b/test/functional/mining_ephemeral_priority.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php. +"""Priority space must not select an ephemeral-dust parent independently.""" + +from decimal import Decimal +import time + +from test_framework.messages import COutPoint, CTxIn, CTxInWitness, CTxOut +from test_framework.script_util import PAY_TO_ANCHOR +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet + + +class EphemeralPriorityTest(BitcoinTestFramework): + def add_options(self, parser): + parser.add_argument("--test-subdust-penalty", action="store_true", + help="Also test native dust penalties (requires the ephemeral fee precheck fix)") + + def set_test_params(self): + self.num_nodes = 1 + + def template_txids(self): + # Invalidate the five-second mempool-change template cache. + self.mock_time += 10 + self.nodes[0].setmocktime(self.mock_time) + return {tx["txid"] for tx in self.nodes[0].getblocktemplate({"rules": ["segwit"]})["transactions"]} + + def run_test(self): + self.mock_time = int(time.time()) + for penalty in ([0, 1] if self.options.test_subdust_penalty else [0]): + self.test_priority(penalty) + + def test_priority(self, penalty): + self.log.info(f"Check priority selection with subdustfeepenalty={penalty}") + args = ["-corepolicy=0", "-acceptnonstdtxn=0", "-persistmempool=0", + "-blockprioritysize=1000000", "-blockmaxsize=1000000", + "-blockmintxfee=0.01000000", f"-subdustfeepenalty={penalty}"] + self.restart_node(0, extra_args=args) + node = self.nodes[0] + wallet = MiniWallet(node) + parent = wallet.create_self_transfer(version=3, fee_rate=0, confirmed_only=True)["tx"] + parent.vout.append(CTxOut(0, PAY_TO_ANCHOR)) + parent.rehash() + sponsor = wallet.get_utxo(confirmed_only=True) + child = wallet.create_self_transfer(utxo_to_spend=sponsor, version=3, fee=Decimal("0.00001000"))["tx"] + child.vin.append(CTxIn(COutPoint(parent.sha256, 1))) + child.wit.vtxinwit.append(CTxInWitness()) + child.rehash() + assert_equal(node.submitpackage([parent.serialize().hex(), child.serialize().hex()])["package_msg"], "success") + + # Even with a child, priority selection must not bypass package fees. + assert parent.hash not in self.template_txids() + assert child.hash not in self.template_txids() + + # Replace only the child's independent sponsor spend, stranding the parent. + conflict = wallet.create_self_transfer(utxo_to_spend=sponsor, version=3, fee=Decimal("0.00005000"))["tx"] + node.sendrawtransaction(conflict.serialize().hex()) + assert_equal(set(node.getrawmempool()), {parent.hash, conflict.hash}) + template = self.template_txids() + assert parent.hash not in template + # The high mining fee floor excludes this ordinary transaction from + # fee selection; its inclusion proves priority space is still active. + assert conflict.hash in template + + # Restore the child with a fee meeting the mining package threshold. + restored = wallet.create_self_transfer(version=3, fee=Decimal("0.01000000"), confirmed_only=True)["tx"] + restored.vin.append(CTxIn(COutPoint(parent.sha256, 1))) + restored.wit.vtxinwit.append(CTxInWitness()) + restored.rehash() + node.sendrawtransaction(restored.serialize().hex(), 0) + assert_equal(set(node.getrawmempool()), {parent.hash, conflict.hash, restored.hash}) + assert {parent.hash, restored.hash}.issubset(self.template_txids()) + block = node.getblock(self.generate(node, 1)[0]) + assert {parent.hash, restored.hash}.issubset(set(block["tx"])) + assert_equal(node.gettxout(parent.hash, 1), None) + + +if __name__ == "__main__": + EphemeralPriorityTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 337d66721045..728579b09ab2 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -218,6 +218,7 @@ 'wallet_txn_clone.py', 'wallet_txn_clone.py --segwit', 'mining_coin_age_priority.py', + 'mining_ephemeral_priority.py', 'rpc_getchaintips.py', 'rpc_misc.py', 'p2p_1p1c_network.py',