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

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

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

    i) right after first startup, it hasn't tracked enough blocks yet; ii) after a cold restart (persistence off, or mempool.dat missing/corrupt); and iii) when the mempool is still loading.

    (ii) currently returns the minimum relay fee when the last saved mempool was healthy. This can be a significant underestimation, since the now-missing mempool may have held much higher-feerate transactions.

    Hence this PR falls back to the block policy estimate while the mempool estimator isn't ready, i.e. during its data-gathering phase. It also falls back when the mempool's coverage of recent blocks is too low. Calling estimatesmartfee with fee_rate_estimator=mempool_policy explicitly still returns the failure.

    It also updates init to notify the mempool fee rate estimator of the mempool reload outcome. When the reload fails (e.g. -persistmempool=0, or data corruption), we drop the mined-block statistics, and for the next 144 blocks (~24 hours) the estimator does not serve the fee rate floor. An empty mempool right after a failed reload may mean we lost the mempool rather than it being genuinely empty, so instead of returning the floor we return no estimate and fall back to block policy. This warmup period also applies during startup, so the estimator waits for the mempool to fill again before serving; in that case too, we return a block policy fee rate estimate. For simplicity, when the mempool is still loading, the combined estimate also returns a block policy fee rate estimate.

    The warmup progress is persisted, so it is not needlessly restarted on a routine restart: a short restart resumes the remaining warmup, since blocks are tracked contiguously. After a long offline period the node re-syncs through IBD, during which blocks are not tracked, so tracking resumes with a height gap. That gap clears the mined-block window, which re-start the warmup from the current tip.

    This matters because a long offline period drains the reloaded mempool (its backlog is mined during the re-sync and, with rebroadcast disabled, not replaced), so re-arming avoids serving the floor off a stale, sparse mempool.

    This is an alternative to #36095.

    ~60% of the added lines here are tests

  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
    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:

    • #36275 (build: enable -Wunused-const-variable by fanquake)
    • #36091 (test: Add debug output to common tested types by rustaceanrob)
    • #35581 (node: add block template manager and track waitNext fee inflow by ismaelsadeeq)
    • #35511 (RFC: consensus: Make CAmount a class by hodlinator)
    • #29700 (kernel, refactor: return error status on all fatal errors by ryanofsky)

    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):

    • MineBlocks(estimator, reorg_tip, 1) in src/test/mempool_fee_estimator_tests.cpp
    • 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-17 13:55:53</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.

  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.

  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. 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.
    7b07d5132b
  83. 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, which the warmup fallback needs.
    405b157799
  84. ismaelsadeeq force-pushed on Sep 17, 2026
  85. fees: warm up the mempool fee estimator after a fresh or failed load
    On a fresh start, or after a failed mempool load (persistence off, or a
    missing/corrupt mempool.dat), the estimator has no recent backlog to build on. An
    empty or sparse mempool makes the block-template estimate fall back to the relay
    floor, which can badly underbid the real fee environment.
    
    The estimator now warms up: for MEMPOOL_WARMUP_BLOCKS (144, ~24 hours) tracked
    blocks it defers, rather than returning the floor, for a percentile the mempool is
    too sparse to fill, so the combined estimate uses block policy meanwhile.
    
    Warmup starts whenever the tracked mined-block window is empty: a fresh or failed
    load, or a gap that cleared it. init reports a failed load through a new
    MempoolLoadFailed notification, which clears any restored stats; the next tracked
    block then finds the window empty and starts warmup from the current tip.
    
    Warmup progress is persisted, so a short restart resumes it. After a long downtime
    the node re-syncs through IBD, during which blocks are not tracked, so tracking
    resumes with a height gap that clears the window and restarts warmup, avoiding
    serving the floor off a mempool that drained during the re-sync.
    3db35b0b4b
  86. ismaelsadeeq force-pushed on Sep 17, 2026
  87. 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.

  88. 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.


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-09-20 14:52 UTC

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