test: ensure fee estimator provide fee rate estimate < 1 s/vb
What changed, and why it matters
This commit only changes a Bitcoin Core functional test file. It adds a new test to verify that the fee estimator can return estimates below 1 satoshi per virtual byte, and refactors how the test tracks confirmed and unspent transaction outputs. There are no changes to production code, consensus rules, networking, or wallet behavior, so it does not introduce or fix a live security issue.
No security action required. This is a test-only change improving fee-estimator test coverage. Reviewers may optionally verify the new test passes in CI.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff modifies test/functional/feature_fee_estimation.py. It introduces MIN_BUCKET_FEERATE (100 sat/COIN = 0.00000100 BTC/kvB, i.e. 1 sat/vb), TXS_COUNT = 24, an update_utxo helper, and a new test_sub_1s_per_vb_estimates() method that broadcasts transactions at 0.5 sat/vb and 1 sat/vb and asserts estimatesmartfee returns the sub-satoshi rate. Existing helpers are refactored to use self.confutxo/self.memutxo instead of a passed utxos list. No C++/Python production code is altered.
Changed components
test/functional/feature_fee_estimation.pyInspect captured patch +47 / −24
diff --git a/test/functional/feature_fee_estimation.py b/test/functional/feature_fee_estimation.py
index 3815c7ab..13da4035 100755
--- a/test/functional/feature_fee_estimation.py
+++ b/test/functional/feature_fee_estimation.py
@@ -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.
@@ -402,30 +411,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")
@@ -467,12 +486,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(
Why this scored 15/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.