Repository: bitcoin/bitcoin (language: cpp)

Issue:
Duplicate coinbase transaction space reservation in CreateNewBlock
In `BlockAssembler::CreateNewBlock`, we initiate `nBlockWeight` to 4000 WU, reserving space for
coinbase transaction.

Latter on, while iterating on transactions for block inclusion in `addPackagesTxs`, we verify that candidate package doesn't overflow the max block weight. This check is done in `TestPackage` against `nBlockMaxWeight`. However, default value is DEFAULT_BLOCK_MAX_WEIGHT which is equal to MAX_BLOCK_WEIGHT - 4000 (L20 in `src/policy/policy.cpp`). So block weight assembled is starting at 4000 WU and can't overflow 3996000 WU.

Do we have duplicated coinbase transaction space reservation ? I can see this a safety margin to avoid creating invalid blocks if you create inflated coinbase transaction, at the price of few weight units leftover.

If so, maybe we should leave it as it is but document this behavior, I was surprised hitting this while scratching unrelated tests.

See [comment](https://github.com/bitcoin/bitcoin/pull/11100#discussion_r135124178) in #11100


Discussion / hints:
This is actually pretty interesting: I quickly looked through the weight of the last 50-60 blocks and F2Pool is the ONLY pool generating blocks with total weight higher greater than 3,996,000. I started writing a unit test for this, it seems like that double coinbase reservation may actually put miners who rely on bitcoin core template generation at a disadvantage.

And actually it looks like ALL of F2Pool's recent blocks are actually > 3,996,000 in weight. They may be the only pool using their own software to create block templates?
> And actually it looks like ALL of F2Pool's recent blocks are actually > 3,996,000 in weight. They may be the only pool using their own software to create block templates?

I'm fairly certain they use Bitcoin Core. You can control the max block weight with `-blockmaxweight` (default is 3996000 WU). Also, F2Pool is known for running with custom patches on top of Bitcoin Core to maximize fees: e.g. they reduced the number of sigops reserved for the coinbase. This [bit them twice](https://twitter.com/0xB10C/status/1643871608401014785) a few months ago due to not keeping that patch up-to-date.

End of last year, Poolin also mined blocks with more than the default max block weight. See for example [762816](https://miningpool.observer/template-and-block/00000000000000000002373c6610fe88ca7a48023a7ffac351f1ff388dea93cb). The EclipseMC pool mined a few, for example, [752015](https://miningpool.observer/template-and-block/00000000000000000006e05b1e14860d33517d10bad0cf38f62aa452c1365bd2), the Huobi Pool with e.g. [710871](https://miningpool.observer/template-and-block/0000000000000000000451109149b22d6716fd1c9200c08ee295b0c6b6d82d28), and the KuCoin Pool too with, for example, [751909](https://miningpool.observer/template-and-block/000000000000000000063efdbd724ae035ce4b56d512a041ad2bd139374152ff).
@0xB10C thanks for these great insights. What do you think about the issue? The `4000` is reserved in two places:

https://github.com/bitcoin/bitcoin/blob/f4a8269dfc144cc918570bdb870aa5143a11c1fe/src/node/miner.cpp#L97

https://github.com/bitcoin/bitcoin/blob/f4a8269dfc144cc918570bdb870aa5143a11c1fe/src/policy/policy.h#L23

Should we remove one of these?
I agree that reserving 4000 WU twice is confusing. However, I'm not familiar enough with the code to judge on which to remove.
TIL about this, despite having authored #30356.

I think `-blockmaxweight` should refer to the actual worst case block, i.e. assuming the pool uses all 4000 WU. So its default should be equal to `MAX_BLOCK_WEIGHT`. It's documentation should clarify this.

We do need to make sure that if a miner has been setting `-blockmaxweight=4000000` manually, nothing breaks.

An additional argument for having `-blockmaxweight` not adjust for the reservation, is that in Stratum v2 each connected Template Provider (i.e. a pool or an individual miner making its own templates) can specify how much space to reserve. So `4000` only applies to `getblocktemplate` users, whereas `-blockmaxweight` applies everywhere.

It would be good to have a functional test for this.

Some history:

`MAX_BLOCK_COST` was initially introduced for a setting called `-blockmaxcost` as part of the main SegWit pull request #8149. It existed alongside `-blockmaxsize` and was effectively a way to limit the amount of witness data.

At the time `DEFAULT_BLOCK_MAX_SIZE` was `750000`, i.e. 3/4th of the maximum size. So `DEFAULT_BLOCK_MAX_WEIGHT` was set to 3 million, 3/4th of the maximum weight.

Later on the combination of #8363 and #11100 replaced the confusing `MAX_BLOCK_COST`, `-blockmaxsize` and `-blockmaxcost` by just `DEFAULT_BLOCK_MAX_WEIGHT` and `-blockmaxweight`. That first PR kept the 3/4th of the maximum default.

The second PR #11100 changed the default to 4 million. It also introduced the 4000 adjustment. Unfortunately increasing the default caused a bunch of arguing, which probably didn't aid in review.

However the mistake _was_ caught in review, but considered not to be a big deal:
https://github.com/bitcoin/bitcoin/pull/11100#discussion_r135124178 (PR description also links to this comment)

I do think we should fix this, because the alternative is that people start patching this stuff themselves and make very expensive mistakes. 
Note that the reserved weight is NOT only used for the coinbase transaction. The block header takes up 320 WU (80 bytes * 4) and the `var_int` for the number of transactions takes up either 4 WU (1 byte `var_int` for up to 252 txns in the block) or 12 WU (3 byte `var_int` for up to 65535 txns). For most mainnet blocks this is an additional 332 WU. 

See also: https://delvingbitcoin.org/t/analyzing-mining-pool-behavior-to-address-bitcoin-cores-double-coinbase-reservation-issue/1351/4?u=0xb10c

Fix patch:
diff --git a/doc/release-notes-31384.md b/doc/release-notes-31384.md
new file mode 100644
index 0000000000000..9256ec16f4621
--- /dev/null
+++ b/doc/release-notes-31384.md
@@ -0,0 +1,34 @@
+- Node and Mining
+
+---
+
+- **PR #31384** fixed an issue where block reserved weight for fixed-size block header, transactions count,
+  and coinbase transaction was done in two separate places.
+  Before this pull request, the policy default for the maximum block weight was `3,996,000` WU, calculated by
+  subtracting `4,000 WU` from the `4,000,000 WU` consensus limit to account for the fixed-size block header,
+  transactions count, and coinbase transaction. During block assembly, Bitcoin Core clamped custom `-blockmaxweight`
+  value to not be more than the policy default.
+
+  Additionally, the mining code added another `4,000 WU` to the initial reservation, reducing the effective block template
+  size to `3,992,000 WU`.
+
+  Due to this issue, the total reserved weight was always `8,000 WU`, meaning that even when specifying a `-blockmaxweight`
+  higher than the policy default, the actual block size never exceeded `3,992,000 WU`.
+
+  The fix consolidates the reservation into a single place and introduces a new startup option,
+  `-blockreservedweight` (default: `8,000 WU`). This startup option specifies the reserved weight for
+  the fixed-size block header, transactions count, and coinbase transaction.
+  The default value of `-blockreservedweight` was chosen to preserve the previous behavior.
+
+  **Upgrade Note:** The default `-blockreservedweight` ensures backward compatibility for users who relied on the previous behavior.
+
+  Users who manually set `-blockmaxweight` to its maximum value of `4,000,000 WU` should be aware that this
+  value previously had no effect since it was clamped to `3,996,000 WU`.
+
+  Users lowering `-blockreservedweight` should ensure that the total weight (for the block header, transaction count, and coinbase transaction)
+   does not exceed the reduced value.
+
+  As a safety check, Bitcoin core will **fail to start** when `-blockreservedweight` init parameter value is lower than `2000` weight units.
+
+  Bitcoin Core will also **fail to start** if the `-blockmaxweight` or `-blockreservedweight` init parameter exceeds
+  consensus limit of `4,000,000` weight units.
diff --git a/src/init.cpp b/src/init.cpp
index 1f597cb7cb29e..03c525d5b4983 100644
--- a/src/init.cpp
+++ b/src/init.cpp
@@ -19,6 +19,7 @@
 #include <common/args.h>
 #include <common/system.h>
 #include <consensus/amount.h>
+#include <consensus/consensus.h>
 #include <deploymentstatus.h>
 #include <hash.h>
 #include <httprpc.h>
@@ -648,6 +649,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc)
 
 
     argsman.AddArg("-blockmaxweight=<n>", strprintf("Set maximum BIP141 block weight (default: %d)", DEFAULT_BLOCK_MAX_WEIGHT), ArgsManager::ALLOW_ANY, OptionsCategory::BLOCK_CREATION);
+    argsman.AddArg("-blockreservedweight=<n>", strprintf("Reserve space for the fixed-size block header plus the largest coinbase transaction the mining software may add to the block. (default: %d).", DEFAULT_BLOCK_RESERVED_WEIGHT), ArgsManager::ALLOW_ANY, OptionsCategory::BLOCK_CREATION);
     argsman.AddArg("-blockmintxfee=<amt>", strprintf("Set lowest fee rate (in %s/kvB) for transactions to be included in block creation. (default: %s)", CURRENCY_UNIT, FormatMoney(DEFAULT_BLOCK_MIN_TX_FEE)), ArgsManager::ALLOW_ANY, OptionsCategory::BLOCK_CREATION);
     argsman.AddArg("-blockversion=<n>", "Override block version to test forking scenarios", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::BLOCK_CREATION);
 
@@ -1015,6 +1017,23 @@ bool AppInitParameterInteraction(const ArgsManager& args)
         }
     }
 
+    if (args.IsArgSet("-blockmaxweight")) {
+        const auto max_block_weight = args.GetIntArg("-blockmaxweight", DEFAULT_BLOCK_MAX_WEIGHT);
+        if (max_block_weight > MAX_BLOCK_WEIGHT) {
+            return InitError(strprintf(_("Specified -blockmaxweight (%d) exceeds consensus maximum block weight (%d)"), max_block_weight, MAX_BLOCK_WEIGHT));
+        }
+    }
+
+    if (args.IsArgSet("-blockreservedweight")) {
+        const auto block_reserved_weight = args.GetIntArg("-blockreservedweight", DEFAULT_BLOCK_RESERVED_WEIGHT);
+        if (block_reserved_weight > MAX_BLOCK_WEIGHT) {
+            return InitError(strprintf(_("Specified -blockreservedweight (%d) exceeds consensus maximum block weight (%d)"), block_reserved_weight, MAX_BLOCK_WEIGHT));
+        }
+        if (block_reserved_weight < MINIMUM_BLOCK_RESERVED_WEIGHT) {
+            return InitError(strprintf(_("Specified -blockreservedweight (%d) is lower than minimum safety value of (%d)"), block_reserved_weight, MINIMUM_BLOCK_RESERVED_WEIGHT));
+        }
+    }
+
     nBytesPerSigOp = args.GetIntArg("-bytespersigop", nBytesPerSigOp);
 
     if (!g_wallet_init_interface.ParameterInteraction()) return false;
diff --git a/src/ipc/capnp/mining.capnp b/src/ipc/capnp/mining.capnp
index 50b07bda708b7..e57d6679e184a 100644
--- a/src/ipc/capnp/mining.capnp
+++ b/src/ipc/capnp/mining.capnp
@@ -35,7 +35,7 @@ interface BlockTemplate $Proxy.wrap("interfaces::BlockTemplate") {
 
 struct BlockCreateOptions $Proxy.wrap("node::BlockCreateOptions") {
     useMempool @0 :Bool $Proxy.name("use_mempool");
-    coinbaseMaxAdditionalWeight @1 :UInt64 $Proxy.name("coinbase_max_additional_weight");
+    blockReservedWeight @1 :UInt64 $Proxy.name("block_reserved_weight");
     coinbaseOutputMaxAdditionalSigops @2 :UInt64 $Proxy.name("coinbase_output_max_additional_sigops");
 }
 
diff --git a/src/node/miner.cpp b/src/node/miner.cpp
index 0ff5a1eadead1..b595d91ed97a7 100644
--- a/src/node/miner.cpp
+++ b/src/node/miner.cpp
@@ -67,11 +67,12 @@ void RegenerateCommitments(CBlock& block, ChainstateManager& chainman)
 
 static BlockAssembler::Options ClampOptions(BlockAssembler::Options options)
 {
-    Assert(options.coinbase_max_additional_weight <= DEFAULT_BLOCK_MAX_WEIGHT);
+    Assert(options.block_reserved_weight <= MAX_BLOCK_WEIGHT);
+    Assert(options.block_reserved_weight >= MINIMUM_BLOCK_RESERVED_WEIGHT);
     Assert(options.coinbase_output_max_additional_sigops <= MAX_BLOCK_SIGOPS_COST);
-    // Limit weight to between coinbase_max_additional_weight and DEFAULT_BLOCK_MAX_WEIGHT for sanity:
-    // Coinbase (reserved) outputs can safely exceed -blockmaxweight, but the rest of the block template will be empty.
-    options.nBlockMaxWeight = std::clamp<size_t>(options.nBlockMaxWeight, options.coinbase_max_additional_weight, DEFAULT_BLOCK_MAX_WEIGHT);
+    // Limit weight to between block_reserved_weight and MAX_BLOCK_WEIGHT for sanity:
+    // block_reserved_weight can safely exceed -blockmaxweight, but the rest of the block template will be empty.
+    options.nBlockMaxWeight = std::clamp<size_t>(options.nBlockMaxWeight, options.block_reserved_weight, MAX_BLOCK_WEIGHT);
     return options;
 }
 
@@ -91,14 +92,15 @@ void ApplyArgsManOptions(const ArgsManager& args, BlockAssembler::Options& optio
         if (const auto parsed{ParseMoney(*blockmintxfee)}) options.blockMinFeeRate = CFeeRate{*parsed};
     }
     options.print_modified_fee = args.GetBoolArg("-printpriority", options.print_modified_fee);
+    options.block_reserved_weight = args.GetIntArg("-blockreservedweight", options.block_reserved_weight);
 }
 
 void BlockAssembler::resetBlock()
 {
     inBlock.clear();
 
-    // Reserve space for coinbase tx
-    nBlockWeight = m_options.coinbase_max_additional_weight;
+    // Reserve space for fixed-size block header, txs count, and coinbase tx.
+    nBlockWeight = m_options.block_reserved_weight;
     nBlockSigOpsCost = m_options.coinbase_output_max_additional_sigops;
 
     // These counters do not include coinbase tx
@@ -386,7 +388,7 @@ void BlockAssembler::addPackageTxs(int& nPackagesSelected, int& nDescendantsUpda
             ++nConsecutiveFailed;
 
             if (nConsecutiveFailed > MAX_CONSECUTIVE_FAILURES && nBlockWeight >
-                    m_options.nBlockMaxWeight - m_options.coinbase_max_additional_weight) {
+                    m_options.nBlockMaxWeight - m_options.block_reserved_weight) {
                 // Give up if we're close to full and haven't succeeded in a while
                 break;
             }
diff --git a/src/node/miner.h b/src/node/miner.h
index 3c4c66b0badf0..18e19ea158d7c 100644
--- a/src/node/miner.h
+++ b/src/node/miner.h
@@ -176,7 +176,9 @@ class BlockAssembler
     /** Construct a new block template */
     std::unique_ptr<CBlockTemplate> CreateNewBlock();
 
+    /** The number of transactions in the last assembled block (excluding coinbase transaction) */
     inline static std::optional<int64_t> m_last_block_num_txs{};
+    /** The weight of the last assembled block (including reserved weight for block header, txs count and coinbase tx) */
     inline static std::optional<int64_t> m_last_block_weight{};
 
 private:
diff --git a/src/node/types.h b/src/node/types.h
index 4b0de084ab3d0..290bbc23f1401 100644
--- a/src/node/types.h
+++ b/src/node/types.h
@@ -14,6 +14,7 @@
 #define BITCOIN_NODE_TYPES_H
 
 #include <cstddef>
+#include <policy/policy.h>
 #include <script/script.h>
 
 namespace node {
@@ -34,11 +35,10 @@ struct BlockCreateOptions {
      */
     bool use_mempool{true};
     /**
-     * The maximum additional weight which the pool will add to the coinbase
-     * scriptSig, witness and outputs. This must include any additional
-     * weight needed for larger CompactSize encoded lengths.
+     * The default reserved weight for the fixed-size block header,
+     * transaction count and coinbase transaction.
      */
-    size_t coinbase_max_additional_weight{4000};
+    size_t block_reserved_weight{DEFAULT_BLOCK_RESERVED_WEIGHT};
     /**
      * The maximum additional sigops which the pool will add in coinbase
      * transaction outputs.
diff --git a/src/policy/policy.h b/src/policy/policy.h
index 4412f2db87ab1..2151ec13dd04a 100644
--- a/src/policy/policy.h
+++ b/src/policy/policy.h
@@ -20,7 +20,14 @@ class CFeeRate;
 class CScript;
 
 /** Default for -blockmaxweight, which controls the range of block weights the mining code will create **/
-static constexpr unsigned int DEFAULT_BLOCK_MAX_WEIGHT{MAX_BLOCK_WEIGHT - 4000};
+static constexpr unsigned int DEFAULT_BLOCK_MAX_WEIGHT{MAX_BLOCK_WEIGHT};
+/** Default for -blockreservedweight **/
+static constexpr unsigned int DEFAULT_BLOCK_RESERVED_WEIGHT{8000};
+/** This accounts for the block header, var_int encoding of the transaction count and a minimally viable
+ * coinbase transaction. It adds an additional safety margin, because even with a thorough understanding
+ * of block serialization, it's easy to make a costly mistake when trying to squeeze every last byte.
+ * Setting a lower value is prevented at startup. */
+static constexpr unsigned int MINIMUM_BLOCK_RESERVED_WEIGHT{2000};
 /** Default for -blockmintxfee, which sets the minimum feerate for a transaction in blocks created by mining code **/
 static constexpr unsigned int DEFAULT_BLOCK_MIN_TX_FEE{1000};
 /** The maximum weight for transactions we're willing to relay/mine */
diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp
index 5e3d1120e3201..702925785bfc8 100644
--- a/src/rpc/mining.cpp
+++ b/src/rpc/mining.cpp
@@ -419,8 +419,8 @@ static RPCHelpMan getmininginfo()
                     RPCResult::Type::OBJ, "", "",
                     {
                         {RPCResult::Type::NUM, "blocks", "The current block"},
-                        {RPCResult::Type::NUM, "currentblockweight", /*optional=*/true, "The block weight of the last assembled block (only present if a block was ever assembled)"},
-                        {RPCResult::Type::NUM, "currentblocktx", /*optional=*/true, "The number of block transactions of the last assembled block (only present if a block was ever assembled)"},
+                        {RPCResult::Type::NUM, "currentblockweight", /*optional=*/true, "The block weight (including reserved weight for block header, txs count and coinbase tx) of the last assembled block (only present if a block was ever assembled)"},
+                        {RPCResult::Type::NUM, "currentblocktx", /*optional=*/true, "The number of block transactions (excluding coinbase) of the last assembled block (only present if a block was ever assembled)"},
                         {RPCResult::Type::STR_HEX, "bits", "The current nBits, compact representation of the block difficulty target"},
                         {RPCResult::Type::NUM, "difficulty", "The current difficulty"},
                         {RPCResult::Type::STR_HEX, "target", "The current target"},


Test patch:
diff --git a/src/test/fuzz/mini_miner.cpp b/src/test/fuzz/mini_miner.cpp
index 817bc0f18e566..baa28affceca9 100644
--- a/src/test/fuzz/mini_miner.cpp
+++ b/src/test/fuzz/mini_miner.cpp
@@ -13,6 +13,7 @@
 
 #include <node/miner.h>
 #include <node/mini_miner.h>
+#include <node/types.h>
 #include <primitives/transaction.h>
 #include <random.h>
 #include <txmempool.h>
@@ -157,9 +158,10 @@ FUZZ_TARGET(mini_miner_selection, .init = initialize_miner)
             }
         }
 
-        // Stop if pool reaches DEFAULT_BLOCK_MAX_WEIGHT because BlockAssembler will stop when the
+        const auto block_adjusted_max_weight = MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT;
+        // Stop if pool reaches block_adjusted_max_weight because BlockAssembler will stop when the
         // block template reaches that, but the MiniMiner will keep going.
-        if (pool.GetTotalTxSize() + GetVirtualTransactionSize(*tx) >= DEFAULT_BLOCK_MAX_WEIGHT) break;
+        if (pool.GetTotalTxSize() + GetVirtualTransactionSize(*tx) >= block_adjusted_max_weight) break;
         TestMemPoolEntryHelper entry;
         const CAmount fee{ConsumeMoney(fuzzed_data_provider, /*max=*/MAX_MONEY/100000)};
         assert(MoneyRange(fee));
@@ -181,7 +183,7 @@ FUZZ_TARGET(mini_miner_selection, .init = initialize_miner)
 
     node::BlockAssembler::Options miner_options;
     miner_options.blockMinFeeRate = target_feerate;
-    miner_options.nBlockMaxWeight = DEFAULT_BLOCK_MAX_WEIGHT;
+    miner_options.nBlockMaxWeight = MAX_BLOCK_WEIGHT;
     miner_options.test_block_validity = false;
     miner_options.coinbase_output_script = CScript() << OP_0;
 
diff --git a/test/functional/mining_basic.py b/test/functional/mining_basic.py
index 979eda7d04218..29fb8241e33a6 100755
--- a/test/functional/mining_basic.py
+++ b/test/functional/mining_basic.py
@@ -26,7 +26,11 @@
     CBlock,
     CBlockHeader,
     COIN,
+    DEFAULT_BLOCK_RESERVED_WEIGHT,
+    MAX_BLOCK_WEIGHT,
+    MINIMUM_BLOCK_RESERVED_WEIGHT,
     ser_uint256,
+    WITNESS_SCALE_FACTOR
 )
 from test_framework.p2p import P2PDataStore
 from test_framework.test_framework import BitcoinTestFramework
@@ -77,7 +81,7 @@ def mine_chain(self):
         mining_info = self.nodes[0].getmininginfo()
         assert_equal(mining_info['blocks'], 200)
         assert_equal(mining_info['currentblocktx'], 0)
-        assert_equal(mining_info['currentblockweight'], 4000)
+        assert_equal(mining_info['currentblockweight'], DEFAULT_BLOCK_RESERVED_WEIGHT)
 
         self.log.info('test blockversion')
         self.restart_node(0, extra_args=[f'-mocktime={t}', '-blockversion=1337'])
@@ -193,6 +197,116 @@ def test_pruning(self):
         assert_equal(result, "inconclusive")
         assert_equal(prune_node.getblock(pruned_blockhash, verbosity=0), pruned_block)
 
+
+    def send_transactions(self, utxos, fee_rate, target_vsize):
+        """
+        Helper to create and send transactions with the specified target virtual size and fee rate.
+        """
+        for utxo in utxos:
+            self.wallet.send_self_transfer(
+                from_node=self.nodes[0],
+                utxo_to_spend=utxo,
+                target_vsize=target_vsize,
+                fee_rate=fee_rate,
+            )
+
+    def verify_block_template(self, expected_tx_count, expected_weight):
+        """
+        Create a block template and check that it satisfies the expected transaction count and total weight.
+        """
+        response = self.nodes[0].getblocktemplate(NORMAL_GBT_REQUEST_PARAMS)
+        self.log.info(f"Testing block template: contains {expected_tx_count} transactions, and total weight <= {expected_weight}")
+        assert_equal(len(response["transactions"]), expected_tx_count)
+        total_weight = sum(transaction["weight"] for transaction in response["transactions"])
+        assert_greater_than_or_equal(expected_weight, total_weight)
+
+    def test_block_max_weight(self):
+        self.log.info("Testing default and custom -blockmaxweight startup options.")
+
+        # Restart the node to allow large transactions
+        LARGE_TXS_COUNT = 10
+        LARGE_VSIZE = int(((MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT) / WITNESS_SCALE_FACTOR) / LARGE_TXS_COUNT)
+        HIGH_FEERATE = Decimal("0.0003")
+        self.restart_node(0, extra_args=[f"-datacarriersize={LARGE_VSIZE}"])
+
+        # Ensure the mempool is empty
+        assert_equal(len(self.nodes[0].getrawmempool()), 0)
+
+        # Generate UTXOs and send 10 large transactions with a high fee rate
+        utxos = [self.wallet.get_utxo(confirmed_only=True) for _ in range(LARGE_TXS_COUNT + 4)] # Add 4 more utxos that will be used in the test later
+        self.send_transactions(utxos[:LARGE_TXS_COUNT], HIGH_FEERATE, LARGE_VSIZE)
+
+        # Send 2 normal transactions with a lower fee rate
+        NORMAL_VSIZE = int(2000 / WITNESS_SCALE_FACTOR)
+        NORMAL_FEERATE = Decimal("0.0001")
+        self.send_transactions(utxos[LARGE_TXS_COUNT:LARGE_TXS_COUNT + 2], NORMAL_FEERATE, NORMAL_VSIZE)
+
+        # Check that the mempool contains all transactions
+        self.log.info(f"Testing that the mempool contains {LARGE_TXS_COUNT + 2} transactions.")
+        assert_equal(len(self.nodes[0].getrawmempool()), LARGE_TXS_COUNT + 2)
+
+        # Verify the block template includes only the 10 high-fee transactions
+        self.log.info("Testing that the block template includes only the 10 large transactions.")
+        self.verify_block_template(
+            expected_tx_count=LARGE_TXS_COUNT,
+            expected_weight=MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT,
+        )
+
+        # Test block template creation with custom -blockmaxweight
+        custom_block_weight = MAX_BLOCK_WEIGHT - 2000
+        # Reducing the weight by 2000 units will prevent 1 large transaction from fitting into the block.
+        self.restart_node(0, extra_args=[f"-datacarriersize={LARGE_VSIZE}", f"-blockmaxweight={custom_block_weight}"])
+
+        self.log.info("Testing the block template with custom -blockmaxweight to include 9 large and 2 normal transactions.")
+        self.verify_block_template(
+            expected_tx_count=11,
+            expected_weight=MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT - 2000,
+        )
+
+        # Ensure the block weight does not exceed the maximum
+        self.log.info(f"Testing that the block weight will never exceed {MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT}.")
+        self.restart_node(0, extra_args=[f"-datacarriersize={LARGE_VSIZE}", f"-blockmaxweight={MAX_BLOCK_WEIGHT}"])
+        self.log.info("Sending 2 additional normal transactions to fill the mempool to the maximum block weight.")
+        self.send_transactions(utxos[LARGE_TXS_COUNT + 2:], NORMAL_FEERATE, NORMAL_VSIZE)
+        self.log.info(f"Testing that the mempool's weight matches the maximum block weight: {MAX_BLOCK_WEIGHT}.")
+        assert_equal(self.nodes[0].getmempoolinfo()['bytes'] * WITNESS_SCALE_FACTOR, MAX_BLOCK_WEIGHT)
+
+        self.log.info("Testing that the block template includes only 10 transactions and cannot reach full block weight.")
+        self.verify_block_template(
+            expected_tx_count=LARGE_TXS_COUNT,
+            expected_weight=MAX_BLOCK_WEIGHT - DEFAULT_BLOCK_RESERVED_WEIGHT,
+        )
+
+        self.log.info("Test -blockreservedweight startup option.")
+        # Lowering the -blockreservedweight by 4000 will allow for two more transactions.
+        self.restart_node(0, extra_args=[f"-datacarriersize={LARGE_VSIZE}", "-blockreservedweight=4000"])
+        self.verify_block_template(
+            expected_tx_count=12,
+            expected_weight=MAX_BLOCK_WEIGHT - 4000,
+        )
+
+        self.log.info("Test that node will fail to start when user provide invalid -blockreservedweight")
+        self.stop_node(0)
+        self.nodes[0].assert_start_raises_init_error(
+            extra_args=[f"-blockreservedweight={MAX_BLOCK_WEIGHT + 1}"],
+            expected_msg=f"Error: Specified -blockreservedweight ({MAX_BLOCK_WEIGHT + 1}) exceeds consensus maximum block weight ({MAX_BLOCK_WEIGHT})",
+        )
+
+        self.log.info(f"Test that node will fail to start when user provide -blockreservedweight below {MINIMUM_BLOCK_RESERVED_WEIGHT}")
+        self.stop_node(0)
+        self.nodes[0].assert_start_raises_init_error(
+            extra_args=[f"-blockreservedweight={MINIMUM_BLOCK_RESERVED_WEIGHT - 1}"],
+            expected_msg=f"Error: Specified -blockreservedweight ({MINIMUM_BLOCK_RESERVED_WEIGHT - 1}) is lower than minimum safety value of ({MINIMUM_BLOCK_RESERVED_WEIGHT})",
+        )
+
+        self.log.info("Test that node will fail to start when user provide invalid -blockmaxweight")
+        self.stop_node(0)
+        self.nodes[0].assert_start_raises_init_error(
+            extra_args=[f"-blockmaxweight={MAX_BLOCK_WEIGHT + 1}"],
+            expected_msg=f"Error: Specified -blockmaxweight ({MAX_BLOCK_WEIGHT + 1}) exceeds consensus maximum block weight ({MAX_BLOCK_WEIGHT})",
+        )
+
+
     def run_test(self):
         node = self.nodes[0]
         self.wallet = MiniWallet(node)
@@ -418,6 +532,7 @@ def chain_tip(b_hash, *, status='headers-only', branchlen=1):
         assert_equal(node.submitblock(hexdata=block.serialize().hex()), 'duplicate')  # valid
 
         self.test_blockmintxfee_parameter()
+        self.test_block_max_weight()
         self.test_timewarp()
         self.test_pruning()
 
diff --git a/test/functional/test_framework/messages.py b/test/functional/test_framework/messages.py
index b4a5d9d5efa69..9ebb683a9db0a 100755
--- a/test/functional/test_framework/messages.py
+++ b/test/functional/test_framework/messages.py
@@ -33,6 +33,8 @@
 
 MAX_LOCATOR_SZ = 101
 MAX_BLOCK_WEIGHT = 4000000
+DEFAULT_BLOCK_RESERVED_WEIGHT = 8000
+MINIMUM_BLOCK_RESERVED_WEIGHT = 2000
 MAX_BLOOM_FILTER_SIZE = 36000
 MAX_BLOOM_HASH_FUNCS = 50