From 5d91e6f27834a34669509252341f20816d67ffa3 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 19:59:16 -0600 Subject: [PATCH 1/7] consensus/params: Define the extended coinbase maturity parameters, expiring with RDTS --- src/consensus/consensus.h | 3 +++ src/consensus/params.h | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index 69dd2b605950..7f9990ca2f04 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -19,6 +19,9 @@ static const unsigned int REDUCED_DATA_MAX_BLOCK_WEIGHT = 800000; static const int64_t MAX_BLOCK_SIGOPS_COST = 80000; /** Coinbase transaction outputs can only be spent after this number of new blocks (network rule) */ static const int COINBASE_MATURITY = 100; +/** Coinbase maturity applied by the temporary extended-maturity soft fork to coinbase outputs created + * while it is active: 45 days of ten-minute blocks (see Consensus::Params::ExtendedCoinbaseMaturityActiveAt) */ +static const int EXTENDED_COINBASE_MATURITY = 6480; static const int WITNESS_SCALE_FACTOR = 4; diff --git a/src/consensus/params.h b/src/consensus/params.h index 0231e63b20a3..a6961bc9cb9d 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -123,6 +123,22 @@ struct Params { * behaviour is unchanged on chains that do not set it. */ int64_t RdtsExpiryTime{std::numeric_limits::min()}; + /** + * Extended coinbase maturity, a temporary soft fork: while it is active, + * coinbase outputs created since it activated can only be spent once + * buried under EXTENDED_COINBASE_MATURITY blocks rather than + * COINBASE_MATURITY. It is active for a block when the parent's + * median-time-past has reached this start time and not yet the RDTS + * expiry (see ExtendedCoinbaseMaturityActiveAt): it expires with RDTS, + * after which every coinbase output is again spendable at + * COINBASE_MATURITY. Outputs created before activation are never + * affected, so activating cannot lock an output that was already + * spendable. + * + * The default leaves the deployment unscheduled (the start follows every + * median-time-past). + */ + int64_t ExtendedCoinbaseMaturityStartTime{std::numeric_limits::max()}; /** Don't warn about unknown BIP 9 activations below this height. * This prevents us from warning about the CSV and segwit activations. */ int MinBIP9WarningHeight; @@ -195,6 +211,14 @@ struct Params { { return IsBlake2bHeight(height) && mtp_prev < RdtsExpiryTime; } + + /** Whether the extended coinbase maturity rule applies to a block whose + * parent has the given median-time-past. It expires with RDTS, so it is + * never active on a chain with no RDTS expiry. */ + bool ExtendedCoinbaseMaturityActiveAt(int64_t mtp_prev) const + { + return mtp_prev >= ExtendedCoinbaseMaturityStartTime && mtp_prev < RdtsExpiryTime; + } }; } // namespace Consensus From 53a81bd9cf465dae3d733c28710ac7df19c7299c Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 19:59:16 -0600 Subject: [PATCH 2/7] validation: Enforce 6480-block coinbase maturity while the soft fork is active --- src/consensus/tx_verify.cpp | 16 +++++-- src/consensus/tx_verify.h | 13 +++++- src/test/fuzz/coins_view.cpp | 2 +- src/test/validation_tests.cpp | 87 +++++++++++++++++++++++++++++++++++ src/txmempool.cpp | 7 +-- src/validation.cpp | 52 +++++++++++++++++++-- src/validation.h | 20 ++++++++ 7 files changed, 184 insertions(+), 13 deletions(-) diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp index 84558503c708..9723b12e5212 100644 --- a/src/consensus/tx_verify.cpp +++ b/src/consensus/tx_verify.cpp @@ -172,7 +172,12 @@ bool Consensus::CheckOutputSizes(const CTransaction& tx, TxValidationState& stat return true; } -bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, const CheckTxInputsRules rules) +int Consensus::RequiredCoinbaseMaturity(int coinbase_height, int extended_maturity_start_height) +{ + return coinbase_height >= extended_maturity_start_height ? EXTENDED_COINBASE_MATURITY : COINBASE_MATURITY; +} + +bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, const CheckTxInputsRules rules, const int extended_maturity_start_height) { // are the actual inputs available? if (!inputs.HaveInputs(tx)) { @@ -192,9 +197,12 @@ bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, assert(!coin.IsSpent()); // If prev is coinbase, check that it's matured - if (coin.IsCoinBase() && nSpendHeight - coin.nHeight < COINBASE_MATURITY) { - return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-premature-spend-of-coinbase", - strprintf("tried to spend coinbase at depth %d", nSpendHeight - coin.nHeight)); + if (coin.IsCoinBase()) { + const int maturity{RequiredCoinbaseMaturity(coin.nHeight, extended_maturity_start_height)}; + if (nSpendHeight - coin.nHeight < maturity) { + return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-premature-spend-of-coinbase", + strprintf("tried to spend coinbase at depth %d (maturity %d)", nSpendHeight - coin.nHeight, maturity)); + } } // Check for negative or overflow input values diff --git a/src/consensus/tx_verify.h b/src/consensus/tx_verify.h index 8111a5b77a4f..d676d8bf0b25 100644 --- a/src/consensus/tx_verify.h +++ b/src/consensus/tx_verify.h @@ -49,13 +49,24 @@ namespace Consensus { */ bool CheckOutputSizes(const CTransaction& tx, TxValidationState& state); +/** + * Number of blocks a coinbase output created at coinbase_height must be buried + * under before it may be spent: EXTENDED_COINBASE_MATURITY for outputs created + * at or after extended_maturity_start_height, COINBASE_MATURITY otherwise. + * Callers pass ExtendedCoinbaseMaturityStartHeight() (validation.h) for the + * spending block, which is std::numeric_limits::max() (no output + * qualifies) whenever the extended-maturity soft fork is not active for it. + */ +int RequiredCoinbaseMaturity(int coinbase_height, int extended_maturity_start_height); + /** * Check whether all inputs of this transaction are valid (no double spends and amounts) * This does not modify the UTXO set. This does not check scripts and sigs. * @param[out] txfee Set to the transaction fee if successful. + * @param[in] extended_maturity_start_height See RequiredCoinbaseMaturity. * Preconditions: tx.IsCoinBase() is false. */ -[[nodiscard]] bool CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, CheckTxInputsRules rules); +[[nodiscard]] bool CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, CheckTxInputsRules rules, int extended_maturity_start_height); } // namespace Consensus /** Auxiliary functions for transaction validation (ideally should not be exposed) */ diff --git a/src/test/fuzz/coins_view.cpp b/src/test/fuzz/coins_view.cpp index 6cf72784204c..30e2fcbcff58 100644 --- a/src/test/fuzz/coins_view.cpp +++ b/src/test/fuzz/coins_view.cpp @@ -256,7 +256,7 @@ FUZZ_TARGET(coins_view, .init = initialize_coins_view) // It is not allowed to call CheckTxInputs if CheckTransaction failed return; } - if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out, CheckTxInputsRules::OutputSizeLimit)) { + if (Consensus::CheckTxInputs(transaction, state, coins_view_cache, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()), tx_fee_out, CheckTxInputsRules::OutputSizeLimit, fuzzed_data_provider.ConsumeIntegralInRange(0, std::numeric_limits::max()))) { assert(MoneyRange(tx_fee_out)); } }, diff --git a/src/test/validation_tests.cpp b/src/test/validation_tests.cpp index 4806533bc0b6..9c195168320f 100644 --- a/src/test/validation_tests.cpp +++ b/src/test/validation_tests.cpp @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include #include #include @@ -13,7 +15,9 @@ #include #include +#include #include +#include #include @@ -398,4 +402,87 @@ BOOST_AUTO_TEST_CASE(block_malleation) } } +//! A chain of `length` block indexes with skip pointers, block i stamped `times[i]`. +static std::vector MakeTimedChain(const std::vector& times) +{ + std::vector chain(times.size()); + for (size_t i = 0; i < times.size(); ++i) { + chain[i].nHeight = i; + chain[i].nTime = times[i]; + chain[i].pprev = i == 0 ? nullptr : &chain[i - 1]; + chain[i].BuildSkip(); + } + return chain; +} + +BOOST_AUTO_TEST_CASE(median_time_past_activation_height) +{ + // Consensus keeps median-time-past non-decreasing along a chain (a block's + // time must exceed its parent's median); non-decreasing times reproduce + // that here, with plateaus to exercise the "first block whose parent + // reached it" boundary. + std::vector times; + int64_t t{1000}; + for (int i = 0; i < 300; ++i) { + times.push_back(t); + if (i % 7 == 0) t += 10; // plateaus of equal times, then a jump + } + const auto chain{MakeTimedChain(times)}; + const CBlockIndex& tip{chain.back()}; + + // Brute force: the first height whose parent's median-time-past is >= start. + const auto expected = [&](int64_t start) { + for (int h = 1; h <= tip.nHeight; ++h) { + if (chain[h - 1].GetMedianTimePast() >= start) return h; + } + return tip.nHeight + 1; + }; + for (int64_t start = times.front() - 5; start <= tip.GetMedianTimePast(); ++start) { + BOOST_CHECK_EQUAL(MedianTimePastActivationHeight(tip, start), expected(start)); + // The same answer from any ancestor whose median-time-past has reached it. + for (int h : {tip.nHeight / 2, tip.nHeight - 1}) { + if (chain[h].GetMedianTimePast() >= start) { + BOOST_CHECK_EQUAL(MedianTimePastActivationHeight(chain[h], start), expected(start)); + } + } + } + // A start the genesis block already satisfies activates at height 1. + BOOST_CHECK_EQUAL(MedianTimePastActivationHeight(tip, times.front()), 1); + BOOST_CHECK_EQUAL(MedianTimePastActivationHeight(chain[0], times.front()), 1); +} + +BOOST_AUTO_TEST_CASE(extended_coinbase_maturity_start_height) +{ + std::vector times; + for (int i = 0; i < 200; ++i) times.push_back(1000 + 10 * i); + const auto chain{MakeTimedChain(times)}; + + Consensus::Params params{}; + constexpr int inactive{std::numeric_limits::max()}; + // Unscheduled (defaults): never active, whatever the time. + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain.back()), inactive); + // Start without an RDTS expiry: still never active (it expires with RDTS). + params.ExtendedCoinbaseMaturityStartTime = chain[50].GetMedianTimePast(); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain.back()), inactive); + params.RdtsExpiryTime = chain[150].GetMedianTimePast(); + + // Block 51 is the first whose parent (50) has reached the start; block + // 151 the first whose parent has reached the expiry. + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain[49]), inactive); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain[50]), 51); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain[100]), 51); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain[149]), 51); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain[150]), inactive); + BOOST_CHECK_EQUAL(ExtendedCoinbaseMaturityStartHeight(params, chain.back()), inactive); + + // The maturity a spend in the block after chain[100] requires: outputs + // from the activation height on are extended, earlier ones are not, and + // nothing is once the rule is inactive. + const int start{ExtendedCoinbaseMaturityStartHeight(params, chain[100])}; + BOOST_CHECK_EQUAL(Consensus::RequiredCoinbaseMaturity(50, start), COINBASE_MATURITY); + BOOST_CHECK_EQUAL(Consensus::RequiredCoinbaseMaturity(51, start), EXTENDED_COINBASE_MATURITY); + BOOST_CHECK_EQUAL(Consensus::RequiredCoinbaseMaturity(100, start), EXTENDED_COINBASE_MATURITY); + BOOST_CHECK_EQUAL(Consensus::RequiredCoinbaseMaturity(51, inactive), COINBASE_MATURITY); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/txmempool.cpp b/src/txmempool.cpp index 4e1d35782560..4b6d195e6c66 100644 --- a/src/txmempool.cpp +++ b/src/txmempool.cpp @@ -877,9 +877,10 @@ void CTxMemPool::check(const CCoinsViewCache& active_coins_tip, int64_t spendhei TxValidationState dummy_state; // Not used. CheckTxInputs() should always pass CAmount txfee = 0; assert(!tx.IsCoinBase()); - // Skip output size checks (CheckTxInputsRules::None), as these transactions already passed - // output size limits at mempool acceptance; this check only verifies UTXO consistency - assert(Consensus::CheckTxInputs(tx, dummy_state, mempoolDuplicate, spendheight, txfee, CheckTxInputsRules::None)); + // Skip output size checks (CheckTxInputsRules::None) and the extended coinbase + // maturity rule, as these transactions already passed both at mempool acceptance; + // this check only verifies UTXO consistency + assert(Consensus::CheckTxInputs(tx, dummy_state, mempoolDuplicate, spendheight, txfee, CheckTxInputsRules::None, std::numeric_limits::max())); for (const auto& input: tx.vin) mempoolDuplicate.SpendCoin(input.prevout); AddCoins(mempoolDuplicate, tx, std::numeric_limits::max()); } diff --git a/src/validation.cpp b/src/validation.cpp index f19ef0e45901..e47be978d8a9 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -272,6 +272,34 @@ bool CheckSequenceLocksAtTip(CBlockIndex* tip, return EvaluateSequenceLocks(index, {lock_points.height, lock_points.time}); } +int MedianTimePastActivationHeight(const CBlockIndex& tip, int64_t start_time) +{ + assert(tip.GetMedianTimePast() >= start_time); + // Binary search over the ancestors for the lowest height whose + // median-time-past has reached start_time; the block after it is the first + // whose parent's has. Valid because a block's time must exceed its parent's + // median-time-past, which keeps median-time-past non-decreasing along a chain. + int lo{0}; + int hi{tip.nHeight}; + while (lo < hi) { + const int mid{lo + (hi - lo) / 2}; + if (Assert(tip.GetAncestor(mid))->GetMedianTimePast() >= start_time) { + hi = mid; + } else { + lo = mid + 1; + } + } + return lo + 1; +} + +int ExtendedCoinbaseMaturityStartHeight(const Consensus::Params& params, const CBlockIndex& block_prev) +{ + if (!params.ExtendedCoinbaseMaturityActiveAt(block_prev.GetMedianTimePast())) { + return std::numeric_limits::max(); + } + return MedianTimePastActivationHeight(block_prev, params.ExtendedCoinbaseMaturityStartTime); +} + // Returns the script flags which should be checked for a given block static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const ChainstateManager& chainman); @@ -432,13 +460,18 @@ void Chainstate::MaybeUpdateMempoolForReorg( } // If the transaction spends any coinbase outputs, it must be mature. + // The required maturity depends on the new tip too: the extended + // coinbase maturity rule may apply to the next block again after a + // reorg back across its expiry, or its window may start at a different + // height on the new branch (see ExtendedCoinbaseMaturityStartHeight). if (it->GetSpendsCoinbase()) { + const auto mempool_spend_height{m_chain.Tip()->nHeight + 1}; + const int extended_maturity_start_height{ExtendedCoinbaseMaturityStartHeight(m_chainman.GetConsensus(), *m_chain.Tip())}; for (const CTxIn& txin : tx.vin) { if (m_mempool->exists(GenTxid::Txid(txin.prevout.hash))) continue; const Coin& coin{CoinsTip().AccessCoin(txin.prevout)}; assert(!coin.IsSpent()); - const auto mempool_spend_height{m_chain.Tip()->nHeight + 1}; - if (coin.IsCoinBase() && mempool_spend_height - coin.nHeight < COINBASE_MATURITY) { + if (coin.IsCoinBase() && mempool_spend_height - coin.nHeight < Consensus::RequiredCoinbaseMaturity(coin.nHeight, extended_maturity_start_height)) { return true; } } @@ -1015,7 +1048,11 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) // The mempool holds txs for the next block, so pass height+1 to CheckTxInputs const auto block_height_current = m_active_chainstate.m_chain.Height(); const auto block_height_next = block_height_current + 1; - if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees, CheckTxInputsRules::OutputSizeLimit)) { + // The extended coinbase maturity rule (see ExtendedCoinbaseMaturityStartHeight) + // is likewise evaluated for the next block, whose parent is the tip. + const int extended_maturity_start_height{ExtendedCoinbaseMaturityStartHeight( + m_active_chainstate.m_chainman.GetConsensus(), *m_active_chainstate.m_chain.Tip())}; + if (!Consensus::CheckTxInputs(tx, state, m_view, block_height_next, ws.m_base_fees, CheckTxInputsRules::OutputSizeLimit, extended_maturity_start_height)) { return false; // state filled in by CheckTxInputs } @@ -2993,6 +3030,13 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, const CheckTxInputsRules chk_input_rules{reduced_data_active ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; + // Extended coinbase maturity (see ExtendedCoinbaseMaturityStartHeight): a + // temporary soft fork that expires with RDTS. While it is active for this + // block, coinbase outputs created since its activation on this chain must + // be buried under EXTENDED_COINBASE_MATURITY blocks; earlier outputs keep + // COINBASE_MATURITY. When inactive no output qualifies. + const int extended_maturity_start_height{ExtendedCoinbaseMaturityStartHeight(params.GetConsensus(), *pindex->pprev)}; + // Check generation tx output sizes if REDUCED_DATA is active if (chk_input_rules.test(CheckTxInputsRules::OutputSizeLimit)) { TxValidationState tx_state; @@ -3020,7 +3064,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, { CAmount txfee = 0; TxValidationState tx_state; - if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, chk_input_rules)) { + if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, chk_input_rules, extended_maturity_start_height)) { // Any transaction validation failure in ConnectBlock is a block consensus failure state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, tx_state.GetRejectReason(), diff --git a/src/validation.h b/src/validation.h index e38d63f554d2..04e583b9bf09 100644 --- a/src/validation.h +++ b/src/validation.h @@ -347,6 +347,26 @@ std::optional CalculateLockPointsAtTip( bool CheckSequenceLocksAtTip(CBlockIndex* tip, const LockPoints& lock_points); +/** + * The activation height, on the chain ending at `tip`, of a deployment + * scheduled by median-time-past: the height of the first block whose parent's + * median-time-past is at least `start_time`. Median-time-past never decreases + * along a chain, so the blocks it activates for form a suffix of the chain. + * Requires tip.GetMedianTimePast() >= start_time, so that the answer is at most + * tip.nHeight + 1 (the block built on `tip`). + */ +int MedianTimePastActivationHeight(const CBlockIndex& tip, int64_t start_time); + +/** + * Extended coinbase maturity (see Consensus::Params::ExtendedCoinbaseMaturityActiveAt). + * For the block built on `block_prev`, the height from which coinbase outputs + * are subject to EXTENDED_COINBASE_MATURITY when spent in it: the deployment's + * activation height on that chain, or std::numeric_limits::max() when the + * rule is not active for that block, so that no output qualifies (see + * Consensus::RequiredCoinbaseMaturity). + */ +int ExtendedCoinbaseMaturityStartHeight(const Consensus::Params& params, const CBlockIndex& block_prev); + void LimitMempoolSize(CTxMemPool&, CCoinsViewCache&); /** From cd62af6197d1061bc5f05b2493cc3c0773c8a53e Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 19:59:16 -0600 Subject: [PATCH 3/7] rpc: Report the extended coinbase maturity deployment in getdeploymentinfo and getblocktemplate --- src/rpc/blockchain.cpp | 57 +++++++++++++++++++++++++++++++----------- src/rpc/mining.cpp | 6 +++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 8353366862c7..3e3c6b592778 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1913,10 +1913,11 @@ RPCHelpMan getblockchaininfo() namespace { const std::vector RPCHelpForDeployment{ {RPCResult::Type::STR, "type", "one of \"buried\", \"bip9\", \"flagday\""}, - {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which enforces the rules (only for \"buried\" and \"flagday\" types, or \"bip9\" type with \"active\" status; for \"flagday\" this is the BLAKE2b hardfork height)"}, + {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which enforces the rules (only for \"buried\" and \"flagday\" types, or \"bip9\" type with \"active\" status; for \"reduced_data\" this is the BLAKE2b hardfork height; for \"extended_coinbase_maturity\" it is the activation height on the queried block's chain, present once that chain's median time past has reached start_time)"}, {RPCResult::Type::NUM, "height_end", /*optional=*/true, "height of the last block which enforces the rules (only for \"bip9\" type with \"active\" status and temporary deployments)"}, {RPCResult::Type::BOOL, "active", "true if the rules are enforced for the mempool and the next block (the mempool applies the RDTS rules regardless of this flag)"}, - {RPCResult::Type::NUM_TIME, "expiry_time", /*optional=*/true, "median time past at and after which the rules are no longer enforced (only for \"flagday\" type; a block is past expiry when its parent's median time past has reached this value)"}, + {RPCResult::Type::NUM_TIME, "start_time", /*optional=*/true, "median time past at and after which the rules are enforced (only for the \"extended_coinbase_maturity\" flag-day deployment; a block enforces the rules when its parent's median time past has reached this value)"}, + {RPCResult::Type::NUM_TIME, "expiry_time", /*optional=*/true, "median time past at and after which the rules are no longer enforced (only for \"flagday\" type; a block is past expiry when its parent's median time past has reached this value; \"extended_coinbase_maturity\" shares the \"reduced_data\" expiry)"}, {RPCResult::Type::OBJ, "bip9", /*optional=*/true, "status of bip9 softforks (only for \"bip9\" type)", { {RPCResult::Type::NUM, "bit", /*optional=*/true, "the bit (0-28) in the block version field used to signal this softfork (only for \"started\" and \"locked_in\" status)"}, @@ -1940,26 +1941,51 @@ const std::vector RPCHelpForDeployment{ }}, }; -// RDTS (a flag-day deployment, not a versionbits one): rules apply to every -// block from the BLAKE2b hardfork height until the parent block's -// median-time-past reaches RdtsExpiryTime. Reported as-of the queried block, -// with "active" meaning the next block, like the versionbits entries. Omitted -// entirely when unscheduled (plain regtest), as NEVER_ACTIVE deployments are. -void RdtsFlagDayDescPushBack(const CBlockIndex* blockindex, UniValue& softforks, const ChainstateManager& chainman) +// Flag-day deployments (not versionbits ones): rules apply to every block +// from an activation point until the parent block's median-time-past reaches +// an expiry. Reported as-of the queried block, with "active" meaning the next +// block, like the versionbits entries. Omitted entirely when unscheduled +// (plain regtest), as NEVER_ACTIVE deployments are. +void FlagDayDescPushBack(UniValue& softforks, const std::string& name, std::optional height, std::optional start_time, int64_t expiry_time, bool active_next) { - const Consensus::Params& params{chainman.GetConsensus()}; - if (params.Blake2bHeight == std::numeric_limits::max() || - params.RdtsExpiryTime == std::numeric_limits::min()) return; + if (expiry_time == std::numeric_limits::min()) return; UniValue rv(UniValue::VOBJ); rv.pushKV("type", "flagday"); - rv.pushKV("height", params.RdtsActivationHeight()); - rv.pushKV("expiry_time", params.RdtsExpiryTime); + if (height) rv.pushKV("height", *height); + if (start_time) rv.pushKV("start_time", *start_time); + rv.pushKV("expiry_time", expiry_time); + rv.pushKV("active", active_next); + softforks.pushKV(name, std::move(rv)); +} + +// RDTS: from the BLAKE2b hardfork height until RdtsExpiryTime. +void RdtsFlagDayDescPushBack(const CBlockIndex* blockindex, UniValue& softforks, const ChainstateManager& chainman) +{ + const Consensus::Params& params{chainman.GetConsensus()}; + if (params.Blake2bHeight == std::numeric_limits::max()) return; // "active" describes the NEXT block, as the versionbits entries do, so the // queried block is that block's parent and its median-time-past is exactly // the parent median-time-past RdtsActiveAt expects. - rv.pushKV("active", params.RdtsActiveAt(blockindex->nHeight + 1, blockindex->GetMedianTimePast())); - softforks.pushKV("reduced_data", std::move(rv)); + FlagDayDescPushBack(softforks, "reduced_data", params.RdtsActivationHeight(), std::nullopt, params.RdtsExpiryTime, + params.RdtsActiveAt(blockindex->nHeight + 1, blockindex->GetMedianTimePast())); +} + +// Extended coinbase maturity: from the block whose parent's median-time-past +// reaches ExtendedCoinbaseMaturityStartTime until the RDTS expiry (it expires +// with RDTS). The activation height depends on the chain, so it is reported +// once the queried block's chain has determined it. +void ExtendedCoinbaseMaturityDescPushBack(const CBlockIndex* blockindex, UniValue& softforks, const ChainstateManager& chainman) +{ + const Consensus::Params& params{chainman.GetConsensus()}; + if (params.ExtendedCoinbaseMaturityStartTime == std::numeric_limits::max()) return; + const int64_t mtp{blockindex->GetMedianTimePast()}; + std::optional height; + if (mtp >= params.ExtendedCoinbaseMaturityStartTime) { + height = MedianTimePastActivationHeight(*blockindex, params.ExtendedCoinbaseMaturityStartTime); + } + FlagDayDescPushBack(softforks, "extended_coinbase_maturity", height, params.ExtendedCoinbaseMaturityStartTime, params.RdtsExpiryTime, + params.ExtendedCoinbaseMaturityActiveAt(mtp)); } UniValue DeploymentInfo(const CBlockIndex* blockindex, const ChainstateManager& chainman) @@ -1973,6 +1999,7 @@ UniValue DeploymentInfo(const CBlockIndex* blockindex, const ChainstateManager& SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_TESTDUMMY); SoftForkDescPushBack(blockindex, softforks, chainman, Consensus::DEPLOYMENT_TAPROOT); RdtsFlagDayDescPushBack(blockindex, softforks, chainman); + ExtendedCoinbaseMaturityDescPushBack(blockindex, softforks, chainman); return softforks; } } // anon namespace diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index 5c93cf652fee..de42e4844581 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -1082,6 +1082,12 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C if (rdts_active) { aRules.push_back("reduced_data"); } + // Extended coinbase maturity (a flag-day deployment expiring with RDTS). + // Enforced during transaction selection; advertised unprefixed like + // "reduced_data", as clients need no special support for it. + if (pindexPrev != nullptr && consensusParams.ExtendedCoinbaseMaturityActiveAt(pindexPrev->GetMedianTimePast())) { + aRules.push_back("extended_coinbase_maturity"); + } result.pushKV("version", block_header.GetCompleteVersion()); result.pushKV("rules", std::move(aRules)); From 9b63b7ec0be1b87b12a3d784b867b74c89961922 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 19:59:16 -0600 Subject: [PATCH 4/7] wallet: Account for the extended coinbase maturity when reporting immature coins --- src/interfaces/chain.h | 6 ++++++ src/node/interfaces.cpp | 9 +++++++++ src/qt/transactiondesc.cpp | 7 ++++++- src/wallet/wallet.cpp | 11 ++++++++++- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h index 45dbb98bed15..3424c7c214b0 100644 --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -144,6 +144,12 @@ class Chain //! pruned), and contains transactions. virtual bool haveBlockOnDisk(int height) = 0; + //! Number of blocks a coinbase output created at the given height must + //! be buried under before it can be spent in the block following the + //! current chain tip: COINBASE_MATURITY, or EXTENDED_COINBASE_MATURITY + //! while the temporary extended-maturity soft fork applies to it. + virtual int coinbaseMaturity(int coinbase_height) = 0; + virtual bool pruneLockExists(const std::string& name) const = 0; virtual bool updatePruneLock(const std::string& name, const node::PruneLockInfo& lock_info, bool sync=false) = 0; virtual bool deletePruneLock(const std::string& name) = 0; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index fec8adb20966..35ade241ecfd 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -581,6 +583,13 @@ class ChainImpl : public Chain const CBlockIndex* block{chainman().ActiveChain()[height]}; return block && ((block->nStatus & BLOCK_HAVE_DATA) != 0) && block->nTx > 0; } + int coinbaseMaturity(int coinbase_height) override + { + LOCK(::cs_main); + const CBlockIndex* tip{chainman().ActiveChain().Tip()}; + if (!tip) return COINBASE_MATURITY; + return Consensus::RequiredCoinbaseMaturity(coinbase_height, ExtendedCoinbaseMaturityStartHeight(chainman().GetConsensus(), *tip)); + } bool pruneLockExists(const std::string& name) const override { LOCK(cs_main); diff --git a/src/qt/transactiondesc.cpp b/src/qt/transactiondesc.cpp index 5ceb7621d3da..97f1eb9213d1 100644 --- a/src/qt/transactiondesc.cpp +++ b/src/qt/transactiondesc.cpp @@ -329,7 +329,12 @@ QString TransactionDesc::toHTML(interfaces::Node& node, interfaces::Wallet& wall if (wtx.is_coinbase) { - quint32 numBlocksToMaturity = COINBASE_MATURITY + 1; + // While still maturing, the wallet's count reflects the maturity that + // actually applies (the temporary extended maturity soft fork may + // require more than COINBASE_MATURITY); once mature, show the default. + quint32 numBlocksToMaturity = status.blocks_to_maturity > 0 && status.is_in_main_chain + ? status.blocks_to_maturity + status.depth_in_main_chain + : COINBASE_MATURITY + 1; strHTML += "
" + tr("Generated coins must mature %1 blocks before they can be spent. When you generated this block, it was broadcast to the network to be added to the block chain. If it fails to get into the chain, its state will change to \"not accepted\" and it won't be spendable. This may occasionally happen if another node generates a block within a few seconds of yours.").arg(QString::number(numBlocksToMaturity)) + "
"; } diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index c8dbcfd1b790..275e4a1678eb 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -3646,7 +3646,16 @@ int CWallet::GetTxBlocksToMaturity(const CWalletTx& wtx) const } int chain_depth = GetTxDepthInMainChain(wtx); assert(chain_depth >= 0); // coinbase tx should not be conflicted - return std::max(0, (COINBASE_MATURITY+1) - chain_depth); + int maturity{COINBASE_MATURITY}; + // A confirmed coinbase may be subject to the temporary extended maturity + // soft fork; outputs already buried deeper than that are mature under + // either rule, so only ask the chain about the ones that could differ. + if (chain_depth <= EXTENDED_COINBASE_MATURITY && HaveChain()) { + if (auto* conf = wtx.state()) { + maturity = chain().coinbaseMaturity(conf->confirmed_block_height); + } + } + return std::max(0, (maturity + 1) - chain_depth); } bool CWallet::IsTxImmatureCoinBase(const CWalletTx& wtx) const From aba4402b3eee8dd17465999d5d15aef5cf0d6749 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 19:59:16 -0600 Subject: [PATCH 5/7] chainparams: Add regtest scheduling for the extended coinbase maturity soft fork --- src/chainparams.cpp | 14 ++++++++++++++ src/chainparamsbase.cpp | 2 ++ src/kernel/chainparams.cpp | 12 ++++++++++++ src/kernel/chainparams.h | 5 +++++ 4 files changed, 33 insertions(+) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 54ab526e2b7c..08a5d23f587c 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -93,6 +93,20 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti options.rdts_expiry_time = expiry; } + if (const auto arg{args.GetArg("-extendedcoinbasematurity", "")}; !arg.empty()) { + // The rule expires with RDTS: without an RDTS expiry, or with a start + // at or after it, it could never apply, which is indistinguishable + // from not scheduling it at all. + if (!options.rdts_expiry_time) { + throw std::runtime_error("-extendedcoinbasematurity requires -rdtsexpiry=