{
 "number": 36356,
 "repo": "bitcoin/bitcoin",
 "url": "https://github.com/bitcoin/bitcoin/pull/36356",
 "title": "mining: move getblocktemplate cache into BlockTemplateManager",
 "author": "w0xlt",
 "author_association": "CONTRIBUTOR",
 "created_at": "2026-09-27T08:24:58Z",
 "updated_at": "2026-10-31T23:59:05Z",
 "description_hash": "25e929011a39c95d",
 "age_days": 35,
 "draft": false,
 "labels": [
  "Mining"
 ],
 "milestone": null,
 "base": "master",
 "head_sha": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
 "head_ref": "block-template-manager-gbt-cache",
 "head_repo": "w0xlt/bitcoin",
 "head_history": [
  {
   "t": "2026-10-28T21:52:29Z",
   "sha": "c12310c589c0277ed6d69d26c31a20bd0b51e6ac"
  },
  {
   "t": "2026-10-28T22:43:53Z",
   "sha": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f"
  }
 ],
 "additions": 137,
 "deletions": 32,
 "changed_files": 4,
 "commit_count": 3,
 "size_bucket": "M",
 "mergeable_state": "clean",
 "bot": {
  "drahtbot": {
   "present": true,
   "reviews": {
    "concept_ack": [
     {
      "login": "ismaelsadeeq",
      "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6058035761"
     }
    ]
   },
   "conflicts": []
  }
 },
 "acks_parsed": {
  "ismaelsadeeq": {
   "kind": "concept_ack",
   "hash": null,
   "t": "2026-10-08T10:38:21Z",
   "stale": false
  }
 },
 "acks_tally": {
  "ack": 0,
  "stale_ack": 0,
  "concept_ack": 1,
  "approach_ack": 0,
  "nack": 0,
  "concept_nack": 0,
  "approach_nack": 0
 },
 "reviews": {
  "approved": 0,
  "changes_requested": 0,
  "distinct_reviewers": [
   "Sjors",
   "ismaelsadeeq"
  ]
 },
 "signals": {
  "needs_rebase": false,
  "ci_failed": false,
  "mergeable_state": "clean",
  "last_author_activity": "2026-10-28T23:10:09Z",
  "last_reviewer_activity": "2026-10-28T09:55:17Z",
  "last_reviewer": "Sjors",
  "author_silent_days": 4,
  "waiting_on_author_days": 0,
  "days_since_update": 1
 },
 "refs": {
  "mentioned": [
   33421,
   33758,
   35675
  ],
  "depends_on": [
   35675
  ],
  "fixes": [],
  "linked_issues": [],
  "references": [
   {
    "number": 35675,
    "type": "pull",
    "state": "closed",
    "merged": true,
    "merged_at": "2026-09-24",
    "title": "mining: add block template manager"
   },
   {
    "number": 33758,
    "type": "issue",
    "state": "open",
    "merged": false,
    "merged_at": null,
    "title": "BlockTemplate Manager Tracking issue"
   },
   {
    "number": 33421,
    "type": "pull",
    "state": "closed",
    "merged": false,
    "merged_at": null,
    "title": "node: add  `BlockTemplateCache`"
   }
  ],
  "conflicts": []
 },
 "stack": {
  "shares_commits_with": [],
  "based_on": [],
  "base_for": []
 },
 "review_paths": [
  "src/node/block_template_manager.h"
 ],
 "body": "Follow-up to #35675. This addresses the `getblocktemplate` part of the \"Remove remaining global states\" TODO in #33758, as suggested in https://github.com/bitcoin/bitcoin/pull/35675#pullrequestreview-5299625960.\n\n`getblocktemplate` reuses its last template until the tip changes, or until the mempool changes and the template is more than 5 seconds old. That cache currently lives in four function-local statics, which:\n\n- live as long as the process, so the cached `CBlockIndex*` can outlive its `ChainstateManager` (as in unit tests);\n- rely on `cs_main` being held, which nothing checks;\n- can't be unit tested.\n\nThis PR moves the cache into `BlockTemplateManager`, in three commits:\n\n1. **rpc:** refactors the statics in place into a `CachedBlockTemplate` struct and a `GetNewOrCachedBlockTemplate()` helper, `GUARDED_BY(::cs_main)`. The template's `hashPrevBlock` replaces the cached `CBlockIndex*`, and the 5 seconds becomes `GBT_TEMPLATE_REBUILD_INTERVAL`.\n2. **refactor:** moves them into `BlockTemplateManager`. It's easiest to review with `git show --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change`.\n3. **test:** adds a unit test for each rebuild rule and for the retry after a failed build.\n\nNo behavior change is intended, and only `getblocktemplate` uses the cache. It uses `cs_main` rather than its own mutex, since the RPC already holds it and building a template takes it anyway.",
 "commits": [
  {
   "sha": "cde2bb87733b03c0ffca4b52772744e8539856bf",
   "date": "2026-10-28T22:38:36Z",
   "message": "rpc: gather the getblocktemplate cache state into a struct\n\nReplace the function-local statics in getblocktemplate with a\nCachedBlockTemplate struct and a GetNewOrCachedBlockTemplate() helper,\nboth local to rpc/mining.cpp. The cache is still process-wide, but it is\nnow GUARDED_BY(::cs_main), so its synchronization is checked.\n\nGetNewOrCachedBlockTemplate() keeps the existing rules: the template is\nrebuilt when the tip changes, or when the mempool changed and the cached\ntemplate is more than 5 seconds old. A failed rebuild leaves the cached\ntemplate unset so that the next call tries again.\n\nThis prepares for moving the cache into BlockTemplateManager in the next\ncommit. While the code is still next to the original, it also:\n\n- Drops the static CBlockIndex pointer. pindexPrev is looked up from the\n  template's hashPrevBlock, so it cannot outlive the ChainstateManager\n  that owns it.\n- Reads the active chain tip inside GetNewOrCachedBlockTemplate() and\n  compares it with the template's hashPrevBlock, so the cache cannot\n  pair a template with a tip it does not build on. The caller no longer\n  needs to re-read the tip after longpolling.\n- Builds the longpollid from hashPrevBlock, so it always names the block\n  the template builds on.\n- Replaces the deprecated GetTime() with the equivalent\n  Now<NodeSeconds>().\n- Names the 5 second rebuild interval GBT_TEMPLATE_REBUILD_INTERVAL."
  },
  {
   "sha": "9414d05949e16e77e2b84560612a465b644ad30a",
   "date": "2026-10-28T22:40:13Z",
   "message": "refactor: move getblocktemplate cache into BlockTemplateManager\n\nMove CachedBlockTemplate, GBT_TEMPLATE_REBUILD_INTERVAL and\nGetNewOrCachedBlockTemplate() from rpc/mining.cpp into\nBlockTemplateManager. The file-scope cache becomes a member that is\nstill guarded by cs_main, and the interval becomes a public static\nmember. The longpolling code reads the cached mempool counter through\nthe new GetCachedTransactionsUpdated().\n\nThe cache now lives as long as the node context instead of the process.\n\nApart from the member accesses and declarations, this is a move. It\ncan be reviewed with:\n\ngit show --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change"
  },
  {
   "sha": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
   "date": "2026-10-28T22:40:13Z",
   "message": "test: cover the BlockTemplateManager getblocktemplate cache\n\nCheck that GetNewOrCachedBlockTemplate() reuses the cached template\nuntil the tip changes, or until the mempool changes and the template is\nmore than 5 seconds old, and that GetCachedTransactionsUpdated() reports\nthe mempool counter sampled for the last build. Also check that a failed\nrebuild leaves the cache without a template, so that the next call\nretries without waiting for the 5 seconds to pass."
  }
 ],
 "timeline": [
  {
   "t": "2026-10-07T10:08:42Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "I plan to review this.",
   "id": "c:6035716281",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6035716281",
   "hash": "11c33517e7c45ae3",
   "edited": null
  },
  {
   "t": "2026-10-08T10:38:21Z",
   "kind": "comment",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "text": "Concept ACK\n\nI think we can implement the broader cache as attempted here #33421, mempool estimator dependency on `ChainStateManager` will be gone, and the ipc `createnewblock`, `waitnext` and `getblocktemplate` RPC can use it too. That way we have shared code for all three?\n\nThis has a bit of an advantage when the 7 sec for the mempool fee rate estimator elapses and no mempool change, we wont rebuilt; same for the waitnext too. We can later extend to use actual top block inflow to decide not just mempool transaction list number change, that may not affect the top block.\n\nsee POC\n\n```diff\ndiff --git a/src/init.cpp b/src/init.cpp\nindex 3632a210a4..a7ab529a52 100644\n--- a/src/init.cpp\n+++ b/src/init.cpp\n@@ -1956,7 +1956,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)\n             return InitError(strprintf(_(\"acceptstalefeeestimates is not supported on %s chain.\"), chainparams.GetChainTypeString()));\n         }\n         MaybeMigrateLegacyFeeEstimates(args);\n-        node.fee_estimator_man = std::make_unique<FeeRateEstimatorManager>(BlockPolicyFeeEstPath(args), read_stale_estimates, MempoolPolicyEstimatorPath(args), *Assert(node.mempool), chainman);\n+        node.fee_estimator_man = std::make_unique<FeeRateEstimatorManager>(BlockPolicyFeeEstPath(args), read_stale_estimates, MempoolPolicyEstimatorPath(args), *Assert(node.mempool), *Assert(node.block_template_manager));\n\n         // Flush estimates to disk periodically\n         FeeRateEstimatorManager* fee_estimator_man = node.fee_estimator_man.get();\ndiff --git a/src/node/block_template_manager.cpp b/src/node/block_template_manager.cpp\nindex 34f864c99a..d9e2c3d8da 100644\n--- a/src/node/block_template_manager.cpp\n+++ b/src/node/block_template_manager.cpp\n@@ -50,34 +50,84 @@ std::unique_ptr<CBlockTemplate> BlockTemplateManager::CreateNewTemplate(const Bl\n     }.CreateNewBlock();\n }\n\n-unsigned int BlockTemplateManager::GetCachedTransactionsUpdated() const\n+static constexpr size_t MAX_CACHED_TEMPLATES{3};\n+\n+unsigned int BlockTemplateManager::GetCachedTransactionsUpdated(const BlockCreateOptions& options) const\n {\n-    AssertLockHeld(::cs_main);\n-    return m_cached_template.transactions_updated;\n+    LOCK(m_mutex);\n+    for (const auto& cache : m_cached_templates) {\n+        if (cache.options == options) return cache.transactions_updated;\n+    }\n+    return 0;\n+}\n+\n+static bool BuildsOnTip(const CachedBlockTemplate& cache, const uint256& tip)\n+{\n+    return cache.block_template && cache.block_template->block.hashPrevBlock == tip;\n+}\n+\n+static bool MempoolChanged(unsigned int transactions_updated, const CTxMemPool& mempool)\n+{\n+    return mempool.GetTransactionsUpdated() != transactions_updated;\n+}\n+\n+static bool TooOld(NodeSeconds time_start, std::optional<std::chrono::seconds> max_age)\n+{\n+    return max_age && Now<NodeSeconds>() - time_start > *max_age;\n+}\n+\n+static bool Stale(const CachedBlockTemplate& cache, const CTxMemPool& mempool, std::optional<std::chrono::seconds> max_age)\n+{\n+    return TooOld(cache.time_start, max_age) && MempoolChanged(cache.transactions_updated, mempool);\n }\n\n-CachedBlockTemplate BlockTemplateManager::RefreshCachedTemplate()\n+static std::vector<CachedBlockTemplate>::iterator FindCached(std::vector<CachedBlockTemplate>& cached_templates, const BlockCreateOptions& options)\n+{\n+    return std::find_if(cached_templates.begin(), cached_templates.end(),\n+                        [&](const CachedBlockTemplate& cached_template) { return cached_template.options == options; });\n+}\n+\n+CachedBlockTemplate BlockTemplateManager::BuildCachedTemplate(const BlockCreateOptions& options)\n {\n     AssertLockHeld(::cs_main);\n-    CachedBlockTemplate& cache{m_cached_template};\n+    CachedBlockTemplate cache;\n+    cache.options = options;\n+\n+    // Sample the mempool counter before building, so that a change made\n+    // while CreateNewTemplate runs is seen as a change on the next call\n+    cache.transactions_updated = m_mempool.GetTransactionsUpdated();\n+    cache.time_start = Now<NodeSeconds>();\n+    cache.block_template = CreateNewTemplate(options);\n+    return cache;\n+}\n+\n+CachedBlockTemplate& BlockTemplateManager::StoreCachedTemplate(std::vector<CachedBlockTemplate>::iterator existing, CachedBlockTemplate&& rebuilt)\n+{\n+    AssertLockHeld(m_mutex);\n+    if (existing != m_cached_templates.end()) return *existing = std::move(rebuilt);\n+    if (m_cached_templates.size() >= MAX_CACHED_TEMPLATES) m_cached_templates.erase(m_cached_templates.begin());\n+    return m_cached_templates.emplace_back(std::move(rebuilt));\n+}\n+\n+CachedBlockTemplate BlockTemplateManager::GetBlockTemplate(std::optional<std::chrono::seconds> max_age, const BlockCreateOptions& options)\n+{\n+    // Template assembly reads the chainstate under cs_main; the cache is guarded\n+    // by m_mutex, which ranks below cs_main in the lock order.\n+    LOCK2(::cs_main, m_mutex);\n     const uint256 tip{CHECK_NONFATAL(m_chainman.ActiveChain().Tip())->GetBlockHash()};\n-    if (!cache.block_template || cache.block_template->block.hashPrevBlock != tip ||\n-        (m_mempool.GetTransactionsUpdated() != cache.transactions_updated && Now<NodeSeconds>() - cache.time_start > 5s)) {\n-        // Clear the template so future calls make a new block, despite any failures from here on\n-        cache.block_template.reset();\n-\n-        // Sample the mempool counter before building, so that a change made\n-        // while CreateNewTemplate runs is seen as a change on the next call\n-        cache.transactions_updated = m_mempool.GetTransactionsUpdated();\n-        cache.time_start = Now<NodeSeconds>();\n-\n-        // Create new block. Opt-out of cooldown mechanism, because it would add\n-        // a delay to each getblocktemplate call. This differs from typical\n-        // long-lived IPC usage, where the overhead is paid only when creating\n-        // the initial template.\n-        cache.block_template = CreateNewTemplate({});\n+    const auto cached{FindCached(m_cached_templates, options)};\n+    if (cached != m_cached_templates.end() && BuildsOnTip(*cached, tip) && !Stale(*cached, m_mempool, max_age)) {\n+        return *cached;\n     }\n-    return cache;\n+    // Build before touching the cache, so a failed build leaves it unchanged.\n+    return StoreCachedTemplate(cached, BuildCachedTemplate(options));\n+}\n+\n+CachedBlockTemplate BlockTemplateManager::RefreshCachedTemplate(const BlockCreateOptions& options)\n+{\n+    LOCK2(::cs_main, m_mutex);\n+    const auto existing{FindCached(m_cached_templates, options)};\n+    return StoreCachedTemplate(existing, BuildCachedTemplate(options));\n }\n\n namespace {\n@@ -219,7 +269,9 @@ std::unique_ptr<CBlockTemplate> BlockTemplateManager::WaitAndCreateNewBlock(\n          * We'll also create a new template if the tip changed during this iteration.\n          */\n         if (wait_options.fee_threshold < MAX_MONEY || tip_changed) {\n-            auto new_tmpl{CreateNewTemplate(create_options)};\n+            // Fetch via the shared cache (rebuilt whenever the mempool changed),\n+            // then copy into an owned, mutable template.\n+            auto new_tmpl{std::make_unique<CBlockTemplate>(*GetBlockTemplate(/*max_age=*/std::chrono::seconds{0}, create_options).block_template)};\n\n             // If the tip changed, return the new template regardless of its fees.\n             if (tip_changed) return new_tmpl;\ndiff --git a/src/node/block_template_manager.h b/src/node/block_template_manager.h\nindex 3fbd52d7dd..9de3d19d74 100644\n\nismaelsadeeq in \ud83c\udf10 sadiq-server in bitcoin on \ue0a0 pr-36356 [$!?] via \u25b3 v3.31.5 via \ud83d\udc0d v3.12.9 via \u2744  impure (nix-shell-env) took 1m34s\n\u276f nvim src/node/block_template_manager.cpp\n\nismaelsadeeq in \ud83c\udf10 sadiq-server in bitcoin on \ue0a0 pr-36356 [$!?] via \u25b3 v3.31.5 via \ud83d\udc0d v3.12.9 via \u2744  impure (nix-shell-env) took 13m36s\n\u276f git diff\ndiff --git a/src/init.cpp b/src/init.cpp\nindex 3632a210a4..a7ab529a52 100644\n--- a/src/init.cpp\n+++ b/src/init.cpp\n@@ -1956,7 +1956,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info)\n             return InitError(strprintf(_(\"acceptstalefeeestimates is not supported on %s chain.\"), chainparams.GetChainTypeString()));\n         }\n         MaybeMigrateLegacyFeeEstimates(args);\n-        node.fee_estimator_man = std::make_unique<FeeRateEstimatorManager>(BlockPolicyFeeEstPath(args), read_stale_estimates, MempoolPolicyEstimatorPath(args), *Assert(node.mempool), chainman);\n+        node.fee_estimator_man = std::make_unique<FeeRateEstimatorManager>(BlockPolicyFeeEstPath(args), read_stale_estimates, MempoolPolicyEstimatorPath(args), *Assert(node.mempool), *Assert(node.block_template_manager));\n\n         // Flush estimates to disk periodically\n         FeeRateEstimatorManager* fee_estimator_man = node.fee_estimator_man.get();\ndiff --git a/src/node/block_template_manager.cpp b/src/node/block_template_manager.cpp\nindex 34f864c99a..7266ba3e7c 100644\n--- a/src/node/block_template_manager.cpp\n+++ b/src/node/block_template_manager.cpp\n@@ -50,36 +50,75 @@ std::unique_ptr<CBlockTemplate> BlockTemplateManager::CreateNewTemplate(const Bl\n     }.CreateNewBlock();\n }\n\n-unsigned int BlockTemplateManager::GetCachedTransactionsUpdated() const\n+static constexpr size_t MAX_CACHED_TEMPLATES{3};\n+\n+unsigned int BlockTemplateManager::GetCachedTransactionsUpdated(const BlockCreateOptions& options) const\n {\n-    AssertLockHeld(::cs_main);\n-    return m_cached_template.transactions_updated;\n+    LOCK(m_mutex);\n+    for (const auto& cache : m_cached_templates) {\n+        if (cache.options == options) return cache.transactions_updated;\n+    }\n+    return 0;\n+}\n+\n+static bool BuildsOnTip(const CachedBlockTemplate& cache, const uint256& tip)\n+{\n+    return cache.block_template && cache.block_template->block.hashPrevBlock == tip;\n+}\n+\n+static bool MempoolChanged(unsigned int transactions_updated, const CTxMemPool& mempool)\n+{\n+    return mempool.GetTransactionsUpdated() != transactions_updated;\n }\n\n-CachedBlockTemplate BlockTemplateManager::RefreshCachedTemplate()\n+static bool TooOld(NodeSeconds time_start, std::optional<std::chrono::seconds> max_age)\n+{\n+    return max_age && Now<NodeSeconds>() - time_start > *max_age;\n+}\n+\n+static bool Stale(const CachedBlockTemplate& cache, const CTxMemPool& mempool, std::optional<std::chrono::seconds> max_age)\n+{\n+    return TooOld(cache.time_start, max_age) && MempoolChanged(cache.transactions_updated, mempool);\n+}\n+\n+static std::vector<CachedBlockTemplate>::iterator FindCached(std::vector<CachedBlockTemplate>& cached_templates, const BlockCreateOptions& options)\n+{\n+    return std::find_if(cached_templates.begin(), cached_templates.end(),\n+                        [&](const CachedBlockTemplate& cached_template) { return cached_template.options == options; });\n+}\n+\n+CachedBlockTemplate BlockTemplateManager::BuildCachedTemplate(const BlockCreateOptions& options)\n {\n     AssertLockHeld(::cs_main);\n-    CachedBlockTemplate& cache{m_cached_template};\n-    const uint256 tip{CHECK_NONFATAL(m_chainman.ActiveChain().Tip())->GetBlockHash()};\n-    if (!cache.block_template || cache.block_template->block.hashPrevBlock != tip ||\n-        (m_mempool.GetTransactionsUpdated() != cache.transactions_updated && Now<NodeSeconds>() - cache.time_start > 5s)) {\n-        // Clear the template so future calls make a new block, despite any failures from here on\n-        cache.block_template.reset();\n-\n-        // Sample the mempool counter before building, so that a change made\n-        // while CreateNewTemplate runs is seen as a change on the next call\n-        cache.transactions_updated = m_mempool.GetTransactionsUpdated();\n-        cache.time_start = Now<NodeSeconds>();\n-\n-        // Create new block. Opt-out of cooldown mechanism, because it would add\n-        // a delay to each getblocktemplate call. This differs from typical\n-        // long-lived IPC usage, where the overhead is paid only when creating\n-        // the initial template.\n-        cache.block_template = CreateNewTemplate({});\n-    }\n+    CachedBlockTemplate cache;\n+    cache.options = options;\n+    // Sample the mempool counter before building, so that a change made\n+    // while CreateNewTemplate runs is seen as a change on the next call\n+    cache.transactions_updated = m_mempool.GetTransactionsUpdated();\n+    cache.time_start = Now<NodeSeconds>();\n+    cache.block_template = CreateNewTemplate(options);\n     return cache;\n }\n\n+CachedBlockTemplate& BlockTemplateManager::StoreCachedTemplate(std::vector<CachedBlockTemplate>::iterator existing, CachedBlockTemplate&& rebuilt)\n+{\n+    AssertLockHeld(m_mutex);\n+    if (existing != m_cached_templates.end()) return *existing = std::move(rebuilt);\n+    if (m_cached_templates.size() >= MAX_CACHED_TEMPLATES) m_cached_templates.erase(m_cached_templates.begin());\n+    return m_cached_templates.emplace_back(std::move(rebuilt));\n+}\n+\n+CachedBlockTemplate BlockTemplateManager::GetBlockTemplate(std::optional<std::chrono::seconds> max_age, const BlockCreateOptions& options, bool overwrite)\n+{\n+    LOCK2(::cs_main, m_mutex);\n+    const uint256 tip{CHECK_NONFATAL(m_chainman.ActiveChain().Tip())->GetBlockHash()};\n+    const auto cached{FindCached(m_cached_templates, options)};\n+    const bool reusable{cached != m_cached_templates.end() && BuildsOnTip(*cached, tip) && !Stale(*cached, m_mempool, max_age)};\n+    if (reusable && !overwrite) return *cached;\n+    // Build before touching the cache, so a failed build leaves it unchanged.\n+    return StoreCachedTemplate(cached, BuildCachedTemplate(options));\n+}\n+\n namespace {\n class SubmitBlockStateCatcher final : public CValidationInterface\n {\n@@ -219,7 +258,9 @@ std::unique_ptr<CBlockTemplate> BlockTemplateManager::WaitAndCreateNewBlock(\n          * We'll also create a new template if the tip changed during this iteration.\n          */\n         if (wait_options.fee_threshold < MAX_MONEY || tip_changed) {\n-            auto new_tmpl{CreateNewTemplate(create_options)};\n+            // Fetch via the shared cache (rebuilt whenever the mempool changed),\n+            // then copy into an owned, mutable template.\n+            auto new_tmpl{std::make_unique<CBlockTemplate>(*GetBlockTemplate(/*max_age=*/std::chrono::seconds{0}, create_options).block_template)};\n\n             // If the tip changed, return the new template regardless of its fees.\n             if (tip_changed) return new_tmpl;\ndiff --git a/src/node/block_template_manager.h b/src/node/block_template_manager.h\nindex 3fbd52d7dd..1a6acc218f 100644\n--- a/src/node/block_template_manager.h\n+++ b/src/node/block_template_manager.h\n@@ -7,12 +7,14 @@\n\n #include <kernel/cs_main.h>\n #include <node/mining_types.h>\n+#include <sync.h>\n #include <threadsafety.h>\n #include <util/time.h>\n\n #include <memory>\n #include <optional>\n #include <string>\n+#include <vector>\n\n class CBlock;\n class ChainstateManager;\n@@ -27,8 +29,10 @@ namespace node {\n class KernelNotifications;\n struct CBlockTemplate;\n\n-/** Block template shared by getblocktemplate calls, and the state of its last build attempt. */\n+/** A cached block template, keyed by its build options, and the state of its last build attempt. */\n struct CachedBlockTemplate {\n+    //! Options the template was built with; entries are keyed on these.\n+    BlockCreateOptions options{};\n     //! CTxMemPool::GetTransactionsUpdated() sampled before the last build attempt.\n     unsigned int transactions_updated{0};\n     //! Mockable time of the last build attempt.\n@@ -39,8 +43,8 @@ struct CachedBlockTemplate {\n\n /**\n  * Creates block templates, submits solved blocks, and provides tip-waiting\n- * helpers for mining code. Owns the init-time block creation args and the\n- * template cached for getblocktemplate.\n+ * helpers for mining code. Owns the init-time block creation args and caches\n+ * block templates keyed by their build options.\n  */\n class BlockTemplateManager\n {\n@@ -49,7 +53,17 @@ private:\n     ChainstateManager& m_chainman;\n     KernelNotifications& m_notifications;\n     const BlockCreateOptions m_block_create_args;\n-    CachedBlockTemplate m_cached_template GUARDED_BY(::cs_main);\n+    //! Guards the template cache.\n+    mutable Mutex m_mutex;\n+    //! Templates cached per distinct build options, evicted oldest-first (FIFO).\n+    std::vector<CachedBlockTemplate> m_cached_templates GUARDED_BY(m_mutex);\n+\n+    //! Build a fresh template for @p options. Does not touch the cache.\n+    CachedBlockTemplate BuildCachedTemplate(const BlockCreateOptions& options) EXCLUSIVE_LOCKS_REQUIRED(::cs_main);\n+\n+    //! Store @p rebuilt at @p existing (end() to insert a new entry, evicting the\n+    //! oldest when full). @return a reference to the stored entry.\n+    CachedBlockTemplate& StoreCachedTemplate(std::vector<CachedBlockTemplate>::iterator existing, CachedBlockTemplate&& rebuilt) EXCLUSIVE_LOCKS_REQUIRED(m_mutex);\n\n public:\n     explicit BlockTemplateManager(CTxMemPool& mempool,\n@@ -63,18 +77,23 @@ public:\n     /** Create a fresh block template, applying init-time defaults to any unset options. */\n     std::unique_ptr<CBlockTemplate> CreateNewTemplate(const BlockCreateOptions& options);\n\n-    /** @return CTxMemPool::GetTransactionsUpdated() as sampled before the last\n-     *  getblocktemplate template build attempt, for longpolling. */\n-    unsigned int GetCachedTransactionsUpdated() const EXCLUSIVE_LOCKS_REQUIRED(::cs_main);\n-\n-    /** Rebuild the cached getblocktemplate template with init-time options if\n-     *  none is cached, if it does not build on the active chain tip, or if the\n-     *  mempool changed and the template is more than 5 seconds old.\n-     *  @return the cached template, rebuilt if needed. Its block_template is\n-     *  never null.\n-     *  @throws std::runtime_error if building the template fails. The cache is\n-     *  then left without a template, so the next call tries again. */\n-    CachedBlockTemplate RefreshCachedTemplate() EXCLUSIVE_LOCKS_REQUIRED(::cs_main);\n+    /** @return CTxMemPool::GetTransactionsUpdated() sampled before the last build\n+     *  attempt of the template cached for @p options, or 0 if none is cached. */\n+    unsigned int GetCachedTransactionsUpdated(const BlockCreateOptions& options) const EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);\n+\n+    /** Return a cached block template built with @p options, rebuilding it if none\n+     *  is cached for those options or if the cached one does not build on the\n+     *  active chain tip. A template that still builds on the tip is rebuilt once it\n+     *  is older than @p max_age and the mempool has changed since it was built. A\n+     *  std::nullopt @p max_age disables age-based rebuilds, reusing the template\n+     *  until the tip changes. Set @p overwrite to always rebuild and replace the\n+     *  cached entry, ignoring @p max_age. Templates are cached per distinct\n+     *  @p options (bounded, oldest evicted first); callers passing equal options\n+     *  share an entry.\n+     *  @return the cached template; its block_template is never null.\n+     *  @throws std::runtime_error if building the template fails, leaving the\n+     *  cache unchanged; the next call retries. */\n+    CachedBlockTemplate GetBlockTemplate(std::optional<std::chrono::seconds> max_age, const BlockCreateOptions& options = {}, bool overwrite = false) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);\n\n     /** Submit a block via ProcessNewBlock and capture validation state.\n      *  @return whether the block was accepted as a new valid block. */\n@@ -126,7 +145,7 @@ public:\n         const std::unique_ptr<CBlockTemplate>& block_template,\n         const BlockWaitOptions& wait_options,\n         const BlockCreateOptions& create_options,\n-        bool& interrupt_wait);\n+        bool& interrupt_wait) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex);\n };\n } // namespace node\n\ndiff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp\nindex 6ed03b0a69..054ff32071 100644\n--- a/src/node/interfaces.cpp\n+++ b/src/node/interfaces.cpp\n@@ -993,7 +993,10 @@ public:\n             // Also wait during the final catch-up moments after IBD.\n             if (!block_template_manager().CooldownIfHeadersAhead(*maybe_tip, m_interrupt_mining)) return {};\n         }\n-        auto new_template = block_template_manager().CreateNewTemplate(options);\n+        // Build a fresh template (also caching it for waitNext) and copy it into an\n+        // owned, mutable instance, since submitSolution mutates it.\n+        const auto cached{block_template_manager().GetBlockTemplate(/*max_age=*/std::nullopt, options, /*overwrite=*/true).block_template};\n+        auto new_template{std::make_unique<CBlockTemplate>(*cached)};\n         return std::make_unique<BlockTemplateImpl>(options, std::move(new_template), m_node);\n     }\n\ndiff --git a/src/node/mining_types.h b/src/node/mining_types.h\nindex 6e4eb82aa9..121cf762b6 100644\n--- a/src/node/mining_types.h\n+++ b/src/node/mining_types.h\n@@ -89,6 +89,8 @@ struct BlockCreateOptions {\n      * Should only be disabled for tests / benchmarks.\n      */\n     bool test_block_validity{true};\n+\n+    friend bool operator==(const BlockCreateOptions&, const BlockCreateOptions&) = default;\n };\n\n struct BlockWaitOptions {\ndiff --git a/src/policy/fees/estimator_man.cpp b/src/policy/fees/estimator_man.cpp\nindex 498e0580fd..a3b4ca82dd 100644\n--- a/src/policy/fees/estimator_man.cpp\n+++ b/src/policy/fees/estimator_man.cpp\n@@ -16,9 +16,9 @@ FeeRateEstimatorManager::FeeRateEstimatorManager(const fs::path& block_policy_pa\n                                                  bool read_stale_estimates,\n                                                  const fs::path& mempool_estimator_path,\n                                                  const CTxMemPool& mempool,\n-                                                 ChainstateManager& chainman)\n+                                                 node::BlockTemplateManager& block_template_manager)\n     : m_block_policy_estimator(std::make_unique<CBlockPolicyEstimator>(block_policy_path, read_stale_estimates)),\n-      m_mempool_estimator(std::make_unique<MemPoolFeeRateEstimator>(mempool_estimator_path, mempool, chainman))\n+      m_mempool_estimator(std::make_unique<MemPoolFeeRateEstimator>(mempool_estimator_path, mempool, block_template_manager))\n {\n }\n\ndiff --git a/src/policy/fees/estimator_man.h b/src/policy/fees/estimator_man.h\nindex 6950414ac1..94169299ea 100644\n--- a/src/policy/fees/estimator_man.h\n+++ b/src/policy/fees/estimator_man.h\n@@ -17,10 +17,13 @@\n #include <memory>\n\n class CFeeRate;\n-class ChainstateManager;\n class CTxMemPool;\n class MemPoolFeeRateEstimator;\n\n+namespace node {\n+class BlockTemplateManager;\n+} // namespace node\n+\n // How often to flush data to disk\n inline constexpr std::chrono::hours FEE_FLUSH_INTERVAL{1};\n\n@@ -35,13 +38,13 @@ public:\n      * @param[in] read_stale_estimates Whether to load stale estimates from disk.\n      * @param[in] mempool_estimator_path Path to the mempool policy estimator data file.\n      * @param[in] mempool              The mempool to use for the mempool fee rate estimator.\n-     * @param[in] chainman             The chainstate manager.\n+     * @param[in] block_template_manager The block template manager.\n      */\n     FeeRateEstimatorManager(const fs::path& block_policy_path,\n                             bool read_stale_estimates,\n                             const fs::path& mempool_estimator_path,\n                             const CTxMemPool& mempool,\n-                            ChainstateManager& chainman);\n+                            node::BlockTemplateManager& block_template_manager);\n\n     virtual ~FeeRateEstimatorManager();\n\ndiff --git a/src/policy/fees/mempool_estimator.cpp b/src/policy/fees/mempool_estimator.cpp\nindex 5f384925fd..560670f3aa 100644\n--- a/src/policy/fees/mempool_estimator.cpp\n+++ b/src/policy/fees/mempool_estimator.cpp\n@@ -4,7 +4,9 @@\n\n #include <policy/fees/mempool_estimator.h>\n\n+#include <interfaces/types.h>\n #include <logging.h>\n+#include <node/block_template_manager.h>\n #include <node/miner.h>\n #include <policy/feerate.h>\n #include <policy/policy.h>\n@@ -79,18 +81,6 @@ void AddMinedBlockStats(std::vector<MinedBlockStats>& mined_blocks, MinedBlockSt\n     mined_blocks.push_back(stats);\n }\n\n-struct ActiveTip {\n-    int height;\n-    uint256 hash;\n-};\n-\n-std::optional<ActiveTip> GetActiveTip(const ChainstateManager& chainman)\n-{\n-    LOCK(::cs_main);\n-    const CBlockIndex* tip{chainman.ActiveTip()};\n-    if (!tip) return std::nullopt;\n-    return ActiveTip{tip->nHeight, tip->GetBlockHash()};\n-}\n } // namespace\n\n MemPoolFeeRateEstimator::Percentiles MemPoolFeeRateEstimator::CalculateMaxWeightPercentiles(std::span<const FeePerVSize> chunk_feerates)\n@@ -114,32 +104,6 @@ MemPoolFeeRateEstimator::Percentiles MemPoolFeeRateEstimator::CalculateMaxWeight\n     return percentiles;\n }\n\n-bool MemPoolFeeRateEstimatorCache::IsStale() const\n-{\n-    return !m_fee_rate_estimation || (m_last_updated + CACHE_LIFE) < NodeClock::now();\n-}\n-\n-std::optional<MemPoolFeeRateEstimatorCache::FeeRateEstimate>\n-MemPoolFeeRateEstimatorCache::GetCachedEstimate(const uint256& tip_hash) const\n-{\n-    if (IsStale() || tip_hash != m_tip_hash) return std::nullopt;\n-    return m_fee_rate_estimation;\n-}\n-\n-void MemPoolFeeRateEstimatorCache::Update(FeePerVSize conservative, FeePerVSize economical, const uint256& tip_hash)\n-{\n-    m_fee_rate_estimation = {conservative, economical};\n-    m_tip_hash = tip_hash;\n-    m_last_updated = NodeClock::now();\n-}\n-\n-void MemPoolFeeRateEstimatorCache::Clear()\n-{\n-    m_fee_rate_estimation.reset();\n-    m_tip_hash.SetNull();\n-    m_last_updated = {};\n-}\n-\n //! Build the error result for a failed mempool fee rate estimation.\n static util::Unexpected<FeeRateEstimationError> EstimationError(std::string error)\n {\n@@ -162,9 +126,9 @@ static std::optional<std::string_view> MempoolHealthError(MemPoolFeeRateEstimato\n\n MemPoolFeeRateEstimator::MemPoolFeeRateEstimator(fs::path mempool_estimator_file_path,\n                                                  const CTxMemPool& mempool,\n-                                                 ChainstateManager& chainman)\n+                                                 node::BlockTemplateManager& block_template_manager)\n     : m_mempool(mempool),\n-      m_chainman(chainman),\n+      m_block_template_manager(block_template_manager),\n       m_mempool_estimator_file_path(std::move(mempool_estimator_file_path))\n {\n     ReadFromDisk();\n@@ -218,7 +182,7 @@ bool MemPoolFeeRateEstimator::Read(AutoFile& file)\n         }\n         if (!blocks.empty()) {\n             const auto& last_block{blocks.back()};\n-            const std::optional<ActiveTip> active_tip{GetActiveTip(m_chainman)};\n+            const std::optional<interfaces::BlockRef> active_tip{m_block_template_manager.GetTip()};\n             if (!active_tip) {\n                 LogWarning(\"%s: Mined-block stats read end at height %s block %s, but there is no active chain tip; ignoring file\",\n                            FeeRateEstimatorTypeToString(FeeRateEstimatorType::MEMPOOL_POLICY),\n@@ -236,7 +200,6 @@ bool MemPoolFeeRateEstimator::Read(AutoFile& file)\n         LOCK(cs);\n         m_prev_mined_blocks = std::move(blocks);\n         m_mined_blocks_tip_hash = tip_hash;\n-        m_cache.Clear();\n     } catch (const std::exception&) {\n         LogWarning(\"%s: Unable to read mined-block stats from stream (non-fatal)\",\n                    FeeRateEstimatorTypeToString(FeeRateEstimatorType::MEMPOOL_POLICY));\n@@ -315,7 +278,6 @@ void MemPoolFeeRateEstimator::MempoolTxsRemovedForBlock(const std::shared_ptr<co\n         });\n     AddMinedBlockStats(m_prev_mined_blocks, {block_height, removed_weight, block_weight});\n     m_mined_blocks_tip_hash = block->GetHash();\n-    m_cache.Clear();\n }\n\n // Require at least one block worth of activity across the window before using\n@@ -368,38 +330,18 @@ util::Expected<FeeRateEstimation, FeeRateEstimationError> MemPoolFeeRateEstimato\n     if (auto error{MempoolHealthError(GetMempoolHealth())}) {\n         return EstimationError(strprintf(\"%s: %s\", FeeRateEstimatorTypeToString(estimator_type), *error));\n     }\n-    // The estimator lock is not held while building a block template, so\n-    // in a rare edge case concurrent callers may duplicate work.\n-    //\n-    // Cached fee rate estimates are tagged with the chain tip they were computed on\n-    // and only served from the cache while that tip is current.\n-    //\n-    // The fee rate estimate returned directly below may still reflect a tip that went\n-    // stale during the call; that is an accepted tradeoff of not holding\n-    // locks across block assembly.\n-    {\n-        const uint256 tip_hash{WITH_LOCK(::cs_main, return Assume(m_chainman.CurrentChainstate().m_chain.Tip())->GetBlockHash())};\n-        LOCK(cs);\n-        const auto cached_estimate = m_cache.GetCachedEstimate(tip_hash);\n-        if (cached_estimate) {\n-            const auto cached_feerate{\n-                conservative ? cached_estimate->m_conservative : cached_estimate->m_economical};\n-            return FeeRateEstimation{estimator_type, cached_feerate, MEMPOOL_FEE_ESTIMATOR_MAX_TARGET};\n-        }\n-    }\n-    node::BlockCreateOptions options;\n-    options.test_block_validity = false;\n-    const auto blocktemplate = WITH_LOCK(::cs_main, return (node::BlockAssembler{m_chainman.CurrentChainstate(), &m_mempool, options}).CreateNewBlock());\n+    const auto blocktemplate{m_block_template_manager.GetBlockTemplate(/*max_age=*/CACHE_LIFE, {.test_block_validity = false}).block_template};\n     if (!blocktemplate) return EstimationError(strprintf(\"%s: Failed to create block template for fee rate estimation\", FeeRateEstimatorTypeToString(estimator_type)));\n-    // Sort again because the rounding up when converting from weight to vsize may cause slight misorder.\n-    std::sort(blocktemplate->m_package_feerates.begin(), blocktemplate->m_package_feerates.end(), [](const auto& a, const auto& b) { return ByRatio{a} > ByRatio{b}; });\n-    const auto percentiles = CalculateMaxWeightPercentiles(blocktemplate->m_package_feerates);\n+    // Sort a copy because the rounding up when converting from weight to vsize may\n+    // cause slight misorder, and the cached template is shared and immutable.\n+    std::vector<FeePerVSize> package_feerates{blocktemplate->m_package_feerates};\n+    std::sort(package_feerates.begin(), package_feerates.end(), [](const auto& a, const auto& b) { return ByRatio{a} > ByRatio{b}; });\n+    const auto percentiles = CalculateMaxWeightPercentiles(package_feerates);\n     // Fall back to a relayable floor (the higher of the min relay fee and the current\n     // mempool min fee) for any percentile the mempool was too sparse to fill.\n     const FeePerVSize floor{std::max(m_mempool.m_opts.min_relay_feerate, m_mempool.GetMinFee()).GetFeePerVSize()};\n     const FeePerVSize p50{percentiles.p50.IsEmpty() ? floor : percentiles.p50};\n     const FeePerVSize p75{percentiles.p75.IsEmpty() ? floor : percentiles.p75};\n-    WITH_LOCK(cs, m_cache.Update(p50, p75, blocktemplate->block.hashPrevBlock));\n     LogDebug(BCLog::ESTIMATEFEE, \"%s: conservative/economical fee rate: %s/%s %s/kvB\",\n              FeeRateEstimatorTypeToString(estimator_type), CFeeRate(p50).GetFeePerK(),\n              CFeeRate(p75).GetFeePerK(), CURRENCY_ATOM);\ndiff --git a/src/policy/fees/mempool_estimator.h b/src/policy/fees/mempool_estimator.h\nindex 7420227ac3..ccab5a958d 100644\n--- a/src/policy/fees/mempool_estimator.h\n+++ b/src/policy/fees/mempool_estimator.h\n@@ -23,9 +23,12 @@\n\n class CBlock;\n class AutoFile;\n-class ChainstateManager;\n class CTxMemPool;\n\n+namespace node {\n+class BlockTemplateManager;\n+} // namespace node\n+\n struct RemovedMempoolTransactionInfo;\n\n // Fee rate estimate for confirmation target above this is not reliable,\n@@ -47,36 +50,6 @@ struct MinedBlockStats {\n     uint64_t m_block_weight{0};\n };\n\n-/**\n- * MemPoolFeeRateEstimatorCache holds a cache of recent fee rate estimates.\n- * A cached fee rate is only provided while it is not older than CACHE_LIFE\n- * and the chain tip has not changed.\n- */\n-class MemPoolFeeRateEstimatorCache\n-{\n-public:\n-    MemPoolFeeRateEstimatorCache() = default;\n-    MemPoolFeeRateEstimatorCache(const MemPoolFeeRateEstimatorCache&) = delete;\n-    MemPoolFeeRateEstimatorCache& operator=(const MemPoolFeeRateEstimatorCache&) = delete;\n-    /** Returns true if the cache is empty or older than CACHE_LIFE. */\n-    bool IsStale() const;\n-    struct FeeRateEstimate {\n-        FeePerVSize m_conservative;\n-        FeePerVSize m_economical;\n-    };\n-    /** Returns cached estimates if not stale and computed on tip_hash, nullopt otherwise. */\n-    std::optional<FeeRateEstimate> GetCachedEstimate(const uint256& tip_hash) const;\n-    /** Update the cache with new estimates computed on tip_hash. */\n-    void Update(FeePerVSize conservative, FeePerVSize economical, const uint256& tip_hash);\n-    /** Clear cached fee rate estimates. */\n-    void Clear();\n-\n-private:\n-    std::optional<FeeRateEstimate> m_fee_rate_estimation;\n-    uint256 m_tip_hash;\n-    NodeClock::time_point m_last_updated{};\n-};\n-\n /**\n  * Estimate the fee rate required for a transaction to be included in the next block.\n  *\n@@ -95,7 +68,7 @@ public:\n\n     MemPoolFeeRateEstimator(fs::path mempool_estimator_file_path,\n                             const CTxMemPool& mempool,\n-                            ChainstateManager& chainman);\n+                            node::BlockTemplateManager& block_template_manager);\n     ~MemPoolFeeRateEstimator() = default;\n     /**\n      * Calculate the 50th and 75th percentile fee rates from block template chunks,\n@@ -148,9 +121,8 @@ private:\n     uint256 m_mined_blocks_tip_hash GUARDED_BY(cs);\n\n     const CTxMemPool& m_mempool;\n-    ChainstateManager& m_chainman;\n+    node::BlockTemplateManager& m_block_template_manager;\n     mutable Mutex cs;\n-    mutable MemPoolFeeRateEstimatorCache m_cache GUARDED_BY(cs);\n     const fs::path m_mempool_estimator_file_path;\n };\n\ndiff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp\nindex 8f063eac2b..05d6e159dd 100644\n--- a/src/rpc/mining.cpp\n+++ b/src/rpc/mining.cpp\n@@ -843,7 +843,7 @@ static RPCMethod getblocktemplate()\n         {\n             // NOTE: Spec does not specify behaviour for non-string longpollid, but this makes testing easier\n             hashWatchedChain = tip;\n-            nTransactionsUpdatedLastLP = block_template_manager.GetCachedTransactionsUpdated();\n+            nTransactionsUpdatedLastLP = block_template_manager.GetCachedTransactionsUpdated({});\n         }\n\n         // Release lock while waiting\n@@ -886,7 +886,7 @@ static RPCMethod getblocktemplate()\n     }\n\n     // Update block\n-    const node::CachedBlockTemplate cached{block_template_manager.RefreshCachedTemplate()};\n+    const node::CachedBlockTemplate cached{block_template_manager.GetBlockTemplate(/*max_age=*/std::chrono::seconds{5})};\n     const std::shared_ptr<const node::CBlockTemplate> block_template{CHECK_NONFATAL(cached.block_template)};\n     const CBlockIndex* const pindexPrev{CHECK_NONFATAL(chainman.m_blockman.LookupBlockIndex(block_template->block.hashPrevBlock))};\n     CBlockHeader block_header{block_template->block};\ndiff --git a/src/test/fuzz/policy_estimator_io.cpp b/src/test/fuzz/policy_estimator_io.cpp\nindex 2d0edaa2fd..7e571c70a9 100644\n--- a/src/test/fuzz/policy_estimator_io.cpp\n+++ b/src/test/fuzz/policy_estimator_io.cpp\n@@ -41,7 +41,7 @@ FUZZ_TARGET(policy_estimator_io, .init = initialize_policy_estimator_io)\n     static MemPoolFeeRateEstimator mempool_feerate_estimator{\n         MempoolPolicyEstimatorPath(*g_setup->m_node.args),\n         *g_setup->m_node.mempool,\n-        *g_setup->m_node.chainman};\n+        *g_setup->m_node.block_template_manager};\n     {\n         AutoFile fuzzed_auto_file_mempool_policy{fuzzed_file_provider.open()};\n         if (mempool_feerate_estimator.Read(fuzzed_auto_file_mempool_policy)) {\ndiff --git a/src/test/mempool_fee_estimator_tests.cpp b/src/test/mempool_fee_estimator_tests.cpp\nindex c75c7c7237..9a3b1f98c3 100644\n--- a/src/test/mempool_fee_estimator_tests.cpp\n+++ b/src/test/mempool_fee_estimator_tests.cpp\n@@ -103,34 +103,9 @@ BOOST_AUTO_TEST_CASE(calculate_max_weight_percentiles)\n     BOOST_CHECK(ByRatio{percentiles.p50} > ByRatio{percentiles.p75});\n }\n\n-BOOST_AUTO_TEST_CASE(mempool_fee_rate_estimator_cache)\n-{\n-    FakeNodeClock clock{};\n-    MemPoolFeeRateEstimatorCache cache;\n-    const uint256 tip_hash{uint256::ONE};\n-    const uint256 next_tip_hash{uint256{2}};\n-    const FeePerVSize conservative{2, 1};\n-    const FeePerVSize economical{1, 1};\n-\n-    BOOST_CHECK(cache.IsStale());\n-    BOOST_CHECK(!cache.GetCachedEstimate(tip_hash));\n-\n-    cache.Update(conservative, economical, tip_hash);\n-    BOOST_CHECK(!cache.IsStale());\n-    const auto cached{cache.GetCachedEstimate(tip_hash)};\n-    BOOST_REQUIRE(cached);\n-    BOOST_CHECK(cached->m_conservative == conservative);\n-    BOOST_CHECK(cached->m_economical == economical);\n-    BOOST_CHECK(!cache.GetCachedEstimate(next_tip_hash));\n-\n-    clock += CACHE_LIFE + std::chrono::seconds{1};\n-    BOOST_CHECK(cache.IsStale());\n-    BOOST_CHECK(!cache.GetCachedEstimate(tip_hash));\n-}\n-\n BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)\n {\n-    auto mempool_estimator = MemPoolFeeRateEstimator(MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *m_node.chainman);\n+    auto mempool_estimator = MemPoolFeeRateEstimator(MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *Assert(m_node.block_template_manager));\n     BOOST_CHECK_EQUAL(mempool_estimator.MaximumTarget(), MEMPOOL_FEE_ESTIMATOR_MAX_TARGET);\n     // Before the mempool has finished loading, no estimate is available.\n     {\n@@ -153,7 +128,7 @@ BOOST_AUTO_TEST_CASE(MempoolFeeRateEstimator)\n     }\n     {\n         MemPoolFeeRateEstimator custom_mempool_estimator{\n-            MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *m_node.chainman};\n+            MempoolPolicyEstimatorPath(*m_node.args), *m_node.mempool, *Assert(m_node.block_template_manager)};\n         unsigned int custom_height{100};\n         for (size_t block_count{1}; block_count < MEMPOOL_HEALTH_WINDOW_BLOCKS; ++block_count) {\n             AddRemovedBlock(custom_mempool_estimator,\ndiff --git a/src/test/miner_tests.cpp b/src/test/miner_tests.cpp\nindex ad9e8dc3a0..b673526f63 100644\n--- a/src/test/miner_tests.cpp\n+++ b/src/test/miner_tests.cpp\n@@ -990,11 +990,12 @@ BOOST_FIXTURE_TEST_CASE(block_template_manager_cached_template, RegTestingSetup)\n     auto& mempool{*Assert(m_node.mempool)};\n     FakeNodeClock clock{};\n     const auto active_tip{[&] { return WITH_LOCK(::cs_main, return m_node.chainman->ActiveChain().Tip()->GetBlockHash()); }};\n-    const auto refresh{[&] { return WITH_LOCK(::cs_main, return block_template_manager.RefreshCachedTemplate()); }};\n+    constexpr auto max_age{5s};\n+    const auto get{[&] { return block_template_manager.GetBlockTemplate(max_age); }};\n\n-    // The first refresh builds a template on the tip.\n+    // The first call builds a template on the tip.\n     const uint256 tip{active_tip()};\n-    const auto first{refresh()};\n+    const auto first{get()};\n     BOOST_REQUIRE(first.block_template);\n     BOOST_CHECK_EQUAL(first.block_template->block.hashPrevBlock, tip);\n     BOOST_CHECK_EQUAL(first.transactions_updated, mempool.GetTransactionsUpdated());\n@@ -1002,45 +1003,45 @@ BOOST_FIXTURE_TEST_CASE(block_template_manager_cached_template, RegTestingSetup)\n\n     // Without a mempool change, the template is reused however old it is.\n     clock += 1h;\n-    BOOST_CHECK(refresh().block_template == first.block_template);\n+    BOOST_CHECK(get().block_template == first.block_template);\n\n-    // A mempool change rebuilds a template that is more than 5 seconds old.\n+    // A mempool change rebuilds a template that is more than max_age old.\n     mempool.AddTransactionsUpdated(1);\n-    const auto second{refresh()};\n+    const auto second{get()};\n     BOOST_CHECK(second.block_template != first.block_template);\n     BOOST_CHECK_EQUAL(second.transactions_updated, mempool.GetTransactionsUpdated());\n     BOOST_CHECK_EQUAL(second.time_start, Now<NodeSeconds>());\n-    BOOST_CHECK_EQUAL(WITH_LOCK(::cs_main, return block_template_manager.GetCachedTransactionsUpdated()), second.transactions_updated);\n+    BOOST_CHECK_EQUAL(block_template_manager.GetCachedTransactionsUpdated({}), second.transactions_updated);\n\n-    // A mempool change does not rebuild a template that is up to 5 seconds old.\n+    // A mempool change does not rebuild a template that is up to max_age old.\n     mempool.AddTransactionsUpdated(1);\n-    clock += 5s;\n-    BOOST_CHECK(refresh().block_template == second.block_template);\n+    clock += max_age;\n+    BOOST_CHECK(get().block_template == second.block_template);\n     clock += 1s;\n-    const auto third{refresh()};\n+    const auto third{get()};\n     BOOST_CHECK(third.block_template != second.block_template);\n\n     // A new tip rebuilds the template.\n     MineBlock(m_node, {});\n     const uint256 new_tip{active_tip()};\n     BOOST_REQUIRE(new_tip != tip);\n-    const auto fourth{refresh()};\n+    const auto fourth{get()};\n     BOOST_REQUIRE(fourth.block_template);\n     BOOST_CHECK(fourth.block_template != third.block_template);\n     BOOST_CHECK_EQUAL(fourth.block_template->block.hashPrevBlock, new_tip);\n\n-    // A failed rebuild leaves no template, so the next refresh tries again\n-    // without waiting for the 5 seconds to pass. An orphan in the mempool\n-    // makes TestBlockValidity() reject the block.\n+    // A failed rebuild leaves no template, so the next call tries again without\n+    // waiting for max_age. An orphan in the mempool makes TestBlockValidity()\n+    // reject the block.\n     CMutableTransaction orphan;\n     orphan.vin.emplace_back(COutPoint{Txid::FromUint256(uint256::ONE), 0});\n     orphan.vout.emplace_back(COIN, CScript() << OP_TRUE);\n     TryAddToMempool(mempool, TestMemPoolEntryHelper{}.Fee(CENT).FromTx(orphan));\n     BOOST_REQUIRE(mempool.exists(orphan.GetHash()));\n-    clock += 6s;\n-    BOOST_CHECK_EXCEPTION(refresh(), std::runtime_error, HasReason(\"bad-txns-inputs-missingorspent\"));\n+    clock += max_age + 1s;\n+    BOOST_CHECK_EXCEPTION(get(), std::runtime_error, HasReason(\"bad-txns-inputs-missingorspent\"));\n     WITH_LOCK(mempool.cs, mempool.removeRecursive(CTransaction{orphan}, MemPoolRemovalReason::REPLACED));\n-    const auto fifth{refresh()};\n+    const auto fifth{get()};\n     BOOST_REQUIRE(fifth.block_template);\n     BOOST_CHECK(fifth.block_template != fourth.block_template);\n     BOOST_CHECK_EQUAL(fifth.block_template->block.hashPrevBlock, new_tip);\ndiff --git a/src/wallet/test/fuzz/fees.cpp b/src/wallet/test/fuzz/fees.cpp\nindex 5b2a225deb..88b0f999a0 100644\n--- a/src/wallet/test/fuzz/fees.cpp\n+++ b/src/wallet/test/fuzz/fees.cpp\n@@ -46,8 +46,8 @@ class FuzzedFeeEstimatorMan : public FeeRateEstimatorManager\n     FuzzedDataProvider& fuzzed_data_provider;\n\n public:\n-    FuzzedFeeEstimatorMan(FuzzedDataProvider& provider, const CTxMemPool& mempool, ChainstateManager& chainman)\n-        : FeeRateEstimatorManager(fs::path{}, false, fs::path{}, mempool, chainman), fuzzed_data_provider(provider) {}\n+    FuzzedFeeEstimatorMan(FuzzedDataProvider& provider, const CTxMemPool& mempool, node::BlockTemplateManager& block_template_manager)\n+        : FeeRateEstimatorManager(fs::path{}, false, fs::path{}, mempool, block_template_manager), fuzzed_data_provider(provider) {}\n\n     util::Expected<FeeRateEstimation, FeeRateEstimationError> GetFeeRateEstimate(int confTarget, bool conservative) const override\n     {\n@@ -89,7 +89,7 @@ FUZZ_TARGET(wallet_fees, .init = initialize_setup)\n     node.block_template_manager.reset();\n     node.mempool = std::make_unique<CTxMemPool>(mempool_opts, error);\n     g_setup->CreateBlockTemplateManager();\n-    std::unique_ptr<FeeRateEstimatorManager> fee_estimator_man = std::make_unique<FuzzedFeeEstimatorMan>(fuzzed_data_provider, *node.mempool, *node.chainman);\n+    std::unique_ptr<FeeRateEstimatorManager> fee_estimator_man = std::make_unique<FuzzedFeeEstimatorMan>(fuzzed_data_provider, *node.mempool, *node.block_template_manager);\n     g_setup->SetFeeEstimatorMan(std::move(fee_estimator_man));\n     auto target_feerate{CFeeRate{ConsumeMoney(fuzzed_data_provider, /*max=*/1'000'000)}};\n     if (target_feerate > node.mempool->m_opts.incremental_relay_feerate &&\n```\n\nWe may need to extend this to clear all cache on tip change though to free some memory, this diff currently has them till shutdown.",
   "id": "c:6058035761",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6058035761",
   "hash": "b7fa76d36d85a59e",
   "edited": null
  },
  {
   "t": "2026-10-08T17:40:01Z",
   "kind": "comment",
   "who": "w0xlt",
   "assoc": "CONTRIBUTOR",
   "text": "@ismaelsadeeq Thanks for the Concept ACK and the POC. I\u2019d prefer to keep this PR focused on moving the existing getblocktemplate cache while preserving its behavior.\n\nThe POC introduces multiple entries keyed by build options, eviction, another mutex, configurable freshness and forced rebuilds, plus changes to IPC and fee estimation. Those bring separate correctness and performance questions, so I think the broader cache would be a better fit for a follow-up.\n\nThis refactor gives that work a place to build on.",
   "id": "c:6065621630",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6065621630",
   "hash": "5bdb5d8a1b7aeb77",
   "edited": null
  },
  {
   "t": "2026-10-09T04:38:00Z",
   "kind": "comment",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "text": "[quoted text omitted]\n\nFair, I think it would be nice to have the cache and make this generic. The reason the broader cache wasn't pursued further in #33421 is thin motivation (i.e., no users as of then).\n\nBut with this, we cache anyway, so making it generic is a nice win and usable by not just `getblocktemplate` rpc.  However, I agree this is a start; we can extend it later. It is just a bit more work to have a single template cache design and then extend it to multiple after merging.\n\nI get that this is simpler to reason about and review, but it would be more ideal if we decide whether we want to do the caching for all or not.\n\nCC @sedited and @stickies-v  since u both weigh in here #33421",
   "id": "c:6074366875",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6074366875",
   "hash": "46bb9e05c6bbce63",
   "edited": null
  },
  {
   "t": "2026-10-09T04:39:05Z",
   "kind": "review_comment",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "path": "src/node/block_template_manager.h",
   "commit": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
   "in_reply_to": null,
   "text": "The cache is kept forever till shutdown. Should we clear it on a block connection?",
   "id": "rc:4226656184",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#discussion_r4226656184",
   "hash": "0cdccf41c9a601f1",
   "edited": "2026-10-09T04:41:10Z"
  },
  {
   "t": "2026-10-09T04:40:47Z",
   "kind": "review_comment",
   "who": "ismaelsadeeq",
   "assoc": "MEMBER",
   "path": "src/node/block_template_manager.h",
   "commit": "ccb8e840397cda6ace76d53b87b463abe6ec5384",
   "in_reply_to": null,
   "text": "Should we pass the `max_age` so that it is not tied to GBT?  Could we also rename it to `GetNewOrCacheBlockTemplate` or something similar?",
   "id": "rc:4226664736",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#discussion_r4226664736",
   "hash": "d8825f656c81c182",
   "edited": "2026-10-09T04:41:10Z"
  },
  {
   "t": "2026-10-28T09:55:17Z",
   "kind": "comment",
   "who": "Sjors",
   "assoc": "MEMBER",
   "text": "I also prefer to keep this simple and focussed.\n\nI'm not sure if the current two commit split is the easiest to review, because instead of moving code we now have one commit that introduces new code and a second commit that deletes old code.",
   "id": "c:6465912025",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6465912025",
   "hash": "0dafb93992cd1f14",
   "edited": null
  },
  {
   "t": "2026-10-28T21:52:29Z",
   "kind": "force_push",
   "who": "w0xlt",
   "commit": "c12310c589c0277ed6d69d26c31a20bd0b51e6ac",
   "id": "push:c12310c589c0277ed6d69d26c31a20bd0b51e6ac"
  },
  {
   "t": "2026-10-28T22:43:53Z",
   "kind": "force_push",
   "who": "w0xlt",
   "commit": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
   "id": "push:ed3dd1469f9a0cb43cb1dea92e32a780bceed63f"
  },
  {
   "t": "2026-10-28T22:54:19Z",
   "kind": "review_comment",
   "who": "w0xlt",
   "assoc": "CONTRIBUTOR",
   "path": "src/node/block_template_manager.h",
   "commit": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
   "in_reply_to": 4226656184,
   "text": "Thanks! Master already keeps the last template until the node stops (in the `getblocktemplate` statics); this PR only moves it.\nIt's a single template, and the next call after a new block replaces it.\nClearing it on block connection would need a new notification hook in the manager, so I'd leave that for a follow-up.",
   "id": "rc:4406943630",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#discussion_r4406943630",
   "hash": "1a28c9025cb17a9a",
   "edited": null
  },
  {
   "t": "2026-10-28T22:57:35Z",
   "kind": "review_comment",
   "who": "w0xlt",
   "assoc": "CONTRIBUTOR",
   "path": "src/node/block_template_manager.h",
   "commit": "ccb8e840397cda6ace76d53b87b463abe6ec5384",
   "in_reply_to": 4226664736,
   "text": "Thanks! Renamed it to `GetNewOrCachedBlockTemplate()`.\n\nI'd keep `max_age` out of this PR. The cache is still a single entry built with the default options, so another caller with a different `max_age` would just keep rebuilding the GBT template. It fits better in a follow-up that makes the cache generic, like your POC. I did give the 5 seconds a name, though: `GBT_TEMPLATE_REBUILD_INTERVAL`.",
   "id": "rc:4406966061",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#discussion_r4406966061",
   "hash": "fc5004081d716db0",
   "edited": null
  },
  {
   "t": "2026-10-28T23:02:53Z",
   "kind": "comment",
   "who": "w0xlt",
   "assoc": "CONTRIBUTOR",
   "text": "Thanks, good point. I've split it into three commits:\n\n1. The first refactors the cache in place in `rpc/mining.cpp`, so the diff compares each change directly against master's code.\n2. The second moves it into `BlockTemplateManager`. It should be easy to check with `git show --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change`.\n3. The third adds the unit test.",
   "id": "c:6482952144",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6482952144",
   "hash": "b41068548684e6a4",
   "edited": null
  },
  {
   "t": "2026-10-28T23:10:09Z",
   "kind": "comment",
   "who": "w0xlt",
   "assoc": "CONTRIBUTOR",
   "text": "PR description updated.",
   "id": "c:6483085981",
   "url": "https://github.com/bitcoin/bitcoin/pull/36356#issuecomment-6483085981",
   "hash": "504bb7c9cda8befe",
   "edited": null
  }
 ],
 "labels_log": [
  {
   "t": "2026-09-27T08:25:02Z",
   "action": "labeled",
   "label": "Mining",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-14T10:39:54Z",
   "action": "labeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-28T22:45:24Z",
   "action": "labeled",
   "label": "CI failed",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-28T23:19:26Z",
   "action": "unlabeled",
   "label": "Needs rebase",
   "who": "DrahtBot"
  },
  {
   "t": "2026-10-28T23:34:11Z",
   "action": "unlabeled",
   "label": "CI failed",
   "who": "DrahtBot"
  }
 ],
 "state_log": [],
 "text_chars": 51287,
 "text_tokens_estimate": 12821,
 "changed_paths": [
  "src/node/block_template_manager.cpp",
  "src/node/block_template_manager.h",
  "src/rpc/mining.cpp",
  "src/test/miner_tests.cpp"
 ],
 "files": [
  {
   "path": "src/node/block_template_manager.cpp",
   "add": 33,
   "del": 0
  },
  {
   "path": "src/node/block_template_manager.h",
   "add": 32,
   "del": 1
  },
  {
   "path": "src/rpc/mining.cpp",
   "add": 6,
   "del": 31
  },
  {
   "path": "src/test/miner_tests.cpp",
   "add": 66,
   "del": 0
  }
 ],
 "test_lines": 66,
 "git": {
  "head": "ed3dd1469f9a0cb43cb1dea92e32a780bceed63f",
  "head_matches_backup": true,
  "base": "0f27bed5b2924420d65caeca439e6b036d3c6cb2",
  "patch_id": "5bb0d71be71a79aa",
  "commits": [
   {
    "sha": "cde2bb8773",
    "subject": "rpc: gather the getblocktemplate cache state into a struct",
    "files": 1,
    "add": 56,
    "del": 31
   },
   {
    "sha": "9414d05949",
    "subject": "refactor: move getblocktemplate cache into BlockTemplateManager",
    "files": 3,
    "add": 67,
    "del": 53
   },
   {
    "sha": "ed3dd1469f",
    "subject": "test: cover the BlockTemplateManager getblocktemplate cache",
    "files": 1,
    "add": 66,
    "del": 0
   }
  ],
  "patch_truncated": false
 },
 "patch_id": "5bb0d71be71a79aa",
 "input_hash": "5cfcc0f3dee7db84",
 "extracted_at": "2026-11-02T01:09:49+00:00"
}