policy: don't CheckEphemeralSpends on reorg
What changed, and why it matters
This Bitcoin Core change adjusts how the network's memory pool (mempool) handles tiny-value 'dust' transactions when a blockchain reorganization occurs. Previously, during a reorg, transactions that had been mined in a block were re-checked under normal mempool rules, which could wrongly reject related transactions that spend dust outputs. The fix skips that specific dust-spending check during reorgs, allowing legitimate reorged transactions to re-enter the mempool. It is a policy/standardness fix, not a consensus bug, so it cannot change which blocks are valid or steal funds.
Treat as a normal policy bugfix. Reviewers should confirm that bypassing CheckEphemeralSpends during reorg cannot be abused to keep invalid or non-standard transactions in the mempool permanently, since reorged transactions still pass other standardness and consensus checks. No urgent security response is indicated.
Security signals we found
Policy/standardness bypass only under reorg path (m_bypass_limits)
Functional test rewritten to assert reorged sweep and descendants are accepted
No consensus code changed; no signature or script validation altered
No explicit security advisory, CVE, or researcher attribution in commit
Evidence from the diff
The commit modifies mempool acceptance in validation.cpp to bypass the ephemeral-dust spend check (CheckEphemeralSpends) when args.m_bypass_limits is true, which is the case for transactions re-entering the mempool during a reorg. It also updates comments in ephemeral_policy.h and rewrites the functional test test_reorgs to assert that both the dusty parent and its sweeps/children re-enter the mempool after a reorg, rather than expecting the sweep to be rejected. The change is narrowly scoped to standardness policy under reorg conditions.
Changed components
src/validation.cpp mempool acceptance pathsrc/policy/ephemeral_policy.h commentstest/functional/mempool_ephemeral_dust.py reorg testsInspect captured patch +24 / −20
diff --git a/src/policy/ephemeral_policy.h b/src/policy/ephemeral_policy.h
index 33876b49..8ac060ee 100644
--- a/src/policy/ephemeral_policy.h
+++ b/src/policy/ephemeral_policy.h
@@ -41,13 +41,13 @@ class TxValidationState;
/* All the following checks are only called if standardness rules are being applied. */
-/** Must be called for each transaction once transaction fees are known.
+/** Called for each transaction once transaction fees are known.
* Does context-less checks about a single transaction.
* @returns false if the fee is non-zero and dust exists, populating state. True otherwise.
*/
bool PreCheckEphemeralTx(const CTransaction& tx, CFeeRate dust_relay_rate, CAmount base_fee, CAmount mod_fee, TxValidationState& state);
-/** Must be called for each transaction(package) if any dust is in the package.
+/** Called for each transaction(package) if any dust is in the package.
* Checks that each transaction's parents have their dust spent by the child,
* where parents are either in the mempool or in the package itself.
* Sets out_child_state and out_child_wtxid on failure.
diff --git a/src/validation.cpp b/src/validation.cpp
index 8456dbb9..557720ac 100644
--- a/src/validation.cpp
+++ b/src/validation.cpp
@@ -1385,7 +1385,7 @@ MempoolAcceptResult MemPoolAccept::AcceptSingleTransactionInternal(const CTransa
return MempoolAcceptResult::Failure(ws.m_state);
}
- if (m_pool.m_opts.require_standard) {
+ if (!args.m_bypass_limits && m_pool.m_opts.require_standard) {
Wtxid dummy_wtxid;
if (!CheckEphemeralSpends(/*package=*/{ptx}, m_pool.m_opts.dust_relay_feerate, m_pool, ws.m_state, dummy_wtxid)) {
return MempoolAcceptResult::Failure(ws.m_state);
diff --git a/test/functional/mempool_ephemeral_dust.py b/test/functional/mempool_ephemeral_dust.py
index 0616ae8f..2e582de0 100755
--- a/test/functional/mempool_ephemeral_dust.py
+++ b/test/functional/mempool_ephemeral_dust.py
@@ -323,7 +323,7 @@ class EphemeralDustTest(BitcoinTestFramework):
assert_mempool_contents(self, self.nodes[0], expected=[])
def test_reorgs(self):
- self.log.info("Test that reorgs breaking the truc topology doesn't cause issues")
+ self.log.info("Test that reorgs avoid ephemeral dust spentness checks")
assert_equal(self.nodes[0].getrawmempool(), [])
@@ -331,7 +331,7 @@ class EphemeralDustTest(BitcoinTestFramework):
self.disconnect_nodes(0, 1)
# Get dusty tx mined, then check that it makes it back into mempool on reorg
- # due to bypass_limits allowing 0-fee individually
+ # due to bypass_limits allowing 0-fee individually, and creation of single dust
# Prep for fork with empty blocks
fork_blocks = create_empty_fork(self.nodes[0])
@@ -351,29 +351,33 @@ class EphemeralDustTest(BitcoinTestFramework):
# Prep for fork with empty blocks
fork_blocks = create_empty_fork(self.nodes[0])
- # Mine the sweep then re-org, the sweep will not make it back in due to spend checks
+ # Mine the sweep then re-org, the sweep will make it back in due to lack of eph dust spend checks on reorg
self.generateblock(self.nodes[0], self.wallet.get_address(), [dusty_tx["hex"], sweep_tx["hex"]], sync_fun=self.no_op)
self.trigger_reorg(fork_blocks)
- assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"]], sync=False)
+ assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx["tx"]], sync=False)
- # Should re-enter if dust is swept
- sweep_tx_2 = self.wallet.create_self_transfer_multi(fee_per_output=0, utxos_to_spend=dusty_tx["new_utxos"], version=3)
- self.add_output_to_create_multi_result(sweep_tx_2)
- assert_raises_rpc_error(-26, "min relay fee not met", self.nodes[0].sendrawtransaction, sweep_tx_2["hex"])
+ # Test that dusty tx being reorged back into mempool doesn't invalidate descendants
+ # whether they spend dust or not
- # Prep for fork with empty blocks
+ # Mine the parent transaction only while preparing a fork
fork_blocks = create_empty_fork(self.nodes[0])
+ self.generateblock(self.nodes[0], self.wallet.get_address(), [dusty_tx["hex"]], sync_fun=self.no_op)
+ utxo = self.wallet.get_utxo()
+ # No in-mempool deps, use version=2 and chain off of it
+ second_sweep_tx = self.wallet.send_self_transfer_multi(from_node=self.nodes[0], utxos_to_spend=[dusty_tx["new_utxos"][1], utxo], version=2)
+ child_chain = self.wallet.send_self_transfer_chain(from_node=self.nodes[0], chain_length=10, utxo_to_spend=second_sweep_tx["new_utxos"][0])
- self.generateblock(self.nodes[0], self.wallet.get_address(), [dusty_tx["hex"], sweep_tx_2["hex"]], sync_fun=self.no_op)
- self.trigger_reorg(fork_blocks)
- assert_mempool_contents(self, self.nodes[0], expected=[dusty_tx["tx"], sweep_tx_2["tx"]], sync=False)
+ # Everything but parent in pool
+ expected_pool = [sweep_tx["tx"], second_sweep_tx["tx"]] + [child["tx"] for child in child_chain]
+ assert_mempool_contents(self, self.nodes[0], expected=expected_pool, sync=False)
- # TRUC transactions restriction for ephemeral dust disallows further spends of ancestor chains
- child_tx = self.wallet.create_self_transfer_multi(utxos_to_spend=sweep_tx_2["new_utxos"], version=3)
- assert_raises_rpc_error(-26, "TRUC-violation", self.nodes[0].sendrawtransaction, child_tx["hex"])
+ # Add ultimate parent back into mempool
+ expected_pool = [dusty_tx["tx"]] + expected_pool
+ self.trigger_reorg(fork_blocks)
+ assert_mempool_contents(self, self.nodes[0], expected=expected_pool, sync=False)
- # Clean up the mempool
- self.generateblock(self.nodes[0], self.wallet.get_address(), [dusty_tx["hex"], sweep_tx_2["hex"]], sync_fun=self.no_op)
+ hex_to_mine = [tx.serialize().hex() for tx in expected_pool]
+ self.generateblock(self.nodes[0], self.wallet.get_address(), hex_to_mine, sync_fun=self.no_op)
assert_equal(self.nodes[0].getrawmempool(), [])
self.log.info("Test that ephemeral dust tx with fees or multi dust don't enter mempool via reorg")
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.