fees: return mempool estimates when it's lower than block policy

Integrate MemPoolFeeRateEstimator into FeeRateEstimatorManager.
When both estimators succeed, select the lower of the block policy
and mempool estimates.

When either estimator fails, return its error instead of falling back
to the block policy estimate: if the mempool estimator cannot produce
an estimate, the combined estimate fails.
Callers that want a block-policy-only estimate can request it explicitly
via fee_rate_estimator option.

estimatesmartfee now emits the estimator field only for successful
manager-selected estimates.

Add a test that ensures estimatesmartfee returns the mempool fee rate
estimate when it is lower than the block policy estimate, and can request
the mempool policy estimator explicitly

Two wallet functional tests also need adjusting. When the mempool is
too sparse to fill its percentile buckets, MemPoolFeeRateEstimator
returns a relayable floor of max(min relay fee, mempool min fee), so in
regtest getFeeRateEstimate now returns the min relay fee where the
wallet previously had no estimate and fell back to a higher rate:

- wallet_taproot.py: the cleanup sendall used automatic fee estimation.
  GetMinimumFeeRate previously fell back to the wallet fallback fee
  (fallbackfee, 20 sat/vB in the test framework); it now uses the min
  relay fee floor. At that lower feerate the wallet's underestimate of
  the taproot script-path witness size drops the effective feerate
  below min relay, so the transaction is rejected. Pin fee_rate=20 to
  match the framework fallbackfee.

- wallet_bumpfee.py: GetDiscardRate() previously fell back to the
  wallet discard rate (-discardfee); it now takes the minimum of that
  and the estimate, so the min relay fee floor collapses the discard
  rate down to the dust relay feerate. The lower discard rate reduces
  the cost of change, so the ~614 sat leftover change in
  test_dust_to_fee is now retained instead of being dropped to fee.
  Rework the test to leave a sub-dust (20/270 sat) change that is
  dropped regardless of the discard rate.

Co-authored-by: willcl-ark <will@256k1.dev>
This commit is contained in:
ismaelsadeeq
2026-04-23 14:37:44 +01:00
parent 693b1351af
commit 0d88558f95
10 changed files with 161 additions and 56 deletions

View File

@@ -11,6 +11,9 @@ import time
from test_framework.messages import (
COIN,
DEFAULT_BLOCK_RESERVED_WEIGHT,
MAX_BLOCK_WEIGHT,
WITNESS_SCALE_FACTOR,
)
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
@@ -27,6 +30,7 @@ MAX_FILE_AGE = 60
SECONDS_PER_HOUR = 60 * 60
MIN_BUCKET_FEERATE = Decimal(100) / Decimal(COIN)
TXS_COUNT = 24
BLOCK_POLICY_ESTIMATOR_ERROR = "Insufficient data or no feerate found"
def small_txpuzzle_randfee(
wallet, from_node, conflist, unconflist, amount, min_fee, fee_increment, batch_reqs
@@ -140,6 +144,14 @@ def check_fee_estimates_btw_modes(node, expected_conservative, expected_economic
assert_equal(fee_est_economical, expected_economical)
assert_equal(fee_est_default, expected_economical)
def verify_estimate_response(estimate, feerate, errors):
if feerate:
assert_equal(estimate["feerate"], feerate)
if errors:
assert all(err in estimate["errors"] for err in errors)
else:
assert "errors" not in estimate
class EstimateFeeTest(BitcoinTestFramework):
def set_test_params(self):
@@ -330,7 +342,7 @@ class EstimateFeeTest(BitcoinTestFramework):
# Start node and ensure the fee_estimates.dat file was not read
self.start_node(0)
assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["errors"], ["Insufficient data or no feerate found"])
assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["errors"], [BLOCK_POLICY_ESTIMATOR_ERROR])
def test_estimate_dat_is_flushed_periodically(self):
@@ -413,35 +425,42 @@ class EstimateFeeTest(BitcoinTestFramework):
self.sync_blocks()
assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["errors"], ["Insufficient data or no feerate found"])
def broadcast_many(self, broadcaster, feerate, count, miner=None):
def broadcast_and_maybe_mine(self, broadcaster, feerate, txs, blocks=1, miner=None):
"""Broadcast and maybe mine some number of transactions with a specified fee rate."""
tx_batch = []
for _ in range(count):
tx = self.wallet.create_self_transfer(fee_rate=feerate, utxo_to_spend=self.confutxo.pop(0))
self.memutxo.append(tx["new_utxo"])
tx_batch.append(tx)
# To speed up the test, submit the transactions in batches to the nodes directly
# avoiding having to wait for p2p to propagate them between the nodes.
batch_send_tx = [broadcaster.sendrawtransaction.get_request(hexstring=tx["hex"]) for tx in tx_batch]
for node in self.nodes:
node.batch(batch_send_tx)
self.sync_mempools(wait=0.1, nodes=[self.nodes[0], self.nodes[1], self.nodes[2]])
if miner:
mined = miner.getblock(self.generate(miner, 1)[0], True)["tx"]
self.update_utxo(mined)
for _ in range(blocks):
tx_batch = []
for _ in range(txs):
tx = self.wallet.create_self_transfer(fee_rate=feerate, utxo_to_spend=self.confutxo.pop(0))
self.memutxo.append(tx["new_utxo"])
tx_batch.append(tx)
# To speed up the test, submit the transactions in batches to the nodes directly
# avoiding having to wait for p2p to propagate them between the nodes.
batch_send_tx = [broadcaster.sendrawtransaction.get_request(hexstring=tx["hex"]) for tx in tx_batch]
for node in self.nodes:
node.batch(batch_send_tx)
self.sync_mempools(wait=0.1, nodes=[self.nodes[0], self.nodes[1], self.nodes[2]])
if miner:
mined = miner.getblock(self.generate(miner, 1)[0], True)["tx"]
self.update_utxo(mined)
def send_transactions(self, utxos, fee_rate, target_vsize):
for utxo in utxos:
self.wallet.send_self_transfer(
from_node=self.nodes[0],
utxo_to_spend=utxo,
fee_rate=fee_rate,
target_vsize=target_vsize,
)
def test_estimation_modes(self):
low_feerate = Decimal("0.001")
high_feerate = Decimal("0.005")
# Broadcast and mine high fee transactions for the first 12 blocks.
for _ in range(12):
self.broadcast_many(self.nodes[1], high_feerate, TXS_COUNT, self.nodes[2])
self.broadcast_and_maybe_mine(self.nodes[1], high_feerate, TXS_COUNT, 12, self.nodes[2])
check_fee_estimates_btw_modes(self.nodes[0], high_feerate, high_feerate)
# We now track 12 blocks; short horizon stats will start decaying.
# Broadcast and mine low fee transactions for the next 4 blocks.
for _ in range(4):
self.broadcast_many(self.nodes[1], low_feerate, TXS_COUNT, self.nodes[2])
self.broadcast_and_maybe_mine(self.nodes[1], low_feerate, TXS_COUNT, 4, self.nodes[2])
# conservative mode will consider longer time horizons while economical mode does not
# Check the fee estimates for both modes after mining low fee transactions.
check_fee_estimates_btw_modes(self.nodes[0], high_feerate, low_feerate)
@@ -450,10 +469,60 @@ class EstimateFeeTest(BitcoinTestFramework):
feerate_0_5_s_per_vb = MIN_BUCKET_FEERATE * 5
feerate_1_s_per_vb = Decimal(1000) / Decimal(COIN)
for i in range(6):
self.broadcast_many(self.nodes[1], feerate_0_5_s_per_vb, TXS_COUNT)
self.broadcast_many(self.nodes[1], feerate_1_s_per_vb, TXS_COUNT, self.nodes[2])
self.broadcast_and_maybe_mine(self.nodes[1], feerate_0_5_s_per_vb, TXS_COUNT)
self.broadcast_and_maybe_mine(self.nodes[1], feerate_1_s_per_vb, TXS_COUNT, 1, self.nodes[2])
assert_equal(feerate_0_5_s_per_vb, self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["feerate"])
def test_estimatesmartfee_return_mempool_estimates(self):
node0 = self.nodes[0]
miner = self.nodes[1]
self.log.info("Ensure node0's mempool is empty at the start")
assert_equal(node0.getmempoolinfo()['size'], 0)
self.log.info("Test estimatesmartfee with empty mempool and no block policy estimator data")
estimate_after_restart = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})
verify_estimate_response(estimate_after_restart, None, [BLOCK_POLICY_ESTIMATOR_ERROR])
self.log.info("Populate block policy estimator with high-feerate history")
# Generate high-feerate transactions and mine them over 6 blocks to give block policy data.
high_feerate = Decimal("0.004")
self.broadcast_and_maybe_mine(node0, high_feerate, TXS_COUNT, 6, miner)
self.log.info("Test estimatesmartfee returns block policy estimator estimate when mempool is higher")
# Add 10 large insane-feerate transactions enough to generate a block template
num_txs = 10
target_vsize = int(((MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT) / WITNESS_SCALE_FACTOR) / num_txs)
utxos = [self.wallet.get_utxo(confirmed_only=True) for _ in range(num_txs)]
insane_feerate = Decimal("0.01")
self.send_transactions(utxos, insane_feerate, target_vsize)
estimate_after_spike = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})
verify_estimate_response(estimate_after_spike, high_feerate, [])
assert_equal(estimate_after_spike["estimator"], "block_policy")
mempool_policy_estimate = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "mempool_policy"})
verify_estimate_response(mempool_policy_estimate, insane_feerate, [])
# Confirm the spike transactions so they leave the mempool; the mined block
# keeps the mempool representation healthy. Then broadcast fresh low-feerate
# transactions so the mempool estimate is now the lower of the two.
self.generate(node0, 1, sync_fun=lambda: None)
assert_equal(node0.getmempoolinfo()['size'], 0)
low_feerate = Decimal("0.00004")
low_utxos = [self.wallet.get_utxo(confirmed_only=True) for _ in range(num_txs)]
self.send_transactions(low_utxos, low_feerate, target_vsize)
lower_estimate = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})
verify_estimate_response(lower_estimate, low_feerate, [])
self.log.info("Test estimatesmartfee returns the fee rate floor when the mempool is empty but healthy")
self.generate(node0, 1, sync_fun=lambda: None)
assert_equal(node0.getmempoolinfo()['size'], 0)
block_policy_estimate = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})
assert "feerate" in block_policy_estimate
# With an empty but healthy mempool the mempool estimator has no percentile data,
# so it falls back to the fee rate floor: the max of minrelaytxfee and mempoolminfee.
# That floor is lower than the block policy estimate, so the combined estimator returns it.
mempool_info = node0.getmempoolinfo()
floor = max(mempool_info["minrelaytxfee"], mempool_info["mempoolminfee"])
combined_estimate = node0.estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})
verify_estimate_response(combined_estimate, floor, [])
assert_equal(combined_estimate["estimator"], "mempool_policy")
def run_test(self):
self.log.info("This test is time consuming, please be patient")
self.log.info("Splitting inputs so we can generate tx's")
@@ -504,6 +573,10 @@ class EstimateFeeTest(BitcoinTestFramework):
self.log.info("Test that estimatesmartfee returns a sub 1s/vb fee rate estimate")
self.test_sub_1s_per_vb_estimates()
self.log.info("Test that estimatesmartfee returns mempool estimates when lower")
self.clear_estimates()
self.test_estimatesmartfee_return_mempool_estimates()
self.log.info("Testing that fee estimation is disabled in blocksonly.")
self.restart_node(0, ["-blocksonly"])
assert_raises_rpc_error(