From 28261a5688a376c789f9bdd421592a5c000b9f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 9 Oct 2026 15:46:29 -0400 Subject: [PATCH 1/4] consensus: add a buried deployment for contiguous retarget windows --- src/chainparams.cpp | 15 +++++++++++++++ src/chainparamsbase.cpp | 1 + src/consensus/params.h | 15 ++++++++++++++- src/deploymentinfo.cpp | 4 ++++ src/kernel/chainparams.cpp | 12 ++++++++++++ src/kernel/chainparams.h | 3 +++ src/rpc/blockchain.cpp | 11 +++++++++++ 7 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index e2851d88c986..6e1f80979c73 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -49,6 +49,21 @@ void ReadSigNetArgs(const ArgsManager& args, CChainParams::SigNetOptions& option } options.pow_target_spacing = *signetblocktime; } + if (args.IsArgSet("-signettimewarpfixheight")) { + if (!args.IsArgSet("-signetchallenge")) { + throw std::runtime_error("-signettimewarpfixheight cannot be set without -signetchallenge"); + } + // Parsed from the raw string rather than via GetIntArg, which funnels through + // LocaleIndependentAtoi and yields 0 for anything unparseable: a typo would then + // silently schedule the fork at genesis and split this signet from its peers. + // Same treatment -testactivationheight already gets. + int height{0}; + const std::string value{args.GetArg("-signettimewarpfixheight", "")}; + if (!ParseInt32(value, &height) || height < 0 || height >= std::numeric_limits::max()) { + throw std::runtime_error(strprintf("Invalid height value (%s) for -signettimewarpfixheight.", value)); + } + options.timewarpfix_height = height; + } } void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& options) diff --git a/src/chainparamsbase.cpp b/src/chainparamsbase.cpp index de5a866c7c4f..6202a3442775 100644 --- a/src/chainparamsbase.cpp +++ b/src/chainparamsbase.cpp @@ -26,6 +26,7 @@ void SetupChainParamsBaseOptions(ArgsManager& argsman) argsman.AddArg("-signet", "Use the signet chain. Equivalent to -chain=signet. Note that the network is defined by the -signetchallenge parameter", ArgsManager::ALLOW_ANY, OptionsCategory::CHAINPARAMS); argsman.AddArg("-signetchallenge", "Blocks must satisfy the given script to be considered valid (only for signet networks; defaults to the global default signet test network challenge)", ArgsManager::ALLOW_ANY | ArgsManager::DISALLOW_NEGATION, OptionsCategory::CHAINPARAMS); argsman.AddArg("-signetblocktime", "Difficulty adjustment will target a block time of the given amount in seconds (only for custom signet networks, must have -signetchallenge set; defaults to 10 minutes)", ArgsManager::ALLOW_ANY | ArgsManager::DISALLOW_NEGATION, OptionsCategory::CHAINPARAMS); + argsman.AddArg("-signettimewarpfixheight", "Activate contiguous difficulty retarget windows at the given height (only for custom signet networks, must have -signetchallenge set; unscheduled by default)", ArgsManager::ALLOW_ANY | ArgsManager::DISALLOW_NEGATION, OptionsCategory::CHAINPARAMS); argsman.AddArg("-signetseednode", "Specify a seed node for the signet network, in the hostname[:port] format, e.g. sig.net:1234 (may be used multiple times to specify multiple seed nodes; defaults to the global default signet test network seed node(s))", ArgsManager::ALLOW_ANY | ArgsManager::DISALLOW_NEGATION, OptionsCategory::CHAINPARAMS); } diff --git a/src/consensus/params.h b/src/consensus/params.h index 45ef3291eedf..4798c6c85628 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -29,8 +29,9 @@ enum BuriedDeployment : int16_t { DEPLOYMENT_CSV, DEPLOYMENT_SEGWIT, DEPLOYMENT_BLAKE2B, + DEPLOYMENT_TIMEWARPFIX, }; -constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_BLAKE2B; } +constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_TIMEWARPFIX; } enum DeploymentPos : uint16_t { DEPLOYMENT_TESTDUMMY, @@ -139,6 +140,11 @@ struct Params { int Blake2bHeight{std::numeric_limits::max()}; std::vector Blake2bHeadline; uint8_t Blake2bTargetShift{20}; + /** Height from which contiguous retarget windows apply. Both rules key on the block + * closing a window, so the first one actually affected is the first closing block at + * or above this height, not the block at this height. Unscheduled by default: it is + * a hardfork in its own right, and the BLAKE2b fork is already past. */ + int TimewarpFixHeight{std::numeric_limits::max()}; /** * RDTS (BIP110 ReducedData Temporary Softfork) deployment expiry. * @@ -216,6 +222,8 @@ struct Params { return SegwitHeight; case DEPLOYMENT_BLAKE2B: return Blake2bHeight; + case DEPLOYMENT_TIMEWARPFIX: + return TimewarpFixHeight; } // no default case, so the compiler can warn about missing cases return std::numeric_limits::max(); } @@ -225,6 +233,11 @@ struct Params { return height >= Blake2bHeight; } + bool IsTimewarpFixHeight(int height) const + { + return height >= TimewarpFixHeight; + } + /** RDTS activates at the BLAKE2b hardfork; there is no separate schedule. */ int RdtsActivationHeight() const { return Blake2bHeight; } diff --git a/src/deploymentinfo.cpp b/src/deploymentinfo.cpp index 80aa0a668bac..d0e848f58b7f 100644 --- a/src/deploymentinfo.cpp +++ b/src/deploymentinfo.cpp @@ -35,6 +35,8 @@ std::string DeploymentName(Consensus::BuriedDeployment dep) return "segwit"; case Consensus::DEPLOYMENT_BLAKE2B: return "blake2b"; + case Consensus::DEPLOYMENT_TIMEWARPFIX: + return "timewarpfix"; } // no default case, so the compiler can warn about missing cases return ""; } @@ -53,6 +55,8 @@ std::optional GetBuriedDeployment(const std::string return Consensus::BuriedDeployment::DEPLOYMENT_CSV; } else if (name == "blake2b") { return Consensus::BuriedDeployment::DEPLOYMENT_BLAKE2B; + } else if (name == "timewarpfix") { + return Consensus::BuriedDeployment::DEPLOYMENT_TIMEWARPFIX; } return std::nullopt; } diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 5c2279555ea7..627a05d6f543 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -546,6 +546,9 @@ class SigNetParams : public CChainParams { consensus.SegwitHeight = 1; consensus.nPowTargetTimespan = 14 * 24 * 60 * 60; // two weeks consensus.nPowTargetSpacing = options.pow_target_spacing; + if (options.timewarpfix_height) { + consensus.TimewarpFixHeight = *options.timewarpfix_height; + } consensus.fPowAllowMinDifficultyBlocks = false; consensus.enforce_BIP94 = false; consensus.fPowNoRetargeting = false; @@ -656,6 +659,15 @@ class CRegTestParams : public CChainParams case Consensus::BuriedDeployment::DEPLOYMENT_BLAKE2B: consensus.Blake2bHeight = int{height}; break; + case Consensus::BuriedDeployment::DEPLOYMENT_TIMEWARPFIX: + // CalculateNextWorkRequired takes the BIP94 base target without the + // contiguous-window shift, so the fix would be only half applied here. + // Refuse rather than run a half-applied consensus rule. + if (consensus.enforce_BIP94) { + throw std::runtime_error("timewarpfix activation cannot be combined with BIP94 enforcement"); + } + consensus.TimewarpFixHeight = int{height}; + break; case Consensus::BuriedDeployment::DEPLOYMENT_SEGWIT: consensus.SegwitHeight = int{height}; break; diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index 34734a6fb48b..0f25b9f8bc51 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -144,6 +144,9 @@ class CChainParams std::optional> challenge{}; std::optional> seeds{}; int64_t pow_target_spacing{10 * 60}; + //! Height at which contiguous retarget windows activate (see + //! -signettimewarpfixheight). Left unscheduled by default. + std::optional timewarpfix_height{}; }; /** diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 8af7a86aae01..d7f31c56d0ab 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -2010,6 +2010,10 @@ RPCHelpMan getdeploymentinfo() {RPCResult::Type::NUM, "height", "the height the hardfork activates at"}, {RPCResult::Type::BOOL, "active", "whether the hardfork rules apply to the block after this one"}, }}, + {RPCResult::Type::OBJ, "timewarpfix", /*optional=*/true, "contiguous retarget window schedule, present only when one is configured", { + {RPCResult::Type::NUM, "height", "the height the rules activate at"}, + {RPCResult::Type::BOOL, "active", "whether the rules apply to the block after this one"}, + }}, } }, RPCExamples{ HelpExampleCli("getdeploymentinfo", "") + HelpExampleRpc("getdeploymentinfo", "") }, @@ -2051,6 +2055,13 @@ RPCHelpMan getdeploymentinfo() Consensus::DEPLOYMENT_BLAKE2B)); deploymentinfo.pushKV("blake2b", std::move(hf)); } + if (consensus.TimewarpFixHeight != std::numeric_limits::max()) { + UniValue tw(UniValue::VOBJ); + tw.pushKV("height", consensus.TimewarpFixHeight); + tw.pushKV("active", DeploymentActiveAfter(blockindex, chainman, + Consensus::DEPLOYMENT_TIMEWARPFIX)); + deploymentinfo.pushKV("timewarpfix", std::move(tw)); + } return deploymentinfo; }, }; From 71c7f120c6b5469cb42a7cbb3ecfb4bd5739c0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 9 Oct 2026 15:46:31 -0400 Subject: [PATCH 2/4] pow: make retarget windows contiguous and floor the block that closes one --- src/node/miner.cpp | 8 ++++++-- src/node/miner.h | 4 ++-- src/pow.cpp | 21 ++++++++++++++++++++- src/pow.h | 13 +++++++++++++ src/rpc/mining.cpp | 6 +++--- 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/node/miner.cpp b/src/node/miner.cpp index 5fe1039cba77..890b38f41b98 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -31,8 +31,9 @@ namespace node { -int64_t GetMinimumTime(const CBlockIndex* pindexPrev, const int64_t difficulty_adjustment_interval) +int64_t GetMinimumTime(const CBlockIndex* pindexPrev, const Consensus::Params& params) { + const int64_t difficulty_adjustment_interval{params.DifficultyAdjustmentInterval()}; int64_t min_time{pindexPrev->GetMedianTimePast() + 1}; // Height of block to be mined. const int height{pindexPrev->nHeight + 1}; @@ -41,13 +42,16 @@ int64_t GetMinimumTime(const CBlockIndex* pindexPrev, const int64_t difficulty_a if (height % difficulty_adjustment_interval == 0) { min_time = std::max(min_time, pindexPrev->GetBlockTime() - MAX_TIMEWARP); } + if (const std::optional closing_min{MinimumClosingBlockTime(pindexPrev, params)}) { + min_time = std::max(min_time, *closing_min); + } return min_time; } int64_t UpdateTime(CBlockHeader* pblock, const Consensus::Params& consensusParams, const CBlockIndex* pindexPrev) { int64_t nOldTime = pblock->nTime; - int64_t nNewTime{std::max(GetMinimumTime(pindexPrev, consensusParams.DifficultyAdjustmentInterval()), + int64_t nNewTime{std::max(GetMinimumTime(pindexPrev, consensusParams), TicksSinceEpoch(NodeClock::now()))}; if (nOldTime < nNewTime) { diff --git a/src/node/miner.h b/src/node/miner.h index acb40e4e81b1..b6347296df9f 100644 --- a/src/node/miner.h +++ b/src/node/miner.h @@ -232,9 +232,9 @@ class BlockAssembler /** * Get the minimum time a miner should use in the next block. This always * accounts for the BIP94 timewarp rule, so does not necessarily reflect the - * consensus limit. + * consensus limit. It also applies MinimumClosingBlockTime, which does. */ -int64_t GetMinimumTime(const CBlockIndex* pindexPrev, const int64_t difficulty_adjustment_interval); +int64_t GetMinimumTime(const CBlockIndex* pindexPrev, const Consensus::Params& params); int64_t UpdateTime(CBlockHeader* pblock, const Consensus::Params& consensusParams, const CBlockIndex* pindexPrev); diff --git a/src/pow.cpp b/src/pow.cpp index 11473ec9907e..4423675cae7d 100644 --- a/src/pow.cpp +++ b/src/pow.cpp @@ -60,6 +60,12 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast, const CBlockHead } else { // Go back by what we want to be 14 days worth of blocks int nHeightFirst = pindexLast->nHeight - (params.DifficultyAdjustmentInterval()-1); + if (params.IsTimewarpFixHeight(pindexLast->nHeight) && nHeightFirst > 0) { + // Measure from the previous window's last block, so windows share an + // endpoint and the seam between them is no longer unmeasured. Gated on + // pindexLast, the block MinimumClosingBlockTime floors: both must name it. + --nHeightFirst; + } assert(nHeightFirst >= 0); const CBlockIndex* pindexFirst = pindexLast->GetAncestor(nHeightFirst); assert(pindexFirst); @@ -75,6 +81,18 @@ unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast, const CBlockHead return nBits; } +std::optional MinimumClosingBlockTime(const CBlockIndex* pindexPrev, const Consensus::Params& params) +{ + const int height{pindexPrev->nHeight + 1}; + // Only the block closing a window, and only once windows are contiguous. Gate first: + // FindInheritedInvalidBlocks calls this for every entry in the block index. + if (!params.IsTimewarpFixHeight(height)) return std::nullopt; + const int64_t interval{params.DifficultyAdjustmentInterval()}; + if (height < interval || height % interval != interval - 1) return std::nullopt; + // Non-null by the guard above: 0 <= height - interval <= pindexPrev->nHeight. + return Assert(pindexPrev->GetAncestor(static_cast(height - interval)))->GetBlockTime(); +} + unsigned int CalculateNextWorkRequired(const CBlockIndex* pindexLast, int64_t nFirstBlockTime, const Consensus::Params& params) { if (params.fPowNoRetargeting) @@ -91,7 +109,8 @@ unsigned int CalculateNextWorkRequired(const CBlockIndex* pindexLast, int64_t nF const arith_uint256 bnPowLimit = UintToArith256(params.powLimit); arith_uint256 bnNew; - // Special difficulty rule for Testnet4 + // Special difficulty rule for Testnet4. Note: no contiguous-window decrement here, so + // the fix would be half applied on a BIP94 chain; chainparams refuses that combination. if (params.enforce_BIP94) { // Here we use the first block of the difficulty period. This way // the real difficulty is always preserved in the first block as diff --git a/src/pow.h b/src/pow.h index 1ad1a1e14f81..bcaa3d13aa2f 100644 --- a/src/pow.h +++ b/src/pow.h @@ -28,6 +28,19 @@ class arith_uint256; std::optional DeriveTarget(unsigned int nBits, const uint256 pow_limit); unsigned int GetNextWorkRequired(const CBlockIndex* pindexLast, const CBlockHeader *pblock, const Consensus::Params&) ARG_NONNULL(1) ARG_NONNULL(2); + +/** Earliest timestamp the block after pindexPrev may carry on account of the retarget + * window it closes, or nullopt where nothing beyond the usual timestamp rules applies. + * + * With contiguous windows the block closing one is where the next starts measuring, so + * stamping it earlier lets the next window re-measure time the previous one already did. + * The clamps do not prevent that: a negative span is credited as nPowTargetTimespan/4, + * which RAISES difficulty, and an attacker spends that rise to buy two 4x drops. + * + * Enforced from ContextualCheckBlockHeaderVolatile (see there for why); mirrored in + * GetMinimumTime so a template cannot be built that validation would reject. */ +std::optional MinimumClosingBlockTime(const CBlockIndex* pindexPrev, const Consensus::Params& params) ARG_NONNULL(1); + unsigned int CalculateNextWorkRequired(const CBlockIndex* pindexLast, int64_t nFirstBlockTime, const Consensus::Params&); /** Check whether a block hash satisfies the proof-of-work requirement specified by nBits */ diff --git a/src/rpc/mining.cpp b/src/rpc/mining.cpp index 61565468fe05..81b4a9bcce66 100644 --- a/src/rpc/mining.cpp +++ b/src/rpc/mining.cpp @@ -698,7 +698,7 @@ static RPCHelpMan getblocktemplate() {RPCResult::Type::NUM, "coinbasevalue", "maximum allowable input to coinbase transaction, including the generation award and transaction fees (in satoshis)"}, {RPCResult::Type::STR, "longpollid", "an id to include with a request to longpoll on an update to this template"}, {RPCResult::Type::STR, "target", "The hash target"}, - {RPCResult::Type::NUM_TIME, "mintime", "The minimum timestamp appropriate for the next block time, expressed in " + UNIX_EPOCH_TIME + ". Adjusted for the proposed BIP94 timewarp rule."}, + {RPCResult::Type::NUM_TIME, "mintime", "The minimum timestamp appropriate for the next block time, expressed in " + UNIX_EPOCH_TIME + ". Adjusted for the proposed BIP94 timewarp rule, and for the floor on a block closing a retarget period."}, {RPCResult::Type::ARR, "mutable", "list of ways the block template may be changed", { {RPCResult::Type::STR, "value", "A way the block template may be changed, e.g. 'time', 'transactions', 'prevblock'"}, @@ -707,7 +707,7 @@ static RPCHelpMan getblocktemplate() {RPCResult::Type::NUM, "sigoplimit", "limit of sigops in blocks"}, {RPCResult::Type::NUM, "sizelimit", "limit of block size"}, {RPCResult::Type::NUM, "weightlimit", /*optional=*/true, "limit of block weight"}, - {RPCResult::Type::NUM_TIME, "curtime", "current timestamp in " + UNIX_EPOCH_TIME + ". Adjusted for the proposed BIP94 timewarp rule."}, + {RPCResult::Type::NUM_TIME, "curtime", "current timestamp in " + UNIX_EPOCH_TIME + ". Adjusted for the proposed BIP94 timewarp rule, and for the floor on a block closing a retarget period."}, {RPCResult::Type::STR, "bits", "compressed target of next block"}, {RPCResult::Type::NUM, "height", "The height of the next block"}, {RPCResult::Type::STR_HEX, "signet_challenge", /*optional=*/true, "Only on signet"}, @@ -1099,7 +1099,7 @@ static UniValue TemplateToJSON(const Consensus::Params& consensusParams, const C result.pushKV("coinbasevalue", (int64_t)block.vtx[0]->vout[0].nValue); result.pushKV("longpollid", pindexPrev->GetBlockHash().GetHex() + ToString(nTransactionsUpdatedLast)); result.pushKV("target", hashTarget.GetHex()); - result.pushKV("mintime", GetMinimumTime(pindexPrev, consensusParams.DifficultyAdjustmentInterval())); + result.pushKV("mintime", GetMinimumTime(pindexPrev, consensusParams)); result.pushKV("mutable", std::move(aMutable)); result.pushKV("noncerange", "00000000ffffffff"); int64_t nSigOpLimit = MAX_BLOCK_SIGOPS_COST; From 8677a718ec23c5f89d0318897b4f45ecfe05c03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 9 Oct 2026 15:46:32 -0400 Subject: [PATCH 3/4] validation: enforce the closing-block floor and correct inherited chains --- src/init.cpp | 24 +++++---- src/validation.cpp | 119 ++++++++++++++++++++++++++++++++++++--------- src/validation.h | 4 +- 3 files changed, 109 insertions(+), 38 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 50c4d691bf67..2faf1d8d0d6f 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2038,23 +2038,21 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) // the reindex prompt below offers recovery, rather than running on (or // partially rewinding) an invalid chain. // - // Skip this on any reindex: both -reindex and -reindex-chainstate reconnect - // blocks through ConnectBlock, which re-runs the bad-version-blake2b header - // check (it is contextual/volatile) and rejects the offending blocks during - // the rebuild, so the correction is redundant. The same holds whenever the - // active chain has not been built yet (a coins database with no best block, - // reindex or not); the correction itself is a no-op in that state. + // Skipped only on a full -reindex, which re-accepts every header and so needs no + // correction. -reindex-chainstate IS corrected: the rebuild alone misses inherited + // retarget nBits (see ContextualCheckBlockHeaderVolatile), and with the coins + // database wiped there is no tip, so the call below marks without reorganizing. // // The block index is shared, so the invalid marks apply to any background // (assumeutxo) chainstate too; only the active chainstate is reorganized here. - // Safe while every snapshot base stays below the BLAKE2b fork height (as - // today); a future snapshot base above it would need re-review. - if (status == ChainstateLoadStatus::SUCCESS && !ShutdownRequested(node) && - !do_reindex && !do_reindex_chainstate) { - bilingual_str rdts_error; - if (!node.chainman->ActiveChainstate().CorrectRdtsInvalidBlocks(rdts_error)) { + // Safe while every snapshot base stays below both the BLAKE2b fork height and + // TimewarpFixHeight (as today, the latter being unscheduled everywhere); a future + // snapshot base above either would need re-review. + if (status == ChainstateLoadStatus::SUCCESS && !ShutdownRequested(node) && !do_reindex) { + bilingual_str inherited_error; + if (!node.chainman->ActiveChainstate().CorrectInheritedInvalidBlocks(inherited_error)) { status = ChainstateLoadStatus::FAILURE; - error = rdts_error; + error = inherited_error; } } diff --git a/src/validation.cpp b/src/validation.cpp index 26661fbee5aa..451b9509157e 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2390,6 +2390,8 @@ void Chainstate::InvalidChainFound(CBlockIndex* pindexNew) // of that heuristic. Only inherited data directories hold such branches // (headers at or above the fork height are rejected at acceptance), and // this keeps them consistent with freshly synced nodes. + // A contiguous-window violator is deliberately NOT suppressed here: unlike BLAKE2b's, + // it only outweighs us if those miners hold more hash rate, which is worth warning about. if (!m_chainman.ContinuesSha256dPastFork(*pindexNew) && (!m_chainman.m_best_invalid || pindexNew->nChainWork > m_chainman.m_best_invalid->nChainWork)) { m_chainman.m_best_invalid = pindexNew; @@ -4780,8 +4782,10 @@ static bool ContextualCheckBlockHeader(const CBlockHeader& block, BlockValidatio return true; } -/** Context-dependent validity checks, but rechecked in ConnectBlock(). - * Note that -reindex-chainstate skips the validation that happens here! +/** Context-dependent validity checks that ARE rechecked in ConnectBlock(), and so are + * enforced again on every block replay, including -reindex-chainstate. That is the + * difference from ContextualCheckBlockHeader, which ConnectBlock does not call: a rule + * placed there is never re-derived for a header already in the block index. */ static bool ContextualCheckBlockHeaderVolatile(const CBlockHeader& block, BlockValidationState& state, const ChainstateManager& chainman, const CBlockIndex* pindexPrev) EXCLUSIVE_LOCKS_REQUIRED(::cs_main) { @@ -4798,6 +4802,16 @@ static bool ContextualCheckBlockHeaderVolatile(const CBlockHeader& block, BlockV } } + // A block closing a retarget period may not precede the block that period was + // measured from; see MinimumClosingBlockTime. Unlike ContextualCheckBlockHeader, + // this is reached with a null pindexPrev for genesis, which it would dereference. + if (pindexPrev != nullptr) { + if (const std::optional min_time{MinimumClosingBlockTime(pindexPrev, consensusParams)}; + min_time && block.GetBlockTime() < *min_time) { + return state.Invalid(BlockValidationResult::BLOCK_INVALID_HEADER, "time-timewarp-closing", "block's timestamp precedes the start of its difficulty period"); + } + } + // Mandatory signaling for deployments approaching max_activation_height for (int i = 0; i < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; i++) { const Consensus::DeploymentPos pos = static_cast(i); @@ -5631,10 +5645,43 @@ std::vector ChainstateManager::FindInheritedInvalidBlocks() // change as other blocks are invalidated and one scan suffices. // Checkpoints are deliberately not consulted: separation is performed by // the PoW change, and checkpoints are optional (-checkpoints=0). + // + // Both contiguous-window rules need the same treatment: a connected chain is never + // revisited, so neither is re-derived for it. Both verdicts read stored timestamps + // and nBits, which no invalidation changes, so one pass suffices. for (auto& [_, index] : m_blockman.m_block_index) { if (index.nStatus & BLOCK_FAILED_MASK) continue; // already handled if (params.IsBlake2bHeight(index.nHeight) && !index.m_header_v2) { violators.push_back(&index); + continue; + } + if (index.pprev == nullptr) continue; + // Both conditions below walk ancestry, which is only meaningful if the stored + // height agrees with the parent link. Nothing this software writes violates that + // (AddToBlockIndex always sets nHeight = pprev->nHeight + 1), but nHeight lives + // outside the PoW-committed header and LoadBlockIndex does not check it, and a + // dangling hashPrev mints a placeholder parent with nHeight 0 and nBits 0. Judging + // a block against either would invalidate a valid chain, so skip instead. + if (index.nHeight != index.pprev->nHeight + 1) continue; + // Rule B: the block closing a period may not precede the block that period was + // measured from. Self-gating: MinimumClosingBlockTime keys on this block's own + // height, so it must NOT be gated on the parent's height the way rule A is. + const std::optional floor{MinimumClosingBlockTime(index.pprev, params)}; + if (floor && index.GetBlockTime() < *floor) { + violators.push_back(&index); + continue; + } + // Rule A: an inherited retarget block carries the legacy nBits. This is the + // likelier of the two, since it diverges on any honest chain at the first + // retarget after activation while the floor only bites under attack. Gated on the + // parent's height to match GetNextWorkRequired's gate, so pre-fork history is not + // re-judged. + if (params.IsTimewarpFixHeight(index.pprev->nHeight) && + index.nHeight % params.DifficultyAdjustmentInterval() == 0) { + const CBlockHeader header{index.GetBlockHeader()}; + if (index.nBits != GetNextWorkRequired(index.pprev, &header, params)) { + violators.push_back(&index); + } } } @@ -5902,7 +5949,7 @@ bool Chainstate::RewindForChainstateRevalidation(bilingual_str& error) return true; } -bool Chainstate::CorrectRdtsInvalidBlocks(bilingual_str& error) +bool Chainstate::CorrectInheritedInvalidBlocks(bilingual_str& error) { AssertLockNotHeld(m_chainstate_mutex); AssertLockNotHeld(::cs_main); @@ -5911,14 +5958,40 @@ bool Chainstate::CorrectRdtsInvalidBlocks(bilingual_str& error) bool tip_failed{false}; { LOCK(cs_main); - // No active chain yet: the coins database had no best block (a - // chainstate directory removed by hand, or a run killed before its - // first flush), so LoadChainTip was skipped. There is nothing to - // reorganize away from, and InvalidateBlock and InvalidChainFound both - // require a tip. The rebuild that follows connects every block through - // ConnectBlock, which enforces the same rule; any header-only violator - // is corrected by the next normal startup. - if (m_chain.Tip() == nullptr) return true; + // No active chain yet: the coins database had no best block (-reindex-chainstate, + // a chainstate directory removed by hand, or a run killed before its first + // flush), so LoadChainTip was skipped. There is nothing to reorganize away from, + // and InvalidateBlock and InvalidChainFound both require a tip. + // + // The violators still have to be marked before the rebuild chooses a chain. + // ConnectBlock re-runs ContextualCheckBlockHeaderVolatile, so it rejects a + // closing-block floor violator on its own; it does NOT re-run + // ContextualCheckBlockHeader, so an inherited retarget block carrying the legacy + // contiguous-window nBits would be reconnected and the node would come back up on + // a chain the network rejects. Marking needs only the block index; it is the + // reorg that needs a tip. + if (m_chain.Tip() == nullptr) { + const std::vector pending{m_chainman.FindInheritedInvalidBlocks()}; + int marked{0}; + for (CBlockIndex* target : pending) { + if (target->nStatus & BLOCK_FAILED_MASK) continue; + target->nStatus |= BLOCK_FAILED_VALID; + m_blockman.m_dirty_blockindex.insert(target); + ++marked; + } + // Descendants are deliberately not walked here. FindMostWorkChain marks + // BLOCK_FAILED_CHILD, inserts dirty and drops candidates when it walks back + // onto a failed ancestor, and the rebuild that follows does exactly that. + // Doing it here instead would cost a full index scan per violator: unlike + // the tip path below there is no height ordering to make the skip above fire, + // and an inherited BLAKE2b chain has one violator per block. + if (marked > 0) { + LogPrintf("%d block(s) inherited from a client that was not enforcing this " + "hardfork are invalid under it; marked before the chainstate " + "rebuild so it cannot reconnect them\n", marked); + } + return true; + } const CBlockIndex& tip{*m_chain.Tip()}; const Consensus::Params& params{m_chainman.GetConsensus()}; // The expiry is a fixed date while the fork is a height: a fork @@ -5957,8 +6030,8 @@ bool Chainstate::CorrectRdtsInvalidBlocks(bilingual_str& error) "before the coins database was written); rewinding to the last valid block\n"); } if (!violators.empty()) { - LogPrintf("RDTS: %d block(s) inherited from a client that was not enforcing the " - "BLAKE2b hardfork are invalid under it; correcting\n", violators.size()); + LogPrintf("%d block(s) inherited from a client that was not enforcing the hardfork " + "rules in force here are invalid under them; correcting\n", violators.size()); } bool invalidated{tip_failed}; @@ -5992,22 +6065,22 @@ bool Chainstate::CorrectRdtsInvalidBlocks(bilingual_str& error) } } if (needs_reindex) { - LogError("RDTS: cannot correct inherited invalid block %s at height %d: required block " + LogError("cannot correct inherited invalid block %s at height %d: required block " "data has been pruned\n", target->GetBlockHash().ToString(), target->nHeight); - error = _("A block inherited from a client that was not enforcing the BLAKE2b hardfork is invalid under it, and correcting it needs block data that has been pruned"); + error = _("A block inherited from a client that was not enforcing this hardfork is invalid under it, and correcting it needs block data that has been pruned"); return false; } - LogPrintf("RDTS: block %s at height %d is invalid under the BLAKE2b " - "hardfork and was inherited from a non-enforcing client; " + LogPrintf("block %s at height %d is invalid under the hardfork rules in " + "force here and was inherited from a non-enforcing client; " "marking it invalid%s\n", target->GetBlockHash().ToString(), target->nHeight, rewind ? strprintf(" and rewinding the active chain by %d block(s)", rewind) : ""); BlockValidationState state; if (!InvalidateBlock(state, target) || !state.IsValid()) { - LogError("RDTS: failed to invalidate %s: %s\n", target->GetBlockHash().ToString(), state.ToString()); - error = _("Failed to correct an inherited block that is invalid under the BLAKE2b hardfork"); + LogError("failed to invalidate %s: %s\n", target->GetBlockHash().ToString(), state.ToString()); + error = _("Failed to correct an inherited block that is invalid under this hardfork"); return false; } invalidated = true; @@ -6017,16 +6090,16 @@ bool Chainstate::CorrectRdtsInvalidBlocks(bilingual_str& error) if (invalidated) { BlockValidationState state; if (!ActivateBestChain(state) || !state.IsValid()) { - LogError("RDTS: failed to activate best chain after correction: %s\n", state.ToString()); - error = _("Failed to correct an inherited block that is invalid under the BLAKE2b hardfork"); + LogError("failed to activate best chain after correction: %s\n", state.ToString()); + error = _("Failed to correct an inherited block that is invalid under this hardfork"); return false; } // Make the corrected state durable now rather than at the next // periodic flush: a crash before then would have to repeat the rewind // (see LoadChainTip for how an interrupted flush is recovered). if (!FlushStateToDisk(state, FlushStateMode::ALWAYS)) { - LogError("RDTS: failed to flush the corrected chain state: %s\n", state.ToString()); - error = _("Failed to correct an inherited block that is invalid under the BLAKE2b hardfork"); + LogError("failed to flush the corrected chain state: %s\n", state.ToString()); + error = _("Failed to correct an inherited block that is invalid under this hardfork"); return false; } } diff --git a/src/validation.h b/src/validation.h index 467b83ecd54a..66ae03a3e103 100644 --- a/src/validation.h +++ b/src/validation.h @@ -795,7 +795,7 @@ class Chainstate * invalidate/reorg failed); the caller should refuse to start and direct the * operator to -reindex rather than run on an invalid chain. */ - [[nodiscard]] bool CorrectRdtsInvalidBlocks(bilingual_str& error) + [[nodiscard]] bool CorrectInheritedInvalidBlocks(bilingual_str& error) EXCLUSIVE_LOCKS_REQUIRED(!m_chainstate_mutex) LOCKS_EXCLUDED(::cs_main); @@ -1051,7 +1051,7 @@ class ChainstateManager * the BLAKE2b fork height (the index-side analog of bad-version-blake2b). * The verdict is header-derived and independent of other blocks' * validity, so a single scan is sufficient. Lives on the manager because - * it needs only the block index. @sa Chainstate::CorrectRdtsInvalidBlocks */ + * it needs only the block index. @sa Chainstate::CorrectInheritedInvalidBlocks */ [[nodiscard]] std::vector FindInheritedInvalidBlocks() EXCLUSIVE_LOCKS_REQUIRED(cs_main); /** Whether the branch ending in @p index continued SHA256d proof-of-work * at or past the BLAKE2b fork height, i.e. its block at that height is not From 0bba5c5a721e927432d6ec50226b5aef911e8800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Fri, 9 Oct 2026 15:46:34 -0400 Subject: [PATCH 4/4] test: coverage for contiguous retarget windows and the closing-block floor --- src/test/CMakeLists.txt | 1 + src/test/fuzz/CMakeLists.txt | 1 + src/test/fuzz/timewarp.cpp | 191 +++++++ src/test/timewarp_tests.cpp | 526 ++++++++++++++++++ test/functional/feature_timewarp.py | 189 +++++++ .../functional/feature_timewarp_activation.py | 155 ++++++ test/functional/feature_timewarp_retarget.py | 130 +++++ test/functional/feature_timewarp_sync.py | 197 +++++++ test/functional/test_runner.py | 7 + 9 files changed, 1397 insertions(+) create mode 100644 src/test/fuzz/timewarp.cpp create mode 100644 src/test/timewarp_tests.cpp create mode 100755 test/functional/feature_timewarp.py create mode 100755 test/functional/feature_timewarp_activation.py create mode 100755 test/functional/feature_timewarp_retarget.py create mode 100755 test/functional/feature_timewarp_sync.py diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 44540d34eb7a..9fbbfdcb9b3f 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -107,6 +107,7 @@ add_executable(test_bitcoin system_ram_tests.cpp system_tests.cpp timeoffsets_tests.cpp + timewarp_tests.cpp torcontrol_tests.cpp transaction_tests.cpp translation_tests.cpp diff --git a/src/test/fuzz/CMakeLists.txt b/src/test/fuzz/CMakeLists.txt index 78c396e8aad6..bab36ba55989 100644 --- a/src/test/fuzz/CMakeLists.txt +++ b/src/test/fuzz/CMakeLists.txt @@ -118,6 +118,7 @@ add_executable(fuzz strprintf.cpp system.cpp timeoffsets.cpp + timewarp.cpp torcontrol.cpp transaction.cpp txdownloadman.cpp diff --git a/src/test/fuzz/timewarp.cpp b/src/test/fuzz/timewarp.cpp new file mode 100644 index 000000000000..acdaf0371890 --- /dev/null +++ b/src/test/fuzz/timewarp.cpp @@ -0,0 +1,191 @@ +// Copyright (c) 2026 The Bitcoin Knots developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +// The existing `pow` target cannot exercise this code. It sets nHeight and pprev from +// independent ConsumeBool() calls, so a block may claim a height its parent chain does +// not support, and CBlockIndex::GetAncestor asserts on the inconsistency before any +// rule is reached. Mainnet's 2016-block interval also needs a deeper chain than that +// target's entropy builds. +// +// This one builds a well-formed chain instead: heights are consecutive, pprev is +// linked, skip pointers are built, and the retarget interval is shortened so a chain +// long enough to retarget many times fits in a fuzz input. The rules under test do not +// depend on the interval's value. +// +// The attacker model is the strongest one consensus permits: every timestamp is chosen +// freely, subject only to the rules a node would enforce. What is asserted is that the +// two rules stay consistent with each other, and that the security bound they are +// supposed to give actually holds for every schedule reachable this way. + +namespace { + +//! A short retarget interval, so the fuzzer can reach many retargets. +constexpr int64_t kInterval{16}; + +Consensus::Params TimewarpParams() +{ + Consensus::Params params{Params().GetConsensus()}; + params.nPowTargetSpacing = 600; + params.nPowTargetTimespan = kInterval * params.nPowTargetSpacing; + params.fPowNoRetargeting = false; + params.fPowAllowMinDifficultyBlocks = false; + params.enforce_BIP94 = false; + params.TimewarpFixHeight = 0; // active from the start of the chain + return params; +} + +void initialize_timewarp() +{ + SelectParams(ChainType::MAIN); +} + +} // namespace + +FUZZ_TARGET(timewarp, .init = initialize_timewarp) +{ + FuzzedDataProvider provider{buffer.data(), buffer.size()}; + const Consensus::Params params{TimewarpParams()}; + const int64_t interval{params.DifficultyAdjustmentInterval()}; + Assert(interval == kInterval); + const int64_t T{params.nPowTargetTimespan}; + + // Room for several retargets, but bounded so one input cannot run forever. + const int blocks{provider.ConsumeIntegralInRange(2 * static_cast(interval), + 12 * static_cast(interval))}; + + std::vector> chain; + chain.reserve(static_cast(blocks)); + + const int64_t start_time{provider.ConsumeIntegralInRange(1'000'000'000, 2'000'000'000)}; + const uint32_t start_bits{UintToArith256(params.powLimit).GetCompact()}; + + auto& genesis{*chain.emplace_back(std::make_unique())}; + genesis.pprev = nullptr; + genesis.nHeight = 0; + genesis.nTime = static_cast(start_time); + genesis.nBits = start_bits; + genesis.BuildSkip(); + + int64_t measured{0}; // sum of the clamped spans the retargets credited + int retargets{0}; + // The telescoping endpoints: where the first accounted window started and where the + // last accounted one ended. Block timestamps are not monotonic (only median time + // past is), so the chain's final block is NOT necessarily its latest timestamp and + // cannot stand in for these. + int64_t span_start{-1}, span_end{-1}; + + for (int height = 1; height < blocks; ++height) { + CBlockIndex* prev{chain.back().get()}; + + // Lowest timestamp consensus permits here: median time past plus one, and the + // closing-block floor where it applies. + int64_t floor_time{prev->GetMedianTimePast() + 1}; + const std::optional closing{MinimumClosingBlockTime(prev, params)}; + if (closing) { + // THE CONSISTENCY INVARIANT. The floor must anchor to exactly the block the + // next retarget measures from, or the two rules disagree and a miner can be + // forced to produce a block validation will reject. height is the closing + // block, so the retarget follows it at height + 1. + Assert((height + 1) % interval == 0); + const CBlockIndex* window_start{prev->GetAncestor(static_cast(height - interval))}; + Assert(window_start != nullptr); + Assert(*closing == window_start->GetBlockTime()); + floor_time = std::max(floor_time, *closing); + } else if ((height + 1) % interval == 0 && height >= interval) { + // A closing block at or above the activation height must always carry a + // floor; a gap here is the off-by-one that would reopen the attack. + Assert(false); + } + + // The attacker picks any legal timestamp at or above the floor. + const int64_t ntime{provider.ConsumeIntegralInRange( + floor_time, floor_time + 16 * T)}; + + auto& block{*chain.emplace_back(std::make_unique())}; + block.pprev = prev; + block.nHeight = height; + block.nTime = static_cast(ntime); + CBlockHeader header; + header.nTime = static_cast(ntime); + block.nBits = GetNextWorkRequired(prev, &header, params); + block.BuildSkip(); + + // Account for what each retarget credited, to check the bound at the end. + if (height % interval == 0 && height > static_cast(interval)) { + const CBlockIndex* last{block.pprev}; // closing block + const CBlockIndex* first{last->GetAncestor(static_cast(last->nHeight - interval))}; + if (first != nullptr) { + const int64_t span{last->GetBlockTime() - first->GetBlockTime()}; + // Rule B forbids a negative span outright. + Assert(span >= 0); + measured += std::clamp(span, T / 4, T * 4); + if (span_start < 0) span_start = first->GetBlockTime(); + span_end = last->GetBlockTime(); + ++retargets; + } + } + } + + // FindInheritedInvalidBlocks re-derives both rules over the whole block index at + // startup, for an activation height that need not be the one the chain was built + // under: that is the late-upgrade case it exists for. Run its two conditions over + // this chain under a DIFFERENT activation height than it was built with, which is + // the shape most likely to reach an edge the normal paths do not. Nothing about the + // verdict is asserted here, because both conditions are "recompute and compare" and + // any assertion against the same recomputation would only restate determinism. What + // is being checked is that neither condition can assert, crash, or walk off the end + // of a chain, for any shape the fuzzer can build. + { + Consensus::Params rescan{params}; + rescan.TimewarpFixHeight = provider.ConsumeIntegralInRange(0, blocks + 1); + // nHeight is stored outside the PoW-committed header and is not checked at load, + // so an index read from disk can claim a height its parent chain does not + // support. That is the shape that makes GetAncestor walk past genesis, and it is + // exactly what a chain built consistently above cannot produce, so perturb one + // entry here. The scan must skip such an entry rather than judge it. + if (chain.size() > 2 && provider.ConsumeBool()) { + const size_t victim{provider.ConsumeIntegralInRange(1, chain.size() - 1)}; + chain[victim]->nHeight = provider.ConsumeIntegralInRange(0, 4 * static_cast(interval)); + } + for (const auto& idx : chain) { + if (idx->pprev == nullptr) continue; + if (idx->nHeight != idx->pprev->nHeight + 1) continue; // as the scan does + if (const std::optional floor{MinimumClosingBlockTime(idx->pprev, rescan)}) { + (void)(idx->GetBlockTime() < *floor); + } + if (rescan.IsTimewarpFixHeight(idx->pprev->nHeight) && + idx->nHeight % rescan.DifficultyAdjustmentInterval() == 0) { + CBlockHeader h; + h.nTime = idx->nTime; + (void)GetNextWorkRequired(idx->pprev, &h, rescan); + } + } + } + + // THE SECURITY BOUND. Contiguous windows make consecutive spans share an endpoint, + // so they telescope: their sum is exactly span_end - span_start, however the + // attacker arranges the timestamps in between. The floor keeps each span + // non-negative, so the only way credited time can exceed that is the low clamp + // crediting a span below T/4 as T/4. + if (retargets > 0) { + const int64_t telescoped{span_end - span_start}; + Assert(telescoped >= 0); + Assert(measured <= telescoped + retargets * (T / 4)); + } +} diff --git a/src/test/timewarp_tests.cpp b/src/test/timewarp_tests.cpp new file mode 100644 index 000000000000..ce60c068a8f8 --- /dev/null +++ b/src/test/timewarp_tests.cpp @@ -0,0 +1,526 @@ +// Coverage for the timewarp attack and the two rules that close it: contiguous retarget +// windows (src/pow.cpp) and a floor on the block that closes a window +// (MinimumClosingBlockTime). Both are stated at their change sites. +// +// Everything here drives the real GetNextWorkRequired(), the real +// MinimumClosingBlockTime() and the real CBlockIndex::GetMedianTimePast() against +// mainnet consensus parameters, so the arithmetic under test is production code. The +// one thing modelled rather than measured is the attacker's hash rate, marked +// ASSUMPTION where it is used. +// +// Height convention, since every test depends on it: GetNextWorkRequired(pindexLast) +// retargets when (pindexLast->nHeight + 1) % 2016 == 0, so a retarget follows only a +// tip at height 2016k - 1. Each test primes the chain to such a height first. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +BOOST_FIXTURE_TEST_SUITE(timewarp_tests, BasicTestingSetup) + +namespace { + +//! Arbitrary attack start time; nothing depends on its value. +constexpr int64_t T_START{1700000000}; + +//! nBits for a difficulty 2^shift above the chain's minimum. +uint32_t BitsAboveFloor(const Consensus::Params& params, int shift) +{ + arith_uint256 target = UintToArith256(params.powLimit); + target >>= shift; + return target.GetCompact(); +} + +double TargetOf(uint32_t nbits) +{ + arith_uint256 target; + target.SetCompact(nbits); + return target.getdouble(); +} + +double DifficultyOf(uint32_t nbits, const Consensus::Params& params) +{ + return UintToArith256(params.powLimit).getdouble() / TargetOf(nbits); +} + +struct ChainSim { + std::vector blocks; + Consensus::Params params; + size_t n{0}; + //! Lets a test mine what the closing-block floor forbids, to show it is load-bearing. + bool ignore_closing_floor{false}; + + ChainSim(size_t cap, const Consensus::Params& p) : blocks(cap), params(p) {} + + CBlockIndex* Tip() { return &blocks[n - 1]; } + + void Start(int64_t ntime, uint32_t nbits) + { + blocks[0].pprev = nullptr; + blocks[0].nHeight = 0; + blocks[0].nTime = static_cast(ntime); + blocks[0].nBits = nbits; + blocks[0].BuildSkip(); + n = 1; + } + + uint32_t NextBits() + { + CBlockHeader header; + header.nTime = static_cast(Tip()->nTime + 1); + return GetNextWorkRequired(Tip(), &header, params); + } + + void Add(int64_t ntime) + { + BOOST_REQUIRE(n < blocks.size()); + BOOST_REQUIRE_GE(ntime, MinNextTime()); + CBlockIndex& b = blocks[n]; + b.pprev = &blocks[n - 1]; + b.nHeight = static_cast(n); + b.nBits = NextBits(); + b.nTime = static_cast(ntime); + b.BuildSkip(); + ++n; + } + + //! Lowest timestamp consensus permits for the next block: median time past plus one, + //! and the closing-block floor where that applies. Calling the production helper + //! keeps the simulated attacker inside the rules by construction. + int64_t MinNextTime() + { + int64_t t{Tip()->GetMedianTimePast() + 1}; + if (ignore_closing_floor) return t; + if (const std::optional closing{MinimumClosingBlockTime(Tip(), params)}) { + t = std::max(t, *closing); + } + return t; + } + + int64_t TimeAt(int64_t height) const { return static_cast(blocks[height].nTime); } +}; + +//! Honest mining: blocks arrive at exactly the target spacing. +void HonestBlocks(ChainSim& c, int64_t count) +{ + for (int64_t i = 0; i < count; ++i) c.Add(c.Tip()->nTime + c.params.nPowTargetSpacing); +} + +//! Prime a chain to height 2016k - 1, the only height at which a retarget follows. +void PrimeToRetarget(ChainSim& c) +{ + HonestBlocks(c, c.params.DifficultyAdjustmentInterval() - 1); +} + +Consensus::Params LegacyParams(const ArgsManager& args) +{ + return CreateChainParams(args, ChainType::MAIN)->GetConsensus(); +} + +//! Mainnet parameters with the new rules active from the start of the chain. +Consensus::Params ForkParams(const ArgsManager& args) +{ + auto params = LegacyParams(args); + params.TimewarpFixHeight = 0; + return params; +} + +} // namespace + +// The window for the retarget at height R is [R-2016, R-1]: 2016 blocks but only 2015 +// inter-block intervals, so a chain running at exactly the target spacing is measured +// one spacing short every period. What that ratio is, rather than where it leads, is +// what this checks: holding the spacing fixed is not an equilibrium under the legacy +// rule, and with the usual difficulty feedback the chain instead settles at a block +// interval of 600 * 2016/2015 = 600.298s, 0.05% slow, for as long as the rule stands. +// The contiguous window measures 2016 intervals, so its ratio is exactly 1 and its +// equilibrium spacing is exactly the target. +BOOST_AUTO_TEST_CASE(off_by_one_biases_honest_chain) +{ + const auto legacy = LegacyParams(*m_node.args); + const int64_t interval = legacy.DifficultyAdjustmentInterval(); + BOOST_CHECK_EQUAL(interval, 2016); + + const uint32_t start_bits{BitsAboveFloor(legacy, 48)}; + + ChainSim c(3 * interval + 2, legacy); + c.Start(T_START, start_bits); + PrimeToRetarget(c); + const double before = TargetOf(c.NextBits()); + HonestBlocks(c, interval); + const double after = TargetOf(c.NextBits()); + + BOOST_TEST_MESSAGE("honest chain, current rule: target ratio per period = " + << after / before << " (2015/2016 = " << 2015.0 / 2016.0 << ")"); + BOOST_CHECK_CLOSE(after / before, 2015.0 / 2016.0, 0.01); + + ChainSim f(3 * interval + 2, ForkParams(*m_node.args)); + f.Start(T_START, start_bits); + PrimeToRetarget(f); + const double f_before = TargetOf(f.NextBits()); + HonestBlocks(f, interval); + const double f_after = TargetOf(f.NextBits()); + + BOOST_TEST_MESSAGE("honest chain, fork rule: target ratio per period = " << f_after / f_before); + BOOST_CHECK_CLOSE(f_after / f_before, 1.0, 0.01); +} + +// The attack itself, under today's rules: freeze the period at median time past plus +// one, spike the closing block, and the backward jump into the unmeasured seam costs +// nothing. +BOOST_AUTO_TEST_CASE(current_rule_drains_difficulty) +{ + const auto legacy = LegacyParams(*m_node.args); + const int64_t interval = legacy.DifficultyAdjustmentInterval(); + const int periods = 8; + + ChainSim c(static_cast(interval) * (periods + 2) + 2, legacy); + const uint32_t start_bits{BitsAboveFloor(legacy, 48)}; + c.Start(T_START, start_bits); + PrimeToRetarget(c); + + const double d_start = DifficultyOf(start_bits, legacy); + double real_now = static_cast(c.Tip()->nTime); + const double real_at_start = real_now; + + for (int p = 0; p < periods; ++p) { + for (int64_t i = 0; i < interval; ++i) { + // ASSUMPTION: the attacker holds the hash rate that produced d_start. + real_now += legacy.nPowTargetSpacing * DifficultyOf(c.NextBits(), legacy) / d_start; + c.Add(i == interval - 1 ? std::max(c.MinNextTime(), + static_cast(real_now) + MAX_FUTURE_BLOCK_TIME) + : c.MinNextTime()); + } + } + + const double d_end = DifficultyOf(c.NextBits(), legacy); + BOOST_TEST_MESSAGE("current rule: " << periods << " periods, difficulty x" << d_end / d_start + << " of start, per-period divisor " << std::pow(d_start / d_end, 1.0 / periods) + << ", real time spent " << (real_now - real_at_start) / 86400.0 << "d"); + BOOST_CHECK(d_end < d_start / 100.0); +} + +// Contiguous windows mean consecutive windows share an endpoint, so the spans partition +// the chain's own elapsed time exactly. Recovered from the real nBits the code produces: +// with no period clamped, target_new / target_old is the measured span over the target +// timespan, so the sum below is what production actually measured. +BOOST_AUTO_TEST_CASE(windows_partition_chain_time) +{ + const int64_t interval = LegacyParams(*m_node.args).DifficultyAdjustmentInterval(); + const int periods = 4; + // Per-period spacings that keep every span well inside the clamps. + const int64_t spacing[periods]{500, 700, 640, 560}; + + auto run = [&](const Consensus::Params& params) { + ChainSim c(static_cast(interval) * (periods + 2) + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + PrimeToRetarget(c); + const int64_t first_close{c.Tip()->nHeight}; + double measured{0}; + double prev_target{TargetOf(c.NextBits())}; + for (int p = 0; p < periods; ++p) { + for (int64_t i = 0; i < interval; ++i) c.Add(c.Tip()->nTime + spacing[p]); + const double target{TargetOf(c.NextBits())}; + measured += (target / prev_target) * params.nPowTargetTimespan; + prev_target = target; + } + const double elapsed = static_cast(c.TimeAt(c.Tip()->nHeight) - c.TimeAt(first_close)); + return std::make_pair(measured, elapsed); + }; + + const auto [fork_measured, fork_elapsed] = run(ForkParams(*m_node.args)); + BOOST_TEST_MESSAGE("fork rule: measured " << fork_measured << "s over " << fork_elapsed + << "s of chain time (" << fork_measured / fork_elapsed << "x)"); + BOOST_CHECK_CLOSE(fork_measured, fork_elapsed, 0.01); + + const auto [legacy_measured, legacy_elapsed] = run(LegacyParams(*m_node.args)); + BOOST_TEST_MESSAGE("current rule: measured " << legacy_measured << "s over " << legacy_elapsed + << "s of chain time (" << legacy_measured / legacy_elapsed << "x)"); + BOOST_CHECK_CLOSE(legacy_measured / legacy_elapsed, 2015.0 / 2016.0, 0.01); +} + +// The residual that killed the previous attempt at this fix: an asymmetric cycle of two +// long periods (4x down each) paid for by one period whose span the low clamp credits as +// target/4 (4x up). Under contiguous windows alone it ratchets, because closing a period +// below where the window started rewinds chain time and lets the same stretch of real +// time be measured again. The closing-block floor removes the rewind. +// +// What is left once no time can be manufactured is the DAA's own limit. Splitting M +// seconds of chain time into periods of span s maximises the drop at s = e*T, not at the +// 4x clamp: the ratio is (T/s) per period over M/s periods, so the decay is exp(-M/(e*T)) +// and a schedule of 4T spans is strictly weaker than one of e*T spans. That is the bound +// asserted here. It is the DAA responding to a chain that really is slow, not a timewarp, +// and it is still anchored because M cannot outrun real time by more than +// MAX_FUTURE_BLOCK_TIME once for the chain's life. +BOOST_AUTO_TEST_CASE(murch_zawy_cycle_cannot_rewind_chain_time) +{ + const auto params = ForkParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + const int64_t T = params.nPowTargetTimespan; + const int cycles = 3; + + ChainSim c(static_cast(interval) * (3 * cycles + 2) + 2, params); + const uint32_t start_bits{BitsAboveFloor(params, 48)}; + c.Start(T_START, start_bits); + PrimeToRetarget(c); + + const double d_start = DifficultyOf(start_bits, params); + const int64_t base = c.TimeAt(c.Tip()->nHeight); + int64_t last_close{base}; + double measured{0}; + double prev_target{TargetOf(c.NextBits())}; + + auto close_period_at = [&](int64_t closing_time) { + for (int64_t i = 0; i < interval; ++i) { + c.Add(i == interval - 1 ? std::max(c.MinNextTime(), closing_time) : c.MinNextTime()); + } + const int64_t close{c.TimeAt(c.Tip()->nHeight)}; + // The rewind the attack depends on: a window must start no earlier than the + // previous one ended, or the same real time can be measured twice. + BOOST_CHECK_GE(close, last_close); + last_close = close; + const double target{TargetOf(c.NextBits())}; + measured += (target / prev_target) * T; + prev_target = target; + return DifficultyOf(c.NextBits(), params); + }; + + double d{d_start}; + for (int k = 0; k < cycles; ++k) { + d = close_period_at(base + (8 * k + 4) * T + 1); + BOOST_TEST_MESSAGE("cycle " << k << " long 1: difficulty x" << d / d_start); + d = close_period_at(base + (8 * k + 8) * T + 1); + BOOST_TEST_MESSAGE("cycle " << k << " long 2: difficulty x" << d / d_start); + d = close_period_at(0); // as early as consensus permits + BOOST_TEST_MESSAGE("cycle " << k << " collapse: difficulty x" << d / d_start); + } + + const double chain_time = static_cast(last_close - base); + // Tight bound: equal spans of e*T maximise the decay, giving exp(-M/(e*T)). + const double bound = std::exp(chain_time / (std::exp(1.0) * T)); + // The low clamp credits any span below target/4 as target/4. That credit raises + // difficulty, so it is the only way measured time may exceed elapsed time. + const double credit = 3.0 * cycles * T / 4.0; + BOOST_TEST_MESSAGE("after " << cycles << " asymmetric cycles: difficulty x" << d / d_start + << " of start over " << chain_time / 86400.0 + << "d of chain time; the clamp alone permits x" << 1.0 / bound); + BOOST_TEST_MESSAGE("measured " << measured / 86400.0 << "d against " << chain_time / 86400.0 + << "d of chain time plus " << credit / 86400.0 << "d of low-clamp credit"); + BOOST_CHECK_LE(measured, chain_time + credit); + BOOST_CHECK_GE(d / d_start, 1.0 / bound); +} + +// Without the floor the same schedule beats that bound, which is what makes the test +// above load-bearing rather than a restatement of the clamps. +BOOST_AUTO_TEST_CASE(rewinding_the_window_start_beats_the_clamp_bound) +{ + const auto params = ForkParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + const int64_t T = params.nPowTargetTimespan; + const int cycles = 3; + + ChainSim c(static_cast(interval) * (3 * cycles + 2) + 2, params); + c.ignore_closing_floor = true; + const uint32_t start_bits{BitsAboveFloor(params, 48)}; + c.Start(T_START, start_bits); + PrimeToRetarget(c); + + const double d_start = DifficultyOf(start_bits, params); + const int64_t base = c.TimeAt(c.Tip()->nHeight); + double measured{0}; + double prev_target{TargetOf(c.NextBits())}; + + auto close_period_at = [&](int64_t closing_time) { + for (int64_t i = 0; i < interval; ++i) { + c.Add(i == interval - 1 ? std::max(c.MinNextTime(), closing_time) : c.MinNextTime()); + } + const double target{TargetOf(c.NextBits())}; + measured += (target / prev_target) * T; + prev_target = target; + return DifficultyOf(c.NextBits(), params); + }; + + double d{d_start}; + for (int k = 0; k < cycles; ++k) { + d = close_period_at(base + 4 * T + 1); + d = close_period_at(base + 8 * T + 1); + d = close_period_at(0); + } + + const double chain_time = static_cast(std::max(c.TimeAt(c.Tip()->nHeight) - base, 0)); + const double bound = std::exp(chain_time / (std::exp(1.0) * T)); + const double credit = 3.0 * cycles * T / 4.0; + BOOST_TEST_MESSAGE("with the window start rewound: difficulty x" << d / d_start << " of start over " + << chain_time / 86400.0 << "d of chain time; the clamp alone permits x" << 1.0 / bound); + BOOST_TEST_MESSAGE("measured " << measured / 86400.0 << "d against " << chain_time / 86400.0 + << "d of chain time plus " << credit / 86400.0 << "d of low-clamp credit"); + BOOST_CHECK_GT(measured, chain_time + credit); + BOOST_CHECK_LT(d / d_start, 1.0 / bound); +} + +// The floor applies to the block closing a period, and only from the hardfork height. +BOOST_AUTO_TEST_CASE(minimum_closing_block_time_applies_where_stated) +{ + auto params = LegacyParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + // Activate partway along, at a height that is not a period boundary. + params.TimewarpFixHeight = static_cast(2 * interval + 8); + + ChainSim c(static_cast(interval) * 4 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + HonestBlocks(c, 4 * interval); + + for (int64_t h = 1; h < 4 * interval; ++h) { + const CBlockIndex* prev{&c.blocks[h - 1]}; + const auto floor{MinimumClosingBlockTime(prev, params)}; + const bool closes{h % interval == interval - 1 && h >= interval}; + const bool active{h >= params.TimewarpFixHeight}; + BOOST_CHECK_EQUAL(floor.has_value(), closes && active); + if (floor) BOOST_CHECK_EQUAL(*floor, c.TimeAt(h - interval)); + } +} + +// The floor is the block the next retarget measures from, so a block exactly on it +// produces a span of zero and anything below it would produce a negative one. +BOOST_AUTO_TEST_CASE(floor_is_the_next_windows_start) +{ + const auto params = ForkParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + + ChainSim c(static_cast(interval) * 3 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + PrimeToRetarget(c); + const double before = TargetOf(c.NextBits()); + + HonestBlocks(c, interval - 1); + const auto floor{MinimumClosingBlockTime(c.Tip(), params)}; + BOOST_REQUIRE(floor.has_value()); + HonestBlocks(c, 1); + BOOST_CHECK_EQUAL(c.Tip()->nHeight, 2 * interval - 1); + + // The retarget that follows measured exactly from the floor. + const double measured{(TargetOf(c.NextBits()) / before) * params.nPowTargetTimespan}; + BOOST_CHECK_CLOSE(measured, static_cast(c.TimeAt(c.Tip()->nHeight) - *floor), 0.01); +} + +// A lower bound on a timestamp could in principle demand a time the future-time limit +// forbids, leaving a period impossible to close. It cannot, because the floor is drawn +// from a timestamp the chain already carries, and every such timestamp was inside some +// node's future-time limit when it was accepted. This drives a chain stamped as far +// ahead as the rules ever allow, which is what keeps the floor as high as it can go. +BOOST_AUTO_TEST_CASE(minimum_time_never_reaches_beyond_the_chain) +{ + const auto params = ForkParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + + ChainSim c(static_cast(interval) * 3 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + + int64_t highest{T_START}; + while (c.Tip()->nHeight < 2 * interval) { + BOOST_CHECK_LE(node::GetMinimumTime(c.Tip(), params), highest + 1); + const int64_t next{std::max(c.MinNextTime(), highest)}; + c.Add(next); + highest = std::max(highest, next); + } +} + +// A template must never be built below the floor, or the node would mine a block its own +// validation rejects. +BOOST_AUTO_TEST_CASE(get_minimum_time_applies_the_closing_floor) +{ + const auto params = ForkParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + + ChainSim c(static_cast(interval) * 3 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + HonestBlocks(c, interval - 2); + // Spike the block that closes the first window far ahead, so median time past + // afterwards sits below it and only the floor keeps the next closing block legal. + const int64_t spike{c.Tip()->nTime + 3 * params.nPowTargetTimespan}; + c.Add(spike); + BOOST_CHECK_EQUAL(c.Tip()->nHeight, interval - 1); + while (c.Tip()->nHeight < 2 * interval - 2) c.Add(c.MinNextTime()); + + const CBlockIndex* prev{c.Tip()}; + BOOST_CHECK_EQUAL((prev->nHeight + 1) % interval, interval - 1); + BOOST_CHECK_LT(prev->GetMedianTimePast() + 1, spike); + BOOST_CHECK_EQUAL(node::GetMinimumTime(prev, params), spike); +} + +// A window may only be closed contiguously once its closing block carried the floor, or +// the span it produces would not be guaranteed non-negative. Both rules therefore read +// the closing block's height, so the first contiguous retarget is the one whose window +// was closed at or above the activation height. Nothing is grandfathered. +BOOST_AUTO_TEST_CASE(activation_is_gated_on_the_closing_blocks_height) +{ + auto params = LegacyParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + // Partway through the second period: the block closing it is the first to be floored. + params.TimewarpFixHeight = static_cast(2 * interval + 1); + + ChainSim c(static_cast(interval) * 4 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + PrimeToRetarget(c); + + // Height 4031 closes the second period below the activation height, so the retarget + // at 4032 keeps the old window. + BOOST_CHECK(!MinimumClosingBlockTime(&c.blocks[2 * interval - 2], params).has_value()); + const double at_2016 = TargetOf(c.NextBits()); + HonestBlocks(c, interval); + const double at_4032 = TargetOf(c.NextBits()); + + // Height 6047 closes the third period above it, so the retarget at 6048 is contiguous. + HonestBlocks(c, interval - 1); + BOOST_CHECK(MinimumClosingBlockTime(c.Tip(), params).has_value()); + HonestBlocks(c, 1); + const double at_6048 = TargetOf(c.NextBits()); + + BOOST_TEST_MESSAGE("retarget below activation: x" << at_4032 / at_2016 + << ", first one above it: x" << at_6048 / at_4032); + BOOST_CHECK_CLOSE(at_4032 / at_2016, 2015.0 / 2016.0, 0.01); + BOOST_CHECK_CLOSE(at_6048 / at_4032, 1.0, 0.01); +} + +// The one-off target shift for the new proof of work algorithm is applied on top of the +// contiguous-window retarget when the hardfork height is itself a retarget height. +BOOST_AUTO_TEST_CASE(target_shift_composes_with_contiguous_windows) +{ + auto params = LegacyParams(*m_node.args); + const int64_t interval = params.DifficultyAdjustmentInterval(); + params.TimewarpFixHeight = 0; + params.Blake2bHeight = static_cast(2 * interval); + params.Blake2bTargetShift = 4; + + ChainSim c(static_cast(interval) * 3 + 2, params); + c.Start(T_START, BitsAboveFloor(params, 48)); + PrimeToRetarget(c); + HonestBlocks(c, interval); + BOOST_CHECK_EQUAL(c.Tip()->nHeight + 1, params.Blake2bHeight); + const uint32_t shifted{c.NextBits()}; + + auto unshifted_params = params; + unshifted_params.Blake2bTargetShift = 0; + ChainSim u(static_cast(interval) * 3 + 2, unshifted_params); + u.Start(T_START, BitsAboveFloor(params, 48)); + PrimeToRetarget(u); + HonestBlocks(u, interval); + const uint32_t unshifted{u.NextBits()}; + + BOOST_TEST_MESSAGE("target shift over contiguous windows: x" << TargetOf(shifted) / TargetOf(unshifted)); + BOOST_CHECK_CLOSE(TargetOf(shifted) / TargetOf(unshifted), 16.0, 0.01); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_timewarp.py b/test/functional/feature_timewarp.py new file mode 100755 index 000000000000..5be76b01f50a --- /dev/null +++ b/test/functional/feature_timewarp.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Live-node coverage for the closing-block timestamp floor. + +Once retarget windows are contiguous the block closing a period is also the block the +next window is measured from, so it may not be stamped before the block this window +started from. The arithmetic is covered by timewarp_tests; what is checked here is that +a real node rejects such a block, mines at the floor rather than below it, and applies +none of this before the activation height. + +Regtest's retarget interval is 144 blocks, so height 287 closes the second period and +its floor is the timestamp of height 143. +""" + +from test_framework.blocktools import ( + MAX_FUTURE_BLOCK_TIME, + add_witness_commitment, + create_block, + create_coinbase, +) +from test_framework.messages import CBlockHeader +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_greater_than, + assert_greater_than_or_equal, + assert_raises_rpc_error, +) + +INTERVAL = 144 +CLOSING = 2 * INTERVAL - 1 # 287: (h + 1) % 144 == 0, so h closes a period +WINDOW_START = CLOSING - INTERVAL # 143: where the window the next retarget measures began + +MOCKTIME = 1700000000 +SPIKE = MAX_FUTURE_BLOCK_TIME # how far ahead the window start is stamped: the most + # consensus ever allows, so the floor it produces lands + # exactly on the future-time limit + + +class TimewarpFloorTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 2 + self.extra_args = [ + [f"-testactivationheight=timewarpfix@{CLOSING}"], + [f"-testactivationheight=timewarpfix@{CLOSING + 100000}"], + ] + + def template(self, node): + return node.getblocktemplate({"rules": ["segwit"]}) + + def build(self, node, ntime): + tmpl = self.template(node) + block = create_block(int(tmpl["previousblockhash"], 16), + create_coinbase(height=tmpl["height"]), + ntime, tmpl=tmpl) + add_witness_commitment(block) + block.solve() + return block + + def run_test(self): + forked, unforked = self.nodes + addr = forked.get_deterministic_priv_key().address + + self.log.info("Build a chain whose window start is stamped ahead of everything after it") + self.nodes[0].setmocktime(MOCKTIME) + self.nodes[1].setmocktime(MOCKTIME) + self.generatetoaddress(forked, WINDOW_START - 1, addr) + for node in self.nodes: + node.setmocktime(MOCKTIME + SPIKE) + self.generatetoaddress(forked, 1, addr) + for node in self.nodes: + node.setmocktime(MOCKTIME + 1) + self.generatetoaddress(forked, CLOSING - 1 - WINDOW_START, addr) + + assert_equal(forked.getblockcount(), CLOSING - 1) + assert_equal(unforked.getblockcount(), CLOSING - 1) + floor = forked.getblockheader(forked.getblockhash(WINDOW_START))["time"] + assert_equal(floor, MOCKTIME + SPIKE) + tip_mtp = forked.getblockheader(forked.getbestblockhash())["mediantime"] + assert_greater_than(floor, tip_mtp + 1) + self.log.info(f"window start at height {WINDOW_START} is {floor - tip_mtp}s above the tip's " + f"median time past, so only the floor keeps the closing block legal") + + self.disconnect_nodes(0, 1) + + self.log.info("The activation height is reported, so an operator can see the schedule") + # "active" is asked of the block after the tip, which here is the closing block + # the rule first applies to. + assert_equal(forked.getdeploymentinfo()["timewarpfix"], {"height": CLOSING, "active": True}) + assert_equal(unforked.getdeploymentinfo()["timewarpfix"], + {"height": CLOSING + 100000, "active": False}) + + self.log.info("The floor is what the template offers, not median time past plus one") + tmpl = self.template(forked) + assert_equal(tmpl["mintime"], floor) + assert_greater_than_or_equal(tmpl["curtime"], tmpl["mintime"]) + assert_equal(self.template(unforked)["mintime"], tip_mtp + 1) + + self.log.info("A closing block below the floor is rejected") + too_early = self.build(forked, floor - 1) + assert_raises_rpc_error( + -25, "time-timewarp-closing", + lambda: forked.submitheader(hexdata=CBlockHeader(too_early).serialize().hex()), + ) + assert_equal(forked.submitblock(too_early.serialize().hex()), "time-timewarp-closing") + assert_equal(forked.getblockcount(), CLOSING - 1) + + self.log.info("The same block is accepted below the activation height") + assert_equal(unforked.submitblock(self.build(unforked, floor - 1).serialize().hex()), None) + assert_equal(unforked.getblockcount(), CLOSING) + + self.log.info("The floor can never demand a time the future-time limit forbids") + # A lower bound on a timestamp could in principle ask for a time that the + # future-time limit rejects, leaving a period impossible to close. Here the + # window started a full MAX_FUTURE_BLOCK_TIME ahead of the node's clock, which + # puts the floor exactly on that limit: the tightest case there is. + for node in self.nodes: + node.setmocktime(MOCKTIME) + assert_equal(floor, MOCKTIME + MAX_FUTURE_BLOCK_TIME) + assert_equal(self.template(forked)["mintime"], floor) + too_new = self.build(forked, floor + 1) + assert_raises_rpc_error( + -25, "time-too-new", + lambda: forked.submitheader(hexdata=CBlockHeader(too_new).serialize().hex()), + ) + + self.log.info("A closing block at the floor is accepted") + assert_equal(forked.submitblock(self.build(forked, floor).serialize().hex()), None) + assert_equal(forked.getblockcount(), CLOSING) + assert_equal(forked.getblockheader(forked.getbestblockhash())["time"], floor) + + self.log.info("The floor applies to closing blocks only") + # The block after it opens a period, where the pre-existing BIP94 template clamp + # of 600s below the parent applies instead. + assert_equal(self.template(forked)["mintime"], floor - 600) + assert_equal(forked.submitblock(self.build(forked, floor - 600).serialize().hex()), None) + + mtp = forked.getblockheader(forked.getbestblockhash())["mediantime"] + assert_greater_than(floor, mtp + 1) + assert_equal(self.template(forked)["mintime"], mtp + 1) + assert_equal(forked.submitblock(self.build(forked, mtp + 1).serialize().hex()), None) + assert_equal(forked.getblockcount(), CLOSING + 2) + assert_equal(forked.getblockheader(forked.getbestblockhash())["time"], mtp + 1) + + self.log.info("The floor is read along the branch being validated, not the active chain") + # A branch that forks below the window start and never stamps a block ahead has a + # floor of its own. Headers alone settle this, since the rule is a header rule. + prev = int(forked.getblockhash(WINDOW_START - 1), 16) + ntime = forked.getblockheader(forked.getblockhash(WINDOW_START - 1))["time"] + for height in range(WINDOW_START, CLOSING + 1): + ntime += 1 + branch = create_block(prev, create_coinbase(height), ntime, height=height) + branch.solve() + forked.submitheader(hexdata=CBlockHeader(branch).serialize().hex()) + prev = branch.sha256 + assert_greater_than(floor, ntime) + assert_equal(forked.getblockheader(branch.hash)["height"], CLOSING) + assert_equal(forked.getblockcount(), CLOSING + 2) + + self.log.info("A period's first block has no floor of its own, even more than 2h back") + # This is the deliberate departure from BIP-54, whose first-block rule forbids a + # period opening more than 7200s before the block that closed the last one. Under + # contiguous windows that block is no longer a measurement endpoint, so there is + # nothing to protect and the rule, and its grace period, go away. Pin it with a gap + # BIP-54 would reject: close the next period 12200s ahead of the chain's median + # time, then open the one after at median time plus one. + closing2 = CLOSING + INTERVAL + self.generatetoaddress(forked, closing2 - 1 - forked.getblockcount(), addr, + sync_fun=self.no_op) + assert_equal(forked.getblockcount(), closing2 - 1) + floor2 = forked.getblockheader(forked.getblockhash(closing2 - INTERVAL))["time"] + assert_equal(floor2, floor) + forked.setmocktime(MOCKTIME + 5000) + high = MOCKTIME + 5000 + MAX_FUTURE_BLOCK_TIME + assert_greater_than_or_equal(high, floor2) + assert_equal(forked.submitblock(self.build(forked, high).serialize().hex()), None) + assert_equal(forked.getblockcount(), closing2) + mtp2 = forked.getblockheader(forked.getbestblockhash())["mediantime"] + assert_greater_than(high - (mtp2 + 1), 7200) + assert_equal(forked.submitblock(self.build(forked, mtp2 + 1).serialize().hex()), None) + assert_equal(forked.getblockheader(forked.getbestblockhash())["time"], mtp2 + 1) + self.log.info(f" period opened {high - (mtp2 + 1)}s below the closing block, accepted") + + +if __name__ == "__main__": + TimewarpFloorTest(__file__).main() diff --git a/test/functional/feature_timewarp_activation.py b/test/functional/feature_timewarp_activation.py new file mode 100755 index 000000000000..e04aa06f941c --- /dev/null +++ b/test/functional/feature_timewarp_activation.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Crossing the contiguous-window activation height on live nodes. + +This is the only end-to-end coverage of rule A, the change to what the retarget +measures, so it runs in the default suite rather than the extended one. +feature_timewarp_retarget.py demonstrates the attack itself and is slow enough to stay +opt-in; everything here fits in well under a minute. + +Regtest cannot substitute: fPowNoRetargeting makes CalculateNextWorkRequired return the +previous nBits unchanged, so both rules produce identical nBits there no matter what. +Hence a custom signet, with the retarget interval shortened by -signetblocktime. + +Blocks are spaced at the target interval rather than one second apart, so the measured +spans land between the 0.25x and 4x clamps. That matters: a schedule that drives every +span past a clamp has the one-block difference between the two windows absorbed, and the +rules agree on nBits. A transition is only observable where the spans are unclamped. + +Three nodes, deliberately unconnected: + node0 legacy, no activation height + node1 activation partway along the chain + node2 a legacy twin of node0, so the two upgrade paths can be tested independently +""" + +import subprocess + +from test_framework.blocktools import add_witness_commitment, create_block, create_coinbase +from test_framework.messages import CBlockHeader, from_hex +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + +INTERVAL = 8 # blocks per retarget period +SIGNET_BLOCK_TIME = 1209600 // INTERVAL # keeps nPowTargetTimespan at 14 days +GENESIS_TIME = 1598918400 # signet genesis nTime + +# Activate at the closing block of the second period. The retarget at height INTERVAL +# agrees under both rules here simply because the fork is not active yet at that point, +# so the one at 2*INTERVAL is the first that can diverge. (The genesis edge case, where a +# contiguous window has no predecessor to measure from, would also force agreement at the +# first retarget, but it is not what is doing the work here.) +TRANSITION = 2 * INTERVAL - 1 # 15 + + +class TimewarpActivationTest(BitcoinTestFramework): + def set_test_params(self): + self.chain = "signet" + self.setup_clean_chain = True + self.num_nodes = 3 + base = ["-signetchallenge=51", f"-signetblocktime={SIGNET_BLOCK_TIME}"] + self.extra_args = [ + base, # node0: legacy + base + [f"-signettimewarpfixheight={TRANSITION}"], # node1: forked + base, # node2: legacy twin + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_bitcoin_util() + + def setup_network(self): + self.setup_nodes() # deliberately unconnected: independent chains + + def build(self, node, ntime): + tmpl = node.getblocktemplate({"rules": ["segwit", "signet"]}) + block = create_block(int(tmpl["previousblockhash"], 16), + create_coinbase(height=tmpl["height"]), + ntime, tmpl=tmpl) + add_witness_commitment(block) + head = CBlockHeader.serialize(block).hex() + out = subprocess.run([self.options.bitcoinutil, "grind", head], + stdout=subprocess.PIPE, input=b"", check=True).stdout.strip() + block.nNonce = from_hex(CBlockHeader(), out.decode()).nNonce + block.rehash() + return block + + def run_test(self): + legacy, trans, twin = self.nodes + + self.log.info(f"interval={INTERVAL} blocks, activation at height {TRANSITION}") + for height in range(1, TRANSITION + 1): + block = self.build(legacy, GENESIS_TIME + height * SIGNET_BLOCK_TIME) + for node in (legacy, trans, twin): + assert_equal(node.submitblock(block.serialize().hex()), None) + assert_equal(legacy.getblockcount(), TRANSITION) + assert_equal(legacy.getbestblockhash(), trans.getbestblockhash()) + self.log.info(f"identical up to the activation height {TRANSITION}, including the " + f"retarget at {INTERVAL} where the fork is not yet active") + + # The retarget after the activation closing block is the first measured + # contiguously, so the two nodes now disagree about nBits for the same height. + nxt = GENESIS_TIME + (TRANSITION + 1) * SIGNET_BLOCK_TIME + legacy_block = self.build(legacy, nxt) + fork_block = self.build(trans, nxt) + assert legacy_block.nBits != fork_block.nBits, "windows agreed; the run proves nothing" + + assert_equal(legacy.submitblock(legacy_block.serialize().hex()), None) + assert_equal(twin.submitblock(legacy_block.serialize().hex()), None) + assert_equal(trans.submitblock(legacy_block.serialize().hex()), "bad-diffbits") + assert_equal(trans.getblockcount(), TRANSITION) + + assert_equal(trans.submitblock(fork_block.serialize().hex()), None) + assert_equal(legacy.submitblock(fork_block.serialize().hex()), "bad-diffbits") + assert_equal(legacy.getblockhash(TRANSITION + 1), legacy_block.hash) + assert_equal(trans.getblockhash(TRANSITION + 1), fork_block.hash) + self.log.info(f"split at height {TRANSITION + 1}: legacy nBits {legacy_block.nBits:#x}, " + f"contiguous {fork_block.nBits:#x}; each rejects the other as bad-diffbits") + + # Carry the legacy chain past a SECOND retarget, so the late upgraders below have + # more than one violator. A node that ran months past the fork would have one per + # retarget, and the correction sorts by height and relies on invalidating the + # lowest to fail its descendants; one violator never exercises that. + for height in range(TRANSITION + 2, 3 * INTERVAL + 2): + blk = self.build(legacy, GENESIS_TIME + height * SIGNET_BLOCK_TIME) + for node in (legacy, twin): + assert_equal(node.submitblock(blk.serialize().hex()), None) + assert_equal(legacy.getblockcount(), 3 * INTERVAL + 1) + second = 3 * INTERVAL # 24, the next retarget after the split at 16 + # Captured before any upgrade restart: these heights stop existing once rewound. + second_hash = legacy.getblockhash(second) + self.log.info(f"legacy chain extended to {legacy.getblockcount()}, crossing retargets " + f"{TRANSITION + 1} and {second}") + + # -reindex-chainstate keeps the block index and never re-accepts a header, so its + # only enforcement is what ConnectBlock re-runs. That covers the closing-block + # floor but not bad-diffbits, so rule A needs the startup scan to mark violators + # before the rebuild picks a chain. + self.log.info("a late upgrader using -reindex-chainstate is rewound past every violator") + self.restart_node(0, extra_args=self.extra_args[1] + ["-reindex-chainstate"]) + assert_equal(self.nodes[0].getblockcount(), TRANSITION) + + # The plain-restart path, on the twin: node0 is already corrected and the marks + # persist to the block index, so restarting it again would assert nothing. + self.log.info("a late upgrader carrying legacy nBits is rewound on a plain restart") + assert_equal(twin.getblockcount(), 3 * INTERVAL + 1) + self.restart_node(2, extra_args=self.extra_args[1]) + upgraded = self.nodes[2] + assert_equal(upgraded.getblockcount(), TRANSITION) + assert_equal(upgraded.getblockheader(second_hash)["confirmations"], -1) + self.log.info(f" rewound to {TRANSITION}; both inherited retarget blocks " + f"({TRANSITION + 1}, {second}) are marked invalid") + + # An honest node must NOT be rewound. Every other exercise of the startup scan + # runs against a node that should be corrected, so a false positive in the gate + # would otherwise go unnoticed. + self.log.info("a node already on the correct chain is left alone") + fork_tip = trans.getbestblockhash() + fork_height = trans.getblockcount() + self.restart_node(1, extra_args=self.extra_args[1]) + assert_equal(self.nodes[1].getbestblockhash(), fork_tip) + assert_equal(self.nodes[1].getblockcount(), fork_height) + + +if __name__ == "__main__": + TimewarpActivationTest(__file__).main() diff --git a/test/functional/feature_timewarp_retarget.py b/test/functional/feature_timewarp_retarget.py new file mode 100755 index 000000000000..a7295d8c71a4 --- /dev/null +++ b/test/functional/feature_timewarp_retarget.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""The timewarp attack against live nodes, on a custom signet that actually retargets. + +feature_timewarp.py covers the closing-block floor on regtest, where fPowNoRetargeting +leaves the difficulty arithmetic untouched, and feature_timewarp_activation.py covers +crossing the activation height. This runs the attack itself, which is slow enough to +belong in the extended set. + +Three nodes, deliberately unconnected: + node0 no activation height, today's rules + node1 rules active from the start of the chain + node2 an activation height the chain never reaches + +node0 and node2 are fed byte-identical blocks and must agree at every step, which shows +that an unreached activation height changes nothing. node1 mines its own chain, because +under the new rule the attack produces different difficulty and so different blocks. + +The retarget interval is shortened to 8 blocks with -signetblocktime, which leaves +nPowTargetTimespan at 14 days. The seam being exercised does not depend on the interval: +the window for the retarget at height R is [R-N, R-1], which spans N blocks but only N-1 +inter-block intervals, so the gap between R-1 and R is measured by nothing. +""" + +import subprocess + +from test_framework.blocktools import add_witness_commitment, create_block, create_coinbase +from test_framework.messages import CBlockHeader, from_hex +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_greater_than + +INTERVAL = 8 # blocks per retarget period +SIGNET_BLOCK_TIME = 1209600 // INTERVAL # keeps nPowTargetTimespan at 14 days +GENESIS_TIME = 1598918400 # signet genesis nTime + +# Two warmup epochs lift difficulty 16x off the powLimit floor, which is the headroom +# the attack needs to drop 4x twice without the floor clamping and hiding the result. +WARMUP_EPOCHS = 2 +ATTACK_EPOCHS = 2 +SPIKE_OFFSET = 60 * 24 * 60 * 60 # 60 days, past the 4x clamp at 56 days + +NEVER = 1000000 # an activation height the chain never reaches + + + +class TimewarpRetargetTest(BitcoinTestFramework): + def set_test_params(self): + self.chain = "signet" + self.setup_clean_chain = True + self.num_nodes = 3 + base = ["-signetchallenge=51", f"-signetblocktime={SIGNET_BLOCK_TIME}"] + self.extra_args = [ + base, + base + ["-signettimewarpfixheight=0"], + base + [f"-signettimewarpfixheight={NEVER}"], + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_bitcoin_util() + + def setup_network(self): + self.setup_nodes() # deliberately unconnected: independent chains + + def build(self, node, ntime): + tmpl = node.getblocktemplate({"rules": ["segwit", "signet"]}) + block = create_block(int(tmpl["previousblockhash"], 16), + create_coinbase(height=tmpl["height"]), + ntime, tmpl=tmpl) + add_witness_commitment(block) + head = CBlockHeader.serialize(block).hex() + out = subprocess.run([self.options.bitcoinutil, "grind", head], + stdout=subprocess.PIPE, input=b"", check=True).stdout.strip() + block.nNonce = from_hex(CBlockHeader(), out.decode()).nNonce + block.rehash() + return block + + def schedule(self, height, kind, index): + """The attacker's timestamp for a block at this height.""" + if kind == "attack" and height % INTERVAL == INTERVAL - 1: + return GENESIS_TIME + SPIKE_OFFSET + index + return GENESIS_TIME + height + + def run_epoch(self, nodes, kind, index): + for _ in range(INTERVAL): + height = nodes[0].getblockcount() + 1 + block = self.build(nodes[0], self.schedule(height, kind, index)) + for node in nodes: + assert_equal(node.submitblock(block.serialize().hex()), None) + assert_equal(len({node.getbestblockhash() for node in nodes}), 1) + + def tip(self, node): + return node.getblockheader(node.getbestblockhash()) + + def run_chain(self, nodes, label): + for e in range(WARMUP_EPOCHS): + self.run_epoch(nodes, "warmup", e) + start = self.tip(nodes[0]) + + for e in range(ATTACK_EPOCHS): + self.run_epoch(nodes, "attack", e) + self.log.info(f"{label} attack epoch {e}: difficulty {self.tip(nodes[0])['difficulty']}") + end = self.tip(nodes[0]) + + self.log.info(f"RESULT {label}: difficulty {start['difficulty']} -> {end['difficulty']} " + f"while the chain's own clock advanced {end['time'] - start['time']}s over " + f"{ATTACK_EPOCHS * INTERVAL} blocks") + return start["difficulty"], end["difficulty"] + + def run_test(self): + current, fork, gated = self.nodes[0], self.nodes[1], self.nodes[2] + self.log.info(f"interval={INTERVAL} blocks, target timespan=1209600s, " + f"spacing={SIGNET_BLOCK_TIME}s") + + before, after = self.run_chain([current, gated], "current rule") + assert_greater_than(before, after * 8) + assert_equal(current.getbestblockhash(), gated.getbestblockhash()) + assert_equal(self.tip(current), self.tip(gated)) + self.log.info("an unreached activation height changed nothing at any block") + + f_before, f_after = self.run_chain([fork], "contiguous windows") + assert_greater_than(f_after * 2, f_before) + assert_greater_than(f_after, after * 8) + self.log.info("the same attack does not lower difficulty under contiguous windows") + + + +if __name__ == "__main__": + TimewarpRetargetTest(__file__).main() diff --git a/test/functional/feature_timewarp_sync.py b/test/functional/feature_timewarp_sync.py new file mode 100755 index 000000000000..3b5e3333ee06 --- /dev/null +++ b/test/functional/feature_timewarp_sync.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Knots developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""The closing-block floor over the network and across a restart. + +feature_timewarp.py submits blocks by RPC, which exercises the rule but never carries +it over the wire. The floor is enforced in ContextualCheckBlockHeaderVolatile, reached +both from ContextualCheckBlockHeader when a header is accepted and from ConnectBlock +when a block is connected. Headers sync is therefore its hot path: a node learns about a +violating block from a peer long before it has the block itself. What is checked here is +that + + - a fresh node reaches the same tip by syncing a floor-constrained chain from a peer, + - it reaches that tip again after -reindex, so the rule holds on revalidation, + - a node below the activation height accepts a chain the forked nodes reject, + - a forked node does not follow that chain even when it carries strictly more work, and + - a node that already followed such a chain corrects itself when the rule turns on. + +The last two are the ones that matter. A rule that rejects a block on submitblock but +lets the same block in behind a longer chain is not a consensus rule; and one that a +late upgrader silently keeps violating is a chain split waiting to happen, because +neither check is re-derived for a chain that is already connected. +""" + +from test_framework.blocktools import ( + MAX_FUTURE_BLOCK_TIME, + add_witness_commitment, + create_block, + create_coinbase, +) +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_greater_than, + assert_raises_rpc_error, +) + +INTERVAL = 144 +CLOSING = 2 * INTERVAL - 1 # 287: (h + 1) % 144 == 0, so h closes a period +WINDOW_START = CLOSING - INTERVAL # 143: where the window the next retarget measures began + +MOCKTIME = 1700000000 +SPIKE = MAX_FUTURE_BLOCK_TIME + + +class TimewarpSyncTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 4 + forked = [f"-testactivationheight=timewarpfix@{CLOSING}"] + never = [f"-testactivationheight=timewarpfix@{CLOSING + 100000}"] + self.extra_args = [ + forked, # node0: miner, forked + forked, # node1: syncs from node0 + never, # node2: never activates; becomes the late upgrader + never, # node3: never activates; late upgrader via -reindex-chainstate + ] + + def build(self, node, ntime): + tmpl = node.getblocktemplate({"rules": ["segwit"]}) + block = create_block(int(tmpl["previousblockhash"], 16), + create_coinbase(height=tmpl["height"]), + ntime, tmpl=tmpl) + add_witness_commitment(block) + block.solve() + return block + + def run_test(self): + miner, syncer, legacy, legacy2 = self.nodes + addr = miner.get_deterministic_priv_key().address + + self.log.info("Build the shared prefix, with the window start stamped ahead") + for node in self.nodes: + node.setmocktime(MOCKTIME) + self.generatetoaddress(miner, WINDOW_START - 1, addr) + for node in self.nodes: + node.setmocktime(MOCKTIME + SPIKE) + self.generatetoaddress(miner, 1, addr) + for node in self.nodes: + node.setmocktime(MOCKTIME + 1) + self.generatetoaddress(miner, CLOSING - 1 - WINDOW_START, addr) + self.sync_blocks() + + floor = miner.getblockheader(miner.getblockhash(WINDOW_START))["time"] + assert_equal(floor, MOCKTIME + SPIKE) + for node in self.nodes: + assert_equal(node.getblockcount(), CLOSING - 1) + prefix_tip = miner.getbestblockhash() + + # Split the network completely: each side now builds its own closing block, and + # the two late-upgrader nodes must not learn of anything over the wire. + for a in range(self.num_nodes): + for b in range(a + 1, self.num_nodes): + self.disconnect_nodes(a, b) + + self.log.info("Below the activation height the violating block is accepted") + for node in self.nodes: + node.setmocktime(MOCKTIME) + bad = self.build(legacy, floor - 1) + for node in (legacy, legacy2): + assert_equal(node.submitblock(bad.serialize().hex()), None) + assert_equal(node.getblockcount(), CLOSING) + + self.log.info("The forked miner builds a valid closing block at the floor") + good = self.build(miner, floor) + assert_equal(miner.submitblock(good.serialize().hex()), None) + assert_equal(miner.getblockcount(), CLOSING) + assert good.hash != bad.hash + + self.log.info("A fresh node reaches the same tip by syncing over P2P") + assert_equal(syncer.getblockcount(), CLOSING - 1) + self.connect_nodes(0, 1) + self.sync_blocks([miner, syncer]) + assert_equal(syncer.getbestblockhash(), miner.getbestblockhash()) + assert_equal(syncer.getblockcount(), CLOSING) + assert_equal(syncer.getblockheader(syncer.getbestblockhash())["time"], floor) + self.disconnect_nodes(0, 1) + + self.log.info("It reaches the same tip again after -reindex") + tip_before = syncer.getbestblockhash() + for flag in ("-reindex", "-reindex-chainstate"): + # -reindex rebuilds the block index and revalidates headers; + # -reindex-chainstate keeps the index and replays blocks only, which does not + # re-run ContextualCheckBlockHeader. Both must land on the same tip. + self.restart_node(1, extra_args=self.extra_args[1] + [flag]) + syncer = self.nodes[1] + syncer.setmocktime(MOCKTIME) + assert_equal(syncer.getbestblockhash(), tip_before) + assert_equal(syncer.getblockcount(), CLOSING) + self.log.info(f" {flag}: back at {CLOSING}") + + self.log.info("The legacy chain is extended until it carries strictly more work") + legacy.setmocktime(MOCKTIME + 1) + blocks = self.generatetoaddress(legacy, 3, addr, sync_fun=self.no_op) + for h in blocks: + assert_equal(legacy2.submitblock(legacy.getblock(h, 0)), None) + assert_equal(legacy.getblockcount(), CLOSING + 3) + assert_equal(legacy2.getbestblockhash(), legacy.getbestblockhash()) + assert_greater_than(int(legacy.getblockheader(legacy.getbestblockhash())["chainwork"], 16), + int(miner.getblockheader(miner.getbestblockhash())["chainwork"], 16)) + + self.log.info("A forked node does not follow it, however much work it carries") + # The rule is a header rule, so this is settled in AcceptBlockHeader during + # headers sync, before any block of that chain is ever requested. The header is + # refused outright, so no block index is created for it at all. + for n, node in ((0, miner), (1, syncer)): + with node.assert_debug_log(expected_msgs=["time-timewarp-closing"], timeout=30): + self.connect_nodes(n, 2) + assert_equal(node.getblockcount(), CLOSING) + assert_equal(node.getbestblockhash(), good.hash) + assert_raises_rpc_error(-5, "Block not found", lambda: node.getblock(bad.hash)) + assert bad.hash not in [t["hash"] for t in node.getchaintips()] + self.disconnect_nodes(n, 2) + + self.log.info("The legacy node meanwhile sits on the chain the fork rejected") + assert_equal(legacy.getblockcount(), CLOSING + 3) + assert_equal(legacy.getblockhash(CLOSING), bad.hash) + assert_equal(miner.getblockhash(CLOSING), good.hash) + assert_equal(miner.getblockhash(WINDOW_START), legacy.getblockhash(WINDOW_START)) + self.log.info(f"split confirmed at height {CLOSING}, shared up to {CLOSING - 1}") + + self.log.info("A late upgrader corrects itself instead of keeping the bad chain") + # node2 followed the violating chain while the rule was not in force, which is + # what an operator upgrading after the flag day looks like. Restarting it with + # the rule active must rewind it, not leave it silently diverged: the header is + # already in its block index, so AcceptBlockHeader will never look at it again. + bad_tip = legacy.getbestblockhash() # captured before the restart: same node + self.restart_node(2, extra_args=self.extra_args[0]) + upgraded = self.nodes[2] + upgraded.setmocktime(MOCKTIME) + assert_equal(upgraded.getblockcount(), CLOSING - 1) + assert_equal(upgraded.getbestblockhash(), prefix_tip) + # The block data is still on disk, so this is about the verdict, not the bytes: + # the header is marked invalid and is no longer on the active chain. + assert_equal(upgraded.getblockheader(bad.hash)["confirmations"], -1) + tips = {t["hash"]: t["status"] for t in upgraded.getchaintips()} + assert_equal(tips[bad_tip], "invalid") + self.log.info(f" rewound from {CLOSING + 3} to {CLOSING - 1}, the last block " + f"both sides agree on") + + self.log.info("...and also when it upgrades via -reindex-chainstate") + # -reindex-chainstate keeps the block index and replays blocks through + # ConnectBlock, which re-runs ContextualCheckBlockHeaderVolatile but not + # ContextualCheckBlockHeader. bad-version-blake2b lives in the volatile function, + # which is how the BLAKE2b fork survives this path; the closing-block floor is + # placed there for the same reason. A node taking this path must not be left on + # the bad chain. + self.restart_node(3, extra_args=self.extra_args[0] + ["-reindex-chainstate"]) + rebuilt = self.nodes[3] + rebuilt.setmocktime(MOCKTIME) + assert_equal(rebuilt.getblockcount(), CLOSING - 1) + assert_equal(rebuilt.getbestblockhash(), prefix_tip) + + +if __name__ == "__main__": + TimewarpSyncTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 337d66721045..73314473d2b4 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -86,6 +86,11 @@ 'feature_pruning.py', 'feature_dbcrash.py', 'feature_index_prune.py', + # Both of these mine real signet blocks via bitcoin-util grind. One grind costs + # minutes under the sanitizer CI jobs, so they stay out of the default set; the + # extended CI job exercises them. + 'feature_timewarp_activation.py', + 'feature_timewarp_retarget.py', 'wallet_pruning.py --legacy-wallet', ] @@ -372,6 +377,8 @@ 'rpc_getblockstats.py', 'feature_port.py', 'feature_powchange.py', + 'feature_timewarp.py', + 'feature_timewarp_sync.py', 'feature_bind_port_externalip.py', 'wallet_create_tx.py --legacy-wallet', 'wallet_send.py --legacy-wallet',