Merge bitcoin/bitcoin#33199: fees: enable CBlockPolicyEstimator return sub 1 sat/vb fee rate estimates

8966352df3fc56fd2c00a45eecd292a240a34546 doc: add release notes (ismaelsadeeq)
704a09fe7187d5e4c949dea05baba7fe13bdb676 test: ensure fee estimator provide fee rate estimate < 1 s/vb (ismaelsadeeq)
243e48cf493378acd3a4bde638765544ade9f7b2 fees: delete unused dummy field (ismaelsadeeq)
fc4fbda42af1e84f74acbd8b6a0f384d2711c85b fees: bump fees file version (ismaelsadeeq)
b54dedcc8563286861b2ccda68bc246ad61338c0 fees: reduce `MIN_BUCKET_FEERATE` to 100 (ismaelsadeeq)

Pull request description:

  This is a simple PR that updates the block policy estimator’s `MIN_BUCKET_FEERATE` constant to be 100, which is identical to the policy `DEFAULT_MIN_RELAY_TX_FEE`.

  This change enables the block policy fee rate estimator to return sub-1 sat/vB fee rate estimates.

  The change is correct because the estimator creates buckets of fee rates from
  `MIN_BUCKET_FEERATE`,
  `MIN_BUCKET_FEERATE` + `FEE_SPACING`,
  `MIN_BUCKET_FEERATE` + `2 * FEE_SPACING`,
  … up to `MAX_BUCKET_FEERATE`.

  This means it will record sub-1 sat/vB fee rates in the buckets and may return them as a fee rate estimate when that bucket is the lowest one with sufficient transactions for a given target.

  ---

  While touching this part of the fee estimator code, this PR got rid of the dummy value persisted in the file

ACKs for top commit:
  achow101:
    ACK 8966352df3fc56fd2c00a45eecd292a240a34546
  musaHaruna:
    ACK [8966352](8966352df3)
  polespinasa:
    ACK 8966352df3fc56fd2c00a45eecd292a240a34546

Tree-SHA512: 9424e0820fcbe124adf5e5d9101a8a2983ba802887101875b2d1856d700c8b563d7a6f6ca3e3db29cb939f786719603aadbf5480d59d55d0951ed1c0caa49868
This commit is contained in:
Ava Chow 2026-02-19 11:41:34 -08:00
commit d0998cbe34
No known key found for this signature in database
GPG Key ID: 17565732E08E5E41
4 changed files with 68 additions and 35 deletions

View File

@ -0,0 +1,9 @@
Fee Estimation
========================
- The Bitcoin Core fee estimator minimum fee rate bucket was updated from **1 sat/vB** to **0.1 sat/vB**,
which matches the nodes default `minrelayfee`.
This means that for a given confirmation target, if a sub-1 sat/vB fee rate bucket is the minimum tracked
with sufficient data, its average value will be returned as the fee rate estimate.
- Note: Restarting a node with this change invalidates previously saved estimates in `fee_estimates.dat`, the fee estimator will start tracking fresh stats.

View File

@ -33,8 +33,8 @@
#include <utility>
// The current format written, and the version required to read. Must be
// increased to at least 289900+1 on the next breaking change.
constexpr int CURRENT_FEES_FILE_VERSION{149900};
// increased to at least 309900+1 on the next breaking change.
constexpr int CURRENT_FEES_FILE_VERSION{309900};
static constexpr double INF_FEERATE = 1e99;
@ -980,7 +980,6 @@ bool CBlockPolicyEstimator::Write(AutoFile& fileout) const
try {
LOCK(m_cs_fee_estimator);
fileout << CURRENT_FEES_FILE_VERSION;
fileout << int{0}; // Unused dummy field. Written files may contain any value in [0, 289900]
fileout << nBestSeenHeight;
if (BlockSpan() > HistoricalBlockSpan()/2) {
fileout << firstRecordedHeight << nBestSeenHeight;
@ -1004,8 +1003,8 @@ bool CBlockPolicyEstimator::Read(AutoFile& filein)
{
try {
LOCK(m_cs_fee_estimator);
int nVersionRequired, dummy;
filein >> nVersionRequired >> dummy;
int nVersionRequired;
filein >> nVersionRequired;
if (nVersionRequired > CURRENT_FEES_FILE_VERSION) {
throw std::runtime_error{strprintf("File version (%d) too high to be read.", nVersionRequired)};
}

View File

@ -180,13 +180,15 @@ private:
static constexpr double SUFFICIENT_TXS_SHORT = 0.5;
/** Minimum and Maximum values for tracking feerates
* The MIN_BUCKET_FEERATE should just be set to the lowest reasonable feerate we
* might ever want to track. Historically this has been 1000 since it was
* inheriting DEFAULT_MIN_RELAY_TX_FEE and changing it is disruptive as it
* invalidates old estimates files. So leave it at 1000 unless it becomes
* necessary to lower it, and then lower it substantially.
* The MIN_BUCKET_FEERATE should just be set to the lowest reasonable feerate.
* MIN_BUCKET_FEERATE has historically inherited DEFAULT_MIN_RELAY_TX_FEE.
* It is hardcoded because changing it is disruptive, as it invalidates existing fee
* estimate files.
*
* Whenever DEFAULT_MIN_RELAY_TX_FEE changes, this value should be updated
* accordingly. At the same time CURRENT_FEES_FILE_VERSION should be bumped.
*/
static constexpr double MIN_BUCKET_FEERATE = 1000;
static constexpr double MIN_BUCKET_FEERATE = 100;
static constexpr double MAX_BUCKET_FEERATE = 1e7;
/** Spacing of FeeRate buckets

View File

@ -25,6 +25,8 @@ from test_framework.wallet import MiniWallet
MAX_FILE_AGE = 60
SECONDS_PER_HOUR = 60 * 60
MIN_BUCKET_FEERATE = Decimal(100) / Decimal(COIN)
TXS_COUNT = 24
def small_txpuzzle_randfee(
wallet, from_node, conflist, unconflist, amount, min_fee, fee_increment, batch_reqs
@ -163,8 +165,18 @@ class EstimateFeeTest(BitcoinTestFramework):
# Node2 is a stingy miner, that
# produces too small blocks (room for only 55 or so transactions)
def update_utxo(self, mined):
# update which txouts are confirmed
newmem = []
for utx in self.memutxo:
if utx["txid"] in mined:
self.confutxo.append(utx)
else:
newmem.append(utx)
self.memutxo = newmem
def transact_and_mine(self, numblocks, mining_node):
min_fee = Decimal("0.00001")
min_fee = MIN_BUCKET_FEERATE
# We will now mine numblocks blocks generating on average 100 transactions between each block
# We shuffle our confirmed txout set before each set of transactions
# small_txpuzzle_randfee will use the transactions that have inputs already in the chain when possible
@ -190,14 +202,7 @@ class EstimateFeeTest(BitcoinTestFramework):
node.batch(batch_sendtx_reqs)
self.sync_mempools(wait=0.1)
mined = mining_node.getblock(self.generate(mining_node, 1)[0], True)["tx"]
# update which txouts are confirmed
newmem = []
for utx in self.memutxo:
if utx["txid"] in mined:
self.confutxo.append(utx)
else:
newmem.append(utx)
self.memutxo = newmem
self.update_utxo(mined)
def initial_split(self, node):
"""Split two coinbase UTxOs into many small coins"""
@ -244,7 +249,7 @@ class EstimateFeeTest(BitcoinTestFramework):
check_smart_estimates(self.nodes[1], self.fees_per_kb)
self.restart_node(1)
def sanity_check_rbf_estimates(self, utxos):
def sanity_check_rbf_estimates(self):
"""During 5 blocks, broadcast low fee transactions. Only 10% of them get
confirmed and the remaining ones get RBF'd with a high fee transaction at
the next block.
@ -261,19 +266,20 @@ class EstimateFeeTest(BitcoinTestFramework):
utxos_to_respend = []
txids_to_replace = []
assert_greater_than_or_equal(len(utxos), 250)
assert_greater_than_or_equal(len(self.confutxo), 250)
for _ in range(5):
# Broadcast 45 low fee transactions that will need to be RBF'd
txs = []
for _ in range(45):
u = utxos.pop(0)
u = self.confutxo.pop(0)
tx = make_tx(self.wallet, u, low_feerate)
utxos_to_respend.append(u)
txids_to_replace.append(tx["txid"])
txs.append(tx)
# Broadcast 5 low fee transaction which don't need to
for _ in range(5):
tx = make_tx(self.wallet, utxos.pop(0), low_feerate)
tx = make_tx(self.wallet, self.confutxo.pop(0), low_feerate)
self.memutxo.append(tx["new_utxo"])
txs.append(tx)
batch_send_tx = [node.sendrawtransaction.get_request(tx["hex"]) for tx in txs]
for n in self.nodes:
@ -282,11 +288,13 @@ class EstimateFeeTest(BitcoinTestFramework):
self.sync_mempools(wait=0.1, nodes=[node, miner])
for txid in txids_to_replace:
miner.prioritisetransaction(txid=txid, fee_delta=-COIN)
self.generate(miner, 1)
mined = miner.getblock(self.generate(miner, 1)[0], True)["tx"]
self.update_utxo(mined)
# RBF the low-fee transactions
while len(utxos_to_respend) > 0:
u = utxos_to_respend.pop(0)
tx = make_tx(self.wallet, u, high_feerate)
self.memutxo.append(tx["new_utxo"])
node.sendrawtransaction(tx["hex"])
txs.append(tx)
dec_txs = [res["result"] for res in node.batch([node.decoderawtransaction.get_request(tx["hex"]) for tx in txs])]
@ -295,7 +303,8 @@ class EstimateFeeTest(BitcoinTestFramework):
# Mine the last replacement txs
self.sync_mempools(wait=0.1, nodes=[node, miner])
self.generate(miner, 1)
mined = miner.getblock(self.generate(miner, 1)[0], True)["tx"]
self.update_utxo(mined)
# Only 10% of the transactions were really confirmed with a low feerate,
# the rest needed to be RBF'd. We must return the 90% conf rate feerate.
@ -403,30 +412,40 @@ class EstimateFeeTest(BitcoinTestFramework):
self.sync_blocks()
assert_equal(self.nodes[0].estimatesmartfee(1)["errors"], ["Insufficient data or no feerate found"])
def broadcast_and_mine(self, broadcaster, miner, feerate, count):
"""Broadcast and mine some number of transactions with a specified fee rate."""
def broadcast_many(self, broadcaster, feerate, count, miner=None):
"""Broadcast and maybe mine some number of transactions with a specified fee rate."""
for _ in range(count):
self.wallet.send_self_transfer(from_node=broadcaster, fee_rate=feerate)
self.sync_mempools()
self.generate(miner, 1)
tx = self.wallet.send_self_transfer(from_node=broadcaster, fee_rate=feerate, confirmed_only=True, utxo_to_spend=self.confutxo.pop(0))
self.memutxo.append(tx["new_utxo"])
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 test_estimation_modes(self):
low_feerate = Decimal("0.001")
high_feerate = Decimal("0.005")
tx_count = 24
# Broadcast and mine high fee transactions for the first 12 blocks.
for _ in range(12):
self.broadcast_and_mine(self.nodes[1], self.nodes[2], high_feerate, tx_count)
self.broadcast_many(self.nodes[1], high_feerate, TXS_COUNT, 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_and_mine(self.nodes[1], self.nodes[2], low_feerate, tx_count)
self.broadcast_many(self.nodes[1], low_feerate, TXS_COUNT, 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)
def test_sub_1s_per_vb_estimates(self):
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])
assert_equal(feerate_0_5_s_per_vb, self.nodes[0].estimatesmartfee(1)["feerate"])
def run_test(self):
self.log.info("This test is time consuming, please be patient")
@ -468,12 +487,16 @@ class EstimateFeeTest(BitcoinTestFramework):
self.clear_estimates()
self.log.info("Testing estimates with RBF.")
self.sanity_check_rbf_estimates(self.confutxo + self.memutxo)
self.sanity_check_rbf_estimates()
self.clear_estimates()
self.log.info("Test estimatesmartfee modes")
self.test_estimation_modes()
self.clear_estimates()
self.log.info("Test that estimatesmartfee returns a sub 1s/vb fee rate estimate")
self.test_sub_1s_per_vb_estimates()
self.log.info("Testing that fee estimation is disabled in blocksonly.")
self.restart_node(0, ["-blocksonly"])
assert_raises_rpc_error(