In a similar spirit to my now decade old segwit consensus code review, I (and one of my clients) thought it would be worthwhile to do a code review of the consensus parts of the Knots BIP-110 implementation, to ensure there are no surprises when it activates. Unlike my segwit consensus code review, this review is done from the point of view of user/miner of the existing Bitcoin chain, with unmodified consensus; I’m not writing for the small minority of people who actually intend to follow the BIP-110 chain.

The BIP-110 team has already published a BIP-110 Code Walkthrough. In light of the arguably adversarial nature of the BIP-110 fork, I chose not to rely on that walkthrough, and instead, independently reviewed the codebase. Specifically, I reviewed the consensus code changes between Bitcoin Core v29.3 (99003be), and Bitcoin Knots v29.3.knots20260508, (f41f01e):

git diff 99003bed87333f1be51bf3070235591b3a72f007..f41f01e1e6de7025d52a865bef97f2a67277f0f3

AI tools were not used in this code review. With 73,057 lines of diff output, I certainly am not going to claim I did a review sufficiently detailed to catch any malicious code changes from the point of view of a Knots user.

To summarize my findings, I do not believe that the BIP-110 implementation does anything particularly interesting from the point of view of someone running a non-BIP-110 node. Absent some kind of attack, given their current hash power they will most likely fork off into a separate coin with minority hash power (or even no hash power).

Sometimes auditing things is not very interesting!

Contents

  1. Build System Changes
  2. Output Script Limits
    1. Coinbase Transaction
  3. Temporary, Mandatory, Deployment
  4. Checkpoints
  5. DNS Seeds
  6. Reduced Data Soft Fork Deployment
  7. Data Push Restrictions
    1. General PUSHDATA Restriction
    2. P2SH
    3. P2WSH
    4. Taproot Restrictions
      1. Annex Ban
      2. Control Block Size
    5. OP_IF and OP_NOTIF
    6. Pay-to-Anchor Output Restrictions
  8. Upgrade Hook Ban
  9. Grandfathering
  10. Tests

Build System Changes

I didn’t spent any significant time reviewing these; in theory they could cause consensus behavior changes. Though they should not. There’s lots of little (and not so little) changes, like enabling support for using a system libsecp256k1 library rather than the included, statically linked, library.

I will say that the RDTS_CONSENT mechanism warning message is problematic:

Important: Because this upgrade already has broad community support, reverting to an older software version does not reject it. Running outdated software after any network upgrade only leaves your node vulnerable to displaying fake or fraudulent transactions. To effectively reject this upgrade, you need to run alternative software designed to split away from the upgraded network.

As of right now, the above simply isn’t true, and the BIP-110 backers are already talking about doing a Proof-of-Work change.

Output Script Limits

First of all, the maximum size of scriptPubKey’s is consensus limited in all transactions, including the coinbase transaction. The new maximum size is defined in consensus.h:

diff --git a/src/consensus/consensus.h b/src/consensus/consensus.h
index cffe9cdafd..b02773f490 100644
--- a/src/consensus/consensus.h
+++ b/src/consensus/consensus.h
@@ -34,4 +34,7 @@ static constexpr unsigned int LOCKTIME_VERIFY_SEQUENCE = (1 << 0);
  */
 static constexpr int64_t MAX_TIMEWARP = 600;
 
+static constexpr unsigned int MAX_OUTPUT_SCRIPT_SIZE{34};
+static constexpr unsigned int MAX_OUTPUT_DATA_SIZE{83};
+
 #endif // BITCOIN_CONSENSUS_CONSENSUS_H

…and enforced by Consensus::CheckTxInputs with a new helper function:

diff --git a/src/consensus/tx_verify.cpp b/src/consensus/tx_verify.cpp
index 95466b759c..84558503c7 100644
--- a/src/consensus/tx_verify.cpp
+++ b/src/consensus/tx_verify.cpp
@@ -161,7 +161,18 @@ int64_t GetTransactionSigOpCost(const CTransaction& tx, const CCoinsViewCache& i
     return nSigOps;
 }
 
-bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee)
+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;
+}
+
+bool Consensus::CheckTxInputs(const CTransaction& tx, TxValidationState& state, const CCoinsViewCache& inputs, int nSpendHeight, CAmount& txfee, const CheckTxInputsRules rules)
 {
     // are the actual inputs available?
     if (!inputs.HaveInputs(tx)) {
@@ -169,6 +180,11 @@ 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;

It would make more sense to do that kind of check in CheckTransaction. But the BIP-110 rules are disabled for transactions spending old inputs. So a context-dependent check is necessary.

Returning TxValidationResult::TX_PREMATURE_SPEND doesn’t make logical sense: that error code is supposed to be reserved for nLockTime and similar checks. Also CheckTxInputsRules seems kinda over-engineered to something that at the moment is a single check.

Coinbase Transaction

The limitations on scriptPubKey size do apply to the coinbase transaction, and are separately checked in ConnectBlock:

@@ -2615,14 +2915,32 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,
     // in multiple threads). Preallocate the vector size so a new allocation
     // doesn't invalidate pointers into the vector, and keep txsdata in scope
     // for as long as `control`.
-    CCheckQueueControl<CScriptCheck> control(fScriptChecks && parallel_script_checks ? &m_chainman.GetCheckQueue() : nullptr);
     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};
+
+    // 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());
+        }

Temporary, Mandatory, Deployment

Bitcoin Core already has nVersion based soft-fork deployment code. Knots adds two concepts to this code, deployments that expire:

diff --git a/src/deploymentstatus.h b/src/deploymentstatus.h
index 03d3c531cc..26c66e8be7 100644
--- a/src/deploymentstatus.h
+++ b/src/deploymentstatus.h
@@ -20,7 +20,15 @@ inline bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const Consensus
 inline bool DeploymentActiveAfter(const CBlockIndex* pindexPrev, const Consensus::Params& params, Consensus::DeploymentPos dep, VersionBitsCache&
 versionbitscache)
 {
     assert(Consensus::ValidDeployment(dep));
-    return ThresholdState::ACTIVE == versionbitscache.State(pindexPrev, params, 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;
 }
 
 /** Determine if a deployment is active for this block */

…and deployments that are mandatory for miners to signal via the nVersion mechanism:

@@ -49,4 +57,17 @@ inline bool DeploymentEnabled(const Consensus::Params& params, Consensus::Deploy
     return params.vDeployments[dep].nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE;
 }
 
+/** 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;
+}
+
 #endif // BITCOIN_DEPLOYMENTSTATUS_H

By “mandatory”, we are referring to a User-Activated-Soft-Fork (UASF) mechanism, where from the point of view of the UASF software — Knots in this case — activation of the soft-fork is mandatory.

Enforcement of mandatory signalling is implemented in the new ContextualCheckBlockHeaderVolatile:

@@ -4237,6 +4627,51 @@ static bool ContextualCheckBlockHeader(const CBlockHeader& block, BlockValidatio
                                  strprintf("rejected nVersion=0x%08x block", block.nVersion));
     }
 
+    if (IsThisSoftwareExpired(block.nTime)) {
+        // Wait an extra day before we start rejecting blocks
+        CBlockIndex const *blockindex_old = pindexPrev;
+        for (int i{std::min<int>(144, pindexPrev->nHeight)}; i; --i) {
+            assert(blockindex_old);
+            blockindex_old = blockindex_old->pprev;
+        }
+        assert(blockindex_old);
+        if (IsThisSoftwareExpired(blockindex_old->GetMedianTimePast())) {
+            return state.Invalid(BlockValidationResult::BLOCK_TIME_FUTURE, "node-expired", "node software has expired");
+        }
+    }
+
+    if (!ContextualCheckBlockHeaderVolatile(block, state, chainman, pindexPrev)) return false;
+
+    return true;
+}
+
+/** 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;
 }

Finally, DeploymentMustSignalAfter is the thing that actually calculates when deployment starts. It itself is a fairly complex function:

@@ -49,4 +57,17 @@ inline bool DeploymentEnabled(const Consensus::Params& params, Consensus::Deploy
     return params.vDeployments[dep].nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE;
 }
 
+/** 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;
+}
+
 #endif // BITCOIN_DEPLOYMENTSTATUS_H

For BIP-110, this means that blocks are rejected after block \(965644 - (2016\times2) = 961631\) if they don’t signal for BIP-110.

Checkpoints

Unlike Bitcoin Core, which has almost entirely removed checkpoints, Luke re-added checkpoints to block height 908765. As that block is from about year ago, Aug 5th 2025, any circumstance where the checkpoint was relevant would be a circumstance where Bitcoin was killed off by a massive reorg. But it’s an interesting philosophical difference.

DNS Seeds

The DNS Seed mechanism is relevant to consensus in that a node needs peers of the right type to stay in consensus. Knots has added two seeds supporting the BIP-110 service bit, and removed one:

diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp
index 0f193eff74..ca4b2979c0 100644
--- a/src/kernel/chainparams.cpp
+++ b/src/kernel/chainparams.cpp
@@ -146,8 +169,9 @@ public:
         // release ASAP to avoid it where possible.
         vSeeds.emplace_back("seed.bitcoin.sipa.be."); // Pieter Wuille, only supports x1, x5, x9, and xd
         vSeeds.emplace_back("dnsseed.bluematt.me."); // Matt Corallo, only supports x9
+        vSeeds.emplace_back("dnsseed.bitcoin.dashjr-list-of-p2p-nodes.us."); // Luke Dashjr, support BIP110 seeding (x8000009)
+        vSeeds.emplace_back("seed.bitcoin.haf.ovh."); // Léo Haf, support BIP110 seeding (x8000009)
         vSeeds.emplace_back("seed.bitcoin.jonasschnelli.ch."); // Jonas Schnelli, only supports x1, x5, x9, and xd
-        vSeeds.emplace_back("seed.btc.petertodd.net."); // Peter Todd, only supports x1, x5, x9, and xd
         vSeeds.emplace_back("seed.bitcoin.sprovoost.nl."); // Sjors Provoost
         vSeeds.emplace_back("dnsseed.emzy.de."); // Stephan Oeste
         vSeeds.emplace_back("seed.bitcoin.wiz.biz."); // Jason Maurice

Since Knots nodes are a minority, DNS seeding should be entirely unaffected for Bitcoin Core users, as they will almost certainly have sufficient Core-compatible peers to achieve consensus from a clean start. Even without the existing seeds taking any action; Knots nodes may have issues.

Remember that the DNS seed mechanism is only used if a node can’t find sufficient peers, which in practice essentially only happens the very first time the node is started; Bitcoin Core nodes maintain a persistent list of peers they know about in the peers.dat database.

Once a Knots node finds BIP-110 peers, it is supposed to almost exclusively connect to them, maintaining only 2 non-BIP-110 outbound peers. That said, in my own testing that did not seem to be the case; I did not investigate further as it’s not relevant to Bitcoin Core users.

Unlike Core, Knots nodes do not DoS-ban peers for invalid blocks. This means that Knots nodes will not ban non-BIP-110 P2P nodes after the soft-fork, even though those nodes are relaying blocks that are invalid from the BIP-110 perspective. This shouldn’t have any significant negative impact, as Core nodes will eventually just drop them for being behind the most-work chain. However, this will help ensure that transactions are replayed on both chains.

Reduced Data Soft Fork Deployment

diff --git a/src/consensus/params.h b/src/consensus/params.h
index dd29b9408e..c2ac24bfb5 100644
--- a/src/consensus/params.h
+++ b/src/consensus/params.h
@@ -32,6 +32,7 @@ constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_
 enum DeploymentPos : uint16_t {
     DEPLOYMENT_TESTDUMMY,
     DEPLOYMENT_TAPROOT, // Deployment of Schnorr/Taproot (BIPs 340-342)
+    DEPLOYMENT_REDUCED_DATA, // ReducedData Temporary Softfork (RDTS)
     // NOTE: Also add new deployments to VersionBitsDeploymentInfo in deploymentinfo.cpp
     MAX_VERSION_BITS_DEPLOYMENTS
 };
@@ -52,6 +53,16 @@ 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<int>::max() means no maximum (activation only via signaling). */
+    int max_activation_height{std::numeric_limits<int>::max()};
+    /** For temporary softforks: number of blocks the deployment remains active after activation.
+     *  std::numeric_limits<int>::max() means permanent (never expires). */
+    int active_duration{std::numeric_limits<int>::max()};
+    /** Per-deployment activation threshold. If 0, uses the global nRuleChangeActivationThreshold.
+     *  Otherwise, specifies the number of blocks required for this specific deployment. */
+    int threshold{0};
 
     /** Constant for nTimeout very far in the future. */
     static constexpr int64_t NO_TIMEOUT = std::numeric_limits<int64_t>::max();

The actual deployment parameters depend on the RDTSConsentFlag, which in turn is it’s own complex mechanism:

diff --git a/src/kernel/chainparams.cpp b/src/kernel/chainparams.cpp
index 0f193eff74..ca4b2979c0 100644
--- a/src/kernel/chainparams.cpp
+++ b/src/kernel/chainparams.cpp
@@ -117,8 +123,25 @@ public:
         consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].nTimeout = 1628640000; // August 11th, 2021
         consensus.vDeployments[Consensus::DEPLOYMENT_TAPROOT].min_activation_height = 709632; // Approximately November 12th, 2021
 
-        consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000b1f3b93b65b16d035a82be84"};
-        consensus.defaultAssumeValid = uint256{"00000000000000000001b658dd1120e82e66d2790811f89ede9742ada3ed6d77"}; // 886157
+        // ReducedData Temporary Softfork (RDTS)
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].bit = 4;
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = 1764547200; // December 1st, 2025
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT;
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].min_activation_height = 0;
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].max_activation_height = 965664; // ~September 1st, 2026
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].active_duration = 52416; // ~1 year
+        consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].threshold = 1109; // 55% of 2016
+
+        if (g_rdts_consent == RDTSConsentFlag::UNSUPPORTED_UNSAFE_NO_ENFORCEMENT && !g_enable_rdts) {
+            consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE;
+            consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT;
+            consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].max_activation_height = std::numeric_limits<int>::max();
+            consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].active_duration = std::numeric_limits<int>::max();
+            consensus.vDeployments[Consensus::DEPLOYMENT_REDUCED_DATA].threshold = 0;
+        }
+
+        consensus.nMinimumChainWork = uint256{"0000000000000000000000000000000000000000dee8e2a309ad8a9820433c68"};
+        consensus.defaultAssumeValid = uint256{"00000000000000000000611fd22f2df7c8fbd0688745c3a6c3bb5109cc2a12cb"}; // 912683

Since the consent mechanism isn’t relevant to non-BIP-110 users, I won’t go into it in more detail other than to say that BIP-110 users need to be careful that they’ve actually setup their nodes in the way that they expected: merely downloading Knots might not actually put them on the chain that they are expecting to be on.

Data Push Restrictions

The length of data pushes in scripts is limited to 256 bytes, via a new MAX_SCRIPT_ELEMENT_SIZE_REDUCED constant defined in script.h:

diff --git a/src/script/script.h b/src/script/script.h
index f457984980..276425ddd9 100644
--- a/src/script/script.h
+++ b/src/script/script.h
@@ -26,6 +26,7 @@
 
 // Maximum number of bytes pushable to the stack
 static const unsigned int MAX_SCRIPT_ELEMENT_SIZE = 520;
+static const unsigned int MAX_SCRIPT_ELEMENT_SIZE_REDUCED = 256;
 
 // Maximum number of non-push operations per script
 static const int MAX_OPS_PER_SCRIPT = 201;

However, actually enforcing this new limit is complex, with a variety of different scenarios.

General PUSHDATA Restriction

For scripts in general, EvalScript enforces the new MAX_SCRIPT_ELEMENT_SIZE_REDUCED limit on all script pushes when the SCRIPT_VERIFY_REDUCED_DATA flag is set:

diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp
index 4b7bfcedc6..eab89bbdbc 100644
--- a/src/script/interpreter.cpp
+++ b/src/script/interpreter.cpp
@@ -433,6 +433,8 @@ 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) {
@@ -443,7 +445,7 @@ bool EvalScript(std::vector<std::vector<unsigned char> >& stack, const CScript&
             //
             if (!script.GetOp(pc, opcode, vchPushValue))
                 return set_error(serror, SCRIPT_ERR_BAD_OPCODE);
-            if (vchPushValue.size() > MAX_SCRIPT_ELEMENT_SIZE)
+            if (vchPushValue.size() > max_element_size)
                 return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
 
             if (sigversion == SigVersion::BASE || sigversion == SigVersion::WITNESS_V0) {

All script evaluation eventually goes through EvalScript, so as long as SCRIPT_VERIFY_REDUCED_DATA is set that enforces the length limit consistently. However there are exceptions.

P2SH

The P2SH data push used to embed the P2SH redeemScript is exempted from the new limit:

diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp
index 4b7bfcedc6..eab89bbdbc 100644
--- a/src/script/interpreter.cpp
+++ b/src/script/interpreter.cpp
@@ -2011,6 +2030,12 @@ 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;

Note the indentation issue here: the new check was added in a very non-standard way.

The actual check on the element sizes of the scriptSig in that circumstance happens later in VerifyScript:

@@ -2062,6 +2087,15 @@ bool VerifyScript(const CScript& scriptSig, const CScript& scriptPubKey, const C
         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);
+            }
+        }
+

Element sizes within the redeemScript are checked when EvalScript evaluates the redeemScript against the stack copy.

P2WSH

The element size limit is enforced on elements in the witness stack in ExecuteWitnessScript:

@@ -1851,8 +1865,9 @@ 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_SCRIPT_ELEMENT_SIZE) return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
+        if (elem.size() > max_element_size) return set_error(serror, SCRIPT_ERR_PUSH_SIZE);
     }
 
     // Run the script interpreter.

The enforcement within the witness script itself is again via EvalScript.

Taproot Restrictions

Again, within taproot scripts themselves, enforcement is via EvalScript. Additionally taproot faces further restrictions.

Annex Ban

Usage of the taproot annex is entirely banned:

@@ -1946,6 +1961,9 @@ 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 {

Control Block Size

The size of the control block is limited to 257 bytes, seven leaves deep:

@@ -1962,7 +1980,8 @@ static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion,
             // Script path spending (stack size is >1 after removing optional annex)
             const valtype& control = SpanPopBack(stack);
             const valtype& script = SpanPopBack(stack);
-            if (control.size() < TAPROOT_CONTROL_BASE_SIZE || control.size() > TAPROOT_CONTROL_MAX_SIZE || ((control.size() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE) != 0) {
+            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);

OP_IF and OP_NOTIF

The use of OP_IF and OP_NOTIF are entirely banned in tapscript, and only tapscript:

@@ -616,6 +618,11 @@ bool EvalScript(std::vector<std::vector<unsigned char> >& stack, const CScript&
                        if (sigversion == SigVersion::TAPSCRIPT) {
                             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);
+                            }
                         }

Pay-to-Anchor Output Restrictions

In Bitcoin Core, Pay-to-Anchor (P2A) outputs have no consensus meaning: they simply standardize existing behavior that was always allowed by consensus, the witness script upgrade hooks.

BIP-110, upon activation, adds a subtle consensus rule to P2A outputs: the witness must be empty when spending them:

diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp
index 4b7bfcedc6..eab89bbdbc 100644
--- a/src/script/interpreter.cpp
+++ b/src/script/interpreter.cpp
@@ -1982,7 +2001,7 @@ static bool VerifyWitnessProgram(const CScriptWitness& witness, int witversion,
             }
             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 code is not very obvious, as it’s actually introducing a consensus rule indirectly by virtue of the SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM check just below, which in turn is forced on.

A pull-req to document this behavior in BIP-110 itself was opened, and since closed.

Upgrade Hook Ban

BIP-110 bans a number of mechanisms intended to allow for future soft-forks, by forcing on script verification flags intended allow for new types of witness programs, taproot versions, and the OP_SUCCESS family of opcodes:

diff --git a/src/script/interpreter.h b/src/script/interpreter.h
index d613becb8f..392914b71f 100644
--- a/src/script/interpreter.h
+++ b/src/script/interpreter.h
@@ -143,11 +143,25 @@ enum : uint32_t {
     // Making unknown public key versions (in BIP 342 scripts) non-standard
     SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_PUBKEYTYPE = (1U << 20),
 
+    // Enforce MAX_SCRIPT_ELEMENT_SIZE_REDUCED instead of MAX_SCRIPT_ELEMENT_SIZE
+    // The P2SH redeemScript push is exempted
+    // Taproot control blocks are limited to TAPROOT_CONTROL_MAX_SIZE_REDUCED
+    // Taproot annex is also invalid
+    // OP_IF is also forbidden inside Tapscript
+    SCRIPT_VERIFY_REDUCED_DATA = (1U << 21),
+
     // Constants to point to the highest flag in use. Add new flags above this line.
     //
     SCRIPT_VERIFY_END_MARKER
 };
 
+static constexpr unsigned int REDUCED_DATA_MANDATORY_VERIFY_FLAGS{0
+    | SCRIPT_VERIFY_REDUCED_DATA
+    | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM
+    | SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_TAPROOT_VERSION
+    | SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS
+};
+
 bool CheckSignatureEncoding(const std::vector<unsigned char> &vchSig, unsigned int flags, ScriptError* serror);

BIP-110 argues this is acceptable, as these restrictions eventually time-out, allowing for soft-fork upgrades again in the future.

An important note is these are restrictions on spending an output: you can still create an output with a scriptPubKey subject to these upgrade bans.

Grandfathering

For coins created prior to BIP-110 activation, the REDUCED_DATA_MANDATORY_VERIFY_FLAGS are not enforced, allowing those coins to be spent even if their spending scripts would otherwise break the new BIP-110 rules. Of course, in actual practice wallets that run afoul of those rules, e.g. due to complex miniscript rules that compiled down to an OP_IF, the change outputs would likely accidentally render those coins unspendable. As would the very common practice of re-using an address.

To allow this, first of a per-input flag vector is added to CheckInputScripts:

@ -2161,7 +2448,8 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state,
                        const CCoinsViewCache& inputs, unsigned int flags, bool cacheSigStore,
                        bool cacheFullScriptStore, PrecomputedTransactionData& txdata,
                        ValidationCache& validation_cache,
-                       std::vector<CScriptCheck>* pvChecks)
+                       std::vector<CScriptCheck>* pvChecks,
+                       const std::vector<unsigned int>& flags_per_input)
 {
     if (tx.IsCoinBase()) return true;
 

Notably, this vector — if set — defeats the cache:

@@ -2223,7 +2513,7 @@ bool CheckInputScripts(const CTransaction& tx, TxValidationState& state,
         }
     }
 
-    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);

It may be possible to do some kind of poison block attack by taking advantage of the fact that spending old outputs disables this cache. I don’t have time to investigate this further. But if BIP-110 doesn’t simply fail on arrival, someone should.

In any case, ConnectBlock implements the grandfathering by creating a non-empty flags_per_input vector if any input was created prior to the start height:

@@ -2652,8 +2970,13 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,
             // 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)) {

…and that vector is passed to CheckInputScripts:

@@ -2678,7 +3001,7 @@ bool Chainstate::ConnectBlock(const CBlock& block, BlockValidationState& state,
             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());

Tests

In the interests of time I did not review the significant amount of test-related code added to test BIP-110’s consensus functionality.