Block·Slop

Consensus & upgrade-path correctness

BlockSlop

Same rules. Same block.Different answer.

A late-upgraded node keeps a block that a fresh BIP110 node rejects.

Affected

Knots v29.3.knots20260508

Commit

f41f01e1e6de7025d52a865bef97f2a67277f0f3

Status

Reproduced on regtest

Report date

2026-07-17

Classification

Consensus / upgrade path

Audit scope

Regtest + source review

01 / The gap

The same rules, two different chains.

BIP110 validation runs inside ConnectBlock(). But an ordinary restart loads the existing UTXO chainstate and trusts its persisted tip and validity flags — it never reconnects those blocks. There is no BIP110 equivalent of the SegWit upgrade guard.

The effective treatment of an already-connected block depends on the history of the datadir, not on the block sequence and the node's current rules alone. Two nodes running identical enforcing rules can therefore disagree about the active chain:

NodeDatadir historyResult for the same historical block
Late-upgraded enforcing node Block connected while RDTS was disabled Retained — stays in the active chain
Fresh enforcing node Block first connected while RDTS was enabled Rejected — never enters the chain

Affected transitions

The demonstrated transition is from a build that accepts legacy rules to a BIP110-enforcing build while reusing a compatible datadir. In this checkout, non-enforcing mainnet mode changes DEPLOYMENT_REDUCED_DATA to NEVER_ACTIVE and disables its deadline; enforcing mode restores the hardcoded deployment parameters. Both modes use the same network and database formats, so the later build loads the earlier block index and chainstate.

Directly demonstrated

Datadir reuse between separately compiled RDTS_CONSENT=IMPLICIT and RDTS_CONSENT=UNSUPPORTED_UNSAFE_NO_ENFORCEMENT binaries from the audited commit.

Core and non-enforcing Knots

The affected source history is not Knots-specific. A compatible datadir advanced by Bitcoin Core or by Knots without BIP110 enforcement can accept non-signaling or otherwise BIP110-invalid blocks and enter the same late-upgrade condition. If the activation client later opens it without rebuilding, those applicable blocks may be trusted without ever receiving BIP110 validation. Compatibility across arbitrary versions remains version-dependent.

Other affected histories

Temporarily downgrading to non-enforcing software, re-enabling enforcement later, or otherwise loading a chainstate advanced by software that did not enforce BIP110.

Unaffected history

A node that enforced BIP110 before it first connected every applicable block rejects violations normally and is not induced by this issue to accept them later.

Required preconditions

All four conditions must hold. No malformed database, local file write, or RPC access is required.

Legacy connection. The node connects a block while its software or configuration does not enforce BIP110.
Applicable violation. At that height and ancestry, BIP110 would reject the block for mandatory signaling or an active RDTS rule.
Persisted state. The block obtains enough proof of work to be followed, and the node persists its UTXO chainstate and block-validity flags.
Late enforcement. The operator starts an enforcing client on the same datadir without rebuilding or fully reconnecting the chainstate.

Expected versus actual startup contract

Expected safe behaviorObserved behavior
Prove that every applicable block was connected under the enforcing rules, reconnect it, or refuse startup and require a rebuild/redownload. Startup opens the coins database, accepts its recorded tip and block-index validity, checks six blocks at level 3, and becomes operational without reconnecting inherited blocks.
Current enforcement state and accepted history agree. The node enforces BIP110 on newly connected descendants while continuing from a historical state that those rules could not have produced.
Scope: a concrete late-soft-fork upgrade gap

Persisted Bitcoin block validity is generally not namespaced by software version or by a complete consensus-ruleset identifier, so this shape is not necessarily unique to BIP110. BIP110 makes it concrete because enforcing and explicitly non-enforcing modes share database formats and users may enable enforcement on an existing datadir. This report establishes the BIP110 transition failure; it does not claim that every possible late soft-fork upgrade is promised to recover automatically.

Finding statusDynamically reproduced; technical behavior confirmed.
Classification remains policy-sensitiveMaintainers may treat the transition as a vulnerability, an unsupported upgrade path, or a release-engineering requirement.
02 / Divergence

How the split develops.

A legacy block B is valid under old rules but invalid under BIP110. Whether a node keeps it depends entirely on when enforcement was turned on.

Two nodes · identical current rules · same block B

Continuously enforcing

enforced BIP110 before connecting B

rejects B
h1A
h2B
bad-txns-vout-script-toolarge → follows the BIP110-valid branch

Late-upgraded

connected B while enforcement was off, then upgraded

retains B
h1A
h2B
h3C
h4D
upgrade · builds valid descendants on top of B
Valid & connected
BIP110-invalid, retained
Rejected — never connected

A non-enforcing node accepts block B

B has enough proof of work and is valid under legacy rules, but violates BIP110.

A continuously enforcing node rejects B

It stays on, or searches for, a BIP110-valid branch instead.

The legacy node persists B and its descendants

Block index and UTXO chainstate now record B as valid.

Its operator enables BIP110 on the same datadir

Intending to enforce the new rules going forward.

Startup trusts the persisted validity state

Default level-3 verification disconnects recent blocks but never reconnects them — B is retained without re-validation.

The node accepts new descendants on top of B

Their own contents satisfy BIP110; B itself is never reconsidered.

Not a chain split by itself

This defect does not create block B or guarantee a persistent split. A sufficiently-worked legacy-valid / BIP110-invalid block must first exist. What the defect does is prevent a normal late upgrade from reliably healing that disagreement.

It isn't two chains. It's N.

The two-lane picture above understates the damage. There is no single "BlockSlop chain" set against Bitcoin. Each late-upgraded node freezes in whatever BIP110-invalid history its own datadir happened to hold — and different nodes saw different invalid blocks. The enforcing network can therefore shatter into as many incompatible chains as there are distinct pre-upgrade histories, all simultaneously claiming to enforce BIP110.

One legacy chain · many divergent enforcing chains

legacy Bitcoin chain · accepts every legacy-valid block · pre-upgrade history differs per node
h_kA
invalidB₁
tip···
node group 1frozen on ancestor B₁
h_kA
invalidB₂
tip···
node group 2frozen on ancestor B₂
h_kA
invalidB₃
tip···
node group 3frozen on ancestor B₃
h_kA
validB✓
tip···
cleancontinuously enforcing
1 + N One clean enforcing chain, plus one distinct chain for every unique BIP110-invalid ancestor a late-upgraded node happened to bury. Four branches shown; the real number is bounded only by how many different invalid histories exist across the network.

Why it multiplies

Provenance is per-datadir, not global. Two late-upgraded nodes that buried different invalid blocks each keep their own — and neither will reorg to the other, because both tips are "valid" under their own trusted history.

Why they can't reconverge

A normal reorg only happens toward more-worked chains a node considers valid. Each fragment already treats its buried ancestor as valid, so nothing forces it back to the clean chain — the split is sticky, not transient.

Why it's worse than a classic fork

A classic contentious fork yields two chains people can name and choose between. Here every fragment reports the same deployment as active, so operators believe they agree while their UTXO sets silently disagree.

What bounds N

The count is limited only by the number of distinct legacy-valid / BIP110-invalid histories that different nodes persisted before upgrading — not by any property of BIP110 itself.

03 / Reproduced

Three violations, all retained.

Confirmed on regtest with deployment overrides and two separately compiled binaries — one enforcing, one not — reusing the same datadir. Each row: the upgraded node keeps the block; a fresh enforcing node rejects it.

Phase testedThe offending blockFresh node rejection reason
Mandatory signaling A non-signaling block in the mandatory-signaling interval bad-version-reduced_data
Active RDTS transaction rule A 35-byte non-OP_RETURN output bad-txns-vout-script-toolarge
Active RDTS script rule A witness script revealing a 257-byte pushed element Push value size limit exceeded
The rules themselves work

Both fresh validation and -reindex-chainstate reject the test block correctly. The defect is specifically the ordinary upgrade / startup path — not the BIP110 checks.

Test environment

Clean regtest. Nodes disconnected; blocks submitted directly by RPC. The initial phase ran with the separately compiled non-enforcing binary, the restart phase with the enforcing binary — so the reproduction also proves datadir compatibility across the two compile-time consent modes. Regtest -vbparams supplied deterministic never-active, always-active, and deadline states. The reproducer is intentionally not registered in the default functional-test list and is invoked directly against the configured build.

Enforcing build

RDTS_CONSENT=IMPLICIT

Non-enforcing

RDTS_CONSENT=UNSUPPORTED_UNSAFE_NO_ENFORCEMENT

Checkout

v29.3.knots20260508

Commit

f41f01e1e6de7025d52a865bef97f2a67277f0f3

Chain

regtest

Reproducer

feature_bip110_late_upgrade_chainstate.py

Safe, local reproduction scope

The reproducer uses synthetic low-difficulty regtest blocks. It does not target mainnet, contact peers, or provide a mainnet exploitation workflow. It covers mandatory signaling, active output and script rules, default and level-4 startup, chainstate reindex recovery, and pruning.

Reproducer A

Active output-size rule

bad-txns-vout-script-toolarge
Deployment overrides (–vbparams)
vbparams · reduced_data
legacy/upgraded node:  reduced_data:-2:9223372036854775807   # never active
fresh control node:    reduced_data:-1:9223372036854775807   # always active
Procedure
  1. On the legacy node, submit a valid block at height 1.
  2. Submit a height-2 block whose coinbase carries an extra 35-byte scriptPubKey that does not begin with OP_RETURN.
  3. Submit a valid descendant at height 3.
  4. Restart that same node / datadir with RDTS always active.
  5. Confirm getdeploymentinfo reports reduced_data.active = true and the tip is still height 3.
  6. Submit another valid descendant — accepted at height 4.
  7. Feed the identical height-1 and height-2 blocks to the fresh always-active node.
Observed result
Upgraded nodeRetains the height-2 block and builds to height 4.
Fresh nodeRejects the height-2 block with bad-txns-vout-script-toolarge.
Test-framework output
functional test
CONFIRMED: upgraded enforcing node retains block; fresh enforcing node rejects it
Tests successful
Reproducer B

Mandatory signaling

bad-version-reduced_data
Deployment overrides (–vbparams)
vbparams · reduced_data
legacy/upgraded node:  reduced_data:-2:9223372036854775807
fresh control node:    reduced_data:0:9223372036854775807:0:432:2147483647:108

Regtest's confirmation window is 144 blocks. With max_activation_height=432, DeploymentMustSignalAfter() makes height 144 the first mandatory-signaling height.

Procedure
  1. Build a non-signaling chain through height 143 on the legacy node.
  2. Submit a non-signaling block at height 144 and a descendant at height 145.
  3. Restart the same datadir with the deadline-enforcing parameters.
  4. Confirm it remains at height 145.
  5. Submit a signaling block at height 146 — accepted on the retained chain.
  6. Feed blocks 1–143 and the identical block 144 to the fresh enforcing node.
Observed result
Upgraded nodeRetains the non-signaling height-144 ancestor.
Fresh nodeRejects it with bad-version-reduced_data.
Test-framework output
functional test
CONFIRMED: mandatory-signaling-invalid ancestor survives enforcement upgrade
Tests successful
Reproducer C

Active script rule

Push value size limit exceeded

This case exercises a rule enforced by script execution rather than output-size checks.

Procedure
  1. While RDTS is never active, create a 34-byte P2WSH coinbase output whose witness script contains a 257-byte pushed element.
  2. Mature and spend that output in a block accepted by the non-enforcing node.
  3. Restart the same datadir with RDTS always active and extend the retained chain.
  4. Feed the identical funding history and spending block to a fresh always-active node.
Observed result
Upgraded nodeRetains the spending block and accepts a descendant.
Fresh nodeRejects it; reason contains Push value size limit exceeded.
Test-framework output
functional test
CONFIRMED: active script-invalid ancestor survives enforcement upgrade
Tests successful
04 / Recovery & pruning

Which startup modes detect it — and which don't.

The active-rule setup was restarted three ways on an unpruned datadir, then re-tested on a pruned one. Only paths that actually re-run ConnectBlock() catch the block — and pruning removes even that option.

Startup modeDatadirResult
checklevel=3 checkblocks=6 (default) Unpruned Issue persists — starts at the invalid tip
-checklevel=4 -checkblocks=0 Unpruned Detects — refuses with Corrupted block database detected
-reindex-chainstate Unpruned Recovers — reconnects blocks, rejects the offender, stops at the last valid ancestor
checklevel=3 checkblocks=6 (default) Pruned Issue persists — starts at height 600 with the ancestor retained
-checklevel=4 -checkblocks=0 Pruned Misses it — verification stops when historical block data is unavailable
-reindex-chainstate Pruned Refused — prune mode is incompatible; full -reindex required
Why the pruned case is worse

The pruned audit placed the violation at height 2, extended to height 600 with -fastprune, and pruned away the block containing the violation before enabling enforcement. Because VerifyDB() stops at missing pruned data and VerifyLoadedChainstate() accepts SKIPPED_MISSING_BLOCKS, even a full level-4 scan starts successfully at the unverified tip. Operators must not be told a partial scan proved the history valid.

Unpruned verification output

CONFIRMED: checklevel=4 detects; reindex-chainstate recovers to valid ancestor

Pruned verification output

CONFIRMED: pruned default and full level-4 startup retain the unverified ancestor

Source correspondence: VerifyDB() stops at missing data at src/validation.cpp:5221-5226; startup accepts SKIPPED_MISSING_BLOCKS at src/node/chainstate.cpp:250-253; and prune mode refuses -reindex-chainstate at src/init.cpp:1126-1127.

Existing suites that also passed against the audit build

RDTS

feature_rdts.py

UTXO height

reduced_data_utxo_height

Temporary deploy

reduced_data_temporary_deployment

BIP9 max height

bip9_max_activation_height

versionbits unit

13 cases

Assertions

111,608

These suites validate the underlying RDTS rules and the deployment state machine. The new audit reproducer covers the history-dependent startup behavior they did not.

05 / Root cause

Persisted validity doesn't say which rules ran.

Five mechanisms combine. The checks live in the right place — but nothing on the startup path forces them to run again over inherited history.

01

Checks live in ConnectBlock()

Mandatory signaling, output-size, and script rules are all enforced during connection — and work when it's actually called.

02

Validity flags are rule-blind

BLOCK_VALID_SCRIPTS is the highest persisted level, but it never records that BIP110 was enabled or which rules applied.

03

The startup guard is SegWit-only

NeedsRedownload() scans for BLOCK_OPT_WITNESS. There is no BIP110 ancestry or provenance scan.

04

Default startup never reconnects

Defaults are checkblocks=6, checklevel=3. Level 3 disconnects blocks; only level 4 reconnects via ConnectBlock().

05

Crash recovery isn't validation

RollforwardBlock() only spends and adds coins during replay — it never runs the BIP110 checks.

=

Result

A late-upgraded node loads chainstate produced under non-enforcing rules and trusts it — the invalid ancestor stays active across restarts.

# default startup verification — src/validation.h DEFAULT_CHECKBLOCKS = 6 DEFAULT_CHECKLEVEL = 3 # disconnects blocks, never reconnects # CVerifyDB::VerifyDB() level 1 → CheckBlock() # context-free only level 3 → disconnect blocks # from a temporary coins view level 4 → ConnectBlock() # the only level that re-runs BIP110 ← not default
Even BLOCK_VALID_SCRIPTS isn't literal proof

BLOCK_VALID_SCRIPTS is the highest persisted validity level, but ConnectBlock() can skip script checks for sufficiently buried ancestors of an assumevalid block and still raise the block to that level. The status never records that BIP110 was enabled or which exact rules ran when the block was connected — so any provenance fix must account for assumevalid shortcuts, and must be evaluated after any ReplayBlocks() recovery, since RollforwardBlock() updates coins without running the checks.

06 / Code references

Where each claim lives in the tree.

Real source, verified against commit f41f01e1e6. The BIP110 checks are present and correct in ConnectBlock() — shown first. The gap is that nothing on the startup path re-invokes them over inherited history.

Version scope

Every absence claim and line reference in this report targets the affected commit f41f01e1e6de7025d52a865bef97f2a67277f0f3. Later remediation commits or working-tree changes are outside the reproduced baseline unless separately re-audited.

The checks that do run — during connection

These fire every time ConnectBlock() is actually called. The mandatory-signaling rule that emits bad-version-reduced_data:

src/validation.cpp — ContextualCheckBlockHeaderVolatile() present · correct
// Mandatory signaling for deployments approaching max_activation_heightfor (int i = 0; i < MAX_VERSION_BITS_DEPLOYMENTS; i++) {  const DeploymentPos pos = static_cast<DeploymentPos>(i);  const ThresholdState deployment_state =    m_versionbitscache.State(pindexPrev, consensusParams, pos);  if (DeploymentMustSignalAfter(pindexPrev, consensusParams, pos, deployment_state)) {    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)) {      return state.Invalid(BlockValidationResult::BLOCK_CONSENSUS,                           "bad-version-" + deployment_name,                           strprintf("Block must signal for %s ...", ...));    }  }}

And the active output-size rule that emits bad-txns-vout-script-toolarge, gated on the deployment being active at the block:

src/validation.cpp — Chainstate::ConnectBlock() present · correct
const CheckTxInputsRules chk_input_rules{  DeploymentActiveAt(*pindex, m_chainman, DEPLOYMENT_REDUCED_DATA)    ? CheckTxInputsRules::OutputSizeLimit : CheckTxInputsRules::None};// Check generation tx output sizes if REDUCED_DATA is activeif (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(), ...);  }}
The gap in one line

Both checks are correct — and both live only inside ConnectBlock(). Default level-3 startup never calls ConnectBlock() for inherited blocks, so a block that slipped in under non-enforcing software is never re-tested against either rule.

Full citation index

A BIP110 checks inside ConnectBlock()
src/validation.cpp:2715Chainstate::ConnectBlock() begins here.
src/validation.cpp:2756-2759Calls ContextualCheckBlockHeaderVolatile() — the mandatory-signaling recheck.
src/validation.cpp:4651-4675Rejects a missing deployment bit with bad-version-reduced_data.
src/validation.cpp:2921-2935Derives the activation height and checks coinbase output sizes.
src/validation.cpp:2951-2961Applies output-size checks to non-coinbase transactions.
src/validation.cpp:2973-2979Selects per-input script flags for the activation-height grandfathering rule.
src/validation.cpp:3000-3008Performs script verification.
src/validation.cpp:3064-3067Raises the block to BLOCK_VALID_SCRIPTS after successful connection.
B The SegWit guard that has no BIP110 equivalent
src/chain.h:132BLOCK_OPT_WITNESS — dedicated block-index status bit.
src/validation.cpp:4277-4282Sets the bit when block data is received by a witness-enforcing client.
src/validation.cpp:5417-5432NeedsRedownload() walks active SegWit blocks; no BIP110 check.
src/node/chainstate.cpp:129-134Startup invokes the guard — detects only the SegWit condition.
test/functional/feature_presegwit_node_upgrade.py:22-53The SegWit late-upgrade regression test; no BIP110 equivalent existed.
C Startup verification & replay never reconnect
src/validation.h:72-73DEFAULT_CHECKBLOCKS = 6, DEFAULT_CHECKLEVEL = 3.
src/validation.cpp:5234-5239Level 1 calls only context-free CheckBlock().
src/validation.cpp:5250-5269Level 3 disconnects blocks from a temporary coins view.
src/validation.cpp:5284-5305Only level 4 reconnects via ConnectBlock().
src/validation.cpp:5320-5339RollforwardBlock() only spends and adds coins during replay.
src/validation.cpp:2771-2799assumevalid can skip script checks for buried ancestors.
D Deployment selection & mainnet parameters
src/chainparams.cpp:139-149Selects whether RDTS is enabled.
src/kernel/chainparams.cpp:126-140Defines mainnet RDTS parameters; disables them for the no-enforcement mode.
src/deploymentstatus.h:60-70Formula deriving the mandatory-signaling range from activation height.
E Pruning, snapshots & recovery constraints
src/validation.cpp:5221-5226VerifyDB() stops when historical block data is missing.
src/node/chainstate.cpp:250-253Startup accepts SKIPPED_MISSING_BLOCKS rather than treating it as complete verification.
src/init.cpp:1126-1127Prune mode rejects -reindex-chainstate and requires full -reindex.
src/validation.cpp:6503-6510AssumeUTXO activation synthesizes BLOCK_OPT_WITNESS for snapshot ancestors.
src/kernel/chainparams.cpp:143-144The audited mainnet defaultAssumeValid is at height 912,683.

Audit confidence

AreaVerification status
Cited source paths, startup levels, deployment arithmeticVerified against commit
Mandatory signaling, active output rule, active script ruleReproduced on regtest
Default restart, full level-4 check, reindex recoveryReproduced (unpruned)
Pruned default / full level-4 limitationReproduced on regtest
Separate enforcing / non-enforcing build datadir reuseReproduced
Mainnet deployment state at block 958,470Verified — STARTED, no rule applicable
Mainnet transaction / block scanNot performed — no rule applicable
AssumeUTXO & interrupted-flush replay behaviorSource-reviewed only
Proposed provenance / attestation fixDesign analysis only
07 / Precedent

SegWit already solved this shape.

BIP141 recognized that persisted validity from older software may be insufficient after an upgrade — and added a coordinated guard. The equivalent BIP110 pieces are absent.

SegWit / BIP141 — present
Durable markerBLOCK_OPT_WITNESS records aware handling of applicable history.
Startup scanNeedsRedownload() walks every active SegWit ancestor.
Fail-closedStartup refuses and requests -reindex.
Regression testfeature_presegwit_node_upgrade.py covers it.
BIP110 / RDTS — absent
Durable markerNone — no status bit, ruleset ID, or attestation.
Startup scanNone — no BIP110 ancestry / provenance scan.
Fail-closedStartup succeeds and trusts existing state.
Regression testNone existed — the audit reproducer is the first.

The source comment that names the problem

ConnectBlock() itself documents this exact hazard and points at NeedsRedownload() as the BIP141 remedy — the approach that was never built for BIP110.

src/validation.cpp — Chainstate::ConnectBlock() lines 2728–2736
// Check it again in case a previous version let a bad block in// NOTE: We don't currently (re-)invoke ContextualCheckBlock() or// ContextualCheckBlockHeader() here. This means that if we add a new// consensus rule that is enforced in one of those two functions, then we// may have let in a block that violates the rule prior to updating the// software, and we would NOT be enforcing the rule here. Fully solving// upgrade from one software version to the next after a consensus rule// change is potentially tricky and issue-specific (see NeedsRedownload()// for one approach that was used for BIP 141 deployment).
Why this is the crux

The general contextual header function carries the same upgrade warning. BIP110's mandatory- signaling check was deliberately split into ContextualCheckBlockHeaderVolatile() and explicitly re-invoked by ConnectBlock(), so it works during a real reconnect. The failure is that ordinary startup never reconnects inherited blocks at all. The code therefore anticipates precisely the upgrade hazard demonstrated here, while only SegWit has a startup guard.

The three guard pieces, side by side

BIP141 shipped three coordinated pieces. For each, the real SegWit source is on the left; the BIP110 equivalent on the right is absent.

1

Durable marker

A persisted bit recording that aware software handled the block.

SegWit ✓BIP110 ✕

SegWit — the bit exists

src/chain.hpresent
    BLOCK_OPT_WITNESS = 128,    //!< block data in blk*.dat was    //!< received with a witness-    //!< enforcing client
src/validation.cpp — set on receiptpresent
if (DeploymentActiveAt(*pindexNew,      *this, DEPLOYMENT_SEGWIT)) {  pindexNew->nStatus |= BLOCK_OPT_WITNESS;}

BIP110 — nothing equivalent

No BLOCK_OPT_BIP110_VALIDATED

There is no BIP110 status bit, ruleset identifier, or chainstate attestation. After connection a block reaches BLOCK_VALID_SCRIPTS — which never records which rules ran, and can be reached even when assumevalid skipped script checks.

2

Startup ancestry scan

Walk every applicable active-chain ancestor and check the marker.

SegWit ✓BIP110 ✕

SegWit — NeedsRedownload()

src/validation.cpppresent
bool Chainstate::NeedsRedownload() const{  CBlockIndex* block{m_chain.Tip()};  // walk back over active segwit range  while (block != nullptr &&    DeploymentActiveAt(*block, m_chainman,      DEPLOYMENT_SEGWIT)) {    if (!(block->nStatus & BLOCK_OPT_WITNESS))      return true; // insufficiently validated    block = block->pprev;  }  return false;}

BIP110 — no scan added

NeedsRedownload() is SegWit-only

The function checks DEPLOYMENT_SEGWIT and BLOCK_OPT_WITNESS only. No branch walks the mandatory-signaling interval or the active RDTS range, so no BIP110 ancestor is ever re-examined at startup.

3

Fail-closed recovery

Refuse startup and demand a rebuild when history is insufficient.

SegWit ✓BIP110 ✕

SegWit — startup refuses

src/node/chainstate.cpppresent
if (std::any_of(chainstates.begin(),    chainstates.end(), [](const Chainstate* cs)    { return cs->NeedsRedownload(); })) {  return {ChainstateLoadStatus::FAILURE,    strprintf(_("Witness data for blocks    after height %d requires validation.    Please restart with -reindex."),    ...SegwitHeight)};}

BIP110 — startup succeeds

No refusal, no reindex prompt

With no BIP110 clause in the guard, startup completes normally at the inherited tip. The operator is never told the chainstate could not have been produced under the rules they just enabled.

Don't copy the marker verbatim

Note the asymmetry: BLOCK_OPT_WITNESS is set when block data is received (see piece 1), not after ConnectBlock() runs — so it proves witness-aware receipt, not that every consensus check executed. BIP110's rules are ConnectBlock()-only and contextual, so its marker must record successful validation under the applicable rules, not mere receipt. AssumeUTXO makes this sharper still: snapshot activation literally fakes BLOCK_OPT_WITNESS for ancestors — src/validation.cpp:6503-6510 — which a BIP110 guard must not blindly imitate.

08 / Mainnet reachability

When this becomes reachable on mainnet.

The gap turns consensus-relevant at the first height where an enforcing node would reject a block under either mandatory signaling or an active RDTS rule. Here is where mainnet stood at the audited tip, and where that threshold sits.

Hardcoded mainnet parameters (src/kernel/chainparams.cpp:126-133):

Version bit

4

Max activation

965,664

Conf. window

2,016

Active duration

52,416

Threshold

1,109/2,016

# If the deployment stays in STARTED, the mandatory-signaling range is: mandatory range start = 965,664 − (2 × 2,016) = 961,632 mandatory range end = 965,664 − 2,016 = 963,648 # exclusive # So mandatory signaling would apply across: heights 961,632 → 963,647 # conditional on STARTED; lock-in can change this earlier

Audited tip · 2026-07-17

height 958,470

Independently synchronized and proof-of-work-validated header chain. Hash 0000000000000000000170c2ffa184d0b9f3f4929811672a7c8f019ad0b38270, cross-checked against mempool.space.

Deployment state

STARTED

Replayed src/versionbits.cpp:10-118 over the synced headers. The largest completed-period count was 20; the current period had 8 signals in its first 871 blocks. Completed-period counts from heights 937,440–957,599 matched the BIP110 monitor API.

Rule applicable?

Not yet

958,470 is below 961,632 and the deployment hadn't locked in early, so neither mandatory signaling nor active RDTS rules applied at the snapshot.

Height-specific & time-sensitive

No mainnet block-content scan was performed — it wasn't necessary, because the header-derived state showed no BIP110 rule was active or mandatory at the audited tip. This observation must be refreshed as mainnet advances toward height 961,632. Ordinary threshold lock-in can move the applicable window earlier.

Reachability boundary

The upgrade bug becomes reachable at the first height where an enforcing node would reject a block for mandatory signaling or active RDTS transaction/script rules. Active-rule exposure starts at the actual activation height, whether activation occurs by threshold signaling or at the deadline. The public services above were corroborating checks; deployment state was calculated locally from the synchronized headers and the audited implementation.

09 / Impact

What can and cannot happen.

Severity is left for maintainers to assign. Impact can be high for an affected miner, wallet, or service; exploitability is conditional and, absent an offending block, dormant.

What can happen

  • A user upgrades to enforce BIP110 but unknowingly keeps following a chain that violates it.
  • The enforcing network can fragment into many chains — one per distinct buried invalid ancestor — all reporting the same deployment as active while holding different tips and UTXO sets.
  • Wallet balances and confirmation status can differ between those nodes.
  • A miner or service can keep building on a fork clean nodes reject.
  • Late upgrading fails to deliver the protection the operator intended.

What is not demonstrated

  • No mainnet block is claimed to violate BIP110; no mainnet data was tested.
  • No divergence between two nodes that enforced the same rules from the start.
  • No bypass of ConnectBlock() for newly received blocks.
  • No proof-of-work bypass, database corruption, or remote code execution.
  • No chain split is created by the finding alone; a sufficiently-worked legacy-valid / BIP110-invalid block must first exist.
Why severity is unassigned

Consequences can be high for an affected miner, wallet, or service because it may stay on a branch rejected by clean enforcing nodes. Exploitability has strong preconditions: the violating block must obtain enough proof of work to enter the victim's active chain, and that victim must enable enforcement only after persisting it. Without such a block, the defect remains dormant.

10 / Fix

Prove BIP110 provenance, or fail closed.

A robust fix either makes the supported upgrade boundary explicit and requires revalidation, or persists enough validation provenance to fail closed when a datadir is advanced without enforcement.

Option A

Per-block validation provenance

Add a status bit — e.g. BLOCK_OPT_BIP110_VALIDATED — set only after all applicable BIP110 checks succeed during a real connection. Scan every applicable active-chain ancestor at startup; if any lacks it, fail closed and require reindex. Tie the marker to a fixed, reviewed ruleset.

Option B

Chainstate ruleset attestation

Per-chainstate metadata: a ruleset fingerprint plus the tip it applies through, updated atomically with the coins database's best-block transition. Startup requires the attested tip and ruleset to match the loaded coins tip.

Option C

Explicit unsupported transition

If enabling BIP110 on a datadir that crossed an applicable height isn't meant to be supported, enforce an unconditional rebuild requirement. Automatic refusal based on durable state beats documentation alone.

Per-block provenance design requirements

A marker is safe only if its meaning is precise and durable. Option A must satisfy all of these properties:

Cover every applicable phase. One ancestry-sensitive helper must include both the mandatory-signaling interval and active RDTS transaction/script rules.
Record successful connection. Set provenance only after all applicable checks succeed in a real ConnectBlock() execution.
Exclude weak paths. Never set it in non-enforcing builds, during block receipt, or because BLOCK_VALID_SCRIPTS was already present.
Exclude dry runs. Do not persist provenance for fJustCheck validation that is not becoming a connected chainstate result.
Account for assumevalid. Force applicable BIP110 script checks or withhold the marker whenever a shortcut skipped them.
Bind the ruleset. A bare bit is insufficient if parameters or semantics can change while preserving its meaning; regtest -vbparams makes this concrete.
Scan the complete applicable ancestry. Startup must inspect every active-chain block at which mandatory signaling or active rules apply, not just the recent verification window.
Fail closed with correct recovery. Require -reindex-chainstate -assumevalid=0 when data exists, or full reindex/redownload when pruning removed it.
Run after replay. Evaluate provenance after ReplayBlocks(), because RollforwardBlock() changes coins without executing BIP110 validation.

Side branches

They need not be pre-marked. If a side branch later becomes a reorganization candidate, its blocks pass through ConnectBlock() before entering the active chain and gain provenance only after successful validation.

Status-bit compatibility

Do not repurpose BLOCK_STATUS_RESERVED without a database audit; older databases may retain its previous AssumeUTXO meaning. Prefer a new unambiguous bit or separate metadata.

Why a global boolean is weak

Older or non-enforcing software can advance the datadir without knowing it must invalidate a global flag. Per-block provenance pinpoints which applicable blocks lack enforcing validation.

Unresolved engineering review

Either storage design needs block-index compatibility, atomic flush ordering, multiple-chainstate, downgrade/re-upgrade, and first-upgrade migration review. No fix was prototyped in this audit.

Fix timing matters

If provenance support ships before any BIP110 rule applies on mainnet, no historical block needs it — enforcing nodes mark blocks prospectively and a later transition is detectable without forcing a rebuild. Mainnet was still before that point at the audited height 958,470. This is time-sensitive.

If a fix ships before applicability

Continuously enforcing nodes can mark or attest applicable blocks prospectively, while non-enforcing nodes do not. A later enforcement transition is detectable without rebuilding pre-BIP110 history.

If a fix ships after applicability

Migration must distinguish history genuinely connected by an enforcing release from history inherited from non-enforcing software. The existing database does not reliably encode that distinction, so a one-time reconnect may be unavoidable.

The applicability helper (conceptual)

Both the marker-setting path and the startup scan should share one ancestry-sensitive test for whether BIP110 provenance is required at a block — covering both the mandatory-signaling interval and the active RDTS rules:

bool BIP110ValidationRequiredAt(const CBlockIndex& index, ChainstateManager& chainman) { const auto state = chainman.m_versionbitscache.State( index.pprev, chainman.GetConsensus(), Consensus::DEPLOYMENT_REDUCED_DATA); return DeploymentMustSignalAfter( index.pprev, chainman.GetConsensus(), Consensus::DEPLOYMENT_REDUCED_DATA, state) || DeploymentActiveAt( index, chainman, Consensus::DEPLOYMENT_REDUCED_DATA); } // after a non-fJustCheck connection where every applicable check ran: if (BIP110ValidationRequiredAt(*pindex, m_chainman) && bip110_checks_complete) { pindex->nStatus |= BLOCK_OPT_BIP110_VALIDATED; m_blockman.m_dirty_blockindex.insert(pindex); }

Pruning and AssumeUTXO policy

Missing provenance and missing recovery data are different states. An unpruned node with all applicable blocks can be directed to reconnect. If pruning removed required blocks, a partial scan cannot establish safety and the node must require a full reindex/redownload path.

An AssumeUTXO chainstate must not receive synthesized BIP110 provenance merely because the snapshot base hash commits to header ancestry. Snapshot acceptance needs an explicit policy tying its trust to a BIP110-valid base-chain commitment, or provenance must remain provisional until background validation establishes it. Current snapshot activation synthesizes BLOCK_OPT_WITNESS for ancestors; copying that behavior for BIP110 without a BIP110-specific trust review would recreate the ambiguity.

What does not fully solve it

  • Rechecking only new blocks — already done; the retained ancestor remains.
  • Raising default verification to level 4 for six blocks — the offender may be older.
  • Calling context-free CheckBlock() again — it applies no deployment-dependent rules.
  • Trusting BLOCK_VALID_SCRIPTS — it doesn't encode which soft-fork rules were active.
  • Relying on the operator to remember a manual reindex — without detecting unsafe chainstate.

Required regression coverage

Before a fix is complete, add automated tests for each transition and recovery path:

Signaling boundary

Non-enforcing → enforcing datadir across the mandatory-signaling boundary.

Active output rules

The same transition across active output-size rules.

Active script rule

The same transition with a script-rule violation.

Deep ancestor

An offending ancestor deeper than the default six-block depth.

Clean restart

Normal restart where all applicable blocks have valid provenance — no false-positive rebuild.

Chainstate recovery

-reindex-chainstate -assumevalid=0 reconnects and stops at the last valid ancestor.

Pruned missing data

Applicable history lacks provenance and its block data is unavailable; startup must fail safely.

Interrupted flush

ReplayBlocks() recovery must not silently create or preserve a false attestation.

AssumeUTXO

Cover snapshot activation, trust semantics, background validation, and final chainstate handoff.

Downgrade / re-upgrade

Enforcing state is advanced by non-enforcing software and then loaded by an enforcing client again.

Boundary reorgs

Reorganizations crossing mandatory-signaling, activation, and expiry boundaries.

Pre-applicability history

No false-positive rebuild requirement for blocks before any BIP110 rule applies.

At least one fix regression test should use separate enforcing / non-enforcing binaries rather than only -vbparams. The audit reproducer already supports this through --legacy-bitcoind.

Operational mitigation before a code fix

For an unpruned node that may have connected applicable blocks before enabling BIP110, rebuild the UTXO chainstate through ConnectBlock() with the script-check shortcut disabled:

$ bitcoind … -reindex-chainstate -assumevalid=0

The explicit -assumevalid=0 matters because buried script checks can otherwise be skipped. In this checkout, mainnet's compiled defaultAssumeValid is height 912,683, before the calculated mandatory-signaling range; user-supplied values and future releases still require review. Pruned nodes and AssumeUTXO chainstates may lack the historical data for complete local reconnection and can require a full -reindex / redownload.

A one-time diagnostic can also reconnect every available block:

$ bitcoind … -checklevel=4 -checkblocks=0 -assumevalid=0

This detected the unpruned audit case, but it is expensive, is not a durable provenance mechanism, and stops at missing pruned data. A partial level-4 scan is not proof of complete applicable history.

Remaining work before disclosure or remediation sign-off

The demonstrated divergence does not depend on these follow-ups, but they remain useful:

  • Refresh the height-specific mainnet deployment-state calculation immediately before disclosure or release.
  • Dynamically test AssumeUTXO/background-chainstate behavior.
  • Dynamically exercise an interrupted coins flush followed by ReplayBlocks(); the current replay conclusion is source-derived.
  • Prototype the provenance guard and run the full functional suite.
  • Have a second reviewer independently reproduce the result from this report.
11 / The patch

What the proposed fix actually looks like.

A draft implementation — DathonPwn/bitcoin #1, "validation: require BIP110 revalidation after late upgrade" — builds Option A on top of the audited commit. It adds a durable per-block marker, a startup ancestry scan, and fail-closed recovery: the exact three pieces SegWit has and BIP110 lacked. A second commit, "distinguish BIP110 validation phases," splits the marker into separate signaling and active-rule bits.

Base

29.x-knots

Files changed

7

Insertions

+391

Deletions

−2

Commits

2

New test

286 lines · 7 cases

Independent verificationBuilt the enforcing binary from PR head b57f70afd5 and ran the regression locally — all 7 cases passed.
ResultTests successful — including the new phase-specific provenance case.

One helper returns which phases applied

The report proposed a conceptual boolean BIP110ValidationRequiredAt(). The updated patch ships something more precise: ReducedDataValidationStatusAt() returns a bitmask of the two phases — signaling and active — so each is tracked independently rather than collapsed into one "applicable" flag.

src/validation.cpp — new static helper + added
/** BIP110/RDTS consensus-rule phases selected while connecting this block. */static uint32_t ReducedDataValidationStatusAt(const CBlockIndex& block_index,                                        const ChainstateManager& chainman){  const auto deployment{Consensus::DEPLOYMENT_REDUCED_DATA};  const ThresholdState state{chainman.m_versionbitscache.State(      block_index.pprev, params, deployment)};  uint32_t status{0};  if (DeploymentActiveAt(block_index, chainman, deployment))    status |= BLOCK_OPT_REDUCED_DATA_ACTIVE;  if (DeploymentMustSignalAfter(block_index.pprev, params, deployment, state))    status |= BLOCK_OPT_REDUCED_DATA_SIGNAL;  return status;}
Why the two-bit split matters — proven by a new test

A block can be connected while the deployment is ACTIVE but with mandatory signaling not yet enforced. A single "applicable" bit would let that block's active-rule provenance wrongly satisfy a later signaling check. The updated test test_phase_specific_provenance connects 145 non-signaling blocks under ACTIVE rules, upgrades to a signaling-enforcing config, and confirms startup still refuses — the ACTIVE bit cannot stand in for the SIGNAL bit. I ran this case locally; it passes.

The three missing guard pieces, now present

Compare against Section 07: each is now real code. Added lines are green.

1

Durable marker

A dedicated block-index status bit, set only after successful connection.

now ✓

The new bit — src/chain.h

src/chain.h+ added
  BLOCK_STATUS_RESERVED         = 256,  BLOCK_OPT_REDUCED_DATA_ACTIVE = 512,  //!< active BIP110/RDTS tx & script rules  BLOCK_OPT_REDUCED_DATA_SIGNAL = 1024,  //!< BIP110/RDTS mandatory signaling enforced

Two fresh, unambiguous bits (512 and 1024) — not the reserved AssumeUTXO bit, exactly as the report cautioned. Splitting them lets provenance record which phase was enforced, not merely that some rule applied.

Set after connection — validation.cpp

Chainstate::ConnectBlock()+ added
// Record the specific BIP110 phases selected// while connecting this block.const uint32_t reduced_data_status{    ReducedDataValidationStatusAt(*pindex, m_chainman)};if ((pindex->nStatus & reduced_data_status) != reduced_data_status) {  pindex->nStatus |= reduced_data_status;  m_blockman.m_dirty_blockindex.insert(pindex);}

Placed after the BLOCK_VALID_SCRIPTS raise, on the real (non-fJustCheck) connection path — so it records validation, not receipt. The &/!= guard sets only the missing phase bits.

2

Startup ancestry scan

A BIP110 analogue of NeedsRedownload() — the piece with the most nuance.

now ✓

Chainstate::NeedsReducedDataRevalidation()

src/validation.cpp+ added
bool Chainstate::NeedsReducedDataRevalidation() const {  const CBlockIndex* tip{m_chain.Tip()};  if (tip == nullptr || !DeploymentEnabled(...)) return false;  // 1. bounded mandatory-signaling window  if (max_activation_height < INT_MAX) {    first = max(1, max_activation_height − 2*period);    last  = min(tip->nHeight, max_activation_height − period − 1);    for (height = first; height <= last; ++height)      if (DeploymentMustSignalAfter(...) &&          !(index->nStatus & BLOCK_OPT_REDUCED_DATA_SIGNAL)) return true;  }  // 2. complete historical ACTIVE range — even after EXPIRED  next_state = State(tip, ...);  if (next_state == ACTIVE)  activation_height = StateSinceHeight(tip, ...);  else if (next_state == EXPIRED) { /* recover pre-expiry activation */ }  if (activation_height >= 0 && active_duration > 0) {    last_active = min(tip->nHeight, activation_height + active_duration − 1);    for (height = ...; height <= last_active; ++height)      if (DeploymentActiveAt(...) &&          !(index->nStatus & BLOCK_OPT_REDUCED_DATA_ACTIVE)) return true;  }  return false;}
Goes beyond the report's sketch

The report described scanning "every applicable active-chain block." This scan is sharper: it walks the mandatory-signaling window and the full historical ACTIVE range separately, and deliberately reconstructs the activation height even when the deployment has already reached EXPIRED — so a disable-then-re-enable-after-expiry cycle is still caught. That case gets its own regression test.

3

Fail-closed recovery

Startup refuses when provenance is missing — mirroring the SegWit guard beside it.

now ✓

src/node/chainstate.cpp — right after the SegWit check

CompleteChainstateInitialization()+ added
if (std::any_of(chainstates.begin(), chainstates.end(),    [](const Chainstate* cs) { return cs->NeedsReducedDataRevalidation(); })) {  return {ChainstateLoadStatus::FAILURE, _(    "Blocks subject to BIP110/RDTS require validation.     Please restart with -reindex-chainstate.")};}
operator sees on failed startup
Error: Blocks subject to BIP110/RDTS require validation.
Please restart with -reindex-chainstate.

AssumeUTXO handled the way the report demanded

The report warned against blindly faking provenance for snapshots. The patch marks trusted snapshot ancestors deliberately — the hardcoded commitment substitutes for reconnecting them — right beside the existing SegWit snapshot handling.

ChainstateManager::PopulateAndValidateSnapshot()+ added
if (DeploymentActiveAt(*index, *this, DEPLOYMENT_SEGWIT))  index->nStatus |= BLOCK_OPT_WITNESS;// The hardcoded snapshot commitment substitutes for reconnecting// its ancestors, so treat applicable BIP110 phases as validated too.index->nStatus |= ReducedDataValidationStatusAt(*index, *this);

Does it meet the report's design requirements?

Section 10 listed the properties a per-block provenance marker must satisfy. The updated draft addresses each — and the second commit adds phase separation on top:

Report requirementHow the patch satisfies it
Cover every applicable phaseOne helper returns a bitmask of both phases — DeploymentActiveAt and DeploymentMustSignalAfter — tracked as separate bits.done
Record successful connectionMarker set after the BLOCK_VALID_SCRIPTS raise in ConnectBlock().done
Exclude weak pathsNot set on receipt or in non-enforcing builds; the helper returns false when RDTS is inactive at that ancestry.done
Exclude dry runsSet on the real connect path, not fJustCheck validation.done
Bind to a fixed rulesetMarker keyed to DEPLOYMENT_REDUCED_DATA; the regression exercises multiple -vbparams states.done
Scan the complete applicable ancestryNeedsReducedDataRevalidation() walks the mandatory window and the entire ACTIVE range, including post-EXPIRED.done
Fail closed with correct recoveryStartup refuses and names -reindex-chainstate; pruned nodes are steered to full -reindex.done
Distinguish signaling from active rulesTwo separate bits; the new test_phase_specific_provenance proves ACTIVE provenance cannot satisfy a signaling check.done
New unambiguous status bitUses fresh bits 512 and 1024, not the reserved AssumeUTXO bit.done
AssumeUTXO trust is explicitSnapshot ancestors marked deliberately alongside the SegWit precedent.done
One item still open — independently confirmed

The report flagged the assumevalid interaction, and reading the updated code confirms it survives. In ConnectBlock(), the RDTS script rule runs only when fScriptChecks is true (which assumevalid sets false for sufficiently buried ancestors), but the marker at line 3088 is set with no fScriptChecks condition. So during a reindex that trusts an assumevalid checkpoint, a block can receive BLOCK_OPT_REDUCED_DATA_ACTIVE even though its script rule never executed. The output-size rule is safe — it runs unconditionally — so only the script-rule portion of the ACTIVE phase is exposed. The patch's tests avoid this by always recovering with -assumevalid=0. This remains the sharpest open question before the fix is complete.

Validation — independently re-run

I built the enforcing binary from PR head b57f70afd5 and ran the late-upgrade regression locally — all 7 cases passed (Tests successful). The PR additionally reports the following as passing; those were not re-run here:

New regression — 7 cases

Output-size, script, mandatory-signaling, pruning, safe pre-activation upgrade, disable/re-enable-after-expiry, and the new phase-specific provenance case. Optionally run against a separately compiled non-enforcing bitcoind for the initial history.

Unit suites

Full suite (657 cases) plus focused versionbits_tests, validation_chainstate_tests, validation_chainstatemanager_tests, txvalidationcache_tests.

Functional set

feature_rdts.py, the two reduced-data suites, feature_presegwit_node_upgrade.py, and feature_assumeutxo.py.

Scope

Draft confined to the fork. It does not open or modify any pull request, issue, or branch in bitcoinknots/bitcoin.