Skip to content

Commit f80db62

Browse files
committed
Merge bitcoin#27622: Fee estimation: avoid serving stale fee estimate
d2b39e0 test: ensure old fee_estimate.dat not read on restart and flushed (ismaelsadeeq) cf219f2 tx fees, policy: read stale fee estimates with a regtest-only option (ismaelsadeeq) 3eb241a tx fees, policy: do not read estimates of old fee_estimates.dat (ismaelsadeeq) 5b886f2 tx fees, policy: periodically flush fee estimates to fee_estimates.dat (ismaelsadeeq) Pull request description: Fixes bitcoin#27555 The issue arises when an old `fee_estimates.dat` file is sometimes read during initialization. Or after an unclean shutdown, the latest fee estimates are not flushed to `fee_estimates.dat`. If the fee estimates in the old file are old, they can cause transactions to become stuck in the mempool. This PR ensures that nodes do not use stale estimates from the old file during initialization. If `fee_estimates.dat` has not been updated for 60 hours or more, it is considered stale and will not be read during initialization. To avoid having old estimates, the `fee_estimates.dat` file will be flushed periodically every hour. As mentioned bitcoin#27555 > "The immediate improvement would be to store fee estimates to disk once an hour or so to reduce the chance of having an old file. From there, this case could probably be detected, and refuse to serve estimates until we sync." In addition, I will follow-up PR to persist the `mempoolminfee` across restarts. ACKs for top commit: willcl-ark: ACK d2b39e0 instagibbs: reACK bitcoin@d2b39e0 glozow: ACK d2b39e0. One nit if you follow up. Tree-SHA512: 4f6e0c296995d0eea5cf80c6aefdd79b7295a6a0ba446f2166f32afc105fe4f831cfda1ad3abd13c5c752b4fbea982cf4b97eaeda2af1fd7184670d41edcfeec
2 parents 8f40271 + d2b39e0 commit f80db62

File tree

7 files changed

+168
-8
lines changed

7 files changed

+168
-8
lines changed

src/init.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
#include <util/syserror.h>
8484
#include <util/thread.h>
8585
#include <util/threadnames.h>
86+
#include <util/time.h>
8687
#include <util/translation.h>
8788
#include <validation.h>
8889
#include <validationinterface.h>
@@ -586,6 +587,7 @@ void SetupServerArgs(ArgsManager& argsman)
586587
argsman.AddArg("-acceptnonstdtxn", strprintf("Relay and mine \"non-standard\" transactions (%sdefault: %u)", "testnet/regtest only; ", !testnetChainParams->RequireStandard()), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::NODE_RELAY);
587588
argsman.AddArg("-incrementalrelayfee=<amt>", strprintf("Fee rate (in %s/kvB) used to define cost of relay, used for mempool limiting and replacement policy. (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_INCREMENTAL_RELAY_FEE)), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::NODE_RELAY);
588589
argsman.AddArg("-dustrelayfee=<amt>", strprintf("Fee rate (in %s/kvB) used to define dust, the value of an output such that it will cost more than its value in fees at this fee rate to spend it. (default: %s)", CURRENCY_UNIT, FormatMoney(DUST_RELAY_TX_FEE)), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::NODE_RELAY);
590+
argsman.AddArg("-acceptstalefeeestimates", strprintf("Read fee estimates even if they are stale (%sdefault: %u) fee estimates are considered stale if they are %s hours old", "regtest only; ", DEFAULT_ACCEPT_STALE_FEE_ESTIMATES, Ticks<std::chrono::hours>(MAX_FILE_AGE)), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::DEBUG_TEST);
589591
argsman.AddArg("-bytespersigop", strprintf("Equivalent bytes per sigop in transactions for relay and mining (default: %u)", DEFAULT_BYTES_PER_SIGOP), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
590592
argsman.AddArg("-datacarrier", strprintf("Relay and mine data carrier transactions (default: %u)", DEFAULT_ACCEPT_DATACARRIER), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
591593
argsman.AddArg("-datacarriersize", strprintf("Maximum size of data in data carrier transactions we relay and mine (default: %u)", MAX_OP_RETURN_RELAY), ArgsManager::ALLOW_ANY, OptionsCategory::NODE_RELAY);
@@ -1253,7 +1255,17 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
12531255
assert(!node.fee_estimator);
12541256
// Don't initialize fee estimation with old data if we don't relay transactions,
12551257
// as they would never get updated.
1256-
if (!ignores_incoming_txs) node.fee_estimator = std::make_unique<CBlockPolicyEstimator>(FeeestPath(args));
1258+
if (!ignores_incoming_txs) {
1259+
bool read_stale_estimates = args.GetBoolArg("-acceptstalefeeestimates", DEFAULT_ACCEPT_STALE_FEE_ESTIMATES);
1260+
if (read_stale_estimates && (chainparams.GetChainType() != ChainType::REGTEST)) {
1261+
return InitError(strprintf(_("acceptstalefeeestimates is not supported on %s chain."), chainparams.GetChainTypeString()));
1262+
}
1263+
node.fee_estimator = std::make_unique<CBlockPolicyEstimator>(FeeestPath(args), read_stale_estimates);
1264+
1265+
// Flush estimates to disk periodically
1266+
CBlockPolicyEstimator* fee_estimator = node.fee_estimator.get();
1267+
node.scheduler->scheduleEvery([fee_estimator] { fee_estimator->FlushFeeEstimates(); }, FEE_FLUSH_INTERVAL);
1268+
}
12571269

12581270
// Check port numbers
12591271
for (const std::string port_option : {

src/policy/fees.cpp

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
#include <algorithm>
2626
#include <cassert>
27+
#include <chrono>
2728
#include <cmath>
2829
#include <cstddef>
2930
#include <cstdint>
@@ -527,7 +528,7 @@ bool CBlockPolicyEstimator::_removeTx(const uint256& hash, bool inBlock)
527528
}
528529
}
529530

530-
CBlockPolicyEstimator::CBlockPolicyEstimator(const fs::path& estimation_filepath)
531+
CBlockPolicyEstimator::CBlockPolicyEstimator(const fs::path& estimation_filepath, const bool read_stale_estimates)
531532
: m_estimation_filepath{estimation_filepath}
532533
{
533534
static_assert(MIN_BUCKET_FEERATE > 0, "Min feerate must be nonzero");
@@ -545,9 +546,22 @@ CBlockPolicyEstimator::CBlockPolicyEstimator(const fs::path& estimation_filepath
545546
shortStats = std::unique_ptr<TxConfirmStats>(new TxConfirmStats(buckets, bucketMap, SHORT_BLOCK_PERIODS, SHORT_DECAY, SHORT_SCALE));
546547
longStats = std::unique_ptr<TxConfirmStats>(new TxConfirmStats(buckets, bucketMap, LONG_BLOCK_PERIODS, LONG_DECAY, LONG_SCALE));
547548

548-
// If the fee estimation file is present, read recorded estimations
549549
AutoFile est_file{fsbridge::fopen(m_estimation_filepath, "rb")};
550-
if (est_file.IsNull() || !Read(est_file)) {
550+
551+
// Whenever the fee estimation file is not present return early
552+
if (est_file.IsNull()) {
553+
LogPrintf("%s is not found. Continue anyway.\n", fs::PathToString(m_estimation_filepath));
554+
return;
555+
}
556+
557+
std::chrono::hours file_age = GetFeeEstimatorFileAge();
558+
// fee estimate file must not be too old to avoid wrong fee estimates.
559+
if (file_age > MAX_FILE_AGE && !read_stale_estimates) {
560+
LogPrintf("Fee estimation file %s too old (age=%lld > %lld hours) and will not be used to avoid serving stale estimates.\n", fs::PathToString(m_estimation_filepath), Ticks<std::chrono::hours>(file_age), Ticks<std::chrono::hours>(MAX_FILE_AGE));
561+
return;
562+
}
563+
564+
if (!Read(est_file)) {
551565
LogPrintf("Failed to read fee estimates from %s. Continue anyway.\n", fs::PathToString(m_estimation_filepath));
552566
}
553567
}
@@ -903,10 +917,16 @@ CFeeRate CBlockPolicyEstimator::estimateSmartFee(int confTarget, FeeCalculation
903917

904918
void CBlockPolicyEstimator::Flush() {
905919
FlushUnconfirmed();
920+
FlushFeeEstimates();
921+
}
906922

923+
void CBlockPolicyEstimator::FlushFeeEstimates()
924+
{
907925
AutoFile est_file{fsbridge::fopen(m_estimation_filepath, "wb")};
908926
if (est_file.IsNull() || !Write(est_file)) {
909927
LogPrintf("Failed to write fee estimates to %s. Continue anyway.\n", fs::PathToString(m_estimation_filepath));
928+
} else {
929+
LogPrintf("Flushed fee estimates to %s.\n", fs::PathToString(m_estimation_filepath.filename()));
910930
}
911931
}
912932

@@ -1011,6 +1031,13 @@ void CBlockPolicyEstimator::FlushUnconfirmed()
10111031
LogPrint(BCLog::ESTIMATEFEE, "Recorded %u unconfirmed txs from mempool in %gs\n", num_entries, Ticks<SecondsDouble>(endclear - startclear));
10121032
}
10131033

1034+
std::chrono::hours CBlockPolicyEstimator::GetFeeEstimatorFileAge()
1035+
{
1036+
auto file_time = std::filesystem::last_write_time(m_estimation_filepath);
1037+
auto now = std::filesystem::file_time_type::clock::now();
1038+
return std::chrono::duration_cast<std::chrono::hours>(now - file_time);
1039+
}
1040+
10141041
static std::set<double> MakeFeeSet(const CFeeRate& min_incremental_fee,
10151042
double max_filter_fee_rate,
10161043
double fee_filter_spacing)

src/policy/fees.h

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,25 @@
1414
#include <util/fs.h>
1515

1616
#include <array>
17+
#include <chrono>
1718
#include <map>
1819
#include <memory>
1920
#include <set>
2021
#include <string>
2122
#include <vector>
2223

24+
25+
// How often to flush fee estimates to fee_estimates.dat.
26+
static constexpr std::chrono::hours FEE_FLUSH_INTERVAL{1};
27+
28+
/** fee_estimates.dat that are more than 60 hours (2.5 days) will not be read,
29+
* as the estimates in the file are stale.
30+
*/
31+
static constexpr std::chrono::hours MAX_FILE_AGE{60};
32+
33+
// Whether we allow importing a fee_estimates file older than MAX_FILE_AGE.
34+
static constexpr bool DEFAULT_ACCEPT_STALE_FEE_ESTIMATES{false};
35+
2336
class AutoFile;
2437
class CTxMemPoolEntry;
2538
class TxConfirmStats;
@@ -183,7 +196,7 @@ class CBlockPolicyEstimator
183196
const fs::path m_estimation_filepath;
184197
public:
185198
/** Create new BlockPolicyEstimator and initialize stats tracking classes with default values */
186-
CBlockPolicyEstimator(const fs::path& estimation_filepath);
199+
CBlockPolicyEstimator(const fs::path& estimation_filepath, const bool read_stale_estimates);
187200
~CBlockPolicyEstimator();
188201

189202
/** Process all the transactions that have been included in a block */
@@ -239,6 +252,13 @@ class CBlockPolicyEstimator
239252
void Flush()
240253
EXCLUSIVE_LOCKS_REQUIRED(!m_cs_fee_estimator);
241254

255+
/** Record current fee estimations. */
256+
void FlushFeeEstimates()
257+
EXCLUSIVE_LOCKS_REQUIRED(!m_cs_fee_estimator);
258+
259+
/** Calculates the age of the file, since last modified */
260+
std::chrono::hours GetFeeEstimatorFileAge();
261+
242262
private:
243263
mutable Mutex m_cs_fee_estimator;
244264

src/test/fuzz/policy_estimator.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ void initialize_policy_estimator()
3131
FUZZ_TARGET_INIT(policy_estimator, initialize_policy_estimator)
3232
{
3333
FuzzedDataProvider fuzzed_data_provider(buffer.data(), buffer.size());
34-
CBlockPolicyEstimator block_policy_estimator{FeeestPath(*g_setup->m_node.args)};
34+
CBlockPolicyEstimator block_policy_estimator{FeeestPath(*g_setup->m_node.args), DEFAULT_ACCEPT_STALE_FEE_ESTIMATES};
3535
LIMITED_WHILE(fuzzed_data_provider.ConsumeBool(), 10000) {
3636
CallOneOf(
3737
fuzzed_data_provider,

src/test/fuzz/policy_estimator_io.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ FUZZ_TARGET_INIT(policy_estimator_io, initialize_policy_estimator_io)
2828
FuzzedAutoFileProvider fuzzed_auto_file_provider = ConsumeAutoFile(fuzzed_data_provider);
2929
AutoFile fuzzed_auto_file{fuzzed_auto_file_provider.open()};
3030
// Re-using block_policy_estimator across runs to avoid costly creation of CBlockPolicyEstimator object.
31-
static CBlockPolicyEstimator block_policy_estimator{FeeestPath(*g_setup->m_node.args)};
31+
static CBlockPolicyEstimator block_policy_estimator{FeeestPath(*g_setup->m_node.args), DEFAULT_ACCEPT_STALE_FEE_ESTIMATES};
3232
if (block_policy_estimator.Read(fuzzed_auto_file)) {
3333
block_policy_estimator.Write(fuzzed_auto_file);
3434
}

src/test/util/setup_common.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ ChainTestingSetup::ChainTestingSetup(const ChainType chainType, const std::vecto
179179
m_node.scheduler->m_service_thread = std::thread(util::TraceThread, "scheduler", [&] { m_node.scheduler->serviceQueue(); });
180180
GetMainSignals().RegisterBackgroundSignalScheduler(*m_node.scheduler);
181181

182-
m_node.fee_estimator = std::make_unique<CBlockPolicyEstimator>(FeeestPath(*m_node.args));
182+
m_node.fee_estimator = std::make_unique<CBlockPolicyEstimator>(FeeestPath(*m_node.args), DEFAULT_ACCEPT_STALE_FEE_ESTIMATES);
183183
m_node.mempool = std::make_unique<CTxMemPool>(MemPoolOptionsForTest(m_node));
184184

185185
m_cache_sizes = CalculateCacheSizes(m_args);

test/functional/feature_fee_estimation.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from decimal import Decimal
88
import os
99
import random
10+
import time
1011

1112
from test_framework.messages import (
1213
COIN,
@@ -21,6 +22,8 @@
2122
)
2223
from test_framework.wallet import MiniWallet
2324

25+
MAX_FILE_AGE = 60
26+
SECONDS_PER_HOUR = 60 * 60
2427

2528
def small_txpuzzle_randfee(
2629
wallet, from_node, conflist, unconflist, amount, min_fee, fee_increment, batch_reqs
@@ -290,6 +293,95 @@ def sanity_check_rbf_estimates(self, utxos):
290293
est_feerate = node.estimatesmartfee(2)["feerate"]
291294
assert_equal(est_feerate, high_feerate_kvb)
292295

296+
def test_old_fee_estimate_file(self):
297+
# Get the initial fee rate while node is running
298+
fee_rate = self.nodes[0].estimatesmartfee(1)["feerate"]
299+
300+
# Restart node to ensure fee_estimate.dat file is read
301+
self.restart_node(0)
302+
assert_equal(self.nodes[0].estimatesmartfee(1)["feerate"], fee_rate)
303+
304+
fee_dat = self.nodes[0].chain_path / "fee_estimates.dat"
305+
306+
# Stop the node and backdate the fee_estimates.dat file more than MAX_FILE_AGE
307+
self.stop_node(0)
308+
last_modified_time = time.time() - (MAX_FILE_AGE + 1) * SECONDS_PER_HOUR
309+
os.utime(fee_dat, (last_modified_time, last_modified_time))
310+
311+
# Start node and ensure the fee_estimates.dat file was not read
312+
self.start_node(0)
313+
assert_equal(self.nodes[0].estimatesmartfee(1)["errors"], ["Insufficient data or no feerate found"])
314+
315+
316+
def test_estimate_dat_is_flushed_periodically(self):
317+
fee_dat = self.nodes[0].chain_path / "fee_estimates.dat"
318+
os.remove(fee_dat) if os.path.exists(fee_dat) else None
319+
320+
# Verify that fee_estimates.dat does not exist
321+
assert_equal(os.path.isfile(fee_dat), False)
322+
323+
# Verify if the string "Flushed fee estimates to fee_estimates.dat." is present in the debug log file.
324+
# If present, it indicates that fee estimates have been successfully flushed to disk.
325+
with self.nodes[0].assert_debug_log(expected_msgs=["Flushed fee estimates to fee_estimates.dat."], timeout=1):
326+
# Mock the scheduler for an hour to flush fee estimates to fee_estimates.dat
327+
self.nodes[0].mockscheduler(SECONDS_PER_HOUR)
328+
329+
# Verify that fee estimates were flushed and fee_estimates.dat file is created
330+
assert_equal(os.path.isfile(fee_dat), True)
331+
332+
# Verify that the estimates remain the same if there are no blocks in the flush interval
333+
block_hash_before = self.nodes[0].getbestblockhash()
334+
fee_dat_initial_content = open(fee_dat, "rb").read()
335+
with self.nodes[0].assert_debug_log(expected_msgs=["Flushed fee estimates to fee_estimates.dat."], timeout=1):
336+
# Mock the scheduler for an hour to flush fee estimates to fee_estimates.dat
337+
self.nodes[0].mockscheduler(SECONDS_PER_HOUR)
338+
339+
# Verify that there were no blocks in between the flush interval
340+
assert_equal(block_hash_before, self.nodes[0].getbestblockhash())
341+
342+
fee_dat_current_content = open(fee_dat, "rb").read()
343+
assert_equal(fee_dat_current_content, fee_dat_initial_content)
344+
345+
# Verify that the estimates remain the same after shutdown with no blocks before shutdown
346+
self.restart_node(0)
347+
fee_dat_current_content = open(fee_dat, "rb").read()
348+
assert_equal(fee_dat_current_content, fee_dat_initial_content)
349+
350+
# Verify that the estimates are not the same if new blocks were produced in the flush interval
351+
with self.nodes[0].assert_debug_log(expected_msgs=["Flushed fee estimates to fee_estimates.dat."], timeout=1):
352+
# Mock the scheduler for an hour to flush fee estimates to fee_estimates.dat
353+
self.generate(self.nodes[0], 5, sync_fun=self.no_op)
354+
self.nodes[0].mockscheduler(SECONDS_PER_HOUR)
355+
356+
fee_dat_current_content = open(fee_dat, "rb").read()
357+
assert fee_dat_current_content != fee_dat_initial_content
358+
359+
fee_dat_initial_content = fee_dat_current_content
360+
361+
# Generate blocks before shutdown and verify that the fee estimates are not the same
362+
self.generate(self.nodes[0], 5, sync_fun=self.no_op)
363+
self.restart_node(0)
364+
fee_dat_current_content = open(fee_dat, "rb").read()
365+
assert fee_dat_current_content != fee_dat_initial_content
366+
367+
368+
def test_acceptstalefeeestimates_option(self):
369+
# Get the initial fee rate while node is running
370+
fee_rate = self.nodes[0].estimatesmartfee(1)["feerate"]
371+
372+
self.stop_node(0)
373+
374+
fee_dat = self.nodes[0].chain_path / "fee_estimates.dat"
375+
376+
# Stop the node and backdate the fee_estimates.dat file more than MAX_FILE_AGE
377+
last_modified_time = time.time() - (MAX_FILE_AGE + 1) * SECONDS_PER_HOUR
378+
os.utime(fee_dat, (last_modified_time, last_modified_time))
379+
380+
# Restart node with -acceptstalefeeestimates option to ensure fee_estimate.dat file is read
381+
self.start_node(0,extra_args=["-acceptstalefeeestimates"])
382+
assert_equal(self.nodes[0].estimatesmartfee(1)["feerate"], fee_rate)
383+
384+
293385
def run_test(self):
294386
self.log.info("This test is time consuming, please be patient")
295387
self.log.info("Splitting inputs so we can generate tx's")
@@ -312,12 +404,21 @@ def run_test(self):
312404
self.log.info("Testing estimates with single transactions.")
313405
self.sanity_check_estimates_range()
314406

407+
self.log.info("Test fee_estimates.dat is flushed periodically")
408+
self.test_estimate_dat_is_flushed_periodically()
409+
315410
# check that the effective feerate is greater than or equal to the mempoolminfee even for high mempoolminfee
316411
self.log.info(
317412
"Test fee rate estimation after restarting node with high MempoolMinFee"
318413
)
319414
self.test_feerate_mempoolminfee()
320415

416+
self.log.info("Test acceptstalefeeestimates option")
417+
self.test_acceptstalefeeestimates_option()
418+
419+
self.log.info("Test reading old fee_estimates.dat")
420+
self.test_old_fee_estimate_file()
421+
321422
self.log.info("Restarting node with fresh estimation")
322423
self.stop_node(0)
323424
fee_dat = os.path.join(self.nodes[0].datadir, self.chain, "fee_estimates.dat")

0 commit comments

Comments
 (0)