fees: return `block_policy` fee rate estimate when `mempool_policy` is not ready #36182

pull ismaelsadeeq wants to merge 5 commits into bitcoin:master from ismaelsadeeq:09-2026-fee-estimator-not-ready-fallback changing 14 files +253 −139
  1. ismaelsadeeq commented at 2:16 PM on September 7, 2026: member

    The first commit of this PR has a minor non breaking API change, it renames the estimatesmartfee default estimator option name from "none" to "auto"


    The mempool fee rate estimator can't always return an estimate:

    i) right after first startup (or after re-syncing through IBD) it hasn't tracked enough recent blocks yet; ii) the mempool is empty or too sparse to fill the requested block percentile, e.g. after a cold restart with persistence off or a missing/corrupt mempool.dat. iii) recent blocks cover too little of the mempool (low coverage); and iv) the mempool is still loading.

    Previously, case (ii) with a healthy mempool will returned the fee rate floor (the higher of the minimum relay fee and the current mempool minimum fee). This can be a significant underestimation, since the now-missing mempool may have held much higher-feerate transactions. The remaining cases surfaced the mempool error directly from the combined estimate.

    This PR makes the combined estimate (estimatesmartfee with fee_rate_estimator=none, the default) fall back to the block policy estimate whenever the mempool estimator isn't ready, instead of returning the floor or surfacing the error:

    • The mempool estimator now returns an error instead of flooring an unfilled percentile, so an empty/sparse mempool is treated like any other not-ready state rather than potentially silently underestimating.
    • The combined estimate starts from the block policy estimate and only lowers it with the mempool estimate when one is available. An error is returned only when the block policy estimate itself is unavailable.
    • Calling estimatesmartfee with fee_rate_estimator=mempool_policy explicitly still returns the failure.

    When the mempool fails to load at startup (persistence off, or a missing/corrupt mempool.dat), init notifies the estimator, which clears its tracked mined-block window. Otherwise the estimator would keep a window describing a mempool the node no longer has; clearing it makes the estimator report insufficient data until the window refills from the current tip, so the combined estimate falls back to block policy meanwhile.

    This is an alternative to #36095.

  2. DrahtBot added the label TX fees and policy on Sep 7, 2026
  3. DrahtBot commented at 2:16 PM on September 7, 2026: contributor

    <!--e57a25ab6845829454e8d69fc972939a-->

    The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

    <!--006a51241073e994b41acfe9ec718e94-->

    Code Coverage & Benchmarks

    For details see: https://corecheck.dev/bitcoin/bitcoin/pulls/36182.

    <!--021abf342d371248e50ceaed478a90ca-->

    Reviews

    See the guideline and AI policy for information on the review process.

    Type Reviewers
    Concept ACK sedited, achow101, jeanpablojp, pseudoramdom, willcl-ark
    Approach ACK stickies-v
    Stale ACK polespinasa

    If your review is incorrectly listed, please copy-paste <code>&lt;!--meta-tag:bot-skip--&gt;</code> into the comment that the bot should ignore.

    <!--174a7506f384e20aa4161008e828411d-->

    Conflicts

    Reviewers, this pull request conflicts with the following ones:

    • #36091 (test: Add debug output to common tested types by rustaceanrob)
    • #33741 (rpc: Optionally print feerates in sat/vb by polespinasa)

    If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

    <!--5faf32d7da4f0f540f40219e4f7537a3-->

    LLM Linter (✨ experimental)

    Possible places where named args for integral literals may be used (e.g. func(x, /*named_arg=*/0) in C++, and func(x, named_arg=0) in Python):

    • make_tx(self.wallet, self.wallet.get_utxo(confirmed_only=True), 10, quarter_block_vsize) in test/functional/feature_fee_estimation.py

    <sup>2026-09-28 09:21:25</sup>

  4. sedited commented at 2:40 PM on September 7, 2026: contributor

    Concept ACK

  5. sedited requested review from willcl-ark on Sep 7, 2026
  6. sedited requested review from achow101 on Sep 7, 2026
  7. sedited requested review from polespinasa on Sep 7, 2026
  8. polespinasa commented at 2:48 PM on September 7, 2026: member

    Concept ACK

    Are we too late to get this for v32? Would be nice to fix this, specially case 1 if #34075 is getting into v32.

  9. sedited commented at 3:03 PM on September 7, 2026: contributor

    Are we too late to get this for v32?

    Not too late. Fixes can also still be made after branch off, if it not ends up making it in by the 10th.

  10. sedited added this to the milestone 32.0 on Sep 7, 2026
  11. in src/policy/fees/mempool_estimator.h:139 in 0d05cd4e00
     144 | -        INSUFFICIENT_DATA,
     145 | -        //! Recent blocks include too few mempool transactions to estimate a fee rate.
     146 | -        LOW_COVERAGE,
     147 | -    };
     148 | -    MempoolHealth GetMempoolHealth() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
     149 | +    //! Check whether recent mined blocks look healthy: INSUFFICIENT_DATA or LOW_COVERAGE, else nullopt.
    


    polespinasa commented at 3:33 PM on September 7, 2026:

    in 0d05cd4e00fa73894e38bca32004f1c53b96822e fees: enable the mempool estimator to return a typed failure enum

    nit: the doc feels weird, can be written as INSUFFICIENT_DATA or LOW_COVERAGE means healthy. Consider something similar to

        //! Return INSUFFICIENT_DATA or LOW_COVERAGE if recent mined blocks fail the health check; otherwise, return nullopt.
    

    ismaelsadeeq commented at 8:58 PM on September 7, 2026:

    Fixed

  12. in src/policy/fees/mempool_estimator.cpp:160 in 74b764de51
     156 | @@ -157,6 +157,20 @@ std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failu
     157 |      assert(false);
     158 |  }
     159 |  
     160 | +bool IsNotReady(MempoolEstimationFailure failure)
    


    polespinasa commented at 3:48 PM on September 7, 2026:

    in 74b764de510abb3538fb365a749bff4fe2b2c989 fees: fall back to block policy while the mempool estimator is not ready

    nit: Probably cleaner IsReady(...) and then in extimator_man.cpp use if (!IsReady()). Feels a bit weird to have a function expecting a negative result.

    Also probably worth making this function a member of MemPoolFeeRateEstimator so we can check here and in other parts of the code if necessary in the future if the mempool is ready and skip the other checks.

    <details> <summary> diff </summary>

    $ git diff
    diff --git a/src/policy/fees/estimator_man.cpp b/src/policy/fees/estimator_man.cpp
    index e1ae7e8b03..5b207d4cd8 100644
    --- a/src/policy/fees/estimator_man.cpp
    +++ b/src/policy/fees/estimator_man.cpp
    @@ -29,10 +29,11 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
             LogDebug(BCLog::ESTIMATEFEE, "%s", block_policy_estimate.error().reason);
             return block_policy_estimate;
         }
    +    // If mempool fee rate estimator is not ready, fallback to block
    +    // policy extimator.
    +    if (!m_mempool_estimator->IsReady()) return block_policy_estimate;
         auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
         if (!mempool_estimate) {
    -        if (IsNotReady(mempool_estimate.error())) return block_policy_estimate;
    -        // When mempol fee rate estimator is ready, return the error.
             // Callers can still request block policy explicitly.
             auto mempool_error = EstimationError(mempool_estimate.error());
             LogDebug(BCLog::ESTIMATEFEE, "%s", mempool_error.error().reason);
    diff --git a/src/policy/fees/mempool_estimator.cpp b/src/policy/fees/mempool_estimator.cpp
    index 35ccfcc5f7..42c53c0262 100644
    --- a/src/policy/fees/mempool_estimator.cpp
    +++ b/src/policy/fees/mempool_estimator.cpp
    @@ -157,20 +157,6 @@ std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failu
         assert(false);
     }
     
    -bool IsNotReady(MempoolEstimationFailure failure)
    -{
    -    switch (failure) {
    -    case MempoolEstimationFailure::INSUFFICIENT_DATA:
    -        return true;
    -    case MempoolEstimationFailure::MEMPOOL_NOT_LOADED:
    -    case MempoolEstimationFailure::LOW_COVERAGE:
    -    case MempoolEstimationFailure::BLOCK_TEMPLATE_FAILED:
    -        return false;
    -    }
    -    // no default case, so the compiler can warn about missing cases
    -    assert(false);
    -}
    -
     util::Unexpected<FeeRateEstimationError> EstimationError(MempoolEstimationFailure failure)
     {
         constexpr auto estimator_type{FeeRateEstimatorType::MEMPOOL_POLICY};
    @@ -351,7 +337,7 @@ std::optional<MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealt
     {
         LOCK(cs);
         const auto estimator_name{FeeRateEstimatorTypeToString(FeeRateEstimatorType::MEMPOOL_POLICY)};
    -    if (m_prev_mined_blocks.size() < MEMPOOL_HEALTH_WINDOW_BLOCKS) {
    +    if (!IsReady()) {
             LogDebug(BCLog::ESTIMATEFEE, "%s: mempool health check failed; tracked_blocks=%s required_blocks=%s",
                      estimator_name, m_prev_mined_blocks.size(), MEMPOOL_HEALTH_WINDOW_BLOCKS);
             return MempoolEstimationFailure::INSUFFICIENT_DATA;
    diff --git a/src/policy/fees/mempool_estimator.h b/src/policy/fees/mempool_estimator.h
    index 5475b37563..e4856c92fc 100644
    --- a/src/policy/fees/mempool_estimator.h
    +++ b/src/policy/fees/mempool_estimator.h
    @@ -152,6 +152,8 @@ public:
         //! Serialize mined-block stats without taking ownership of file.
         //! Callers must explicitly close file and check for errors after writing.
         bool Write(AutoFile& file) const EXCLUSIVE_LOCKS_REQUIRED(!cs);
    +    //! Checks if the estimator has enought block data making it ready.
    +    bool IsReady() const { return m_prev_mined_blocks.size() >= MEMPOOL_HEALTH_WINDOW_BLOCKS; };
     
     private:
         void ReadFromDisk() EXCLUSIVE_LOCKS_REQUIRED(!cs);
    diff --git a/src/test/mempool_fee_estimator_tests.cpp b/src/test/mempool_fee_estimator_tests.cpp
    index eda6a69154..3e25d03866 100644
    --- a/src/test/mempool_fee_estimator_tests.cpp
    +++ b/src/test/mempool_fee_estimator_tests.cpp
    @@ -337,14 +337,6 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
         }
     }
     
    -BOOST_AUTO_TEST_CASE(is_not_ready)
    -{
    -    BOOST_CHECK(IsNotReady(MempoolEstimationFailure::INSUFFICIENT_DATA));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::MEMPOOL_NOT_LOADED));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::LOW_COVERAGE));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::BLOCK_TEMPLATE_FAILED));
    -}
    -
     BOOST_AUTO_TEST_CASE(mempool_reload_drops_stale_stats)
     {
         MemPoolFeeRateEstimator estimator{MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *m_node.chainman};
    

    </details>

    Or just remove the function at all and minimize the diff, it is only used once.

    <details> <summary> diff </summary>

    $ git diff
    diff --git a/src/policy/fees/estimator_man.cpp b/src/policy/fees/estimator_man.cpp
    index e1ae7e8b03..bce8ef2368 100644
    --- a/src/policy/fees/estimator_man.cpp
    +++ b/src/policy/fees/estimator_man.cpp
    @@ -31,7 +31,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
         }
         auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
         if (!mempool_estimate) {
    -        if (IsNotReady(mempool_estimate.error())) return block_policy_estimate;
    +        if (MempoolEstimationFailure::INSUFFICIENT_DATA == mempool_estimate.error()) return block_policy_estimate;
             // When mempol fee rate estimator is ready, return the error.
             // Callers can still request block policy explicitly.
             auto mempool_error = EstimationError(mempool_estimate.error());
    diff --git a/src/policy/fees/mempool_estimator.cpp b/src/policy/fees/mempool_estimator.cpp
    index 35ccfcc5f7..b36ad696ec 100644
    --- a/src/policy/fees/mempool_estimator.cpp
    +++ b/src/policy/fees/mempool_estimator.cpp
    @@ -157,20 +157,6 @@ std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failu
         assert(false);
     }
     
    -bool IsNotReady(MempoolEstimationFailure failure)
    -{
    -    switch (failure) {
    -    case MempoolEstimationFailure::INSUFFICIENT_DATA:
    -        return true;
    -    case MempoolEstimationFailure::MEMPOOL_NOT_LOADED:
    -    case MempoolEstimationFailure::LOW_COVERAGE:
    -    case MempoolEstimationFailure::BLOCK_TEMPLATE_FAILED:
    -        return false;
    -    }
    -    // no default case, so the compiler can warn about missing cases
    -    assert(false);
    -}
    -
     util::Unexpected<FeeRateEstimationError> EstimationError(MempoolEstimationFailure failure)
     {
         constexpr auto estimator_type{FeeRateEstimatorType::MEMPOOL_POLICY};
    diff --git a/src/policy/fees/mempool_estimator.h b/src/policy/fees/mempool_estimator.h
    index 5475b37563..2fd740763e 100644
    --- a/src/policy/fees/mempool_estimator.h
    +++ b/src/policy/fees/mempool_estimator.h
    @@ -49,9 +49,6 @@ enum class MempoolEstimationFailure {
     
     std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failure);
     
    -//! Whether a caller should fall back to another estimator for this failure.
    -bool IsNotReady(MempoolEstimationFailure failure);
    -
     //! Flatten a fee rate estimation failure into a fee rate estimation error.
     util::Unexpected<FeeRateEstimationError> EstimationError(MempoolEstimationFailure failure);
     
    diff --git a/src/test/mempool_fee_estimator_tests.cpp b/src/test/mempool_fee_estimator_tests.cpp
    index eda6a69154..3e25d03866 100644
    --- a/src/test/mempool_fee_estimator_tests.cpp
    +++ b/src/test/mempool_fee_estimator_tests.cpp
    @@ -337,14 +337,6 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
         }
     }
     
    -BOOST_AUTO_TEST_CASE(is_not_ready)
    -{
    -    BOOST_CHECK(IsNotReady(MempoolEstimationFailure::INSUFFICIENT_DATA));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::MEMPOOL_NOT_LOADED));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::LOW_COVERAGE));
    -    BOOST_CHECK(!IsNotReady(MempoolEstimationFailure::BLOCK_TEMPLATE_FAILED));
    -}
    -
     BOOST_AUTO_TEST_CASE(mempool_reload_drops_stale_stats)
     {
         MemPoolFeeRateEstimator estimator{MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *m_node.chainman};
    

    </details>


    ismaelsadeeq commented at 8:58 PM on September 7, 2026:

    Good idea, taken, thanks.

  13. in src/policy/fees/estimator_man.cpp:35 in 74b764de51
      30 | @@ -31,8 +31,9 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
      31 |      }
      32 |      auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
      33 |      if (!mempool_estimate) {
      34 | -        // A failed mempool estimate is surfaced as a warning rather than silently returning the
      35 | -        // block policy estimate, which callers can still request explicitly.
      36 | +        if (IsNotReady(mempool_estimate.error())) return block_policy_estimate;
      37 | +        // When mempol fee rate estimator is ready, return the error.
    


    polespinasa commented at 3:48 PM on September 7, 2026:

    in 74b764de510abb3538fb365a749bff4fe2b2c989 fees: fall back to block policy while the mempool estimator is not ready

    nit: mempol -> mempool


    ismaelsadeeq commented at 8:58 PM on September 7, 2026:

    Fixed

  14. in src/rpc/fees.cpp:51 in 74b764de51
      45 | @@ -46,9 +46,9 @@ static RPCMethod estimatesmartfee()
      46 |                  {
      47 |                      {"fee_rate_estimator", RPCArg::Type::STR, RPCArg::Default{"none"},
      48 |                       "Selects which fee rate estimator to use.\n"
      49 | -                     "\"none\" returns the lower of the block policy and mempool estimates. If the mempool\n"
      50 | -                     "estimate is unavailable, it returns that error instead of falling back to the block\n"
      51 | -                     "policy estimate; use \"block_policy\" in that case to get the block policy estimate.\n"
      52 | +                     "\"none\" returns the lower of the block policy and mempool estimates. When the mempool\n"
      53 | +                     "fee rate estimator is not ready to return a fee rate estimate, block policy fee rate \n"
      54 | +                     "estimate is returned. Else we return mempool fee rate estimator errors, when we encounter them."
    


    polespinasa commented at 3:50 PM on September 7, 2026:

    in 74b764d fees: fall back to block policy while the mempool estimator is not ready

                         "estimate is returned. Otherwise, an error from the mempool fee rate estimator is returned."
    

    polespinasa commented at 5:32 PM on September 7, 2026:

    in 0d05cd4 fees: enable the mempool estimator to return a typed failure enum

    Missing \n at the end.


    ismaelsadeeq commented at 8:59 PM on September 7, 2026:

    Fixed


    ismaelsadeeq commented at 9:00 PM on September 7, 2026:

    Fixed

  15. in src/policy/fees/mempool_estimator.cpp:165 in 74b764de51
     156 | @@ -157,6 +157,20 @@ std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failu
     157 |      assert(false);
     158 |  }
     159 |  
     160 | +bool IsNotReady(MempoolEstimationFailure failure)
     161 | +{
     162 | +    switch (failure) {
     163 | +    case MempoolEstimationFailure::INSUFFICIENT_DATA:
     164 | +        return true;
     165 | +    case MempoolEstimationFailure::MEMPOOL_NOT_LOADED:
    


    polespinasa commented at 5:31 PM on September 7, 2026:

    in 74b764d fees: fall back to block policy while the mempool estimator is not ready

    If I understood correctly, MEMPOOL_NOT_LOADED can only happend during node init while the thread is attempting to load the mempool. That state is short and unlikely to be hit, but I think that is the definition of not being ready. Probably MEMPOOL_NOT_LOADED should return true too.


    ismaelsadeeq commented at 9:00 PM on September 7, 2026:

    I think it is okay to wait within this short interval than return the block policy fee rate estimate.

  16. in test/functional/feature_fee_estimation.py:337 in 74b764de51
     333 | @@ -334,6 +334,13 @@ def test_old_fee_estimate_file(self):
     334 |          self.restart_node(0)
     335 |          assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["feerate"], fee_rate)
     336 |  
     337 | +        self.stop_node(0)
    


    polespinasa commented at 5:34 PM on September 7, 2026:

    in 0d05cd4 fees: enable the mempool estimator to return a typed failure enum

    I think this test does not belong in this function. It is not testing an old fee estimate file, but a fallback in case estimator is not ready.


    ismaelsadeeq commented at 9:00 PM on September 7, 2026:

    Yes, fixed.

  17. in src/policy/fees/mempool_estimator.h:40 in b5c1242137
      36 | @@ -37,6 +37,7 @@ constexpr std::chrono::seconds CACHE_LIFE{7};
      37 |  // Constants for mempool sanity checks.
      38 |  constexpr size_t MEMPOOL_HEALTH_WINDOW_BLOCKS = 6;
      39 |  constexpr double MEMPOOL_REPRESENTATION_THRESHOLD = 0.75;
      40 | +constexpr size_t MEMPOOL_COLD_RESTART_STATS_TO_DROP{3};
    


    polespinasa commented at 5:36 PM on September 7, 2026:

    in b5c12421379d14cbe1ab6346c987ff948dc2ecb4 fees: warm up the mempool estimator after a cold restart

    A brief comment on why the value of this constant would be nice.


    ismaelsadeeq commented at 9:01 PM on September 7, 2026:

    Added

  18. in src/policy/fees/mempool_estimator.cpp:318 in b5c1242137
     309 | @@ -310,6 +310,13 @@ void MemPoolFeeRateEstimator::FlushMinedBlockStats()
     310 |               fs::PathToString(m_mempool_estimator_file_path));
     311 |  }
     312 |  
     313 | +void MemPoolFeeRateEstimator::MempoolReloadCompleted(bool reloaded)
     314 | +{
     315 | +    if (reloaded) return;
     316 | +    LOCK(cs);
     317 | +    const size_t to_drop{std::min(MEMPOOL_COLD_RESTART_STATS_TO_DROP, m_prev_mined_blocks.size())};
     318 | +    m_prev_mined_blocks.erase(m_prev_mined_blocks.begin(), m_prev_mined_blocks.begin() + to_drop);
    


    polespinasa commented at 5:37 PM on September 7, 2026:

    in 0d05cd4 fees: enable the mempool estimator to return a typed failure enum

    nit: probably could add a LogDebug or LogInfo here. Could be useful.


    ismaelsadeeq commented at 9:02 PM on September 7, 2026:

    Done, and made an update to only drop when stats are>3, in a way that we always have 3.

  19. polespinasa commented at 5:37 PM on September 7, 2026: member

    reviewed b5c12421379d14cbe1ab6346c987ff948dc2ecb4

    Overall the patch looks pretty good to me :)

  20. ismaelsadeeq force-pushed on Sep 7, 2026
  21. achow101 commented at 9:25 PM on September 7, 2026: member

    Concept ACK

    I'm not sure how useful it is to fallback to the block policy estimator for fresh starts as it also requires seeing a number of blocks before it works. However, I think it makes sense to fallback to block policy if it is available and mempool policy is not.

  22. jeanpablojp commented at 10:16 PM on September 7, 2026: contributor

    Concept ACK

    A question on the warm-up. The window keeps three of the six pre-restart blocks, and GetMempoolHealthCheck sums across the whole window, so those three still carry the ratio. Sweeping the three fresh blocks that follow, the check turns healthy once they reach 50% coverage, with the retained blocks fully covered and of the same weight, against 75% for a window of six fresh blocks.

    So how far the bar drops is set by the blocks on their way out, not by the fresh ones being measured against it. Is that intended?

    doc/release-notes-34075.md and one test comment still describe the old behaviour. Worth updating them here?

    <details><summary>the two places</summary>

    The release note still says the combined estimate returns an error when too few recent blocks have been observed, and the comment above the restart in test_estimatesmartfee_return_mempool_estimates says the same.

    </details>

  23. in src/test/mempool_fee_estimator_tests.cpp:356 in a089204991 outdated
     351 | +    estimator.MempoolReloadCompleted(/*reloaded=*/true);
     352 | +    BOOST_CHECK(estimator.IsMempoolHealthy());
     353 | +
     354 | +    // A cold reload drops the oldest stats, falling below the health window.
     355 | +    estimator.MempoolReloadCompleted(/*reloaded=*/false);
     356 | +    BOOST_CHECK(estimator.GetMempoolHealthCheck() == MempoolEstimationFailure::INSUFFICIENT_DATA);
    


    jeanpablojp commented at 10:16 PM on September 7, 2026:

    This one holds for any window shorter than six, so it does not pin the three that are kept.

        BOOST_CHECK(estimator.GetMempoolHealthCheck() == MempoolEstimationFailure::INSUFFICIENT_DATA);
        BOOST_CHECK_EQUAL(estimator.GetPrevBlockData().size(), MEMPOOL_COLD_RESTART_STATS_TO_KEEP);
    

    ismaelsadeeq commented at 12:24 PM on September 8, 2026:

    Taken, thanks

  24. in test/functional/feature_fee_estimation.py:399 in a089204991
     394 | +        self.start_node(0)
     395 | +        self.assert_cold_restart_falls_back()
     396 | +
     397 | +        # persistence disabled
     398 | +        self.restart_node(0, extra_args=["-persistmempool=0"])
     399 | +        self.assert_cold_restart_falls_back()
    


    jeanpablojp commented at 10:16 PM on September 7, 2026:

    test_fallback_when_mempool_estimator_not_ready deletes fees/mempool_policy_estimator.dat just above and nothing mines after it, so all three cases here start from an empty window, where MempoolReloadCompleted returns early and does nothing. Removing the drop altogether leaves this file passing.

    Refilling the window before each case brings the coverage back, and does fail if the drop goes away.

        def test_cold_restart_falls_back_to_block_policy(self):
            mempool_dat = self.nodes[0].chain_path / "mempool.dat"
            # Each case needs a full mined-block window before the restart. A window
            # that is already short passes these assertions whether or not the cold
            # restart drops anything.
    
            # mempool.dat missing
            self.generate(self.nodes[0], 6, sync_fun=lambda: None)
            self.stop_node(0)
            mempool_dat.unlink(missing_ok=True)
            self.start_node(0)
            self.assert_cold_restart_falls_back()
    
            # mempool.dat corrupted
            self.generate(self.nodes[0], 6, sync_fun=lambda: None)
            self.stop_node(0)
            mempool_dat.write_bytes(b"not a valid mempool.dat")
            self.start_node(0)
            self.assert_cold_restart_falls_back()
    
            # persistence disabled
            self.generate(self.nodes[0], 6, sync_fun=lambda: None)
            self.restart_node(0, extra_args=["-persistmempool=0"])
            self.assert_cold_restart_falls_back()
    

    ismaelsadeeq commented at 12:25 PM on September 8, 2026:

    Indeed, fixed with some modifications.

  25. ismaelsadeeq force-pushed on Sep 8, 2026
  26. ismaelsadeeq commented at 1:08 PM on September 8, 2026: member

    re: #36182#pullrequestreview-5135508585

    AFAICT, it's the same moving mempool representation ratio, now used as a proxy for mempool inflow. The same assumption holds even before a restart: if the first 3 blocks have 100% coverage, we only need 50% from the next 3 to stay healthy.

    In the case you mentioned, 100% coverage before and then 50% after is a good sign your peers are relaying with sane policy rules, and the drop in coverage is most likely just your own lost mempool so using it for fee estimation won't be wildly off.

    I fixed the docs as you suggested. The CI failure seems unrelated.

    Thanks for the review.

  27. DrahtBot added the label CI failed on Sep 8, 2026
  28. DrahtBot removed the label CI failed on Sep 9, 2026
  29. ismaelsadeeq requested review from polespinasa on Sep 10, 2026
  30. ismaelsadeeq requested review from jeanpablojp on Sep 10, 2026
  31. in src/policy/fees/mempool_estimator.h:51 in 7f46e2749b
      46 | +    BLOCK_TEMPLATE_FAILED,
      47 | +};
      48 | +
      49 | +std::string_view MempoolEstimationFailureToString(MempoolEstimationFailure failure);
      50 | +
      51 | +//! Flatten a fee rate estimation failure into a fee rate estimation error.
    


    davidgumberg commented at 8:48 PM on September 10, 2026:

    nit:

    //! Flatten a mempool fee rate estimation failure into a fee rate estimation error.
    

    ismaelsadeeq commented at 6:08 PM on September 11, 2026:

    Fixed.

  32. in src/policy/fees/mempool_estimator.h:142 in 7f46e2749b
     148 | -    MempoolHealth GetMempoolHealth() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
     149 | +    //! Return INSUFFICIENT_DATA or LOW_COVERAGE if recent mined blocks fail the health check; otherwise, return nullopt.
     150 | +    std::optional<MempoolEstimationFailure> GetMempoolHealthCheck() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
     151 |      //! Checks if recent mined blocks indicate a healthy mempool state.
     152 | -    bool IsMempoolHealthy() const EXCLUSIVE_LOCKS_REQUIRED(!cs) { return GetMempoolHealth() == MempoolHealth::HEALTHY; }
     153 | +    bool IsMempoolHealthy() const EXCLUSIVE_LOCKS_REQUIRED(!cs) { return !GetMempoolHealthCheck().has_value(); }
    


    davidgumberg commented at 8:54 PM on September 10, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/7f46e2749b3831401be6202032e765bd4159e863 (fees: enable the mempool estimator to return a typed failure enum)

    nit: IsMempoolHealthy() should be deleted.


    polespinasa commented at 9:32 AM on September 11, 2026:

    Agreed, the function seems to not be used anywhere but in some unit tests.


    ismaelsadeeq commented at 6:08 PM on September 11, 2026:

    Deleted, thanks.

  33. in src/policy/fees/mempool_estimator.h:140 in 7f46e2749b
     145 | -        //! Recent blocks include too few mempool transactions to estimate a fee rate.
     146 | -        LOW_COVERAGE,
     147 | -    };
     148 | -    MempoolHealth GetMempoolHealth() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
     149 | +    //! Return INSUFFICIENT_DATA or LOW_COVERAGE if recent mined blocks fail the health check; otherwise, return nullopt.
     150 | +    std::optional<MempoolEstimationFailure> GetMempoolHealthCheck() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
    


    davidgumberg commented at 9:19 PM on September 10, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/7f46e2749b3831401be6202032e765bd4159e863 (fees: enable the mempool estimator to return a typed failure enum)

    nit: I think the semantics of util::Expected<void, MempoolEstimationFailure> are better than std::optional here. The current approach seems to me to invert the convention around optional which is some value if success, nullopt if there was an error, it might look like e.g. (this is combined with the suggestion below of deleting IsMempoolHealthy()

    diff --git a/src/policy/fees/mempool_estimator.cpp b/src/policy/fees/mempool_estimator.cpp
    index 35f5815bb6..24f0ad16e2 100644
    --- a/src/policy/fees/mempool_estimator.cpp
    +++ b/src/policy/fees/mempool_estimator.cpp
    @@ -326,14 +326,14 @@ void MemPoolFeeRateEstimator::MempoolTxsRemovedForBlock(const std::shared_ptr<co
     // the coverage ratio as a representative mempool health signal.
     static constexpr uint64_t MIN_REPRESENTATIVE_WINDOW_WEIGHT{DEFAULT_BLOCK_MAX_WEIGHT};
     
    -std::optional<MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealthCheck() const
    +util::Expected<void, MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealthCheck() const
     {
         LOCK(cs);
         const auto estimator_name{FeeRateEstimatorTypeToString(FeeRateEstimatorType::MEMPOOL_POLICY)};
         if (m_prev_mined_blocks.size() < MEMPOOL_HEALTH_WINDOW_BLOCKS) {
             LogDebug(BCLog::ESTIMATEFEE, "%s: mempool health check failed; tracked_blocks=%s required_blocks=%s",
                      estimator_name, m_prev_mined_blocks.size(), MEMPOOL_HEALTH_WINDOW_BLOCKS);
    -        return MempoolEstimationFailure::INSUFFICIENT_DATA;
    +        return util::Unexpected{MempoolEstimationFailure::INSUFFICIENT_DATA};
         }
         uint64_t total_block_weight{0};
         uint64_t total_removed_weight{0};
    @@ -348,7 +348,7 @@ std::optional<MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealt
         if (total_block_weight < MIN_REPRESENTATIVE_WINDOW_WEIGHT) {
             LogDebug(BCLog::ESTIMATEFEE, "%s: mempool health check passed; low activity, total_block_weight=%s minimum=%s",
                      estimator_name, total_block_weight, MIN_REPRESENTATIVE_WINDOW_WEIGHT);
    -        return std::nullopt;
    +        return {};
         }
         const double representation_ratio = static_cast<double>(total_removed_weight) / total_block_weight;
         LogDebug(BCLog::ESTIMATEFEE,
    @@ -360,8 +360,8 @@ std::optional<MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealt
                  total_block_weight,
                  representation_ratio,
                  MEMPOOL_REPRESENTATION_THRESHOLD);
    -    if (representation_ratio < MEMPOOL_REPRESENTATION_THRESHOLD) return MempoolEstimationFailure::LOW_COVERAGE;
    -    return std::nullopt;
    +    if (representation_ratio < MEMPOOL_REPRESENTATION_THRESHOLD) return util::Unexpected{MempoolEstimationFailure::LOW_COVERAGE};
    +    return {};
     }
     
     util::Expected<FeeRateEstimation, MempoolEstimationFailure> MemPoolFeeRateEstimator::EstimateFeeRate(bool conservative) const
    @@ -370,8 +370,8 @@ util::Expected<FeeRateEstimation, MempoolEstimationFailure> MemPoolFeeRateEstima
         if (!m_mempool.GetLoadTried()) {
             return util::Unexpected{MempoolEstimationFailure::MEMPOOL_NOT_LOADED};
         }
    -    if (auto health_failure{GetMempoolHealthCheck()}) {
    -        return util::Unexpected{*health_failure};
    +    if (const auto health_check{GetMempoolHealthCheck()}; !health_check) {
    +        return util::Unexpected{health_check.error()};
         }
         // The estimator lock is not held while building a block template, so
         // in a rare edge case concurrent callers may duplicate work.
    diff --git a/src/policy/fees/mempool_estimator.h b/src/policy/fees/mempool_estimator.h
    index 3f6ad58aa7..cc110f2fc1 100644
    --- a/src/policy/fees/mempool_estimator.h
    +++ b/src/policy/fees/mempool_estimator.h
    @@ -137,9 +137,7 @@ public:
                                        unsigned int block_height)
             EXCLUSIVE_LOCKS_REQUIRED(!cs);
         //! Return INSUFFICIENT_DATA or LOW_COVERAGE if recent mined blocks fail the health check; otherwise, return nullopt.
    -    std::optional<MempoolEstimationFailure> GetMempoolHealthCheck() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
    -    //! Checks if recent mined blocks indicate a healthy mempool state.
    -    bool IsMempoolHealthy() const EXCLUSIVE_LOCKS_REQUIRED(!cs) { return !GetMempoolHealthCheck().has_value(); }
    +    util::Expected<void, MempoolEstimationFailure> GetMempoolHealthCheck() const EXCLUSIVE_LOCKS_REQUIRED(!cs);
         void FlushMinedBlockStats() EXCLUSIVE_LOCKS_REQUIRED(!cs);
         //! Deserialize mined-block stats without taking ownership of file.
         bool Read(AutoFile& file) EXCLUSIVE_LOCKS_REQUIRED(!cs);
    

    <details><summary>And the test changes, which are mostly repetitive:</summary>

    diff --git a/src/test/mempool_fee_estimator_tests.cpp b/src/test/mempool_fee_estimator_tests.cpp
    index 98efb2b7c2..e913735a18 100644
    --- a/src/test/mempool_fee_estimator_tests.cpp
    +++ b/src/test/mempool_fee_estimator_tests.cpp
    @@ -140,8 +140,8 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
         }
         m_node.mempool->SetLoadTried(true);
     
    -    BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    -    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck() == MempoolEstimationFailure::INSUFFICIENT_DATA);
    +    auto health = mempool_estimator.GetMempoolHealthCheck();
    +    BOOST_CHECK(!health && health.error() == MempoolEstimationFailure::INSUFFICIENT_DATA);
         {
             const auto result = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
             BOOST_CHECK(!result);
    @@ -156,7 +156,7 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
                                 /*removed_txs_weight=*/0,
                                 /*block_txs_weight=*/0,
                                 custom_height);
    -            BOOST_CHECK(!custom_mempool_estimator.IsMempoolHealthy());
    +            BOOST_CHECK(!custom_mempool_estimator.GetMempoolHealthCheck());
             }
             {
                 const int64_t low_activity_weight{1000};
    @@ -165,7 +165,7 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
             // Below one block worth of total activity across the full window, even
             // poor coverage in the only non-empty block is too noisy to reject the
             // mempool as unhealthy.
    -        BOOST_CHECK(custom_mempool_estimator.IsMempoolHealthy());
    +        BOOST_CHECK(custom_mempool_estimator.GetMempoolHealthCheck());
         }
         size_t block_count = 1;
         const int64_t weight{DEFAULT_BLOCK_MAX_WEIGHT / 2};
    @@ -174,67 +174,67 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
         while (block_count <= MEMPOOL_HEALTH_WINDOW_BLOCKS) {
             AddRemovedBlock(mempool_estimator, weight, weight, height);
             if (block_count < MEMPOOL_HEALTH_WINDOW_BLOCKS) {
    -            BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    +            BOOST_CHECK(!mempool_estimator.GetMempoolHealthCheck());
             }
             block_count += 1;
         }
         // Total txs weight ~11999k WU (~3.0 blocks), removed txs ~11999k WU (~3.0 blocks); coverage = 100%.
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
         // Adding a single underrepresented block will not make the mempool unhealthy
         // while the window coverage remains above the threshold.
         AddRemovedBlock(mempool_estimator, weight / 2, weight, height);
         // Total txs weight ~11999k WU (~3.0 blocks), removed txs ~10999k WU (~2.75 blocks); coverage = ~92%.
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
         // Empty block
         // Total txs weight ~9999k WU (~2.5 blocks), removed txs ~8999k WU (~2.25 blocks); coverage = 90%.
         AddRemovedBlock(mempool_estimator, 0, 0, height);
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
         // Total txs weight ~9999k WU (~2.5 blocks), removed txs ~7999k WU (~2.0 blocks); coverage = 80%.
         AddRemovedBlock(mempool_estimator, weight / 2, weight, height);
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
         // Total txs weight ~9999k WU (~2.5 blocks), removed txs ~7000k WU (~1.75 blocks); coverage = 70%.
         AddRemovedBlock(mempool_estimator, weight / 2, weight, height);
    -    BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    -    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck() == MempoolEstimationFailure::LOW_COVERAGE);
    +    health = mempool_estimator.GetMempoolHealthCheck();
    +    BOOST_CHECK(!health && health.error() == MempoolEstimationFailure::LOW_COVERAGE);
         block_count = 1;
         while (block_count <= 3) {
             AddRemovedBlock(mempool_estimator, weight, weight, height);
             if (block_count < 3) {
    -            BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    +            BOOST_CHECK(!mempool_estimator.GetMempoolHealthCheck());
             }
             block_count += 1;
         }
         // Total txs weight ~9999k WU (~2.5 blocks), removed txs ~7999k WU (~2.0 blocks); coverage = 80%.
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
     
         // Reorg out and replace the last block. Replacing the tip block should keep a full
         // healthy window when the replacement block has good mempool representation.
         height -= 1;
         AddRemovedBlock(mempool_estimator, weight, weight, height);
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
     
         // Reorg out the last two blocks. The estimator should discard the stale suffix,
         // become temporarily unhealthy due to having fewer than MEMPOOL_HEALTH_WINDOW_BLOCKS stats,
         // then recover after the replacement chain catches up.
         height -= 2;
         AddRemovedBlock(mempool_estimator, weight, weight, height);
    -    BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(!mempool_estimator.GetMempoolHealthCheck());
         AddRemovedBlock(mempool_estimator, weight, weight, height);
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
     
         // A forward height gap (e.g. stale persisted stats after an unclean shutdown
         // while the chain advanced) resets the tracked window entirely; the estimator
         // stays unhealthy until a full window of contiguous blocks is seen again.
         height += 3;
         AddRemovedBlock(mempool_estimator, weight, weight, height);
    -    BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(!mempool_estimator.GetMempoolHealthCheck());
         for (size_t i = 1; i < MEMPOOL_HEALTH_WINDOW_BLOCKS; ++i) {
             AddRemovedBlock(mempool_estimator, weight, weight, height);
             if (i < MEMPOOL_HEALTH_WINDOW_BLOCKS - 1) {
    -            BOOST_CHECK(!mempool_estimator.IsMempoolHealthy());
    +            BOOST_CHECK(!mempool_estimator.GetMempoolHealthCheck());
             }
         }
    -    BOOST_CHECK(mempool_estimator.IsMempoolHealthy());
    +    BOOST_CHECK(mempool_estimator.GetMempoolHealthCheck());
         {
             LOCK(m_node.mempool->cs);
             BOOST_CHECK_EQUAL(m_node.mempool->GetTotalTxSize(), 0);
    

    ismaelsadeeq commented at 6:09 PM on September 11, 2026:

    Good idea, taken.

  34. in test/functional/feature_fee_estimation.py:337 in de74e217c3
     332 | +        (self.nodes[0].chain_path / "fees/mempool_policy_estimator.dat").unlink(missing_ok=True)
     333 | +        self.start_node(0)
     334 | +        self.wait_until(lambda: self.nodes[0].getmempoolinfo()["loaded"])
     335 | +        assert "errors" in self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "mempool_policy"})
     336 | +        assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "block_policy")
     337 | +
    


    davidgumberg commented at 9:41 PM on September 10, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/de74e217c3526b97460a35ba2055c7b382f0c892 (fees: fall back to block_policy when mempool_policy has insufficient data)

    To avoid making this test stateful, this should move the mempool estimator .dat, and then move it back, e.g.:

    diff --git a/test/functional/feature_fee_estimation.py b/test/functional/feature_fee_estimation.py
    index be493adcd0..69c0e83ab5 100755
    --- a/test/functional/feature_fee_estimation.py
    +++ b/test/functional/feature_fee_estimation.py
    @@ -329,12 +329,20 @@ class EstimateFeeTest(BitcoinTestFramework):
         def test_fallback_when_mempool_estimator_not_ready(self):
             assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "mempool_policy")
             self.stop_node(0)
    -        (self.nodes[0].chain_path / "fees/mempool_policy_estimator.dat").unlink(missing_ok=True)
    +        # Rename the mempool estimator data temporarily so that the mempool policy
    +        # estimator has insufficient data
    +        original_path = self.nodes[0].chain_path / "fees" / "mempool_policy_estimator.dat"
    +        temp_path = self.nodes[0].chain_path / "fees" / "mempool_policy_estimator.dat.bak"
    +        original_path.rename(temp_path)
    +
             self.start_node(0)
             self.wait_until(lambda: self.nodes[0].getmempoolinfo()["loaded"])
             assert "errors" in self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "mempool_policy"})
             assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "block_policy")
     
    +        # Move the mempool estimator data back
    +        self.stop_node(0)
    +        temp_path.rename(original_path)
    +        self.start_node(0)
    +
         def test_old_fee_estimate_file(self):
             # Get the initial fee rate while node is running
             fee_rate = self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["feerate"]
    

    if leaving as an unlink, should not be missing_ok=True


    ismaelsadeeq commented at 6:15 PM on September 11, 2026:

    Fixed.

  35. in src/policy/fees/mempool_estimator.cpp:329 in 7f46e2749b
     325 | @@ -322,14 +326,14 @@ void MemPoolFeeRateEstimator::MempoolTxsRemovedForBlock(const std::shared_ptr<co
     326 |  // the coverage ratio as a representative mempool health signal.
     327 |  static constexpr uint64_t MIN_REPRESENTATIVE_WINDOW_WEIGHT{DEFAULT_BLOCK_MAX_WEIGHT};
     328 |  
     329 | -MemPoolFeeRateEstimator::MempoolHealth MemPoolFeeRateEstimator::GetMempoolHealth() const
     330 | +std::optional<MempoolEstimationFailure> MemPoolFeeRateEstimator::GetMempoolHealthCheck() const
    


    pseudoramdom commented at 9:44 PM on September 10, 2026:

    In fees: enable the mempool estimator to return a typed failure enum


    I think this would read better if we made the return type as util::Expected<void, MempoolEstimationFailure>

    Currently, a truthy optional means failure (which is counter intuitive). With Expected, a truthy result means success.


    pseudoramdom commented at 9:46 PM on September 10, 2026:

    nit: MemPoolFeeRateEstimator::PerformMempoolHealthCheck() sounds more like an operation returning a success/failure


    pseudoramdom commented at 10:16 PM on September 10, 2026:

    I just saw @davidgumberg wrote the same feedback in a better way #36182 (review)

    Dangit, David! 😅

  36. in src/policy/fees/mempool_estimator.cpp:373 in 7f46e2749b
     373 | -        return EstimationError(strprintf("%s: Mempool not loaded yet, no fee rate estimate available", FeeRateEstimatorTypeToString(estimator_type)));
     374 | +        return util::Unexpected{MempoolEstimationFailure::MEMPOOL_NOT_LOADED};
     375 |      }
     376 | -    if (auto error{MempoolHealthError(GetMempoolHealth())}) {
     377 | -        return EstimationError(strprintf("%s: %s", FeeRateEstimatorTypeToString(estimator_type), *error));
     378 | +    if (auto health_failure{GetMempoolHealthCheck()}) {
    


    pseudoramdom commented at 9:53 PM on September 10, 2026:

    Related to https://github.com/bitcoin/bitcoin/pull/36182/changes/7f46e2749b3831401be6202032e765bd4159e863#r3983769548 comment. Calling GetMempoolHealthCheck and expecting a non-null value for a failure is counter-intuitive.

    If we update to return util::Expected<void, MempoolEstimationFailure> this would read clearer -

    if (auto health = GetMempoolHealthCheck(); !health) {
          return util::Unexpected{health.error()};
    }
    

    ismaelsadeeq commented at 6:09 PM on September 11, 2026:

    Taken

  37. in src/policy/fees/mempool_estimator.cpp:160 in 7f46e2749b
     169 | -    return std::nullopt;
     170 | +    // no default case, so the compiler can warn about missing cases
     171 | +    assert(false);
     172 | +}
     173 | +
     174 | +util::Unexpected<FeeRateEstimationError> EstimationError(MempoolEstimationFailure failure)
    


    pseudoramdom commented at 9:59 PM on September 10, 2026:

    Could this move to estimator_man.cpp if that's the only file consuming this?


    polespinasa commented at 9:48 AM on September 11, 2026:

    Actually I find this functions a bit confusing. This function has not much logic it is only calling another EstimationError function declared in util/fees.h. Which at the same time is only calling a constructor. I would rather simplify both functions into some FeeRateEstimationError constructors and where this functions are called just return util::Unexpected{FeeRateEstimationError(... params ...)}


    ismaelsadeeq commented at 6:09 PM on September 11, 2026:

    Moved, thanks.

  38. in src/rpc/fees.cpp:52 in de74e217c3 outdated
      50 | -                     "estimate is unavailable, it returns that error instead of falling back to the block\n"
      51 | -                     "policy estimate; use \"block_policy\" in that case to get the block policy estimate.\n"
      52 | +                     "\"none\" (the default) combines both estimators, returning the lower of the block policy and\n"
      53 | +                     "mempool fee rate estimates. If the mempool fee rate estimator has insufficient data, the block\n"
      54 | +                     "policy fee rate estimate is returned instead. If the block policy fee rate estimate is\n"
      55 | +                     "unavailable, or the mempool fee rate estimator fails for any other reason, an error is returned.\n"
    


    pseudoramdom commented at 10:10 PM on September 10, 2026:

    Should we also update the documentation for GetFeeRateEstimate overload in estimator_man.h?


    ismaelsadeeq commented at 6:10 PM on September 11, 2026:

    Updated, thanks.

  39. in src/rpc/fees.cpp:49 in de74e217c3 outdated
      45 | @@ -46,9 +46,10 @@ static RPCMethod estimatesmartfee()
      46 |                  {
      47 |                      {"fee_rate_estimator", RPCArg::Type::STR, RPCArg::Default{"none"},
      48 |                       "Selects which fee rate estimator to use.\n"
      49 | -                     "\"none\" returns the lower of the block policy and mempool estimates. If the mempool\n"
      50 | -                     "estimate is unavailable, it returns that error instead of falling back to the block\n"
      51 | -                     "policy estimate; use \"block_policy\" in that case to get the block policy estimate.\n"
      52 | +                     "\"none\" (the default) combines both estimators, returning the lower of the block policy and\n"
    


    pseudoramdom commented at 10:12 PM on September 10, 2026:

    Unrelated to this PR - It's weird that setting none uses all available estimators. This should probably have been called auto or combined and the enum case to be FeeRateEstimatorType::AUTOMATIC

  40. pseudoramdom commented at 10:13 PM on September 10, 2026: contributor

    Concept ACK. Left a few comments.

  41. in src/policy/fees/mempool_estimator.h:41 in abd298270b
      36 | @@ -37,6 +37,8 @@ constexpr std::chrono::seconds CACHE_LIFE{7};
      37 |  // Constants for mempool sanity checks.
      38 |  constexpr size_t MEMPOOL_HEALTH_WINDOW_BLOCKS = 6;
      39 |  constexpr double MEMPOOL_REPRESENTATION_THRESHOLD = 0.75;
      40 | +// Recent mined-block stats kept after a restart without a successful mempool reload.
      41 | +constexpr size_t MEMPOOL_STATS_TO_KEEP_WITHOUT_RELOAD{3};
    


    davidgumberg commented at 12:02 AM on September 11, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 (fees: drop stale mined-block stats when the mempool does not reload)

    Why keep any?


    willcl-ark commented at 1:27 PM on September 11, 2026:

    In abd298270b07b044be98c151e91c97eac2c1a682

    I was wondering ~ the same: what's your rationale for keeping three observations from the mempool we lost?

    With equally weighted blocks, three old blocks at 100% coverage lets three fresh blocks pass at only 50% coverage. Are we sure that this is enough evidence that the current mempool has recovered enough for fee estimation. The alternative is clearing the whole window, although this would cause a longer wait...


    ismaelsadeeq commented at 6:27 PM on September 11, 2026:

    Why keep any?

    I was wondering ~ the same: what's your rationale for keeping three observations from the mempool we lost?

    Yeah, it's to reduce the wait to half an hour. Also, see my reply here on why 50% coverage of the next 3 blocks may be okay. #36182 (comment).


    ismaelsadeeq commented at 7:43 PM on September 13, 2026:

    On a synced mainnet node I repeatedly deleted mempool.dat, restarted, and recorded what estimatesmartfee 6 economical {"fee_rate_estimator":"none"} returns the moment mempool_policy starts answering again after MempoolLoadFailed(). For each recovery, I logged the mempool size at that instant and the next block's real fee rates.

    I tested both variants of MempoolLoadFailed(): keeping the newest 3 mined blocks' stats, and clearing all of 6 them.

    mine-blocks-stats run available at mempool size estimate block p5 / p50 / p90 minrelaytxfee?
    keep 3 2 +3 1.36 block 0.315 0.11 / 1.05 / 3.50 no
    keep 3 3 +3 1.17 block 0.283 0.11 / 0.33 / 2.02 no
    keep 3 4 +3 0.57 block 0.100 0.11 / 0.32 / 2.01 yes
    clear all 1 +7 0.16 block 0.100 0.11 / 0.29 / 0.38 yes
    clear all 2 +6 1.40 block 0.290 0.18 / 0.30 / 1.50 no
    clear all 3 +6 1.14 block 0.306 0.18 / 1.04 / 3.56 no
    clear all 4 +6 1.39 block 0.350 0.46 / 1.01 / 3.01 no
    clear all 5 +6 0.50 block 0.100 0.18 / 0.30 / 1.00 yes

    *(keep-3 runs 1 & 5 aren't shown, the run ended before CheckMempoolHealth() passed we did not have enough transaction seen.

    The estimate floors to the relay minimum (0.1 sat/vB) exactly when the mempool is < ~0.75 block deep at the query instant because economical = p75 of a full block's weight, and a sparse pool leaves p75 empty, hence we return max(min_relay, mempool_min_fee). In "clear all" runs 1 and 5 the served value (0.100) was below that block's p5, i.e. below the marginal inclusion feerate an underbid, even though the mempool was healthy; it was just caught right after a block drained it.

    This indicates to me that the drop n blocks approach alone may not be the potential answer to whether we are ready to serve mempool fee rate estimates. What matters is that we had mempool data and how good our connectivity and policy rules are; we miss one thing, which is the mempool txs data. I think what we need to do is use TOTAL mempool size also as an indicator; with that we know the potential next block and the remaining backlog so that we don't wrongly serve a fee rate flow immediately our top block is swept. We think there is no backlog, but there is for other nodes, but we can't see them because we just restart.

    However, mempool depth alone isn't enough: a genuinely quiet mempool is a legitimate state where min_relay_feerate is the correct answer, and a pure depth gate would refuse to ever serve it (falling back to block_policy and overpaying too). So we need either our mempool depth requirement to be satisfied or a ceiling (e.g. after N blocks with a sane mempool, we assume the mempool is truly empty and serve min_relay_feerate as a real estimate.

    This seems like a more resilient solution and can be achieved simply by keeping a counter, set to N when MempoolLoadFailed is called, and whenever an estimate is requested, if we don't have enough mempool txs data and N> 0, don't serve the min relay tx fee floor; we indeed have insufficient data. We can choose a to be 24 hours 144 blocks.


    willcl-ark commented at 8:58 AM on September 14, 2026:

    Thanks for the data and tests @ismaelsadeeq

    I still find myself thinking we should clear the whole mined-block window when the mempool does not reload. Those observations measured the mempool we lost, while the health check is meant to tell us whether the current mempool matches mined blocks.

    Sure, keeping three old observations reduces the wait, but also changes what can pass the check, and I'm not convinced in a positive way?

    I feel like the simpler/dumber rule is superior here: after a failed reload, wait for six fresh blocks, roughly an hour on average, before using the mempool estimate again. The block policy estimator remains available during that time.

    I can't think of a good reason that the shorter wait is important enough to accept the mixed window...


    ismaelsadeeq commented at 7:46 PM on September 14, 2026:

    I pushed both in the new push, clearing the stats and also having a warm-up time where we suspect the mempool is sparse because the node has not run long enough.

    Thanks for the review.

  42. in src/policy/fees/mempool_estimator.cpp:301 in abd298270b
     295 | @@ -296,6 +296,15 @@ void MemPoolFeeRateEstimator::FlushMinedBlockStats()
     296 |               fs::PathToString(m_mempool_estimator_file_path));
     297 |  }
     298 |  
     299 | +void MemPoolFeeRateEstimator::MempoolReloadCompleted(bool reloaded)
     300 | +{
     301 | +    if (reloaded) return;
    


    davidgumberg commented at 12:19 AM on September 11, 2026:

    (https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 fees: drop stale mined-block stats when the mempool does not reload)

    MempoolReloadCompleted shouldn't take a boolean param which makes it a no-op, should probably just not be called by the caller whenever reloaded=true.

    I also think the name is misleading since it only does something interesting when a mempool reload was not successfully completed, maybe it should be called MempoolReloadFailed


    ismaelsadeeq commented at 6:11 PM on September 11, 2026:

    Good idea, taken.

  43. in src/init.cpp:2136 in abd298270b
    2128 | @@ -2129,7 +2129,13 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
    2129 |          }
    2130 |          // Load mempool from disk
    2131 |          if (auto* pool{chainman.ActiveChainstate().GetMempool()}) {
    2132 | -            LoadMempool(*pool, ShouldPersistMempool(args) ? MempoolPath(args) : fs::path{}, chainman.ActiveChainstate(), {});
    2133 | +            fs::path mempool_path;
    2134 | +            if (ShouldPersistMempool(args)) mempool_path = MempoolPath(args);
    2135 | +            const bool reloaded{LoadMempool(*pool, mempool_path, chainman.ActiveChainstate(), {})};
    2136 | +            // If we were interrupted, the node is shutting down and there's nothing to do here.
    2137 | +            if (node.fee_estimator_man && !chainman.m_interrupt) {
    


    davidgumberg commented at 12:54 AM on September 11, 2026:

    (https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 fees: drop stale mined-block stats when the mempool does not reload)

    The reason for not calling MempoolReloadCompleted() when chainman.m_interrupt == true is (to my coarse mind) subtle:

    Mempool loading might fail because mempool.dat is corrupted or missing or blah, but, it also might fail because the user asked for a shut-down in the middle of loading mempool.dat, in which case we don't actually need to delete stuff from mempool_policy_estimator.dat, so we shouldn't call the function, I think the comment could be clearer, e.g.:

                // If mempool loading failed, let the fee estimator know so it can
                // prune data that is not relevant without the mempool loaded.
                // UNLESS failure was caused by the user asking for a shutdown,
                // since then the fee estimator might delete persisted data that is
                // still useful.
                if (node.fee_estimator_man && !chainman.m_interrupt) {
    

    ismaelsadeeq commented at 6:11 PM on September 11, 2026:

    I added a comment there, but did not leak the internals of the fee rate estimation manager here.

  44. in src/init.cpp:2134 in abd298270b
    2128 | @@ -2129,7 +2129,13 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)
    2129 |          }
    2130 |          // Load mempool from disk
    2131 |          if (auto* pool{chainman.ActiveChainstate().GetMempool()}) {
    2132 | -            LoadMempool(*pool, ShouldPersistMempool(args) ? MempoolPath(args) : fs::path{}, chainman.ActiveChainstate(), {});
    2133 | +            fs::path mempool_path;
    2134 | +            if (ShouldPersistMempool(args)) mempool_path = MempoolPath(args);
    2135 | +            const bool reloaded{LoadMempool(*pool, mempool_path, chainman.ActiveChainstate(), {})};
    


    davidgumberg commented at 1:07 AM on September 11, 2026:

    Prefer not to call LoadMempool() with an empty path here since that's a no-op:

                bool reloaded{false};
                if (ShouldPersistMempool(args)) {
                    reloaded = LoadMempool(*pool, MempoolPath(args), chainman.ActiveChainstate(), {});
                }
    

    This could also be made shorter, e.g.:

                const bool reloaded = ShouldPersistMempool(args) && LoadMempool(*pool, MempoolPath(args), chainman.ActiveChainstate(), {});
    

    but I personally find the more explicit variation easier to read.


    ismaelsadeeq commented at 6:11 PM on September 11, 2026:

    Taken

  45. in test/functional/feature_fee_estimation.py:389 in abd298270b
     384 | +        def refill_window_and_stop(blocks):
     385 | +            self.generate(self.nodes[0], blocks, sync_fun=lambda: None)
     386 | +            self.stop_node(0)
     387 | +
     388 | +        refill_window_and_stop(6)
     389 | +        mempool_dat.unlink(missing_ok=True)  # mempool.dat missing
    


    davidgumberg commented at 1:15 AM on September 11, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 (fees: drop stale mined-block stats when the mempool does not reload)

    missing_ok should be false.

            mempool_dat.unlink()  # mempool.dat missing
    

    ismaelsadeeq commented at 6:11 PM on September 11, 2026:

    Fixed.

  46. in test/functional/feature_fee_estimation.py:381 in abd298270b
     372 | @@ -373,6 +373,31 @@ def test_old_fee_estimate_file(self):
     373 |          self.start_node(0)
     374 |          assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "block_policy"})["errors"], [BLOCK_POLICY_ESTIMATOR_ERROR])
     375 |  
     376 | +    def assert_falls_back_without_reload(self):
     377 | +        self.wait_until(lambda: self.nodes[0].getmempoolinfo()["loaded"])
     378 | +        assert "errors" in self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "mempool_policy"})
     379 | +        assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "block_policy")
     380 | +
     381 | +    def test_falls_back_to_block_policy_without_mempool_reload(self):
    


    davidgumberg commented at 1:45 AM on September 11, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 (fees: drop stale mined-block stats when the mempool does not reload)

    test the test:

        def test_falls_back_to_block_policy_without_mempool_reload(self):
            assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "mempool_policy")
    

    (Taken from the earlier commit's test_fallback_when_mempool_estimator_not_ready) Without this check we don't know if we are falling back because of the actions taken in this test, or if we would have used the block policy anyways.

    e.g. this fails on the current branch because of deleting mempool.dat in the previous commit, that happens to be the same thing being done in this test, but it doesn't have to be.


    ismaelsadeeq commented at 6:12 PM on September 11, 2026:

    Done.

  47. in test/functional/feature_fee_estimation.py:689 in abd298270b
     684 | @@ -660,6 +685,9 @@ def run_test(self):
     685 |          self.log.info("Test fallback to block policy when the mempool estimator is not ready")
     686 |          self.test_fallback_when_mempool_estimator_not_ready()
     687 |  
     688 | +        self.log.info("Test fallback to block policy when the mempool does not reload")
     689 | +        self.test_falls_back_to_block_policy_without_mempool_reload()
    


    davidgumberg commented at 2:09 AM on September 11, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/abd298270b07b044be98c151e91c97eac2c1a682 (fees: drop stale mined-block stats when the mempool does not reload)

    nanonit, feel free to disregard:

    Maybe for consistency either:

            self.log.info("Test fallback to block policy when the mempool estimator is not ready")
            self.test_fallback_when_mempool_estimator_not_ready()
    
            self.log.info("Test fallback to block policy when the mempool does not reload")
            self.test_fallback_to_block_policy_without_mempool_reload()
    

    or

            self.log.info("Test fallback to block policy when the mempool estimator is not ready")
            self.test_falls_back_when_mempool_estimator_not_ready()
    
            self.log.info("Test fallback to block policy when the mempool does not reload")
            self.test_falls_back_to_block_policy_without_mempool_reload()
    

    ismaelsadeeq commented at 6:12 PM on September 11, 2026:

    Fixed.

  48. in test/functional/feature_fee_estimation.py:400 in abd298270b
     395 | +        self.start_node(0)
     396 | +        self.assert_falls_back_without_reload()
     397 | +
     398 | +        refill_window_and_stop(3)
     399 | +        self.start_node(0, extra_args=["-persistmempool=0"])  # persistence disabled
     400 | +        self.assert_falls_back_without_reload()
    


    davidgumberg commented at 2:10 AM on September 11, 2026:

    To avoid making this test stateful, should restart without persistmempool=0 and check that it's back to using the mempool estimator:

            self.assert_falls_back_without_reload()
    
            # Clean up and verify that we are back to using the mempool fee estimator.
            refill_window_and_stop(3)
            self.start_node(0)
            assert_equal(self.nodes[0].estimatesmartfee(1, "economical", {"fee_rate_estimator": "none"})["estimator"], "mempool_policy")
    

    ismaelsadeeq commented at 6:14 PM on September 11, 2026:

    Taken

  49. in src/policy/fees/mempool_estimator.h:51 in abd298270b


    polespinasa commented at 9:36 AM on September 11, 2026:

    nit: This is only used inside mempool_estimator.cpp. Probably could drop it from the .h file and keep the declaration static in mempool_estimator.cpp.


    ismaelsadeeq commented at 6:14 PM on September 11, 2026:

    It is now used in the manager so this comment does not apply.


    l0rinc commented at 8:02 PM on September 22, 2026:

    I'm just following this from the sidelines, but while looking into reports of an IBD slowdown potentially caused by the new fee estimates (which may still be unrelated), an AI-assisted review suggested that this check may accept saved fee-estimator data that becomes stale during IBD catch-up. While it makes sense to me superficially, I haven't dug very deep. Could you check whether it's valid and should be covered in this PR? The proposed fix and regression test are at https://github.com/l0rinc/bitcoin/pull/299.


    ismaelsadeeq commented at 2:40 PM on September 23, 2026:

    Hmm, Good catch. The warm-up in the last commit of this PR attempts to prevent serving the floor in those cases.

    The state machine seems to blow-up and this approach is not able to handle all cases, ur diff also only handles during ibd, after ibd u will return flow incorrectly because u do not have the backlog of mempool txs like long running nodes. So I simplify by disallowing the fee rate return flow completely; this is simpler and should handle the most important likely case of underpaying because I'd assume nodes frequently shut down. But having empty mempool usually occurs in test network. And returning block policy in this case won't hurt much because it will also be on the low end.


    w0xlt commented at 7:01 AM on September 26, 2026:

    Stale comments:

    diff --git a/src/policy/fees/estimator_man.h b/src/policy/fees/estimator_man.h
    index 0fada3f479..6e0170d6d0 100644
    --- a/src/policy/fees/estimator_man.h
    +++ b/src/policy/fees/estimator_man.h
    @@ -59,7 +59,7 @@ public:
    
         /**
          * Like GetFeeRateEstimate, but only consults the specified estimator type.
    -     * [@param](/bitcoin-bitcoin/contributor/param/)[in] type The estimator to query. NONE returns the manager-selected combined estimate.
    +     * [@param](/bitcoin-bitcoin/contributor/param/)[in] type The estimator to query. AUTO returns the manager-selected combined estimate.
          * [@param](/bitcoin-bitcoin/contributor/param/)[in] target The target within which the transaction should be confirmed.
          * [@param](/bitcoin-bitcoin/contributor/param/)[in] conservative Whether to select a more conservative, potentially higher, fee rate estimate.
          * [@return](/bitcoin-bitcoin/contributor/return/) Fee rate estimation from the specified estimator, or an error on failure.
    @@ -72,7 +72,10 @@ public:
         /** Flush recorded data to disk as part of shutdown sequence. */
         void ShutdownFlush();
    
    -    /** Called when a non-interrupted mempool load attempt failed. */
    +    /**
    +     * Called when the mempool was not loaded from disk (the load failed, there was no
    +     * mempool file, or -persistmempool=0), unless loading was interrupted by shutdown.
    +     */
         void MempoolLoadFailed();
    
         /**
    

    ismaelsadeeq commented at 9:24 AM on September 28, 2026:

    Fixed

  50. in src/policy/fees/mempool_estimator.cpp:154 in 7f46e2749b
     161 | +    case MempoolEstimationFailure::LOW_COVERAGE:
     162 |          return "Mempool is unreliable for fee rate estimation";
     163 | -    case MemPoolFeeRateEstimator::MempoolHealth::HEALTHY:
     164 | -        return std::nullopt;
     165 | +    case MempoolEstimationFailure::BLOCK_TEMPLATE_FAILED:
     166 | +        return "Failed to create block template for fee rate estimation";
    


    polespinasa commented at 9:50 AM on September 11, 2026:

    Unless I missed it, I don't think there is test coverage for this case.


    ismaelsadeeq commented at 6:13 PM on September 11, 2026:

    Removed and added an Assume.

  51. in src/policy/fees/estimator_man.cpp:34 in de74e217c3
      30 | @@ -31,8 +31,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
      31 |      }
      32 |      auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
      33 |      if (!mempool_estimate) {
      34 | -        // A failed mempool estimate is surfaced as a warning rather than silently returning the
      35 | -        // block policy estimate, which callers can still request explicitly.
      36 | +        if (mempool_estimate.error() == MempoolEstimationFailure::INSUFFICIENT_DATA) return block_policy_estimate;
    


    polespinasa commented at 9:53 AM on September 11, 2026:
            if (mempool_estimate.error() == MempoolEstimationFailure::INSUFFICIENT_DATA) {
                LogDebug(BCLog::ESTIMATEFEE, "Falling back to block policy estimator, insufficient mempool data");
                return block_policy_estimate;
            }
    

    ismaelsadeeq commented at 6:13 PM on September 11, 2026:

    Fixed.

  52. polespinasa commented at 9:56 AM on September 11, 2026: member

    Reviewed abd298270b07b044be98c151e91c97eac2c1a682 Left some more comments, nothing critical. Will probably ACK after comments from Ram and David regarding the use of Expected are addressed :)

  53. fanquake added the label Needs Backport (32.x) on Sep 11, 2026
  54. DrahtBot added the label Needs rebase on Sep 11, 2026
  55. ismaelsadeeq force-pushed on Sep 11, 2026
  56. ismaelsadeeq force-pushed on Sep 11, 2026
  57. DrahtBot added the label CI failed on Sep 11, 2026
  58. DrahtBot removed the label Needs rebase on Sep 11, 2026
  59. DrahtBot removed the label CI failed on Sep 11, 2026
  60. ismaelsadeeq force-pushed on Sep 14, 2026
  61. DrahtBot added the label CI failed on Sep 14, 2026
  62. ismaelsadeeq force-pushed on Sep 15, 2026
  63. willcl-ark commented at 10:15 AM on September 15, 2026: member

    I have more questions (sorry!)

    • Should LOW_COVERAGE also fall back to the block-policy estimate in combined mode? It tells us that we shouldn't use the mempool to lower the estimate, but doesn't necessarily invalidate the available block-policy estimate. An explicit request for mempool_policy could still return the error, I think.

    • If I am reading correctly, the current distinction also creates an odd transition; with five poorly covered blocks, we return the block-policy estimate via INSUFFICIENT_DATA. Receiving the sixth block can then make estimation fail with LOW_COVERAGE. We've gained evidence against using the mempool estimate, but I don't think that should prevent us from returning the same block-policy estimate we were using immediately beforehand...

  64. DrahtBot removed the label CI failed on Sep 15, 2026
  65. ismaelsadeeq force-pushed on Sep 15, 2026
  66. ismaelsadeeq commented at 2:44 PM on September 15, 2026: member

    I have more questions (sorry!)

    Definitely okay with me, bring it on 🔫

    It tells us that we shouldn't use the mempool to lower the estimate, but doesn't necessarily invalidate the available block-policy estimate

    Indeed, I was using it as a proxy to determine the usability of the block policy estimate, because if the mempool is unreliable, then it shows that you are missing the majority of the transactions that get mined. Yet still, the tiny subset your mempool saw that get confirmed could potentially make u have a fee rate estimate from the block policy estimator. It will be nice to empirically see the accuracy of those estimates. But indeed, it is usable.

    Receiving the sixth block can then make estimation fail with LOW_COVERAGE. We've gained evidence against using the mempool estimate, but I don't think that should prevent us from returning the same block-policy estimate we were using immediately beforehand...

    Indeed, I force-pushed fb385bfaac...a623083db1 to fix that. The only exception that we return an error is when the mempool is still loading.

  67. in src/policy/fees/estimator_man.cpp:50 in 80ebfa31d1
      49 |      }
      50 | -    auto selected_estimate = std::min(*block_policy_estimate, *mempool_estimate);
      51 | +    auto selected_estimate = *block_policy_estimate;
      52 | +    if (mempool_estimate) {
      53 | +        selected_estimate = std::min(*block_policy_estimate, *mempool_estimate);
      54 | +    }
    


    polespinasa commented at 5:55 PM on September 15, 2026:

    in 80ebfa31d1b4314ab350dc396dea4bff71d3e44e fees: fall back to block_policy when mempool_policy has insufficient data

    nit:

        auto selected_estimate = mempool_estimate ? std::min(*block_policy_estimate, *mempool_estimate) : *block_policy_estimate
    

    ismaelsadeeq commented at 10:11 AM on September 17, 2026:

    In the latest push, the function is simpler, and ternary can sometimes be unreadable; I'd rather be explicit here. Thanks for the suggestion.

  68. polespinasa commented at 5:58 PM on September 15, 2026: member

    ACK a623083db19060bc152e72b0b33674c9575c86df

    Reviewed again, don't have anything to say :) Just left a small style suggestion that you can freely ignore.

  69. DrahtBot requested review from sedited on Sep 15, 2026
  70. DrahtBot requested review from pseudoramdom on Sep 15, 2026
  71. in src/policy/fees/mempool_estimator.h:46 in 1878ccbab0
      37 | @@ -37,6 +38,16 @@ constexpr std::chrono::seconds CACHE_LIFE{7};
      38 |  constexpr size_t MEMPOOL_HEALTH_WINDOW_BLOCKS = 6;
      39 |  constexpr double MEMPOOL_REPRESENTATION_THRESHOLD = 0.75;
      40 |  
      41 | +//! Why the mempool fee rate estimator fails to return a fee rate estimate.
      42 | +enum class MempoolEstimationFailure {
      43 | +    MEMPOOL_NOT_LOADED,
      44 | +    INSUFFICIENT_DATA,
      45 | +    LOW_COVERAGE,
      46 | +};
    


    davidgumberg commented at 6:49 PM on September 15, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/1878ccbab050233d0c57040ed268876d7400ca2e (fees: return typed failures from the mempool estimator)

    nano-nit feel free to disregard, the comments were lost here:

    enum class MempoolEstimationFailure {
        MEMPOOL_NOT_LOADED,
        //! Too few recent mined blocks to estimate a fee rate.
        INSUFFICIENT_DATA,
        //! Recent blocks include too few mempool transactions to estimate a fee rate.
        LOW_COVERAGE,
    };
    

    ismaelsadeeq commented at 10:11 AM on September 17, 2026:

    Taken, thanks.

  72. in src/policy/fees/estimator_man.cpp:33 in 1878ccbab0
      28 | +{
      29 | +    const auto type{FeeRateEstimatorType::MEMPOOL_POLICY};
      30 | +    return EstimationError(type, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET,
      31 | +                           strprintf("%s: %s", FeeRateEstimatorTypeToString(type), MempoolEstimationFailureToString(failure)));
      32 | +}
      33 | +
    


    davidgumberg commented at 10:10 PM on September 15, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/1878ccbab050233d0c57040ed268876d7400ca2e (fees: return typed failures from the mempool estimator)

    higher-level observation feel-free-to-disregard:

    I'm kind of inclined to wonder if this and the other:

    https://github.com/bitcoin/bitcoin/blob/1878ccbab050233d0c57040ed268876d7400ca2e/src/util/fees.h#L78

    Should return FeeRateEstimationError directly instead of wrapped in util::Unexpected?

    This makes the callers much clearer IMO, since you can tell from the calling function that a Unexpected value is being returned instead of having to follow the call.

    It also seems a bit bad to me, but I haven't thought about this much, to scatter overloaded functions across very different parts of the codebase like this, the other one being in util/fees.h. This particular case is nbd since the function is marked static, but it also seems like an odd pattern in any case to statically overload a function defined elsewhere, maybe the smallest change here would be to rename?

    <details><summary> The bigger change could look like: </summary>

    diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp
    index c1dd8e7a4d..d53b0c3995 100644
    --- a/src/node/interfaces.cpp
    +++ b/src/node/interfaces.cpp
    @@ -740,7 +740,7 @@ public:
         }
         util::Expected<FeeRateEstimation, FeeRateEstimationError> getFeeRateEstimate(int num_blocks, bool conservative) const override
         {
    -        if (!m_node.fee_estimator_man) return EstimationError(FeeRateEstimatorType::NONE, /*returned_target=*/0, /*error=*/{});
    +        if (!m_node.fee_estimator_man) return util::Unexpected{FeeRateEstimationError{FeeRateEstimatorType::NONE, /*returned_target=*/0, /*error=*/{}}};
             return m_node.fee_estimator_man->GetFeeRateEstimate(num_blocks, conservative);
         }
         unsigned int maximumFeeEstimationTargetBlocks() const override
    diff --git a/src/policy/fees/block_policy_estimator.cpp b/src/policy/fees/block_policy_estimator.cpp
    index 3148ac540a..61cf40ae24 100644
    --- a/src/policy/fees/block_policy_estimator.cpp
    +++ b/src/policy/fees/block_policy_estimator.cpp
    @@ -965,7 +965,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> CBlockPolicyEstimator:
         FeeCalculation fee_calc;
         CFeeRate feerate{estimateSmartFee(target, &fee_calc, conservative)};
         if (feerate == CFeeRate(0)) {
    -        return EstimationError(FeeRateEstimatorType::BLOCK_POLICY, fee_calc.returnedTarget, "Insufficient data or no feerate found");
    +        return util::Unexpected{FeeRateEstimationError{FeeRateEstimatorType::BLOCK_POLICY, fee_calc.returnedTarget, "Insufficient data or no feerate found"}};
         }
         return FeeRateEstimation{FeeRateEstimatorType::BLOCK_POLICY, feerate.GetFeePerVSize(), fee_calc.returnedTarget};
     }
    diff --git a/src/policy/fees/estimator_man.cpp b/src/policy/fees/estimator_man.cpp
    index f136449554..000263c023 100644
    --- a/src/policy/fees/estimator_man.cpp
    +++ b/src/policy/fees/estimator_man.cpp
    @@ -24,11 +24,11 @@ FeeRateEstimatorManager::FeeRateEstimatorManager(const fs::path& block_policy_pa
     }
     
     // Flatten a mempool fee rate estimation failure into the fee rate estimation error.
    -static util::Unexpected<FeeRateEstimationError> EstimationError(MempoolEstimationFailure failure)
    +static FeeRateEstimationError GenericEstimationError(MempoolEstimationFailure failure)
     {
         const auto type{FeeRateEstimatorType::MEMPOOL_POLICY};
    -    return EstimationError(type, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET,
    -                           strprintf("%s: %s", FeeRateEstimatorTypeToString(type), MempoolEstimationFailureToString(failure)));
    +    return FeeRateEstimationError{type, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET,
    +                           strprintf("%s: %s", FeeRateEstimatorTypeToString(type), MempoolEstimationFailureToString(failure))};
     }
     
     util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManager::GetFeeRateEstimate(int target, bool conservative) const
    @@ -42,9 +42,9 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
         if (!mempool_estimate) {
             // A failed mempool estimate is surfaced as a warning rather than silently returning the
             // block policy estimate, which callers can still request explicitly.
    -        const auto mempool_error = EstimationError(mempool_estimate.error());
    -        LogDebug(BCLog::ESTIMATEFEE, "%s", mempool_error.error().reason);
    -        return mempool_error;
    +        const auto mempool_error = GenericEstimationError(mempool_estimate.error());
    +        LogDebug(BCLog::ESTIMATEFEE, "%s", mempool_error.reason);
    +        return util::Unexpected{mempool_error};
         }
         auto selected_estimate = std::min(*block_policy_estimate, *mempool_estimate);
         LogDebug(BCLog::ESTIMATEFEE, "Fee rate estimated using %s: target=%s feerate=%s %s/kvB.",
    @@ -62,7 +62,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
             return m_block_policy_estimator->EstimateFeeRate(target, conservative);
         case FeeRateEstimatorType::MEMPOOL_POLICY: {
             auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
    -        if (!mempool_estimate) return EstimationError(mempool_estimate.error());
    +        if (!mempool_estimate) return util::Unexpected{GenericEstimationError(mempool_estimate.error())};
             return *mempool_estimate;
         }
         } // no default case, so the compiler can warn about missing cases
    diff --git a/src/util/fees.h b/src/util/fees.h
    index 3ce3775ad3..5c65860a77 100644
    --- a/src/util/fees.h
    +++ b/src/util/fees.h
    @@ -69,16 +69,15 @@ struct FeeRateEstimation {
     struct FeeRateEstimationError {
         FeeRateEstimation estimation;
         std::string reason;
    -};
     
    -/**
    - * Build a fee rate estimation error result: a zero-value estimation
    - * identifying the estimator and target, alongside the error message.
    - */
    -inline util::Unexpected<FeeRateEstimationError> EstimationError(FeeRateEstimatorType estimator, int returned_target, std::string error)
    -{
    -    return util::Unexpected{FeeRateEstimationError{{estimator, FeePerVSize{0, 0}, returned_target}, std::move(error)}};
    -}
    +    /**
    +     * Build a fee rate estimation error result: a zero-value estimation
    +     * identifying the estimator and target, alongside the error message.
    +     */
    +    FeeRateEstimationError(FeeRateEstimatorType estimator, int returned_target, std::string error)
    +        : estimation({estimator, FeePerVSize{0, 0}, returned_target}), reason(std::move(error)) {}
    +
    +};
     
     /**
      * Return the estimation carried by a fee rate estimate result: the
    

    </summary>

    but just to be clear in case I overstated: these are small, non blocking complaints.


    ismaelsadeeq commented at 10:44 AM on September 17, 2026:

    Thanks for the suggestion, but leaving as is for now.


    polespinasa commented at 11:23 AM on September 17, 2026:

    David that diff is difficult to read :P

  73. in src/policy/fees/estimator_man.cpp:42 in 80ebfa31d1
      38 | @@ -39,14 +39,15 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
      39 |          return block_policy_estimate;
      40 |      }
      41 |      auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
      42 | -    if (!mempool_estimate) {
      43 | -        // A failed mempool estimate is surfaced as a warning rather than silently returning the
      44 | -        // block policy estimate, which callers can still request explicitly.
      45 | +    if (!mempool_estimate && mempool_estimate.error() != MempoolEstimationFailure::INSUFFICIENT_DATA) {
    


    davidgumberg commented at 11:12 PM on September 15, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/80ebfa31d1b4314ab350dc396dea4bff71d3e44e (fees: fall back to block_policy when mempool_policy has insufficient data)

    just an observation: It seems a bit of a smell that this doesn't break any tests, but I guess this case not being covered is the reason we're here

  74. in src/policy/fees/mempool_estimator.cpp:200 in a623083db1 outdated
     195 | @@ -194,6 +196,8 @@ bool MemPoolFeeRateEstimator::Read(AutoFile& file)
     196 |          file >> Using<VectorFormatter<MinedBlockStatsFormatter>>(blocks);
     197 |          uint256 tip_hash;
     198 |          file >> tip_hash;
     199 | +        uint64_t warmup_end_height{0};
     200 | +        file >> warmup_end_height;
    


    davidgumberg commented at 1:09 AM on September 16, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/a623083db19060bc152e72b0b33674c9575c86df (fees: warm up the mempool fee estimator after a fresh or failed load)

    Why does this need to be persisted? In what case would it make sense for a warmup to start and get interrupted and then continue?


    ismaelsadeeq commented at 12:12 PM on September 17, 2026:

    Good catch The reason to persist at all is to avoid needlessly restarting the 144-block warmup on a short routine restart, ideally, we should just resume the remaining warmup rather than start over. But you're right that a persisted absolute end height is meaningless after a long downtime. I will push to address that soon.


    ismaelsadeeq commented at 2:02 PM on September 17, 2026:

    Pushed a fix, warmup now re-starts whenever the tracked mined-block window is empty:

    The scenarios are:

    • Short routine restart: the persisted window reloads, and the next block is contiguous, so the window stays non-empty and the remaining warmup just resumes, not restarting the full 144 blocks.
    • Long downtime: the node re-syncs through IBD, and blocks aren't tracked during IBD (MempoolTransactionsRemovedForBlock only fires when !IsInitialBlockDownload()). Tracking then resumes at a height gap, which clears the window, so warmup starts fresh from the current tip.

    The persisted end height does not hurt; a downtime long enough to make us rewarm is the one that re-syncs through the IBD path I mentioned. I added a unit test for those cases.

  75. in src/policy/fees/mempool_estimator.h:41 in a623083db1 outdated
      36 | @@ -37,6 +37,8 @@ constexpr std::chrono::seconds CACHE_LIFE{7};
      37 |  // Constants for mempool sanity checks.
      38 |  constexpr size_t MEMPOOL_HEALTH_WINDOW_BLOCKS = 6;
      39 |  constexpr double MEMPOOL_REPRESENTATION_THRESHOLD = 0.75;
      40 | +// On startup, a sparse mempool is treated as insufficient data for this many blocks.
      41 | +constexpr uint64_t MEMPOOL_WARMUP_BLOCKS{144};
    


    davidgumberg commented at 1:12 AM on September 16, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/a623083db19060bc152e72b0b33674c9575c86df (fees: warm up the mempool fee estimator after a fresh or failed load)

    I'm not quite sure I follow, previously the wait time after a fresh or failed mempool.dat load was 3 blocks, now it's 144, why 144? Why isn't e.g. the 6 of MEMPOOL_HEALTH_WINDOW_BLOCKS the right number here?


    ismaelsadeeq commented at 10:42 AM on September 17, 2026:

    They are two different things. Keeping the 3 mempool-health-window blocks makes it faster to be able to return a mempool fee rate estimate. Clearing all makes that at least 6 blocks in a row now.

    The warmup period of 144 blocks, on the other hand, is a mechanism I added to avoid underpaying once we are able to make a mempool estimate after these 6 blocks are seen. When the mempool become healthy again, the mempool may still be sparse because we have only just started receiving mempool transactions. One of the reasons, AFAICT, is that we do not have the broadcast module enabled #21061, so a node can have good peers and sane policy rules but still not have the backlog of transactions.

    The algorithm for the mempool-based fee rate estimate works by building a block template and selecting a percentile fee rate; when there are no mempool txs available to do that, it assumes the mempool is empty and returns a relay fee rate floor. This can be misleading when there is indeed a backlog of txs, so the warmup just delays returning a fee rate floor until we have run for at least 24 hours, by which point we have confidence that we now see the mempool's backlog.

    See row 5 of the data in #36182 (review) where we recommend a low fee rate estimate wrongly.


    ismaelsadeeq commented at 3:00 PM on September 23, 2026:

    This is gone now.

  76. in src/policy/fees/estimator_man.cpp:42 in 12496f5c00
      38 | @@ -39,7 +39,7 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
      39 |          return block_policy_estimate;
      40 |      }
      41 |      auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
      42 | -    if (!mempool_estimate && mempool_estimate.error() != MempoolEstimationFailure::INSUFFICIENT_DATA) {
      43 | +    if (!mempool_estimate && mempool_estimate.error() == MempoolEstimationFailure::MEMPOOL_NOT_LOADED) {
    


    davidgumberg commented at 1:14 AM on September 16, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/12496f5c006425ca1d7bd812e859cf8e2a047404 (fees: fall back to block_policy unless the mempool is still loading)

    feel free to disregard: I think it might be better to squash this commit with the previous since they change the same lines, but splitting them up like this to demonstrate the tests also seems reasonable


    davidgumberg commented at 8:27 PM on September 16, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/12496f5c006425ca1d7bd812e859cf8e2a047404 (fees: fall back to block_policy unless the mempool is still loading)

    Question: I feel like I kind of grok why, but just want to ask the rationale for why we shouldn't fall back to the block estimator in this case.

    There are two scenarios I'm thinking where this happens:

    1. The operator is asking for a feerate estimate really really fast after startup and maybe they store their huge mempool.dat on a flash drive for some reason, in this case I'm not sure why not fall back to the block policy?

    2. There is some unrecoverable issue with loading the mempool i.e. the LoadMempool() call hangs indefinitely:

    https://github.com/bitcoin/bitcoin/blob/12496f5c006425ca1d7bd812e859cf8e2a047404/src/init.cpp#L2132-L2133

    in this much less likely case we probably should surface an error to the user, but is fee estimation the way to do it?

    I am not sure, what do you think, I am slightly inclined to suggest for the sake of simplicity that whatever the error is, just fall back to block policy.


    ismaelsadeeq commented at 10:13 AM on September 17, 2026:

    Good idea, squashed.


    ismaelsadeeq commented at 10:14 AM on September 17, 2026:

    Hmm, yeah, for simplicity, I took your suggestion.

  77. instagibbs commented at 7:05 PM on September 16, 2026: member

    got a report from LDK dev this is happening, where block estimates are working but mempool is not

  78. davidgumberg commented at 7:35 PM on September 16, 2026: contributor

    got a report from LDK dev this is happening, where block estimates are working but mempool is not

    Do you have more details, once the node has been operating for a while the mempool estimates are expected to work, no?

  79. instagibbs commented at 7:38 PM on September 16, 2026: member

    @davidgumberg just noting it's another roadblock to currently deployed software; yes it heals over time

  80. davidgumberg commented at 7:52 PM on September 16, 2026: contributor

    @davidgumberg just noting it's another roadblock to currently deployed software; yes it heals over time

    ah got it, sorry I misunderstood your comment

  81. tankyleo commented at 11:09 PM on September 16, 2026: none

    got a report from LDK dev this is happening, where block estimates are working but mempool is not

    Hello yes this happens when I restart my node after leaving it offline for a couple hours.

    The solution proposed here will definitely help.

    I'm also thinking this commit here https://github.com/bitcoin/bitcoin/commit/cfe585df25c482c157e239fcd6c81b7afe3bab00 should be followed-up to account for the fact that a node left offline overnight for example may catch up a batch of blocks with IBD = false. Those blocks currently get recorded into mempool coverage observations, when they shouldn't be.

  82. ismaelsadeeq force-pushed on Sep 17, 2026
  83. ismaelsadeeq force-pushed on Sep 17, 2026
  84. ismaelsadeeq commented at 2:25 PM on September 17, 2026: member

    Hello yes this happens when I restart my node after leaving it offline for a couple hours.

    The solution proposed here will definitely help.

    Thanks for the report. Can you test with this PR and see if it resolves it?

    I'm also thinking this commit here https://github.com/bitcoin/bitcoin/commit/cfe585df25c482c157e239fcd6c81b7afe3bab00 should be followed-up to account for the fact that a node left offline overnight for example may catch up a batch of blocks with IBD = false. Those blocks currently get recorded into mempool coverage observations, when they shouldn't be.

    I don't think it's worth it. Recording them just makes the recent coverage look low (0% coverage), which is the safe outcome; 0% coverage falls back to the block policy estimate until we observe the mempool health again.

    The idea is that it will self-correct, so once the node is back at the tip, observing blocks against a populated mempool, those stale entries get evicted, and the mempool estimate becomes usable again. Block policy covers the gap in the meantime.

  85. tankyleo commented at 5:42 PM on September 17, 2026: none

    Can you test with this PR and see if it resolves it?

    Yes I have tested the PR, can confirm this PR resolves the issue.

  86. ismaelsadeeq requested review from willcl-ark on Sep 21, 2026
  87. ismaelsadeeq requested review from polespinasa on Sep 21, 2026
  88. ismaelsadeeq requested review from davidgumberg on Sep 21, 2026
  89. in src/policy/fees/mempool_estimator.cpp:349 in 3db35b0b4b
     345 | +    UpdateMinedBlockWindow(m_prev_mined_blocks, block_height);
     346 | +    // An empty window means no recent blocks to judge the mempool, so start warmup. It empties on a
     347 | +    // fresh or failed load, a gap, or a reorg deep enough to clear the whole window. A shallow reorg
     348 | +    // leaves a full window non-empty, so a finished warmup is untouched; a reorg that clears the
     349 | +    // window such a reorg is rare, so warming up again is okay.
     350 | +    if (m_prev_mined_blocks.empty()) StartWarmup(block_height);
    


    willcl-ark commented at 1:10 PM on September 21, 2026:

    In 3db35b0b4b8441b5effeea1d17e66ac18a436b82

    is the 144-block warm-up meant to count blocks processed during IBD catch-up? Let's say I was offline for 3 days, I can pass the 144 block window in a few moments when I resume syncing and then return the floor fee here.

    I wonder if the judgement shouldn't start once we are synced?


    ismaelsadeeq commented at 3:11 PM on September 22, 2026:

    Good catch, I attempted to fix it by adding a new bool to the notification that tells us whether the chainstate is current for fee rate estimation. We skip the warmup now and update the stats during that period, as requested by @tankyleo here #36182 (comment). I also detect when the sync is later in the block connection and restart the warm-up. I added tests for these cases.

  90. willcl-ark approved
  91. willcl-ark commented at 1:13 PM on September 21, 2026: member

    ACK d48e76e689bb4d680d9d8a70b67f994be2f0cdd1

    Left one inline comment regarding the catch-up period which could be clarified or touched up as necessary, but on the whole I think this is OK now.

  92. ismaelsadeeq force-pushed on Sep 22, 2026
  93. in src/policy/fees/estimator_man.cpp:30 in 352283bffb outdated
      29 | -        LogDebug(BCLog::ESTIMATEFEE, "%s", block_policy_estimate.error().reason);
      30 | -        return block_policy_estimate;
      31 | +    auto selected_estimate = m_block_policy_estimator->EstimateFeeRate(target, conservative);
      32 | +    if (!selected_estimate) {
      33 | +        LogDebug(BCLog::ESTIMATEFEE, "%s", selected_estimate.error().reason);
      34 | +        return selected_estimate;
    


    l0rinc commented at 12:19 AM on September 23, 2026:

    An AI-assisted review flagged a possible gap here: if the block-policy estimator has no result, this returns an error even when the mempool estimator can provide a fee rate. I put a small test and proposed change in https://github.com/l0rinc/bitcoin/pull/300.

    Could you check whether this PR should cover that case too? I’m not sure using the mempool estimate alone is always right, since it can return the relay floor.


    ismaelsadeeq commented at 2:40 PM on September 23, 2026:

    This is the desired behaviour, it's a belt-and-suspenders on mempool_policy fee rate estimator, when we are not seeing txs confirming enough for block policy to provide an estimate, we also should not rely on mempool policy.


    l0rinc commented at 6:10 PM on September 23, 2026:

    If it's a desired behavior, consider covering it with a test to document that - please see my attempts above.

  94. ismaelsadeeq force-pushed on Sep 23, 2026
  95. ismaelsadeeq force-pushed on Sep 23, 2026
  96. DrahtBot added the label CI failed on Sep 23, 2026
  97. ismaelsadeeq commented at 3:33 PM on September 23, 2026: member

    Forced pushed 651b0a9db877..86ef5cc277

    • The main change is that I simplified the fix: The mempool estimator now returns an error instead of the relay floor when it can't fill a percentile, and the combined estimate falls back to block_policy; hence, an explicit mempool_policy query still returns an error in that case. This should handle the most likely case of underpaying because nodes do frequently restart. But having a completely empty mempool on mainnet is rare, it usually occurs in test networks. And returning a block policy in this case won't hurt because it will also be on the low end or an insufficient data error, so users can explicitly pass the relay floor or use the fallbackfee feature in the wallet.
  98. DrahtBot removed the label CI failed on Sep 23, 2026
  99. in src/policy/fees/mempool_estimator.cpp:295 in 97cffa7212 outdated
     291 | @@ -292,6 +292,13 @@ void MemPoolFeeRateEstimator::FlushMinedBlockStats()
     292 |               fs::PathToString(m_mempool_estimator_file_path));
     293 |  }
     294 |  
     295 | +void MemPoolFeeRateEstimator::MempoolLoadFailed()
    


    davidgumberg commented at 6:33 PM on September 23, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/97cffa7212ac1222da97efea1cdd664b552a7850 (fees: clear mined-block stats when the mempool load fails) nit: This is called and logged on first start:

    $ mkdir /tmp/trash
    $ ./build/bin/bitcoind -regtest -datadir=/tmp/trash -debug=estimatefee | grep "estimatefee"
    2026-09-23T18:26:02Z [estimatefee] mempool_policy: mempool did not load; clearing mined-block stats
    

    clearing m_prev_mined_blocks seems totally fine but the log message is a bit odd, but this is also fine as-is or addressable in a follow-up.


    davidgumberg commented at 5:54 PM on September 24, 2026:

    Latest push fixed this, thanks @ismaelsadeeq.

  100. stickies-v commented at 7:38 PM on September 23, 2026: contributor

    Approach ACK. I was feeling very uneasy about the amount of complexity (process flow more so than code) being tacked on in the state prior to the latest force-push (352283bffbe7ca0b684767baf3ff651b0a9db877..86ef5cc27782ee7b51394753065c1d23215268a4). I think the current approach of simply erroring (and falling back to block policy) whenever the mempool seems like it can't produce an estimate is the best choice for this backport. Solving the biggest problem with the least amount of complexity.

    There are now a few quirks with empty percentiles never resulting in a mempool estimate, but I think their impact is limited enough that this remains a good approach. Some quirks:

    • economical estimate can end up a higher feerate than conservative (if p50 has a result, but p75 doesn't, the mempool estimate is used for conservative and the block policy estimate for economical)
    • as the mempool continually clears until it is almost fully empty, estimated fees will suddenly jump as p50/p75 become empty, the severity depending on how quickly the mempool clears/fees have changed.

    I would prefer addressing these in a follow-up where we have less time pressure to find a good solution.

    nit: the message of 247d47017f still says the change is necessary for "the warmup fallback", which is no longer part of this PR.

  101. in src/policy/fees/mempool_estimator.cpp:411 in 86ef5cc277 outdated
     412 | -    const FeePerVSize p75{percentiles.p75.IsEmpty() ? floor : percentiles.p75};
     413 | -    return FeeRateEstimation{FeeRateEstimatorType::MEMPOOL_POLICY, conservative ? p50 : p75, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET};
     414 | +    const FeePerVSize feerate_estimate{conservative ? percentiles.p50 : percentiles.p75};
     415 | +    // An empty percentile means the mempool was too sparse to fill that fraction of a block.
     416 | +    if (feerate_estimate.IsEmpty()) {
     417 | +        return EstimationError(strprintf("%s: Insufficient mempool transaction data", FeeRateEstimatorTypeToString(estimator_type)));
    


    davidgumberg commented at 11:16 PM on September 23, 2026:

    https://github.com/bitcoin/bitcoin/pull/36182/changes/86ef5cc27782ee7b51394753065c1d23215268a4 (fees: return an error instead of a floored mempool fee rate estimate)

    In the case of a node with a healthy view of the mempool, but a mempool that has less than a p50 or p75 of transactions, don't we actually want to return the floored estimate instead of falling back to the block policy estimator. Not frequent but from looking at the mempool.space chart it has happened as recently as February of this year on mainnet:

    <img width="513" height="588" alt="Image" src="https://github.com/user-attachments/assets/f62e2dca-954a-4862-a506-55ab9f7dbb3e" />

    And I also imagine this will in practice make the mempool estimator never engage on testnet/signet.

    Question: why can't we just rely on the mempool health metrics here?

    What are the odds that someone loads a mempool.dat that happens to do a good job matching the catch-up blocks during loading and that someone asks for a fee estimate during that time? And the current approach will only stop the fee estimate if that happens and at the same time the mempool happens to be kind of empty around the time the fee is being requested which isn't necessarily the case.

    I wonder if maybe a check should be added to GetMempoolHealth() that the current tip height is close to the best known header or something like that but I'll have to look closer tomorrow.


    ismaelsadeeq commented at 9:13 AM on September 24, 2026:

    Valid observation, we do want to return the floor in that case, but as it is now, we do not have a correct and final approach mechanism that differentiates whether we are indeed empty, have just connected, have lots of our mempool, on testnets etc. So a valid e2e fix can be pursued in a follow-up, given that it is very infrequent, this minimal fix is okay, I think.


    davidgumberg commented at 6:05 PM on September 24, 2026:

    I'm not sure I agree, I think it would be better to drop the last two commits entirely from this PR, since it only sometimes fixes a problem that likely no one will ever run into:

    • Fee misestimation because a user starts their node after a day off and their mempool.dat happened to be healthy for 6 blocks in a row (I think this is the thing that's most unlikely) and they request a feerate at that time and it happens that at that time their mempool is nearly empty [if not nearly empty the mempool estimator will still return a stale estimate]

    And creates a problem that all users will run into infrequently:

    • Overestimating fees whenever the mempool is starved.

    The actual fix would be to use a heuristic to decide whether or not we are at or close to the tip, and only return a block estimate then. I think using mempool is empty as a proxy for mempool is stale both produces false positives e.g. when the mempool is actually <3MvB and I suspect produces lots of false negatives, i.e. I suspect that if you restart a node after a few hours or a day of being offline that it will happen quite often that the mempool does not drain but I would have to check that it's just an assumption.

  102. ismaelsadeeq force-pushed on Sep 24, 2026
  103. ismaelsadeeq commented at 9:05 AM on September 24, 2026: member

    Forced pushed 86ef5cc277...3f68d2cdc6 to address feedback by @stickies-v and @davidgumberg

    • I added a check that prevents the economical estimates from being lower than the estimates, we only return an estimate when both have non-empty estimates.
    • I prevent logging that we clear the mined block stats when the vector is empty.
    • Removed stale mention of warmup in commit message.
  104. willcl-ark commented at 4:11 PM on September 24, 2026: member

    I’m generally happy with the PR now.

    One API naming question before 32.0: should we rename fee_rate_estimator=none to auto, as suggested above? I agree that none reads as though fee estimation is disabled, whereas it selects the default combined behavior.

    I know it's not been introduced here, but this PR might be our best chance to fix the public name before 32.0 is released...

    As we coerce all unknown values to none, anyone who has been testing and has none in a script/application already, would not see a breaking change, which makes it feel even more appealing to me.

    It could also be it's own tiny "bugfix" PR perhaps, too.

    Happy to ACK this with or without that change, but wanted to see what folks reviewing this thought.

  105. ismaelsadeeq force-pushed on Sep 25, 2026
  106. ismaelsadeeq commented at 11:22 AM on September 25, 2026: member

    Forced pushed 3f68d2cdc6...dd85315143

    I added a commit here to fix #36182 (comment)

  107. in src/policy/fees/mempool_estimator.cpp:409 in dd85315143
     424 | +    if (auto error{MempoolHealthError(GetMempoolHealth())}) {
     425 | +        return EstimationError(strprintf("%s: %s", FeeRateEstimatorTypeToString(estimator_type), *error));
     426 | +    }
     427 | +
     428 | +    const auto percentiles{GetOrBuildPercentiles()};
     429 | +    if (percentiles.p50.IsEmpty() || percentiles.p75.IsEmpty()) {
    


    w0xlt commented at 7:07 AM on September 26, 2026:

    Stale comments + redundant check.

    diff --git a/src/policy/fees/mempool_estimator.cpp b/src/policy/fees/mempool_estimator.cpp
    index 97c1254d38..701c293f16 100644
    --- a/src/policy/fees/mempool_estimator.cpp
    +++ b/src/policy/fees/mempool_estimator.cpp
    @@ -371,10 +371,10 @@ Percentiles MemPoolFeeRateEstimator::GetOrBuildPercentiles() const
         // The estimator lock is not held while building a block template, so
         // in a rare edge case concurrent callers may duplicate work.
         //
    -    // Cached fee rate estimates are tagged with the chain tip they were computed on
    +    // Cached percentiles are tagged with the chain tip they were computed on
         // and only served from the cache while that tip is current.
         //
    -    // The fee rate estimate returned directly below may still reflect a tip that went
    +    // Freshly built percentiles may still reflect a tip that went
         // stale during the call; that is an accepted tradeoff of not holding
         // locks across block assembly.
         const uint256 tip_hash{WITH_LOCK(::cs_main, return Assume(m_chainman.CurrentChainstate().m_chain.Tip())->GetBlockHash
    ())};
    @@ -406,7 +406,8 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> MemPoolFeeRateEstimato
         }
    
         const auto percentiles{GetOrBuildPercentiles()};
    -    if (percentiles.p50.IsEmpty() || percentiles.p75.IsEmpty()) {
    +    // p75 is only populated once p50 is, so an empty p75 covers both.
    +    if (percentiles.p75.IsEmpty()) {
             return EstimationError(strprintf("%s: Insufficient mempool transaction data", FeeRateEstimatorTypeToString(estima
    tor_type)));
         }
         return FeeRateEstimation{estimator_type, conservative ? percentiles.p50 : percentiles.p75, MEMPOOL_FEE_ESTIMATOR_MAX_
    TARGET};
    

    ismaelsadeeq commented at 9:24 AM on September 28, 2026:

    Good catch, taken.

  108. in src/policy/fees/mempool_estimator.h:56 in dd85315143
      51 | +    FeePerVSize p50;
      52 | +    FeePerVSize p75;
      53 | +};
      54 | +
      55 |  /**
      56 |   * MemPoolFeeRateEstimatorCache holds a cache of recent fee rate estimates.
    


    w0xlt commented at 7:11 AM on September 26, 2026:

    nit: stale comments.

    diff --git a/src/policy/fees/mempool_estimator.h b/src/policy/fees/mempool_estimator.h
    index 80c6545a20..b6d64cafe9 100644
    --- a/src/policy/fees/mempool_estimator.h
    +++ b/src/policy/fees/mempool_estimator.h
    @@ -53,8 +53,8 @@ struct Percentiles {
     };
    
     /**
    - * MemPoolFeeRateEstimatorCache holds a cache of recent fee rate estimates.
    - * A cached fee rate is only provided while it is not older than CACHE_LIFE
    + * MemPoolFeeRateEstimatorCache holds the most recent block-template percentiles.
    + * Cached percentiles are only provided while they are not older than CACHE_LIFE
      * and the chain tip has not changed.
      */
     class MemPoolFeeRateEstimatorCache
    diff --git a/src/test/mempool_fee_estimator_tests.cpp b/src/test/mempool_fee_estimator_tests.cpp
    index be17693742..e8d099ad1f 100644
    --- a/src/test/mempool_fee_estimator_tests.cpp
    +++ b/src/test/mempool_fee_estimator_tests.cpp
    @@ -84,7 +84,7 @@ BOOST_AUTO_TEST_CASE(calculate_max_weight_percentiles)
             } else if (i < 8) {
                 chunk_feerates.emplace_back(medium_fee_rate);
                 // Once 50% coverage is reached but 75% is not, only the p50 (conservative)
    -            // percentile is populated; p75 (economical) is left empty for the caller to floor.
    +            // percentile is populated; p75 (economical) is left empty.
                 if (i < 7) {
                     const auto partial = MemPoolFeeRateEstimator::CalculateMaxWeightPercentiles(chunk_feerates);
                     BOOST_CHECK_EQUAL(partial.p50.fee, high_fee_rate.fee);
    @@ -266,7 +266,7 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)
                     TryAddToMempool(*m_node.mempool, entry.Fee(high_fee).FromTx(MakeRandomTx()));
                 }
             }
    -        // Expire the cached floor estimate so the denser mempool is observed.
    +        // Expire the cached percentiles so the denser mempool is observed.
             SetMockTime(GetTime<std::chrono::seconds>() + CACHE_LIFE + std::chrono::seconds{1});
             const auto result = mempool_estimator.EstimateFeeRate(/*conservative=*/true);
             BOOST_CHECK(!result);
    

    ismaelsadeeq commented at 9:23 AM on September 28, 2026:

    Fixed

  109. in src/policy/fees/estimator_man.cpp:34 in dd85315143 outdated
      38 | -        // A failed mempool estimate is surfaced as a warning rather than silently returning the
      39 | -        // block policy estimate, which callers can still request explicitly.
      40 | -        LogDebug(BCLog::ESTIMATEFEE, "%s", mempool_estimate.error().reason);
      41 | -        return mempool_estimate;
      42 | +    if (mempool_estimate) {
      43 | +        selected_estimate = std::min(*selected_estimate, *mempool_estimate);
    


    w0xlt commented at 7:30 AM on September 26, 2026:

    @polespinasa 's suggestion to log the fallback (r3988008559) was marked fixed, but it seems to have been lost in a later rewrite.

    diff --git a/src/policy/fees/estimator_man.cpp b/src/policy/fees/estimator_man.cpp
    index be98c4c999..2a6494d489 100644
    --- a/src/policy/fees/estimator_man.cpp
    +++ b/src/policy/fees/estimator_man.cpp
    @@ -32,6 +32,9 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> FeeRateEstimatorManage
         auto mempool_estimate = m_mempool_estimator->EstimateFeeRate(conservative);
         if (mempool_estimate) {
             selected_estimate = std::min(*selected_estimate, *mempool_estimate);
    +    } else {
    +        LogDebug(BCLog::ESTIMATEFEE, "%s; falling back to %s", mempool_estimate.error().reason,
    +                 FeeRateEstimatorTypeToString(selected_estimate->feerate_estimator));
         }
         LogDebug(BCLog::ESTIMATEFEE, "Fee rate estimated using %s: target=%s feerate=%s %s/kvB.",
                  FeeRateEstimatorTypeToString(selected_estimate->feerate_estimator),
    

    ismaelsadeeq commented at 9:22 AM on September 28, 2026:

    Fixed, and leave out the falling back to part; it is redundant because right below we log the selected fee rate.

  110. fees: rename the default fee_rate_estimator value to "auto"
    "none" read as though fee estimation was disabled, when it actually
    selects the default combined behavior: the lower of the block policy
    and mempool fee rate estimates, falling back to the block policy
    estimate when the mempool estimator can't produce one.
    
    Rename the public value (and the FeeRateEstimatorType::NONE enumerator)
    to "auto", which describes what it does. Unknown values are still
    treated as to auto, so the previous "none" spelling works.
    f167348db5
  111. fees: fall back to block_policy when the mempool estimator can't estimate
    When the mempool policy estimator cannot produce an estimate, GetFeeRateEstimate()
    now returns the block policy estimate rather than an error. The combined estimate
    starts from the block policy estimate and lowers it with the mempool estimate only
    when present, so the fallback path shares the log that reports the selected fee
    rate. An error is returned only when the block policy estimate itself is unavailable.
    6a07b66e3b
  112. fees: clear mined-block stats when the mempool load fails
    When the mempool fails to load at startup (persistence disabled, or a
    missing or corrupt mempool.dat), init notifies the fee_rate_estimator_man,
    which clears its tracked mined-block window. Otherwise the estimator
    would keep a window describing a mempool the node no longer has and,
    once the mempool refilled, serve an estimate built on it.
    
    Clearing makes the estimator report insufficient data until the window refills from the
    current tip, so the combined estimate falls back to the block policy
    estimate meanwhile.
    305f7df895
  113. fees: cache block-template percentiles instead of floored fee rates
    Hoist the Percentiles struct to file scope and cache the raw p50/p75
    percentiles, applying the relay floor on each call rather than caching the
    already-floored conservative/economical fee rates. The template build moves
    into GetOrBuildPercentiles().
    
    Flooring per call lets a later commit tell an unfilled percentile apart from a
    floored one, so it can return an error instead of the floor and let the
    combined estimate fall back to the block policy estimate.
    1a383bf8b6
  114. fees: return an error instead of a floored mempool fee rate estimate
    When the block template does not fill both the 50th and 75th percentiles of a
    block, EstimateFeeRate() returns an error rather than flooring the estimate to
    the higher of the minimum relay fee and the mempool minimum fee, so the
    combined estimator falls back to the block policy estimate.
    
    Both percentiles are required together so the economical (p75) estimate cannot
    end up above the conservative (p50) one, which could happen if only p50 were
    served while p75 fell back to the block policy estimate.
    eb28c156ee
  115. ismaelsadeeq force-pushed on Sep 28, 2026
  116. ismaelsadeeq marked this as a draft on Sep 28, 2026
  117. maflcko removed the label Needs Backport (32.x) on Sep 28, 2026
  118. maflcko removed this from the milestone 32.0 on Sep 28, 2026
  119. ismaelsadeeq commented at 10:39 AM on September 28, 2026: member

    @davidgumberg raised a concern that, with the last two commits, we may overestimate for everyone when the mempool is empty (see thread: #36182 (review)).

    My rationale for including those two commits before 32.0 is that they preserve the status quo: an empty mempool occurs rarely, whereas users restarting after a while and losing their transaction backlog happens more often. However, an empty mempool is also a valid state and with the last two commits when it occur we overestimate for everyone else.

    I discussed this offline with @davidgumberg, and we concluded that the core issue is being able to distinguish a genuinely empty mempool from a missing backlog. We decided to move forward with the first three commits and discuss the issue further and also these are very edge cases that may not warrant the energy.

    I split the PR and converted this one into a draft. The first three commits are, IMO, what should be in the 32.x milestone.

    Thanks for the review and feedback, all.

  120. achow101 referenced this in commit 0198c85334 on Sep 29, 2026
  121. DrahtBot added the label Needs rebase on Sep 29, 2026
  122. DrahtBot commented at 9:22 PM on September 29, 2026: contributor

    <!--cf906140f33d8803c4a75a2196329ecb-->

    🐙 This pull request conflicts with the target branch and needs rebase.


github-metadata-mirror

This is a metadata mirror of the GitHub repository bitcoin/bitcoin. This site is not affiliated with GitHub. Content is generated from a GitHub metadata backup.
generated: 2026-10-11 08:51 UTC

This site is hosted by @0xB10C
More mirrored repositories can be found on mirror.b10c.me