BIP110 code review for humans

This document has been created in hopes of enabling non-developers to personally review Bitcoin Knots's implementation of the BIP110 consensus rules. While reviewing consensus changes can seem like a daunting task, BIP110 provides a rare opportunity for everyone to do so because of its simplicity. With only 5 modified lines of code and 72 added, across 5 source code files, we can explain it in a simple document.

BIP110 Rule Reference

  1. New output scriptPubKeys exceeding 34 bytes are invalid, unless the first opcode is OP_RETURN, in which case up to 83 bytes are valid.
  2. OP_PUSHDATA* payloads and script argument witness items exceeding 256 bytes are invalid, except for the redeemScript push in BIP16 scriptSigs.
  3. Spending undefined witness (or Tapleaf) versions (ie, not Witness v0/BIP 141, Taproot/BIP 341, or P2A) is invalid. (Creating outputs with undefined witness versions is still valid.)
  4. Witness stacks with a Taproot annex are invalid.
  5. Taproot control blocks larger than 257 bytes (a merkle tree with 128 script leaves) are invalid.
  6. Tapscripts including OP_SUCCESS* opcodes anywhere (even unexecuted) are invalid.
  7. Tapscripts executing the OP_IF or OP_NOTIF instruction (regardless of result) are invalid.

How to read

On the left side is the actual C++ code. Blue lines beginning with a "+" (plus symbol) are added by BIP110. Red lines beginning with a "-" (minus symbol) are removed or replaced. Lines without any symbol are either preexisting and retained code, or documentation comments. On the right side, there is a human-readable explaination of what each of the changes do.

src/consensus/tx_verify.cpp

 bool Consensus::CheckOutputSizes(const CTransaction& tx, TxValidationState& state)
 {
+    for (const auto& txout : tx.vout) {
+        if (txout.scriptPubKey.empty()) continue;
+        if (txout.scriptPubKey.size() > ((txout.scriptPubKey[0] == OP_RETURN) ? MAX_OUTPUT_DATA_SIZE : MAX_OUTPUT_SCRIPT_SIZE)) {
+            return state.Invalid(TxValidationResult::TX_PREMATURE_SPEND, "bad-txns-vout-script-toolarge");
         }
     }
+    return true;
 }
Checks the size of each output according to Rule 1.

A completely empty output script (scriptPubKey) is ignored early (continue), so we don't crash checking if the first opcode is OP_RETURN.

If the size exceeds the applicable limit, the block is declared Invalid.
Part of: bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state,...
                          strprintf("%s: inputs missing/spent", __func__));
     }
 
     // NOTE: CheckTransaction is arguably the more logical place to do this, but it's context-independent, so this is probably the next best place for now
+    if (rules.test(CheckTxInputsRules::OutputSizeLimit) && !CheckOutputSizes(tx, state)) {
+        return false;
     }

     CAmount nValueIn = 0;
     for (unsigned int i = 0; i < tx.vin.size(); ++i) {
         const COutPoint &prevout = tx.vin[i].prevout;
This simply executes the new CheckOutputSizes code when we're checking transaction inputs.

The output-size rule itself is simple, but whether it should be enforced depends on the activation state of the BIP110 softfork. CheckTransaction does not know which consensus deployment rules are active, so CheckTxInputs is the most appropriate place to add these checks.

src/deploymentstatus.h

 inline bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const Consensus::Params& params, Consensus::DeploymentPos dep, VersionBitsCache& versionbitscache)
 {
     assert(Consensus::ValidDeployment(dep));
     if (ThresholdState::ACTIVE != versionbitscache.State(pindexPrev, params, dep)) return false;

     const auto& deployment = params.vDeployments[dep];
     // Permanent deployment (never expires)
+    if (deployment.active_duration == std::numeric_limits<int>::max()) return true;

+    const int activation_height = versionbitscache.StateSinceHeight(pindexPrev, params, dep);
+    const int height = pindexPrev == nullptr ? 0 : pindexPrev->nHeight + 1;
+    return height < activation_height + deployment.active_duration;
 }





This adds the ability for a softfork to expire.

First, it skips the entire new logic for the existing permanent softforks (eg, Taproot).

Then, it looks up the activation height, adds the softfork's duration to that, and compares it to the current block's height.
 /** Determine if mandatory signaling is required for a deployment at the next block */
 inline bool DeploymentMustSignalAfter(const CBlockIndex* pindexPrev, const Consensus::Params& params, Consensus::DeploymentPos dep, ThresholdState state)
 {
     assert(Consensus::ValidDeployment(dep));
     const auto& deployment = params.vDeployments[dep];
+    if (deployment.max_activation_height >= std::numeric_limits<int>::max()) return false;
+    if (state != ThresholdState::STARTED) return false;  // If must_signal height is reached before start time, abstain from enforcement
     const int nPeriod = params.nMinerConfirmationWindow;
+    const int nHeight = pindexPrev == nullptr ? 0 : pindexPrev->nHeight + 1;
+    return nHeight >= deployment.max_activation_height - (2 * nPeriod)
+        && nHeight < deployment.max_activation_height - nPeriod;
 }
This code determines if a block is required to signal for BIP110.

It checks that the softfork is not already "locked in", and that the block is within two 2016-block periods prior to mandatory activation.

If it's already locked in (ie, miners reached 55% signalling), no signal is enforced. If the block is earlier than block 961632 (965664 - 2016 * 2), no signal is enforced.

Note that after this period, 100% of blocks will have signalled, so it will be locked in for the subsequent final pre-activation period (and therefore, no signal is required for those 2 weeks).

src/script/interpreter.cpp

Part of: bool EvalScript(std::vector<std::vector<unsigned char> >& stack, const CScript& ...
     execdata.m_codeseparator_pos = 0xFFFFFFFFUL;
     execdata.m_codeseparator_pos_init = true;
 
+    const unsigned int max_element_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? MAX_SCRIPT_ELEMENT_SIZE_REDUCED : MAX_SCRIPT_ELEMENT_SIZE;

     try
     {
         for (; pc < pend; ++opcode_pos) {
...
When BIP110 is active, the script interpreter simply uses a lower limit for the existing element size checks. (Part of Rule 2)
                         if (sigversion == SigVersion::TAPSCRIPT) {
                             // The input argument to the OP_IF and OP_NOTIF opcodes must be either
                             // exactly 0 (the empty vector) or exactly 1 (the one-byte vector with value 1).
                             if (vch.size() > 1 || (vch.size() == 1 && vch[0] != 1)) {
                                 return set_error(serror, SCRIPT_ERR_TAPSCRIPT_MINIMALIF);
                             }
                             // REDUCED_DATA bans OP_IF/OP_NOTIF entirely in tapscript;
                             // reuses MINIMALIF error code as this is a stricter form of the same restriction
+                            if (flags & SCRIPT_VERIFY_REDUCED_DATA) {
+                                return set_error(serror, SCRIPT_ERR_TAPSCRIPT_MINIMALIF);
                             }
                         }
                         // Under witness v0 rules it is only a policy rule, enabled through SCRIPT_VERIFY_MINIMALIF.
                         if (sigversion == SigVersion::WITNESS_V0 && (flags & SCRIPT_VERIFY_MINIMALIF)) {
Tapscript already strictly restricts the condition for OP_IF to either true or false.

If BIP110 is active, we just reject OP_IF entirely the same way. (Rule 7)
Part of: static bool ExecuteWitnessScript(const Span<const valtype>& stack_span, const CS...
     // Disallow stack item size > MAX_SCRIPT_ELEMENT_SIZE in witness stack
+    const unsigned int max_element_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? MAX_SCRIPT_ELEMENT_SIZE_REDUCED : MAX_SCRIPT_ELEMENT_SIZE;
     for (const valtype& elem : stack) {
         if (elem.size() > max_element_size) return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
     }
 
     // Run the script interpreter.
As with EvalScript, this uses the same lower limit for script argument witness items when BIP110 is active. (Part of Rule 2)
Part of: static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion,...
         if (stack.size() >= 2 && !stack.back().empty() && stack.back()[0] == ANNEX_TAG) {
             // Drop annex (this is non-standard; see IsWitnessStandard)
             const valtype& annex = SpanPopBack(stack);
+            if (flags & SCRIPT_VERIFY_REDUCED_DATA) {
+                return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
             }
             execdata.m_annex_hash = (HashWriter{} << annex).GetSHA256();
             execdata.m_annex_present = true;
         } else {
When BIP110 is active, this rejects any witness annex. (Rule 4)
...
             // Script path spending (stack size is >1 after removing optional annex)
             const valtype& control = SpanPopBack(stack);
             const valtype& script = SpanPopBack(stack);
+            const unsigned int max_control_size = (flags & SCRIPT_VERIFY_REDUCED_DATA) ? TAPROOT_CONTROL_MAX_SIZE_REDUCED : TAPROOT_CONTROL_MAX_SIZE;
             if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > max_control_size || ((control.size() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE) != 0) {
                 return set_error(serror, SCRIPT_ERR_TAPROOT_WRONG_CONTROL_SIZE);
             }
             execdata.m_tapleaf_hash = ComputeTapleafHash(control[0] & TAPROOT_LEAF_MASK, script);
When BIP110 is active, the taproot control block is limited to a smaller size. (Rule 5)
...
             }
             return set_success(serror);
         }
-    } else if (!is_p2sh && CScript::IsPayToAnchor(witversion, program)) {
+    } else if (stack.empty() && !is_p2sh && CScript::IsPayToAnchor(witversion, program)) {
         return true;
     } else {
         if (flags & SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM) {
This is actually a bugfix for a bug in Core. Anchor outputs cannot have a witness stack. Core enforces this with a policy filter (which you can't turn off). However, since Rule 3 makes undefined witness versions invalid, we have to fix this here.
Part of: bool VerifyScript(const CScript& scriptSig, const CScript& scriptPubKey, const C...
     // scriptSig and scriptPubKey must be evaluated sequentially on the same stack
     // rather than being simply concatenated (see CVE-2010-5141)
     std::vector<std::vector<unsigned char> > stack, stackCopy;
+    if (scriptPubKey.IsPayToScriptHash()) {
         // Disable SCRIPT_VERIFY_REDUCED_DATA for pushing the P2SH redeemScript
+        if (!EvalScript(stack, scriptSig, flags & ~SCRIPT_VERIFY_REDUCED_DATA, checker, SigVersion::BASE, serror))
             // serror is set
+            return false;
+    } else
     if (!EvalScript(stack, scriptSig, flags, checker, SigVersion::BASE, serror))
         // serror is set
         return false;
This executes the pre-Segwit scriptSig for inputs being spent in a transaction.

BIP110 makes an exception to Rule 2 for the P2SH scriptSig, so this change removes the BIP110 flag when executing it in that case.
...
         CScript pubKey2(pubKeySerialized.begin(), pubKeySerialized.end());
         popstack(stack);
 
+        if (flags & SCRIPT_VERIFY_REDUCED_DATA) {
             // We bypassed the reduced data check above to exempt redeemScript
             // Now enforce it on the rest of the stack items here
             // This is sufficient because P2SH requires scriptSig to be push-only
+            for (const valtype& elem : stack) {
+                if (elem.size() > MAX_SCRIPT_ELEMENT_SIZE_REDUCED) return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
             }
         }

         if (!EvalScript(stack, pubKey2, flags, checker, SigVersion::BASE, serror))
             // serror is set
             return false;
Because of the exception we just made, it's possible the P2SH scriptSig exceeded the lowered element size limit.

After taking pubKey2 off the stack, we check the sizes of everything else.
(Part of Rule 2)
P2SH only allows adding elements to the stack, so nothing could have "snuck by" during EvalScript that isn't still there.

src/validation.cpp

Part of: bool CheckInputScripts(const CTransaction& tx, TxValidationState& state,...
         txdata.Init(tx, std::move(spent_outputs));
     }
     assert(txdata.m_spent_outputs.size() == tx.vin.size());
     assert(flags_per_input.empty() || flags_per_input.size() == tx.vin.size());
 
     for (unsigned int i = 0; i < tx.vin.size(); i++) {
+        if (!flags_per_input.empty()) flags = flags_per_input[i];
 
         // We very carefully only pass in things to CScriptCheck which
         // are clearly committed to by tx' witness hash. This provides
CheckInputScripts previously assumed all input scripts would be executed with the same flags.

However, because of BIP110's grandfathering provision, it is now possible that some will be checked with BIP110 enabled, while others are checked with its rules disabled.
This code adds the ability to override flags per-input.
...
         }
     }
 
-    if (cacheFullScriptStore && !pvChecks) {
+    if (cacheFullScriptStore && (!pvChecks) && flags_per_input.empty()) {
         // We executed all of the provided scripts, and were told to
         // cache the result. Do so now.
         validation_cache.m_script_execution_cache.insert(hashCacheEntry);
The result of script checks is sometimes cached for performance. But the cache is keyed to the one-flags-for-all assumption. We could change that, but it's simpler to just disable the cache in this case.
Part of: static unsigned int GetBlockScriptFlags(const CBlockIndex& block_index, const Ch...
         flags |= SCRIPT_VERIFY_NULLDUMMY;
     }
 
+    if (DeploymentActiveAt(block_index, chainman, Consensus::DEPLOYMENT_REDUCED_DATA)) {
+        flags |= REDUCED_DATA_MANDATORY_VERIFY_FLAGS;
     }

     return flags;
 }
GetBlockScriptFlags decides what flags scripts are executed with for a given block.

If BIP110 is active, this selects its flags (which enable enforcement of Rules 2-7).

Note that most of the nuanced logic for this is actually already implemented in Bitcoin Core as policies/filters.
Part of: bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,...
     uint256 hashPrevBlock = pindex->pprev == nullptr ? uint256() : pindex->pprev->GetBlockHash();
     assert(hashPrevBlock == view.GetBestBlock());
 
+    if (!ContextualCheckBlockHeaderVolatile(block, state, m_chainman, pindex->pprev)) {
         LogError("%s: Consensus::ContextualCheckBlockHeaderVolatile: %s\n", __func__, state.ToString());
+        return false;
     }

     m_chainman.num_blocks_total++;
 
     // Special case for the genesis block, skipping connection of its transactions
This adds a new check to blocks (details below).
...
     std::vector<PrecomputedTransactionData> txsdata(block.vtx.size());
     CCheckQueueControl<CScriptCheck> control(fScriptChecks && parallel_script_checks ? &m_chainman.GetCheckQueue() : nullptr);

     // For BIP9 deployments, get the activation height dynamically
+    const auto reduced_data_start_height = DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA)
+        ? m_chainman.m_versionbitscache.StateSinceHeight(pindex->pprev, params.GetConsensus(), Consensus::DEPLOYMENT_REDUCED_DATA)
+        : std::numeric_limits<int>::max();

+    const CheckTxInputsRules chk_input_rules{DeploymentActiveAt(*pindex, m_chainman, Consensus::DEPLOYMENT_REDUCED_DATA) ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None};
This simply determines the start height of BIP110 from the perspective of a single block.

If it's not active yet, the block treats it as active in the far future (when none of this code works at all anymore).

Otherwise, reduced_data_start_height is assigned to the height of the first BIP110 block.

Likewise, chk_input_rules is set to OutputSizeLimit if active, or None if not.
     // Check generation tx output sizes if REDUCED_DATA is active
+    if (chk_input_rules.test(CheckTxInputsRules::OutputSizeLimit)) {
+        TxValidationState tx_state;
+        if (!Consensus::CheckOutputSizes(*block.vtx[0], tx_state)) {
+            return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS,
                                  tx_state.GetRejectReason(),
                                  tx_state.GetDebugMessage() + " in generation tx " + block.vtx[0]->GetHash().ToString());
         }
     }
Because CheckTxInputs doesn't run on the generation transaction (it has no inputs), this explicitly checks it with CheckOutputSizes individually. (Rule 1)

     std::vector<int> prevheights;
     CAmount nFees = 0;
     int nInputs = 0;
     int64_t nSigOpsCost = 0;
     blockundo.vtxundo.reserve(block.vtx.size() - 1);
+    std::vector<unsigned int> flags_per_input;
     for (unsigned int i = 0; i < block.vtx.size(); i++)
     {
         if (!state.IsValid()) break;
This sets up a new flags_per_input list, in case it's needed below:
...
         {
             CAmount txfee = 0;
             TxValidationState tx_state;
-            if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee)) {
+            if (!Consensus::CheckTxInputs(tx, tx_state, view, pindex->nHeight, txfee, chk_input_rules)) {
                 // Any transaction validation failure in ConnectBlock is a block consensus failure
                 state.Invalid(BlockValidationResult::BLOCK_CONSENSUS,
                               tx_state.GetRejectReason(),
Convey the decision to check output sizes into CheckTxInputs.
...
             // BIP68 lock checks (as opposed to nLockTime checks) must
             // be in ConnectBlock because they require the UTXO set
             prevheights.resize(tx.vin.size());
+            flags_per_input.clear();
             for (size_t j = 0; j < tx.vin.size(); j++) {
                 prevheights[j] = view.AccessCoin(tx.vin[j].prevout).nHeight;
+                if (prevheights[j] < reduced_data_start_height) {
+                    flags_per_input.resize(tx.vin.size(), flags);
+                    flags_per_input[j] = flags & ~REDUCED_DATA_MANDATORY_VERIFY_FLAGS;
                 }
             }
 
             if (!SequenceLocks(tx, nLockTimeFlags, prevheights, *pindex)) {
For each transaction being verified, we reset flags_per_input.

Only if at least one input qualifies for grandfathering, we make flags_per_input effective by resizing it to the number of inputs (all defaulted to flags as-is), and individually clear the BIP110 flag for grandfathered inputs.
...
             std::vector<CScriptCheck> vChecks;
             bool fCacheResults = fJustCheck; /* Don't cache results if we're actually connecting blocks (still consult the cache, though) */
             TxValidationState tx_state;
-            if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, parallel_script_checks ? &vChecks : nullptr)) {
+            if (fScriptChecks && !CheckInputScripts(tx, tx_state, view, flags, fCacheResults, fCacheResults, txsdata[i], m_chainman.m_validation_cache, parallel_script_checks ? &vChecks : nullptr, flags_per_input)) {
                 // Any transaction validation failure in ConnectBlock is a block consensus failure
                 state.Invalid(BlockValidationResult::BLOCK_CONSENSUS,
                               tx_state.GetRejectReason(), tx_state.GetDebugMessage());
Convey the new flags_per_input decisions to CheckInputScripts.
... Part of: static bool ContextualCheckBlockHeader(const CBlockHeader& block, BlockValidatio...
         }
     }
  
+    if (!ContextualCheckBlockHeaderVolatile(block, state, chainman, pindexPrev)) return false;

     return true;
 }
Add an additional check (ContextualCheckBlockHeaderVolatile) to block header validation.
 /** Context-dependent validity checks, but rechecked in ConnectBlock().
  *  Note that -reindex-chainstate skips the validation that happens here!
  */
 static bool ContextualCheckBlockHeaderVolatile(const CBlockHeader& block, BlockValidationState& state, const ChainstateManager& chainman, const CBlockIndex* pindexPrev) EXCLUSIVE_LOCKS_REQUIRED(::cs_main)
 {
     const Consensus::Params& consensusParams = chainman.GetConsensus();

     // 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<Consensus::DeploymentPos>(i);
+        const ThresholdState deployment_state = chainman.m_versionbitscache.State(pindexPrev, consensusParams, pos);

+        if (DeploymentMustSignalAfter(pindexPrev, consensusParams, pos, deployment_state)) {
             const auto& deployment = consensusParams.vDeployments[pos];
+            const bool fVersionBits = (block.nVersion & VERSIONBITS_TOP_MASK) == VERSIONBITS_TOP_BITS;
+            const bool fDeploymentBit = (block.nVersion & (uint32_t{1} << deployment.bit)) != 0;

+            if (!(fVersionBits && fDeploymentBit)) {
+                const std::string deployment_name = VersionBitsDeploymentInfo[i].name;
+                return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS,
                                    "bad-version-" + deployment_name,
                                    strprintf("Block must signal for %s approaching max_activation_height=%d",
                                            deployment_name, deployment.max_activation_height));
             }
         }
     }

+    return true;
 }
If DeploymentMustSignalAfter decides we are at a mandatory signalling block height, check the block actually signals. If not, reject it as invalid.

src/versionbits.cpp

Part of: ThresholdState AbstractThresholdConditionChecker::GetStateFor(const CBlockIndex*...
     assert(cache.count(pindexPrev));
     ThresholdState state = cache[pindexPrev];
 
     // Everything is already cached. Return immediately.
+    if (vToCompute.empty()) {
+        return state;
     }
Adds a shortcut to skip the newly-added GetStateSinceHeightFor calculation if the outcome is already cached anyway.
     // 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<int>::max()) {
+        activation_height = GetStateSinceHeightFor(pindexPrev, params, cache);
     }

     // Now walk forward and compute the state of descendants of pindexPrev
     while (!vToCompute.empty()) {
         ThresholdState stateNext = state;
If we're looking at an already-active temporary deployment, look up when it first activated.
...
                     pindexCount = pindexCount->pprev;
                 }
                 if (count >= nThreshold) {
                     // Normal BIP9 activation via signaling
                     stateNext = ThresholdState::LOCKED_IN;
+                } else if (max_activation_height < std::numeric_limits<int>::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;
Ensure the period immediately prior to mandatory activation (if any) is forced to "locked in" state.

The mandatory signalling already ensures the first count >= nThreshold check does this, so this is technically redundant.
...
                 // 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<int>::max()) {
+                        activation_height = pindexPrev->nHeight + 1;
                     }
                 }
                 break;
             }
If we reach active state as we walk the blockchain forward, make a note of what height it was at (only if we're dealing with a temporary deployment).
             case ThresholdState::ACTIVE: {
+                if (active_duration < std::numeric_limits<int>::max() &&
+                    pindexPrev->nHeight + 1 >= activation_height + active_duration) {
+                    stateNext = ThresholdState::EXPIRED;
                 }
                 break;
             }
If an active temporary deployment reaches the end of its duration, update its state to expired.
...
     int Threshold(const Consensus::Params& params) const override {
         // Use per-deployment threshold if set, otherwise fall back to global
-        return params.nRuleChangeActivationThreshold;
+        int deploymentThreshold = params.vDeployments[id].threshold;
+        return deploymentThreshold > 0 ? deploymentThreshold : params.nRuleChangeActivationThreshold;
     }
If a deployment doesn't have a threshold set, use the default defined by the chain parameters.