Implement new RBF logic for cluster mempool

With a total ordering on mempool transactions, we are now able to calculate a
transaction's mining score at all times. Use this to improve the RBF logic:

- we no longer enforce a "no new unconfirmed parents" rule

- we now require that the mempool's feerate diagram must improve in order
  to accept a replacement

- the topology restrictions for conflicts in the package rbf setting have been
  eliminated

Revert the temporary change to mempool_ephemeral_dust.py that were previously
made due to RBF validation checks being reordered.

Co-authored-by: Gregory Sanders <gsanders87@gmail.com>, glozow <gloriajzhao@gmail.com>
This commit is contained in:
Suhas DaftuarandGregory Sanders <gsanders87@gmail.com>, glozow committed 2025-11-18 08:53:59 -05:00
1 parent ff8f115dec
commit 216e693729
11 files changed
+182 -758

No files matched your search

+32 -102
View File
@@ -13,6 +13,7 @@ from test_framework.messages import (
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
assert_greater_than,
assert_greater_than_or_equal,
assert_raises_rpc_error,
get_fee,
@@ -39,6 +40,7 @@ class ReplaceByFeeTest(BitcoinTestFramework):
def run_test(self):
self.wallet = MiniWallet(self.nodes[0])
self.generate(self.nodes[0], 100)
self.log.info("Running test simple doublespend...")
self.test_simple_doublespend()
@@ -58,12 +60,12 @@ class ReplaceByFeeTest(BitcoinTestFramework):
self.log.info("Running test new unconfirmed inputs...")
self.test_new_unconfirmed_inputs()
self.log.info("Running test new unconfirmed input from low feerate tx...")
self.test_new_unconfirmed_input_with_low_feerate()
self.log.info("Running test too many replacements...")
self.test_too_many_replacements()
self.log.info("Running test too many replacements using default mempool params...")
self.test_too_many_replacements_with_default_mempool_params()
self.log.info("Running test RPC...")
self.test_rpc()
@@ -114,7 +116,7 @@ class ReplaceByFeeTest(BitcoinTestFramework):
# This will raise an exception due to insufficient fee
reject_reason = "insufficient fee"
reject_details = f"{reject_reason}, rejecting replacement {tx.txid_hex}; new feerate 0.00300000 BTC/kvB <= old feerate 0.00300000 BTC/kvB"
reject_details = f"{reject_reason}, rejecting replacement {tx.txid_hex}, not enough additional fees to relay; 0.00 < 0.00000011"
res = self.nodes[0].testmempoolaccept(rawtxs=[tx_hex])[0]
assert_equal(res["reject-reason"], reject_reason)
assert_equal(res["reject-details"], reject_details)
@@ -265,7 +267,7 @@ class ReplaceByFeeTest(BitcoinTestFramework):
)["hex"]
# This will raise an exception due to insufficient fee
assert_raises_rpc_error(-26, "insufficient fee", self.nodes[0].sendrawtransaction, tx1b_hex, 0)
assert_raises_rpc_error(-26, "does not improve feerate diagram", self.nodes[0].sendrawtransaction, tx1b_hex, 0)
def test_spends_of_conflicting_outputs(self):
"""Replacements that spend conflicting tx outputs are rejected"""
@@ -315,9 +317,9 @@ class ReplaceByFeeTest(BitcoinTestFramework):
assert_raises_rpc_error(-26, "bad-txns-spends-conflicting-tx", self.nodes[0].sendrawtransaction, tx2_hex, 0)
def test_new_unconfirmed_inputs(self):
"""Replacements that add new unconfirmed inputs are rejected"""
"""Replacements that add new unconfirmed inputs may be accepted"""
confirmed_utxo = self.make_utxo(self.nodes[0], int(1.1 * COIN))
unconfirmed_utxo = self.make_utxo(self.nodes[0], int(0.1 * COIN), confirmed=False)
unconfirmed_utxo = self.make_utxo(self.nodes[0], int(0.2 * COIN), confirmed=False)
self.wallet.send_self_transfer(
from_node=self.nodes[0],
@@ -333,19 +335,30 @@ class ReplaceByFeeTest(BitcoinTestFramework):
)["tx"]
tx2_hex = tx2.serialize().hex()
# This will raise an exception
reject_reason = "replacement-adds-unconfirmed"
reject_details = f"{reject_reason}, replacement {tx2.txid_hex} adds unconfirmed input, idx 1"
res = self.nodes[0].testmempoolaccept(rawtxs=[tx2_hex])[0]
assert_equal(res["reject-reason"], reject_reason)
assert_equal(res["reject-details"], reject_details)
assert_raises_rpc_error(-26, f"{reject_details}", self.nodes[0].sendrawtransaction, tx2_hex, 0)
# This will not raise an exception
tx2_id = self.nodes[0].sendrawtransaction(tx2_hex, 0)
assert tx2_id in self.nodes[0].getrawmempool()
def test_new_unconfirmed_input_with_low_feerate(self):
"""Replacements that add new unconfirmed inputs are allowed, but must pass the feerate diagram check"""
confirmed_utxos = [self.make_utxo(self.nodes[0], int(1.1 * COIN)) for _ in range(3)]
large_low_feerate = self.wallet.create_self_transfer(utxo_to_spend=confirmed_utxos[0], target_vsize=10000, fee=Decimal("0.00001000"))
self.nodes[0].sendrawtransaction(large_low_feerate['hex'])
unconfirmed_utxo = large_low_feerate['new_utxo']
# These two transactions are approximately the same size. The replacement tx pays twice the fee.
tx_to_replace = self.wallet.create_self_transfer_multi(utxos_to_spend=[confirmed_utxos[1], confirmed_utxos[2]], fee_per_output=2000)
tx_replacement = self.wallet.create_self_transfer_multi(utxos_to_spend=[confirmed_utxos[1], unconfirmed_utxo], fee_per_output=4000)
assert_greater_than(tx_replacement['fee']*tx_to_replace['tx'].get_vsize(), tx_to_replace['fee']*tx_replacement['tx'].get_vsize())
self.nodes[0].sendrawtransaction(tx_to_replace['hex'])
assert_raises_rpc_error(-26, "insufficient feerate: does not improve feerate diagram", self.nodes[0].sendrawtransaction, tx_replacement['hex'])
def test_too_many_replacements(self):
"""Replacements that evict too many transactions are rejected"""
# Try directly replacing more than MAX_REPLACEMENT_LIMIT
# transactions
"""Replacements that conflict with too many clusters are rejected"""
# Try directly replacing transactions in more than MAX_REPLACEMENT_LIMIT
# distinct clusters
# Start by creating a single transaction with many outputs
initial_nValue = 10 * COIN
@@ -385,7 +398,7 @@ class ReplaceByFeeTest(BitcoinTestFramework):
# This will raise an exception
reject_reason = "too many potential replacements"
reject_details = f"{reject_reason}, rejecting replacement {double_tx.txid_hex}; too many potential replacements ({MAX_REPLACEMENT_LIMIT + 1} > {MAX_REPLACEMENT_LIMIT})"
reject_details = f"{reject_reason}, rejecting replacement {double_tx.txid_hex}; too many conflicting clusters ({MAX_REPLACEMENT_LIMIT + 1} > {MAX_REPLACEMENT_LIMIT})"
res = self.nodes[0].testmempoolaccept(rawtxs=[double_tx_hex])[0]
assert_equal(res["reject-reason"], reject_reason)
assert_equal(res["reject-details"], reject_details)
@@ -397,89 +410,6 @@ class ReplaceByFeeTest(BitcoinTestFramework):
double_tx_hex = double_tx.serialize().hex()
self.nodes[0].sendrawtransaction(double_tx_hex, 0)
def test_too_many_replacements_with_default_mempool_params(self):
"""
Test rule 5 (do not allow replacements that cause more than 100
evictions) without having to rely on non-default mempool parameters.
In order to do this, create a number of "root" UTXOs, and then hang
enough transactions off of each root UTXO to exceed the MAX_REPLACEMENT_LIMIT.
Then create a conflicting RBF replacement transaction.
"""
# Clear mempools to avoid cross-node sync failure.
for node in self.nodes:
self.generate(node, 1)
normal_node = self.nodes[1]
wallet = MiniWallet(normal_node)
# This has to be chosen so that the total number of transactions can exceed
# MAX_REPLACEMENT_LIMIT without having any one tx graph run into the descendant
# limit; 10 works.
num_tx_graphs = 10
# (Number of transactions per graph, rule 5 failure expected)
cases = [
# Test the base case of evicting fewer than MAX_REPLACEMENT_LIMIT
# transactions.
((MAX_REPLACEMENT_LIMIT // num_tx_graphs) - 1, False),
# Test hitting the rule 5 eviction limit.
(MAX_REPLACEMENT_LIMIT // num_tx_graphs, True),
]
for (txs_per_graph, failure_expected) in cases:
self.log.debug(f"txs_per_graph: {txs_per_graph}, failure: {failure_expected}")
# "Root" utxos of each txn graph that we will attempt to double-spend with
# an RBF replacement.
root_utxos = []
# For each root UTXO, create a package that contains the spend of that
# UTXO and `txs_per_graph` children tx.
for graph_num in range(num_tx_graphs):
root_utxos.append(wallet.get_utxo())
parent_tx = wallet.send_self_transfer_multi(
from_node=normal_node,
utxos_to_spend=[root_utxos[graph_num]],
num_outputs=txs_per_graph,
)
new_utxos = parent_tx['new_utxos']
for utxo in new_utxos:
# Create spends for each output from the "root" of this graph.
child_tx = wallet.send_self_transfer(
from_node=normal_node,
utxo_to_spend=utxo,
)
assert normal_node.getmempoolentry(child_tx['txid'])
num_txs_invalidated = len(root_utxos) + (num_tx_graphs * txs_per_graph)
if failure_expected:
assert num_txs_invalidated > MAX_REPLACEMENT_LIMIT
else:
assert num_txs_invalidated <= MAX_REPLACEMENT_LIMIT
# Now attempt to submit a tx that double-spends all the root tx inputs, which
# would invalidate `num_txs_invalidated` transactions.
tx_hex = wallet.create_self_transfer_multi(
utxos_to_spend=root_utxos,
fee_per_output=10_000_000, # absurdly high feerate
)["hex"]
if failure_expected:
assert_raises_rpc_error(
-26, "too many potential replacements", normal_node.sendrawtransaction, tx_hex, 0)
else:
txid = normal_node.sendrawtransaction(tx_hex, 0)
assert normal_node.getmempoolentry(txid)
# Clear the mempool once finished, and rescan the other nodes' wallet
# to account for the spends we've made on `normal_node`.
self.generate(normal_node, 1)
self.wallet.rescan_utxos()
def test_prioritised_transactions(self):
# Ensure that fee deltas used via prioritisetransaction are
# correctly used by replacement logic
@@ -503,7 +433,7 @@ class ReplaceByFeeTest(BitcoinTestFramework):
)["hex"]
# Verify tx1b cannot replace tx1a.
assert_raises_rpc_error(-26, "insufficient fee", self.nodes[0].sendrawtransaction, tx1b_hex, 0)
assert_raises_rpc_error(-26, "does not improve feerate diagram", self.nodes[0].sendrawtransaction, tx1b_hex, 0)
# Use prioritisetransaction to set tx1a's fee to 0.
self.nodes[0].prioritisetransaction(txid=tx1a_txid, fee_delta=int(-0.1 * COIN))