From 80fcce56c6a24956ea66aa6d2ce3006f79c5c399 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 20:07:46 -0600 Subject: [PATCH 1/7] consensus/params: Define the extended coinbase maturity parameters, expiring with RDTS --- src/consensus/consensus.h | 5 +++++ src/consensus/params.h | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h index 69dd2b605950..e4553fe2a10c 100644 --- a/src/consensus/consensus.h +++ b/src/consensus/consensus.h @@ -19,6 +19,11 @@ 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; +/** Extended coinbase maturity, applied by the temporary soft fork to coinbase outputs created while it is + * active: the spending block's parent must have a median-time-past at least this many seconds past the + * coinbase block's median-time-past (45 days, the time 6480 ten-minute blocks would take; + * see Consensus::Params::ExtendedCoinbaseMaturityActiveAt) */ +static const int64_t EXTENDED_COINBASE_MATURITY_TIME = 3888000; static const int WITNESS_SCALE_FACTOR = 4; diff --git a/src/consensus/params.h b/src/consensus/params.h index 0231e63b20a3..9a10951f96fb 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -123,6 +123,23 @@ 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 the + * spending block's parent has a median-time-past at least + * EXTENDED_COINBASE_MATURITY_TIME past the coinbase block's, on top of + * the usual COINBASE_MATURITY blocks. 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 +212,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 6e81179963e8dd1576f3510b2a6045c780908460 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 20:07:46 -0600 Subject: [PATCH 2/7] validation: Enforce a 45-day median-time-past coinbase maturity while the soft fork is active --- src/test/validation_tests.cpp | 108 ++++++++++++++++++++++++++++++++++ src/validation.cpp | 74 ++++++++++++++++++++++- src/validation.h | 23 ++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) diff --git a/src/test/validation_tests.cpp b/src/test/validation_tests.cpp index 4806533bc0b6..28ff293cf424 100644 --- a/src/test/validation_tests.cpp +++ b/src/test/validation_tests.cpp @@ -4,6 +4,8 @@ #include #include +#include +#include #include #include #include @@ -13,7 +15,9 @@ #include #include +#include #include +#include #include @@ -398,4 +402,108 @@ 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(coinbase_mature_for_extended_rule) +{ + // Ten-minute blocks, so median-time-past advances 600 s per block. + std::vector times; + for (int i = 0; i < 300; ++i) times.push_back(1'000'000 + 600 * i); + const auto chain{MakeTimedChain(times)}; + const auto coinbase_at = [](int height) { return Coin{CTxOut{1, CScript{}}, height, /*fCoinBaseIn=*/true}; }; + const Coin plain{CTxOut{1, CScript{}}, 60, /*fCoinBaseIn=*/false}; + + Consensus::Params params{}; + // Unscheduled (defaults): every output passes, whatever the times. + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(60), chain[61])); + // A start without an RDTS expiry never activates (it expires with RDTS). + params.ExtendedCoinbaseMaturityStartTime = chain[50].GetMedianTimePast(); + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(60), chain[61])); + params.RdtsExpiryTime = chain[250].GetMedianTimePast(); + // Block 51 is the first whose parent (50) has reached the start; block + // 251 the first whose parent has reached the expiry. + + // Non-coinbase outputs are never subject. + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, plain, chain[100])); + // Outputs created before activation (block 50 and earlier) are never + // subject, however young. + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(50), chain[51])); + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(49), chain[200])); + // An in-window output waits until the parent's median-time-past is + // EXTENDED_COINBASE_MATURITY_TIME past its block's: from block 51's + // median-time-past that is 3888000 / 600 = 6480 blocks later, beyond + // this chain, so it stays locked throughout the window... + BOOST_CHECK(!CoinbaseMatureForExtendedRule(params, coinbase_at(51), chain[52])); + BOOST_CHECK(!CoinbaseMatureForExtendedRule(params, coinbase_at(51), chain[151])); + BOOST_CHECK(!CoinbaseMatureForExtendedRule(params, coinbase_at(51), chain[249])); + // ...and is released by the hard expiry (block 251's parent has reached it). + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(51), chain[250])); + BOOST_CHECK(CoinbaseMatureForExtendedRule(params, coinbase_at(51), chain[299])); + + // The exact boundary, with a shorter maturity: build a chain whose + // median-time-past jumps by the maturity time between two blocks. + const int64_t maturity{EXTENDED_COINBASE_MATURITY_TIME}; + std::vector jump_times; + for (int i = 0; i < 120; ++i) jump_times.push_back(1'000'000 + (i < 80 ? 600 * i : 600 * 80 + maturity + 600 * (i - 80))); + const auto jump{MakeTimedChain(jump_times)}; + Consensus::Params jump_params{}; + jump_params.ExtendedCoinbaseMaturityStartTime = jump[20].GetMedianTimePast(); + jump_params.RdtsExpiryTime = std::numeric_limits::max() / 2; + const Coin cb{coinbase_at(60)}; + const int64_t due{jump[60].GetMedianTimePast() + maturity}; + for (int h = 61; h < 120; ++h) { + BOOST_CHECK_EQUAL(CoinbaseMatureForExtendedRule(jump_params, cb, jump[h]), jump[h].GetMedianTimePast() >= due); + } + // Sanity: the boundary is actually crossed inside this chain. + BOOST_CHECK(!CoinbaseMatureForExtendedRule(jump_params, cb, jump[80])); + BOOST_CHECK(CoinbaseMatureForExtendedRule(jump_params, cb, jump[119])); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/validation.cpp b/src/validation.cpp index f19ef0e45901..71cdad97ce5f 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -272,6 +272,45 @@ 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; +} + +bool CoinbaseMatureForExtendedRule(const Consensus::Params& params, const Coin& coin, const CBlockIndex& block_prev) +{ + if (!coin.IsCoinBase()) return true; + const int64_t mtp_prev{block_prev.GetMedianTimePast()}; + if (!params.ExtendedCoinbaseMaturityActiveAt(mtp_prev)) return true; + const CBlockIndex* coinbase_block{Assert(block_prev.GetAncestor(coin.nHeight))}; + // Only outputs created while the rule was active are subject to it + // (grandfathering). The spending block is inside the window, so the + // creating block, an ancestor, is past the expiry only if it is too. + if (coinbase_block->pprev == nullptr || + !params.ExtendedCoinbaseMaturityActiveAt(coinbase_block->pprev->GetMedianTimePast())) { + return true; + } + // Both ends are median-time-past values, which never decrease along a + // chain, so the answer for the next block is knowable from the tip and + // holds unchanged until the tip changes. + return mtp_prev >= coinbase_block->GetMedianTimePast() + EXTENDED_COINBASE_MATURITY_TIME; +} + // Returns the script flags which should be checked for a given block static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const ChainstateManager& chainman); @@ -431,7 +470,10 @@ void Chainstate::MaybeUpdateMempoolForReorg( } } - // If the transaction spends any coinbase outputs, it must be mature. + // If the transaction spends any coinbase outputs, it must be mature, + // under the extended rule too: it may apply to the next block again + // after a reorg back across its expiry, and the new tip's + // median-time-past may be lower (see CoinbaseMatureForExtendedRule). if (it->GetSpendsCoinbase()) { for (const CTxIn& txin : tx.vin) { if (m_mempool->exists(GenTxid::Txid(txin.prevout.hash))) continue; @@ -441,6 +483,9 @@ void Chainstate::MaybeUpdateMempoolForReorg( if (coin.IsCoinBase() && mempool_spend_height - coin.nHeight < COINBASE_MATURITY) { return true; } + if (!CoinbaseMatureForExtendedRule(m_chainman.GetConsensus(), coin, *m_chain.Tip())) { + return true; + } } } // Transaction is still valid and cached LockPoints are updated. @@ -1019,6 +1064,16 @@ bool MemPoolAccept::PreChecks(ATMPArgs& args, Workspace& ws) return false; // state filled in by CheckTxInputs } + // The extended coinbase maturity rule (see CoinbaseMatureForExtendedRule) + // is likewise evaluated for the next block, whose parent is the tip. + for (const CTxIn& txin : tx.vin) { + const Coin& coin{m_view.AccessCoin(txin.prevout)}; + if (!CoinbaseMatureForExtendedRule(m_active_chainstate.m_chainman.GetConsensus(), coin, *m_active_chainstate.m_chain.Tip())) { + return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-premature-spend-of-coinbase", + strprintf("coinbase at height %d is not yet mature under the extended maturity rule", coin.nHeight)); + } + } + if (m_pool.m_opts.minrelaymaturity) { auto max_coin_height = block_height_next - m_pool.m_opts.minrelaymaturity; static_assert(std::is_signed_v, "Unsigned max_coin_height needs a range check"); @@ -2993,6 +3048,14 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, const CheckTxInputsRules chk_input_rules{reduced_data_active ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None}; + // Extended coinbase maturity (see CoinbaseMatureForExtendedRule): a + // temporary soft fork that expires with RDTS. While it is active for this + // block, coinbase outputs created since its activation on this chain may + // only be spent once this block's parent has a median-time-past at least + // EXTENDED_COINBASE_MATURITY_TIME past the coinbase block's; the check is + // made per input below, alongside the BIP68 height lookup. + const bool extended_coinbase_maturity_active{params.GetConsensus().ExtendedCoinbaseMaturityActiveAt(pindex->pprev->GetMedianTimePast())}; + // Check generation tx output sizes if REDUCED_DATA is active if (chk_input_rules.test(CheckTxInputsRules::OutputSizeLimit)) { TxValidationState tx_state; @@ -3040,12 +3103,19 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state, prevheights.resize(tx.vin.size()); flags_per_input.clear(); for (size_t j = 0; j < tx.vin.size(); j++) { - prevheights[j] = view.AccessCoin(tx.vin[j].prevout).nHeight; + const Coin& coin{view.AccessCoin(tx.vin[j].prevout)}; + prevheights[j] = coin.nHeight; if (prevheights[j] < reduced_data_start_height) { flags_per_input.resize(tx.vin.size(), flags); flags_per_input[j] = flags & ~REDUCED_DATA_MANDATORY_VERIFY_FLAGS; } + if (extended_coinbase_maturity_active && !CoinbaseMatureForExtendedRule(params.GetConsensus(), coin, *pindex->pprev)) { + state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-txns-premature-spend-of-coinbase", + strprintf("coinbase at height %d is not yet mature under the extended maturity rule, spent in transaction %s", coin.nHeight, tx.GetHash().ToString())); + break; + } } + if (!state.IsValid()) break; if (!SequenceLocks(tx, nLockTimeFlags, prevheights, *pindex)) { state.Invalid(BlockValidationResult::BLOCK_CONSENSUS, "bad-txns-nonfinal", diff --git a/src/validation.h b/src/validation.h index e38d63f554d2..2e32cc779c83 100644 --- a/src/validation.h +++ b/src/validation.h @@ -347,6 +347,29 @@ 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): + * whether spending `coin` in the block built on `block_prev` satisfies the + * rule. True for any non-coinbase output, whenever the rule is not active for + * that block, and for a coinbase output created before the rule activated on + * that chain; otherwise the parent's median-time-past must be at least + * EXTENDED_COINBASE_MATURITY_TIME past the coinbase block's. The COINBASE_MATURITY + * depth requirement is separate (Consensus::CheckTxInputs) and always applies. + * `coin` must be an unspent output of that chain, so that its creating block is + * an ancestor of `block_prev`. + */ +bool CoinbaseMatureForExtendedRule(const Consensus::Params& params, const Coin& coin, const CBlockIndex& block_prev); + void LimitMempoolSize(CTxMemPool&, CCoinsViewCache&); /** From 93aeadbf189a40f8ddd4dc979560124cb7a522cc Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 20:07:46 -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 0cfe40e903e5a81e4b96983becd18019146f7386 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 20:07:46 -0600 Subject: [PATCH 4/7] wallet: Account for the extended coinbase maturity when reporting immature coins --- src/interfaces/chain.h | 8 ++++++++ src/node/interfaces.cpp | 15 +++++++++++++++ src/qt/transactiondesc.cpp | 8 +++++++- src/wallet/wallet.cpp | 11 ++++++++++- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/interfaces/chain.h b/src/interfaces/chain.h index 45dbb98bed15..70d9086db4bb 100644 --- a/src/interfaces/chain.h +++ b/src/interfaces/chain.h @@ -144,6 +144,14 @@ class Chain //! pruned), and contains transactions. virtual bool haveBlockOnDisk(int height) = 0; + //! Whether the temporary extended coinbase maturity soft fork still + //! holds back a coinbase output created at the given height of the + //! active chain from being spent in the block after the current tip, + //! and if so an estimate of the blocks until it no longer does (at + //! least 1). 0 when it does not (the COINBASE_MATURITY depth rule is + //! the caller's to apply). + virtual int extendedCoinbaseBlocksToMaturity(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..852f30642ef0 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +67,7 @@ #include // IWYU pragma: keep +#include #include #include #include @@ -581,6 +583,19 @@ class ChainImpl : public Chain const CBlockIndex* block{chainman().ActiveChain()[height]}; return block && ((block->nStatus & BLOCK_HAVE_DATA) != 0) && block->nTx > 0; } + int extendedCoinbaseBlocksToMaturity(int coinbase_height) override + { + LOCK(::cs_main); + const Consensus::Params& params{chainman().GetConsensus()}; + const CBlockIndex* tip{chainman().ActiveChain().Tip()}; + if (!tip || !params.ExtendedCoinbaseMaturityActiveAt(tip->GetMedianTimePast())) return 0; + const CBlockIndex* coinbase_block{chainman().ActiveChain()[coinbase_height]}; + if (!coinbase_block || !coinbase_block->pprev || + !params.ExtendedCoinbaseMaturityActiveAt(coinbase_block->pprev->GetMedianTimePast())) return 0; + const int64_t remaining{coinbase_block->GetMedianTimePast() + EXTENDED_COINBASE_MATURITY_TIME - tip->GetMedianTimePast()}; + if (remaining <= 0) return 0; + return std::max(1, (remaining + params.nPowTargetSpacing - 1) / params.nPowTargetSpacing); + } 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..512c07a503ed 100644 --- a/src/qt/transactiondesc.cpp +++ b/src/qt/transactiondesc.cpp @@ -329,7 +329,13 @@ 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, as an estimate); 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..902ad5e52f48 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 blocks_to_maturity{std::max(0, (COINBASE_MATURITY + 1) - chain_depth)}; + // A confirmed coinbase may also be held back by the temporary extended + // maturity soft fork; the chain knows for how much longer (an estimate, + // since that rule is a median-time-past one). + if (HaveChain()) { + if (auto* conf = wtx.state()) { + blocks_to_maturity = std::max(blocks_to_maturity, chain().extendedCoinbaseBlocksToMaturity(conf->confirmed_block_height)); + } + } + return blocks_to_maturity; } bool CWallet::IsTxImmatureCoinBase(const CWalletTx& wtx) const From 91889c49bfeb79a3f0134f2fb3303a0b86ba7525 Mon Sep 17 00:00:00 2001 From: Chris Guida Date: Thu, 10 Sep 2026 20:07:46 -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=