From 5fc85dc9c985a9d160895c4ddeb0b23ef902b1d1 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 15:47:18 -0600 Subject: [PATCH 01/29] versionbits: add expiry support to versionbit deployments --- src/consensus/params.h | 3 +++ src/rpc/blockchain.cpp | 10 +++++++-- src/test/fuzz/versionbits.cpp | 13 ++++++++++- src/versionbits.cpp | 42 ++++++++++++++++++++++++++++++++++- src/versionbits_impl.h | 7 +++++- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/consensus/params.h b/src/consensus/params.h index 93ee071fdc16..ef44d6db0bac 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -62,6 +62,9 @@ struct BIP9Deployment { * Examples: 1916 for 95%, 1512 for testchains. */ uint32_t threshold{1916}; + /** For temporary softforks: number of blocks the deployment remains active after activation. + * std::numeric_limits::max() means permanent (never expires). */ + int active_duration{std::numeric_limits::max()}; /** Constant for nTimeout very far in the future. */ static constexpr int64_t NO_TIMEOUT = std::numeric_limits::max(); diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index c631a93674aa..8313c7cd3d77 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1354,6 +1354,11 @@ static void SoftForkDescPushBack(const CBlockIndex* blockindex, UniValue& softfo if (info.active_since.has_value()) { rv.pushKV("height", *info.active_since); is_active = (*info.active_since <= blockindex->nHeight + 1); + // Add height_end for temporary softforks + const auto& deployment = chainman.GetConsensus().vDeployments[id]; + if (deployment.active_duration < std::numeric_limits::max()) { + rv.pushKV("height_end", *info.active_since + deployment.active_duration - 1); + } } rv.pushKV("active", is_active); rv.pushKV("bip9", bip9); @@ -1449,7 +1454,8 @@ RPCHelpMan getblockchaininfo() namespace { const std::vector RPCHelpForDeployment{ {RPCResult::Type::STR, "type", "one of \"buried\", \"bip9\""}, - {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which the rules are or will be enforced (only for \"buried\" type, or \"bip9\" type with \"active\" status)"}, + {RPCResult::Type::NUM, "height", /*optional=*/true, "height of the first block which enforces the rules (only for \"buried\" type, or \"bip9\" type with \"active\" status)"}, + {RPCResult::Type::NUM, "height_end", /*optional=*/true, "height of the last block which enforces the rules (only for \"bip9\" type with \"active\" status and temporary deployments)"}, {RPCResult::Type::BOOL, "active", "true if the rules are enforced for the mempool and the next block"}, {RPCResult::Type::OBJ, "bip9", /*optional=*/true, "status of bip9 softforks (only for \"bip9\" type)", { @@ -1457,7 +1463,7 @@ const std::vector RPCHelpForDeployment{ {RPCResult::Type::NUM_TIME, "start_time", "the minimum median time past of a block at which the bit gains its meaning"}, {RPCResult::Type::NUM_TIME, "timeout", "the median time past of a block at which the deployment is considered failed if not yet locked in"}, {RPCResult::Type::NUM, "min_activation_height", "minimum height of blocks for which the rules may be enforced"}, - {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\")"}, + {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\", \"expired\")"}, {RPCResult::Type::NUM, "since", "height of the first block to which the status applies"}, {RPCResult::Type::STR, "status_next", "status of deployment at the next block"}, {RPCResult::Type::OBJ, "statistics", /*optional=*/true, "numeric statistics about signalling for a softfork (only for \"started\" and \"locked_in\" status)", diff --git a/src/test/fuzz/versionbits.cpp b/src/test/fuzz/versionbits.cpp index a2085e6a4098..73b459373eee 100644 --- a/src/test/fuzz/versionbits.cpp +++ b/src/test/fuzz/versionbits.cpp @@ -33,6 +33,7 @@ class TestConditionChecker : public VersionBitsConditionChecker assert(dep.threshold <= dep.period); assert(0 <= dep.bit && dep.bit < 32 && dep.bit < VERSIONBITS_NUM_BITS); assert(0 <= dep.min_activation_height); + assert(dep.active_duration > 0); } ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, m_cache); } @@ -148,6 +149,7 @@ FUZZ_TARGET(versionbits, .init = initialize) if (fuzzed_data_provider.ConsumeBool()) dep.nTimeout += interval / 2; } dep.min_activation_height = fuzzed_data_provider.ConsumeIntegralInRange(0, period * max_periods); + dep.active_duration = fuzzed_data_provider.ConsumeBool() ? std::numeric_limits::max() : (fuzzed_data_provider.ConsumeIntegralInRange(1, max_periods) * period); return dep; }()}; TestConditionChecker checker(dep); @@ -314,13 +316,22 @@ FUZZ_TARGET(versionbits, .init = initialize) assert(exp_state == ThresholdState::FAILED); } break; + case ThresholdState::EXPIRED: + assert(!always_active_test); + assert(dep.active_duration < std::numeric_limits::max()); + assert(dep.min_activation_height <= current_block->nHeight + 1); + assert(exp_state == ThresholdState::EXPIRED || exp_state == ThresholdState::ACTIVE); + if (exp_state == ThresholdState::ACTIVE) { + assert(since == exp_since + dep.active_duration); // EXPIRED starts exactly active_duration blocks after ACTIVE started + } + break; default: assert(false); } if (blocks.size() >= period * max_periods) { // we chose the timeout (and block times) so that by the time we have this many blocks it's all over - assert(state == ThresholdState::ACTIVE || state == ThresholdState::FAILED); + assert(state == ThresholdState::ACTIVE || state == ThresholdState::FAILED || state == ThresholdState::EXPIRED); } if (always_active_test) { diff --git a/src/versionbits.cpp b/src/versionbits.cpp index e282d21d2e5f..3b2fdde895b9 100644 --- a/src/versionbits.cpp +++ b/src/versionbits.cpp @@ -19,15 +19,18 @@ std::string StateName(ThresholdState state) case LOCKED_IN: return "locked_in"; case ACTIVE: return "active"; case FAILED: return "failed"; + case EXPIRED: return "expired"; } return "invalid"; } +// NOLINTBEGIN(misc-no-recursion) ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* pindexPrev, ThresholdConditionCache& cache) const { int nPeriod = Period(); int nThreshold = Threshold(); int min_activation_height = MinActivationHeight(); + int active_duration = ActiveDuration(); int64_t nTimeStart = BeginTime(); int64_t nTimeTimeout = EndTime(); @@ -67,6 +70,23 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* assert(cache.contains(pindexPrev)); ThresholdState state = cache[pindexPrev]; + // Everything is already cached. Return immediately. + if (vToCompute.empty()) { + return state; + } + + // For temporary deployments, we need to know when ACTIVE started to determine the + // ACTIVE -> EXPIRED transition. We get this by calling GetStateSinceHeightFor, which + // internally calls GetStateFor on earlier periods. Those calls could recurse back here + // and call GetStateSinceHeightFor again, but the early return above prevents this: + // the walk-back above guarantees all periods before pindexPrev are already cached, + // and GetStateSinceHeightFor only walks backwards, so its GetStateFor calls always hit + // the cache, have empty vToCompute, and return immediately via the early return. + int activation_height = 0; + if (state == ThresholdState::ACTIVE && active_duration < std::numeric_limits::max()) { + activation_height = GetStateSinceHeightFor(pindexPrev, cache); + } + // Now walk forward and compute the state of descendants of pindexPrev while (!vToCompute.empty()) { ThresholdState stateNext = state; @@ -101,11 +121,21 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* // Progresses into ACTIVE provided activation height will have been reached. if (pindexPrev->nHeight + 1 >= min_activation_height) { stateNext = ThresholdState::ACTIVE; + if (active_duration < std::numeric_limits::max()) { + activation_height = pindexPrev->nHeight + 1; + } } break; } - case ThresholdState::FAILED: case ThresholdState::ACTIVE: { + if (active_duration < std::numeric_limits::max() && + pindexPrev->nHeight + 1 >= activation_height + active_duration) { + stateNext = ThresholdState::EXPIRED; + } + break; + } + case ThresholdState::FAILED: + case ThresholdState::EXPIRED: { // Nothing happens, these are terminal states. break; } @@ -115,6 +145,7 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* return state; } +// NOLINTEND(misc-no-recursion) BIP9Stats AbstractThresholdConditionChecker::GetStateStatisticsFor(const CBlockIndex* pindex, std::vector* signalling_blocks) const { @@ -154,6 +185,13 @@ BIP9Stats AbstractThresholdConditionChecker::GetStateStatisticsFor(const CBlockI return stats; } +// WARNING: This function is called from GetStateFor and calls GetStateFor in turn. +// The recursion is safe because this function calls GetStateFor first (which populates +// the cache), then only walks BACKWARDS through periods that are now cached. GetStateFor +// returns immediately for cached entries (via the early return when vToCompute is empty). +// If the backwards walk is ever changed to query uncached periods, infinite recursion +// will result. +// NOLINTBEGIN(misc-no-recursion) int AbstractThresholdConditionChecker::GetStateSinceHeightFor(const CBlockIndex* pindexPrev, ThresholdConditionCache& cache) const { int64_t start_time = BeginTime(); @@ -188,6 +226,7 @@ int AbstractThresholdConditionChecker::GetStateSinceHeightFor(const CBlockIndex* // Adjust the result because right now we point to the parent block. return pindexPrev->nHeight + 1; } +// NOLINTEND(misc-no-recursion) BIP9Info VersionBitsCache::Info(const CBlockIndex& block_index, const Consensus::Params& params, Consensus::DeploymentPos id) { @@ -240,6 +279,7 @@ BIP9GBTStatus VersionBitsCache::GBTStatus(const CBlockIndex& block_index, const switch (state) { case DEFINED: case FAILED: + case EXPIRED: // Not exposed to GBT break; case STARTED: diff --git a/src/versionbits_impl.h b/src/versionbits_impl.h index bd59bf73d885..43afc3be1f9f 100644 --- a/src/versionbits_impl.h +++ b/src/versionbits_impl.h @@ -9,6 +9,8 @@ #include #include +#include + /** BIP 9 defines a finite-state-machine to deploy a softfork in multiple stages. * State transitions happen during retarget period if conditions are met * In case of reorg, transitions can go backward. Without transition, state is @@ -18,8 +20,9 @@ enum class ThresholdState : uint8_t { DEFINED, // First state that each softfork starts out as. The genesis block is by definition in this state for each deployment. STARTED, // For blocks past the starttime. LOCKED_IN, // For at least one retarget period after the first retarget period with STARTED blocks of which at least threshold have the associated bit set in nVersion, until min_activation_height is reached. - ACTIVE, // For all blocks after the LOCKED_IN retarget period (final state) + ACTIVE, // For all blocks after the LOCKED_IN retarget period (final state for permanent deployments) FAILED, // For all blocks once the first retarget period after the timeout time is hit, if LOCKED_IN wasn't already reached (final state) + EXPIRED, // For temporary deployments: all blocks after active_duration periods past activation (final state) }; /** Get a string with the state name */ @@ -34,6 +37,7 @@ class AbstractThresholdConditionChecker { virtual int64_t BeginTime() const =0; virtual int64_t EndTime() const =0; virtual int MinActivationHeight() const { return 0; } + virtual int ActiveDuration() const { return std::numeric_limits::max(); } virtual int Period() const =0; virtual int Threshold() const =0; @@ -62,6 +66,7 @@ class VersionBitsConditionChecker : public AbstractThresholdConditionChecker { int64_t BeginTime() const override { return dep.nStartTime; } int64_t EndTime() const override { return dep.nTimeout; } int MinActivationHeight() const override { return dep.min_activation_height; } + int ActiveDuration() const override { return dep.active_duration; } int Period() const override { return dep.period; } int Threshold() const override { return dep.threshold; } From eee1987f2bc2f00a991c97a7f3f74490207638a9 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 16:05:30 -0600 Subject: [PATCH 02/29] versionbits: add max_activation_height for mandatory BIP9 activation --- src/consensus/params.h | 4 ++++ src/rpc/blockchain.cpp | 4 ++++ src/versionbits.cpp | 8 ++++++++ src/versionbits_impl.h | 2 ++ 4 files changed, 18 insertions(+) diff --git a/src/consensus/params.h b/src/consensus/params.h index ef44d6db0bac..902a933c9d00 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -54,6 +54,10 @@ struct BIP9Deployment { * boundary. */ int min_activation_height{0}; + /** Maximum height for activation. If less than INT_MAX, the deployment will activate + * at this height regardless of signaling (similar to BIP8 flag day). + * std::numeric_limits::max() means no maximum (activation only via signaling). */ + int max_activation_height{std::numeric_limits::max()}; /** Period of blocks to check signalling in (usually retarget period, ie params.DifficultyAdjustmentInterval()) */ uint32_t period{2016}; /** diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 8313c7cd3d77..8e536522c9ed 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1322,6 +1322,9 @@ static void SoftForkDescPushBack(const CBlockIndex* blockindex, UniValue& softfo bip9.pushKV("start_time", depparams.nStartTime); bip9.pushKV("timeout", depparams.nTimeout); bip9.pushKV("min_activation_height", depparams.min_activation_height); + if (depparams.max_activation_height < std::numeric_limits::max()) { + bip9.pushKV("max_activation_height", depparams.max_activation_height); + } // BIP9 status bip9.pushKV("status", info.current_state); @@ -1463,6 +1466,7 @@ const std::vector RPCHelpForDeployment{ {RPCResult::Type::NUM_TIME, "start_time", "the minimum median time past of a block at which the bit gains its meaning"}, {RPCResult::Type::NUM_TIME, "timeout", "the median time past of a block at which the deployment is considered failed if not yet locked in"}, {RPCResult::Type::NUM, "min_activation_height", "minimum height of blocks for which the rules may be enforced"}, + {RPCResult::Type::NUM, "max_activation_height", /*optional=*/true, "height at which the deployment will unconditionally activate (absent for miner-vetoable deployments)"}, {RPCResult::Type::STR, "status", "status of deployment at specified block (one of \"defined\", \"started\", \"locked_in\", \"active\", \"failed\", \"expired\")"}, {RPCResult::Type::NUM, "since", "height of the first block to which the status applies"}, {RPCResult::Type::STR, "status_next", "status of deployment at the next block"}, diff --git a/src/versionbits.cpp b/src/versionbits.cpp index 3b2fdde895b9..9a116da476ed 100644 --- a/src/versionbits.cpp +++ b/src/versionbits.cpp @@ -30,6 +30,7 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* int nPeriod = Period(); int nThreshold = Threshold(); int min_activation_height = MinActivationHeight(); + int max_activation_height = MaxActivationHeight(); int active_duration = ActiveDuration(); int64_t nTimeStart = BeginTime(); int64_t nTimeTimeout = EndTime(); @@ -111,8 +112,15 @@ ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex* pindexCount = pindexCount->pprev; } if (count >= nThreshold) { + // Normal BIP9 activation via signaling + stateNext = ThresholdState::LOCKED_IN; + } else if (max_activation_height < std::numeric_limits::max() && pindexPrev->nHeight + 1 >= max_activation_height - nPeriod) { + // Force LOCKED_IN one period before max_activation_height + // This ensures activation happens AT max_activation_height (not one period later) + // Overrides timeout to guarantee activation stateNext = ThresholdState::LOCKED_IN; } else if (pindexPrev->GetMedianTimePast() >= nTimeTimeout) { + // Timeout without activation stateNext = ThresholdState::FAILED; } break; diff --git a/src/versionbits_impl.h b/src/versionbits_impl.h index 43afc3be1f9f..c3dfaabcdd23 100644 --- a/src/versionbits_impl.h +++ b/src/versionbits_impl.h @@ -37,6 +37,7 @@ class AbstractThresholdConditionChecker { virtual int64_t BeginTime() const =0; virtual int64_t EndTime() const =0; virtual int MinActivationHeight() const { return 0; } + virtual int MaxActivationHeight() const { return std::numeric_limits::max(); } virtual int ActiveDuration() const { return std::numeric_limits::max(); } virtual int Period() const =0; virtual int Threshold() const =0; @@ -66,6 +67,7 @@ class VersionBitsConditionChecker : public AbstractThresholdConditionChecker { int64_t BeginTime() const override { return dep.nStartTime; } int64_t EndTime() const override { return dep.nTimeout; } int MinActivationHeight() const override { return dep.min_activation_height; } + int MaxActivationHeight() const override { return dep.max_activation_height; } int ActiveDuration() const override { return dep.active_duration; } int Period() const override { return dep.period; } int Threshold() const override { return dep.threshold; } From 1d29cec047e5c6a005a96fa8b4aaa7baef87a602 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Tue, 18 Nov 2025 22:46:30 -0600 Subject: [PATCH 03/29] chainparams: support regtest vbparams for max_activation_height and active_duration --- src/chainparams.cpp | 26 ++++++++++++++++++++++---- src/kernel/chainparams.cpp | 6 ++++++ src/kernel/chainparams.h | 3 +++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 32c3bff73482..f5b95736896a 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -68,8 +68,8 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti for (const std::string& strDeployment : args.GetArgs("-vbparams")) { std::vector vDeploymentParams = SplitString(strDeployment, ':'); - if (vDeploymentParams.size() < 3 || 4 < vDeploymentParams.size()) { - throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height]"); + if (vDeploymentParams.size() < 3 || 6 < vDeploymentParams.size()) { + throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration]]]"); } CChainParams::VersionBitsParameters vbparams{}; const auto start_time{ToIntegral(vDeploymentParams[1])}; @@ -91,13 +91,31 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti } else { vbparams.min_activation_height = 0; } + if (vDeploymentParams.size() >= 5) { + const auto max_activation_height{ToIntegral(vDeploymentParams[4])}; + if (!max_activation_height) { + throw std::runtime_error(strprintf("Invalid max_activation_height (%s)", vDeploymentParams[4])); + } + vbparams.max_activation_height = *max_activation_height; + } + if (vDeploymentParams.size() >= 6) { + const auto active_duration{ToIntegral(vDeploymentParams[5])}; + if (!active_duration) { + throw std::runtime_error(strprintf("Invalid active_duration (%s)", vDeploymentParams[5])); + } + vbparams.active_duration = *active_duration; + } + // Validate that timeout and max_activation_height are mutually exclusive + if (vbparams.timeout != Consensus::BIP9Deployment::NO_TIMEOUT && vbparams.max_activation_height < std::numeric_limits::max()) { + throw std::runtime_error(strprintf("Cannot specify both timeout (%ld) and max_activation_height (%d) for deployment %s. Use timeout for BIP9 or max_activation_height for mandatory activation deadline, not both.", vbparams.timeout, vbparams.max_activation_height, vDeploymentParams[0])); + } bool found = false; for (int j=0; j < (int)Consensus::MAX_VERSION_BITS_DEPLOYMENTS; ++j) { if (vDeploymentParams[0] == VersionBitsDeploymentInfo[j].name) { options.version_bits_parameters[Consensus::DeploymentPos(j)] = vbparams; found = true; - LogInfo("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d", - vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height); + LogInfo("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d", + vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration); break; } } diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 64809a75911e..292a75d75b23 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -629,6 +629,12 @@ class CRegTestParams : public CChainParams consensus.vDeployments[deployment_pos].nStartTime = version_bits_params.start_time; consensus.vDeployments[deployment_pos].nTimeout = version_bits_params.timeout; consensus.vDeployments[deployment_pos].min_activation_height = version_bits_params.min_activation_height; + consensus.vDeployments[deployment_pos].max_activation_height = version_bits_params.max_activation_height; + consensus.vDeployments[deployment_pos].active_duration = version_bits_params.active_duration; + // Validated here rather than in src/chainparams.cpp because period is not yet available at parse time + if (version_bits_params.active_duration != std::numeric_limits::max() && version_bits_params.active_duration % consensus.vDeployments[deployment_pos].period != 0) { + throw std::runtime_error(strprintf("active_duration (%d) must be a multiple of period (%d)", version_bits_params.active_duration, consensus.vDeployments[deployment_pos].period)); + } } genesis = CreateGenesisBlock(1296688602, 2, 0x207fffff, 1, 50 * COIN); diff --git a/src/kernel/chainparams.h b/src/kernel/chainparams.h index f7209bee1b06..dff79e8a0469 100644 --- a/src/kernel/chainparams.h +++ b/src/kernel/chainparams.h @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -142,6 +143,8 @@ class CChainParams int64_t start_time; int64_t timeout; int min_activation_height; + int max_activation_height{std::numeric_limits::max()}; + int active_duration{std::numeric_limits::max()}; }; /** From 646a7f8596d310b9dca50e6eb817076ea8bef2c0 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 26 Jan 2026 22:37:45 -0600 Subject: [PATCH 04/29] test: add versionbits unit tests for max_activation_height and active_duration --- src/test/versionbits_tests.cpp | 653 +++++++++++++++++++++++++++++++++ 1 file changed, 653 insertions(+) diff --git a/src/test/versionbits_tests.cpp b/src/test/versionbits_tests.cpp index ad7920531175..a8ccf7e21306 100644 --- a/src/test/versionbits_tests.cpp +++ b/src/test/versionbits_tests.cpp @@ -481,4 +481,657 @@ BOOST_FIXTURE_TEST_CASE(versionbits_computeblockversion, BlockVersionTest) } } +/** + * Test condition checker with max_activation_height for mandatory activation deadline. + * When max_activation_height is set, the deployment forces LOCKED_IN one period before + * max_activation_height, even if threshold signaling was not met. + */ +class TestMaxActivationHeightConditionChecker : public AbstractThresholdConditionChecker +{ +private: + mutable ThresholdConditionCache cache; + int m_max_activation_height; + +public: + explicit TestMaxActivationHeightConditionChecker(int max_height) : m_max_activation_height(max_height) {} + + int64_t BeginTime() const override { return 0; } + int64_t EndTime() const override { return Consensus::BIP9Deployment::NO_TIMEOUT; } + int Period() const override { return 144; } + int Threshold() const override { return 108; } + int MaxActivationHeight() const override { return m_max_activation_height; } + bool Condition(const CBlockIndex* pindex) const override { return (pindex->nVersion & 0x100); } + + ThresholdState GetStateFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateFor(pindexPrev, cache); } + int GetStateSinceHeightFor(const CBlockIndex* pindexPrev) const { return AbstractThresholdConditionChecker::GetStateSinceHeightFor(pindexPrev, cache); } + void ClearCache() { cache.clear(); } +}; + +BOOST_AUTO_TEST_CASE(versionbits_max_activation_height) +{ + // Test that max_activation_height forces LOCKED_IN one period before max_activation_height + // even without sufficient signaling. + // + // Timeline with period=144, max_activation_height=432: + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED (no signaling -> normally would stay STARTED) + // - Period 2 (288-431): LOCKED_IN (forced because 288 >= 432 - 144) + // - Period 3 (432+): ACTIVE + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + // max_activation_height = 432 (period 3 start) + TestMaxActivationHeightConditionChecker checker(432); + + // Helper to create blocks + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Mine through period 0 (DEFINED) - 144 blocks (0-143) + for (int i = 0; i < 144; i++) { + mine_block(0); // No signaling + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 143); + // At tip 143, next block (144) would be STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 144); + + // Mine through period 1 (STARTED) without signaling - blocks 144-287 + for (int i = 0; i < 144; i++) { + mine_block(0); // No signaling + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // At tip 287, next block (288) would be LOCKED_IN due to max_activation_height + // 288 >= 432 - 144, so forced LOCKED_IN + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + // Mine through period 2 (LOCKED_IN) - blocks 288-431 + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 431); + // At tip 431, next block (432) would be ACTIVE + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + // Mine into period 3 (ACTIVE) - blocks 432+ + for (int i = 0; i < 10; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 441); + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 432); + + cleanup(); + + // Test 2: Verify that signaling still works to activate earlier than max_activation_height + TestMaxActivationHeightConditionChecker checker2(1000); // max_activation_height far in future + + // Period 0: DEFINED + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: Signal 108+ blocks (threshold) + for (int i = 0; i < 108; i++) { + mine_block(0x100); // Signal + } + for (int i = 0; i < 36; i++) { + mine_block(0); // No signal + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // Should be LOCKED_IN via signaling, not via max_activation_height + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker2.GetStateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN -> ACTIVE + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker2.GetStateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker2.GetStateSinceHeightFor(blocks.back()), 432); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_max_activation_height_boundary) +{ + // Test edge case: verify exact boundary where LOCKED_IN is forced + // With period=144 and max_activation_height=432: + // - At height 287, next block is 288, which is >= 432-144=288, so LOCKED_IN + // - At height 286, next block is 287, which is < 288, so would stay STARTED + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + TestMaxActivationHeightConditionChecker checker(432); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Mine to height 143 (end of period 0) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 143); + // State for block 144 is STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Mine period 1 without signaling (blocks 144-287) + // But stop at block 286 first to check boundary + for (int i = 0; i < 143; i++) { + mine_block(0); + } + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 286); + // At tip 286, next block 287 is still in STARTED period + // State is still STARTED + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::STARTED); + + // Mine block 287 (last block of period 1) + mine_block(0); + BOOST_CHECK_EQUAL(blocks.back()->nHeight, 287); + // At tip 287, state for next block (288) is computed + // 288 >= 432 - 144 = 288, so LOCKED_IN + BOOST_CHECK(checker.GetStateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.GetStateSinceHeightFor(blocks.back()), 288); + + cleanup(); +} + +BOOST_FIXTURE_TEST_CASE(versionbits_active_duration, BasicTestingSetup) +{ + // Test active_duration parameter via -vbparams + // Format: deployment:start:timeout:min_activation_height:max_activation_height:active_duration + // + // This tests that the parameter is parsed correctly. The actual expiry logic + // is tested in DeploymentActiveAt/DeploymentActiveAfter which use active_duration. + + { + ArgsManager args; + // start=0, timeout=never, min_height=0, max_height=INT_MAX (disabled), active_duration=144 + args.ForceSetArg("-vbparams", "testdummy:0:999999999999:0:2147483647:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.nStartTime, 0); + BOOST_CHECK_EQUAL(deployment.nTimeout, 999999999999); + BOOST_CHECK_EQUAL(deployment.min_activation_height, 0); + BOOST_CHECK_EQUAL(deployment.max_activation_height, std::numeric_limits::max()); + BOOST_CHECK_EQUAL(deployment.active_duration, 144); + } + + { + ArgsManager args; + // Test with max_activation_height set + // start=0, timeout=NO_TIMEOUT, min_height=288, max_height=432, active_duration=1008 (144*7) + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:432:1008"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.min_activation_height, 288); + BOOST_CHECK_EQUAL(deployment.max_activation_height, 432); + BOOST_CHECK_EQUAL(deployment.active_duration, 1008); + } + + { + ArgsManager args; + // Test permanent deployment (active_duration = INT_MAX) + args.ForceSetArg("-vbparams", "testdummy:0:999999999999:0:2147483647:2147483647"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.active_duration, std::numeric_limits::max()); + } +} + +BOOST_FIXTURE_TEST_CASE(versionbits_max_activation_height_parsing, BasicTestingSetup) +{ + // Test max_activation_height parameter via -vbparams + + { + ArgsManager args; + // Test with max_activation_height=432 (mandatory activation deadline) + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:432:2147483647"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.max_activation_height, 432); + // active_duration should be permanent when not specified differently + BOOST_CHECK_EQUAL(deployment.active_duration, std::numeric_limits::max()); + } + + { + ArgsManager args; + // Test combined: max_activation_height + active_duration (RDTS) + // NO_TIMEOUT = INT64_MAX = 9223372036854775807 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:288:576:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& deployment = chainParams->GetConsensus().vDeployments[Consensus::DEPLOYMENT_TESTDUMMY]; + + BOOST_CHECK_EQUAL(deployment.min_activation_height, 288); + BOOST_CHECK_EQUAL(deployment.max_activation_height, 576); + BOOST_CHECK_EQUAL(deployment.active_duration, 144); + } +} + +/** + * Helper to create a BIP9Deployment for temporary deployment tests. + * Uses bit 8 and version mask 0x100 for signaling (same as Deployments::normal). + */ +static Consensus::BIP9Deployment MakeTemporaryDeployment(int active_duration, int max_activation_height = std::numeric_limits::max()) +{ + return Consensus::BIP9Deployment{ + .bit = 8, + .nStartTime = 0, + .nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT, + .min_activation_height = 0, + .max_activation_height = max_activation_height, + .period = 144, + .threshold = 108, + .active_duration = active_duration, + }; +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_state) +{ + // Test that a temporary deployment transitions from ACTIVE to EXPIRED + // after active_duration blocks past activation. + // + // Timeline with period=144, active_duration=288: + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED, signal enough to lock in + // - Period 2 (288-431): LOCKED_IN + // - Period 3 (432-575): ACTIVE (activation_height=432) + // - Period 4 (576-719): ACTIVE (blocks in this period are ACTIVE) + // - At pindexPrev=719: EXPIRED for block 720+ (720 >= 432 + 288) + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + const auto dep = MakeTemporaryDeployment(288); + TestConditionChecker checker(dep); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (blocks 0-143) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: STARTED, signal all blocks to lock in (blocks 144-287) + for (int i = 0; i < 144; i++) { + mine_block(VERSIONBITS_TOP_BITS | 0x100); // Signal + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN (blocks 288-431) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 432); + + // Period 3: ACTIVE (blocks 432-575), activation_height = 432 + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 432); + + // Period 4 (blocks 576-719): blocks in this period are ACTIVE, + // but at pindexPrev=719 the state for block 720+ is EXPIRED (720 >= 432 + 288) + for (int i = 0; i < 144; i++) { + mine_block(0); + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + // Verify EXPIRED is terminal + for (int i = 0; i < 144; i++) { + mine_block(VERSIONBITS_TOP_BITS | 0x100); // Signal shouldn't matter + } + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_unaligned_duration_rejected) +{ + // Test that active_duration that is NOT a multiple of the period is rejected. + ArgsManager args; + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:432:200"); + BOOST_CHECK_THROW(CreateChainParams(args, ChainType::REGTEST), std::runtime_error); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_minimum_duration) +{ + // Test active_duration = 1 period (144). Minimum useful duration. + // Activation at 432, so 432+144=576. At pindexPrev=575, state for 576+: + // 576 >= 576 -> EXPIRED. Only one period of ACTIVE. + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + const auto dep = MakeTemporaryDeployment(144); + TestConditionChecker checker(dep); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(VERSIONBITS_TOP_BITS | 0x100); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::LOCKED_IN); + + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 432); + + // Period 3 (432-575): ACTIVE for this period, but state for 576+: + // 576 >= 432 + 144 = 576 -> EXPIRED immediately at next boundary + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 576); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_cold_cache) +{ + // Test that clearing the cache and re-querying correctly recovers + // the EXPIRED state. This exercises the activation_height recovery + // path in GetStateFor where it calls GetStateSinceHeightFor to find + // when ACTIVE started. + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + const auto dep = MakeTemporaryDeployment(288); + TestConditionChecker checker(dep); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Build chain through to EXPIRED (same as basic test) + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(VERSIONBITS_TOP_BITS | 0x100); + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 3: ACTIVE (432-575) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 4: (576-719) -> EXPIRED at 720 + for (int i = 0; i < 144; i++) mine_block(0); + + // Verify EXPIRED with warm cache + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + // Clear cache completely — simulates node restart + checker.clear(); + + // Re-query: must walk from genesis, recover activation_height, compute EXPIRED + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + // Also verify intermediate states are correct after cache rebuild + checker.clear(); + // Query at end of period 3 (pindexPrev=575) — should be ACTIVE + BOOST_CHECK(checker.StateFor(blocks[575]) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks[575]), 432); + + // Now query at end of period 4 with partially-populated cache + // (period 3 is cached as ACTIVE from the query above) + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_with_max_activation_height) +{ + // Test interaction: deployment with BOTH max_activation_height AND active_duration. + // max_activation_height forces LOCKED_IN, then active_duration causes EXPIRED. + // + // period=144, max_activation_height=432, active_duration=288 + // - Period 0 (0-143): DEFINED + // - Period 1 (144-287): STARTED (no signaling, but forced LOCKED_IN at boundary + // because 288 >= 432 - 144) + // - Period 2 (288-431): LOCKED_IN + // - Period 3 (432-575): ACTIVE (activation_height=432) + // - Period 4 (576-719): ACTIVE + // - At pindexPrev=719: EXPIRED (720 >= 432 + 288) + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + const auto dep = MakeTemporaryDeployment(288, /*max_activation_height=*/432); + TestConditionChecker checker(dep); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143), no signaling + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::STARTED); + + // Period 1: STARTED (144-287), no signaling — forced LOCKED_IN by max_activation_height + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 288); + + // Period 2: LOCKED_IN (288-431) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 432); + + // Period 3: ACTIVE (432-575) + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + + // Period 4: (576-719) -> EXPIRED at 720 + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 720); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_expired_zero_duration) +{ + // Test active_duration = 0. This means the deployment expires immediately + // after activation — zero blocks of enforcement. + // Activation at 432, so 432 + 0 = 432. At pindexPrev=431 (end of LOCKED_IN), + // state for 432+: LOCKED_IN transitions to ACTIVE, sets activation_height=432. + // But that's computed for this period boundary. The ACTIVE->EXPIRED check + // happens on the NEXT iteration of the walk-forward. + // At pindexPrev=575 (end of period 3): 576 >= 432 + 0 = 432 -> EXPIRED. + // So active_duration=0 gives exactly one period of ACTIVE, same as + // active_duration=144 with period=144 (since transitions are per-period). + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + const auto dep = MakeTemporaryDeployment(0); + TestConditionChecker checker(dep); + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) + for (int i = 0; i < 144; i++) mine_block(0); + // Period 1: Signal (144-287) + for (int i = 0; i < 144; i++) mine_block(VERSIONBITS_TOP_BITS | 0x100); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::LOCKED_IN); + + // Period 2: LOCKED_IN (288-431) -> ACTIVE at 432 + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 432); + + // Period 3 (432-575): ACTIVE, but 576 >= 432+0 -> EXPIRED at next boundary + for (int i = 0; i < 144; i++) mine_block(0); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(checker.StateSinceHeightFor(blocks.back()), 576); + + cleanup(); +} + +BOOST_AUTO_TEST_CASE(versionbits_no_signaling_after_expired) +{ + // Test that ComputeBlockVersion does NOT set the deployment bit after EXPIRED. + // Uses regtest with testdummy deployment configured with active_duration. + // + // ComputeBlockVersion only signals for STARTED and LOCKED_IN states. + // After EXPIRED, the bit should not be set. + + ArgsManager args; + // testdummy: start=0, timeout=never, min_height=0, max_height=INT_MAX, active_duration=144 + args.ForceSetArg("-vbparams", "testdummy:0:9223372036854775807:0:2147483647:144"); + const auto chainParams = CreateChainParams(args, ChainType::REGTEST); + const auto& params = chainParams->GetConsensus(); + + VersionBitsCache vbcache; + const auto dep = Consensus::DEPLOYMENT_TESTDUMMY; + TestConditionChecker checker(params.vDeployments[dep]); + const uint32_t bitmask = uint32_t{1} << params.vDeployments[dep].bit; + const int period = params.vDeployments[dep].period; + + std::vector blocks; + auto cleanup = [&blocks]() { + for (auto* b : blocks) delete b; + blocks.clear(); + }; + + auto mine_block = [&blocks](int32_t nVersion) -> CBlockIndex* { + CBlockIndex* pindex = new CBlockIndex(); + pindex->nHeight = blocks.size(); + pindex->pprev = blocks.empty() ? nullptr : blocks.back(); + pindex->nTime = 1415926536 + 600 * pindex->nHeight; + pindex->nVersion = nVersion; + pindex->BuildSkip(); + blocks.push_back(pindex); + return pindex; + }; + + // Period 0: DEFINED (0-143) — bit should not be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, bitmask); // STARTED, bit set + + // Period 1: STARTED — signal to lock in (144-287), bit should be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS | bitmask); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::LOCKED_IN); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, bitmask); // LOCKED_IN, bit set + + // Period 2: LOCKED_IN (288-431), bit should be set + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS | bitmask); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::ACTIVE); + // ACTIVE: bit should NOT be set + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + // Period 3: ACTIVE (432-575) + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + // EXPIRED: bit should NOT be set + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + // Period 4: EXPIRED (576+) — verify bit stays off + for (int i = 0; i < period; i++) mine_block(VERSIONBITS_TOP_BITS); + BOOST_CHECK(checker.StateFor(blocks.back()) == ThresholdState::EXPIRED); + BOOST_CHECK_EQUAL(vbcache.ComputeBlockVersion(blocks.back(), params) & bitmask, 0u); + + cleanup(); +} + BOOST_AUTO_TEST_SUITE_END() From 1ade1a46e537b47701296423bb9969bee628e5e2 Mon Sep 17 00:00:00 2001 From: Dathon Ohm Date: Mon, 29 Dec 2025 15:16:13 -0600 Subject: [PATCH 05/29] chainparams: support regtest vbparams for per-deployment threshold --- src/chainparams.cpp | 15 +++++++++++---- src/kernel/chainparams.cpp | 3 +++ src/kernel/chainparams.h | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index f5b95736896a..8286f93cfa70 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -68,8 +68,8 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti for (const std::string& strDeployment : args.GetArgs("-vbparams")) { std::vector vDeploymentParams = SplitString(strDeployment, ':'); - if (vDeploymentParams.size() < 3 || 6 < vDeploymentParams.size()) { - throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration]]]"); + if (vDeploymentParams.size() < 3 || 7 < vDeploymentParams.size()) { + throw std::runtime_error("Version bits parameters malformed, expecting deployment:start:end[:min_activation_height[:max_activation_height[:active_duration[:threshold]]]]"); } CChainParams::VersionBitsParameters vbparams{}; const auto start_time{ToIntegral(vDeploymentParams[1])}; @@ -105,6 +105,13 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti } vbparams.active_duration = *active_duration; } + if (vDeploymentParams.size() >= 7) { + const auto threshold{ToIntegral(vDeploymentParams[6])}; + if (!threshold) { + throw std::runtime_error(strprintf("Invalid threshold (%s)", vDeploymentParams[6])); + } + vbparams.threshold = *threshold; + } // Validate that timeout and max_activation_height are mutually exclusive if (vbparams.timeout != Consensus::BIP9Deployment::NO_TIMEOUT && vbparams.max_activation_height < std::numeric_limits::max()) { throw std::runtime_error(strprintf("Cannot specify both timeout (%ld) and max_activation_height (%d) for deployment %s. Use timeout for BIP9 or max_activation_height for mandatory activation deadline, not both.", vbparams.timeout, vbparams.max_activation_height, vDeploymentParams[0])); @@ -114,8 +121,8 @@ void ReadRegTestArgs(const ArgsManager& args, CChainParams::RegTestOptions& opti if (vDeploymentParams[0] == VersionBitsDeploymentInfo[j].name) { options.version_bits_parameters[Consensus::DeploymentPos(j)] = vbparams; found = true; - LogInfo("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d", - vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration); + LogInfo("Setting version bits activation parameters for %s to start=%ld, timeout=%ld, min_activation_height=%d, max_activation_height=%d, active_duration=%d, threshold=%d", + vDeploymentParams[0], vbparams.start_time, vbparams.timeout, vbparams.min_activation_height, vbparams.max_activation_height, vbparams.active_duration, vbparams.threshold); break; } } diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp index 292a75d75b23..26b8b78e0582 100644 --- a/src/kernel/chainparams.cpp +++ b/src/kernel/chainparams.cpp @@ -16,6 +16,7 @@ #include #include