Affected
Knots v29.3.knots20260508
Consensus & upgrade-path correctness
Same rules. Same block.Different answer.
A late-upgraded node keeps a block that a fresh BIP110 node rejects.
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:
| Node | Datadir history | Result 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 |
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.
Datadir reuse between separately compiled RDTS_CONSENT=IMPLICIT and RDTS_CONSENT=UNSUPPORTED_UNSAFE_NO_ENFORCEMENT binaries from the audited commit.
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.
Temporarily downgrading to non-enforcing software, re-enabling enforcement later, or otherwise loading a chainstate advanced by software that did not enforce BIP110.
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.
All four conditions must hold. No malformed database, local file write, or RPC access is required.
| Expected safe behavior | Observed 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. |
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.
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 BLate-upgraded
connected B while enforcement was off, then upgraded
retains BB has enough proof of work and is valid under legacy rules, but violates BIP110.
It stays on, or searches for, a BIP110-valid branch instead.
Block index and UTXO chainstate now record B as valid.
Intending to enforce the new rules going forward.
Default level-3 verification disconnects recent blocks but never reconnects them — B is retained without re-validation.
Their own contents satisfy BIP110; B itself is never reconsidered.
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.
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
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.
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.
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.
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.
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 tested | The offending block | Fresh 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 |
Both fresh validation and -reindex-chainstate reject the test block correctly. The
defect is specifically the ordinary upgrade / startup path — not the BIP110 checks.
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
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.
legacy/upgraded node: reduced_data:-2:9223372036854775807 # never active fresh control node: reduced_data:-1:9223372036854775807 # always active
scriptPubKey that does not begin with OP_RETURN.getdeploymentinfo reports reduced_data.active = true and the tip is still height 3.bad-txns-vout-script-toolarge.CONFIRMED: upgraded enforcing node retains block; fresh enforcing node rejects it
Tests successful
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.
bad-version-reduced_data.CONFIRMED: mandatory-signaling-invalid ancestor survives enforcement upgrade
Tests successful
This case exercises a rule enforced by script execution rather than output-size checks.
Push value size limit exceeded.CONFIRMED: active script-invalid ancestor survives enforcement upgrade
Tests successful
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 mode | Datadir | Result |
|---|---|---|
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 |
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.
CONFIRMED: checklevel=4 detects; reindex-chainstate recovers to valid ancestor
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.
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.
Five mechanisms combine. The checks live in the right place — but nothing on the startup path forces them to run again over inherited history.
Mandatory signaling, output-size, and script rules are all enforced during connection — and work when it's actually called.
BLOCK_VALID_SCRIPTS is the highest persisted level, but it never records that BIP110 was enabled or which rules applied.
NeedsRedownload() scans for BLOCK_OPT_WITNESS. There is no BIP110 ancestry or provenance scan.
Defaults are checkblocks=6, checklevel=3. Level 3 disconnects blocks; only level 4 reconnects via ConnectBlock().
RollforwardBlock() only spends and adds coins during replay — it never runs the BIP110 checks.
A late-upgraded node loads chainstate produced under non-enforcing rules and trusts it — the invalid ancestor stays active across restarts.
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.
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.
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.
These fire every time ConnectBlock() is actually called. The mandatory-signaling
rule that emits bad-version-reduced_data:
// 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:
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(), ...); }}
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.
Chainstate::ConnectBlock() begins here.ContextualCheckBlockHeaderVolatile() — the mandatory-signaling recheck.bad-version-reduced_data.BLOCK_VALID_SCRIPTS after successful connection.BLOCK_OPT_WITNESS — dedicated block-index status bit.NeedsRedownload() walks active SegWit blocks; no BIP110 check.DEFAULT_CHECKBLOCKS = 6, DEFAULT_CHECKLEVEL = 3.CheckBlock().ConnectBlock().RollforwardBlock() only spends and adds coins during replay.assumevalid can skip script checks for buried ancestors.VerifyDB() stops when historical block data is missing.SKIPPED_MISSING_BLOCKS rather than treating it as complete verification.-reindex-chainstate and requires full -reindex.BLOCK_OPT_WITNESS for snapshot ancestors.defaultAssumeValid is at height 912,683.| Area | Verification status |
|---|---|
| Cited source paths, startup levels, deployment arithmetic | Verified against commit |
| Mandatory signaling, active output rule, active script rule | Reproduced on regtest |
| Default restart, full level-4 check, reindex recovery | Reproduced (unpruned) |
| Pruned default / full level-4 limitation | Reproduced on regtest |
| Separate enforcing / non-enforcing build datadir reuse | Reproduced |
| Mainnet deployment state at block 958,470 | Verified — STARTED, no rule applicable |
| Mainnet transaction / block scan | Not performed — no rule applicable |
| AssumeUTXO & interrupted-flush replay behavior | Source-reviewed only |
| Proposed provenance / attestation fix | Design analysis only |
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.
-reindex.
ConnectBlock() itself documents this exact hazard and points at NeedsRedownload()
as the BIP141 remedy — the approach that was never built for BIP110.
// 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).
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.
BIP141 shipped three coordinated pieces. For each, the real SegWit source is on the left; the BIP110 equivalent on the right is absent.
A persisted bit recording that aware software handled the block.
SegWit — the bit exists
BLOCK_OPT_WITNESS = 128, //!< block data in blk*.dat was //!< received with a witness- //!< enforcing client
if (DeploymentActiveAt(*pindexNew, *this, DEPLOYMENT_SEGWIT)) { pindexNew->nStatus |= BLOCK_OPT_WITNESS;}
BIP110 — nothing equivalent
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.
Walk every applicable active-chain ancestor and check the marker.
SegWit — NeedsRedownload()
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
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.
Refuse startup and demand a rebuild when history is insufficient.
SegWit — startup refuses
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
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.
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.
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
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.
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.
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.
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.
ConnectBlock() for newly received blocks.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.
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.
A marker is safe only if its meaning is precise and durable. Option A must satisfy all of these properties:
ConnectBlock() execution.BLOCK_VALID_SCRIPTS was already present.fJustCheck validation that is not becoming a connected chainstate result.assumevalid. Force applicable BIP110 script checks or withhold the marker whenever a shortcut skipped them.-vbparams makes this concrete.-reindex-chainstate -assumevalid=0 when data exists, or full reindex/redownload when pruning removed it.ReplayBlocks(), because RollforwardBlock() changes coins without executing BIP110 validation.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.
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.
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.
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.
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.
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.
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.
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:
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.
CheckBlock() again — it applies no deployment-dependent rules.BLOCK_VALID_SCRIPTS — it doesn't encode which soft-fork rules were active.Before a fix is complete, add automated tests for each transition and recovery path:
Non-enforcing → enforcing datadir across the mandatory-signaling boundary.
The same transition across active output-size rules.
The same transition with a script-rule violation.
An offending ancestor deeper than the default six-block depth.
Normal restart where all applicable blocks have valid provenance — no false-positive rebuild.
-reindex-chainstate -assumevalid=0 reconnects and stops at the last valid ancestor.
Applicable history lacks provenance and its block data is unavailable; startup must fail safely.
ReplayBlocks() recovery must not silently create or preserve a false attestation.
Cover snapshot activation, trust semantics, background validation, and final chainstate handoff.
Enforcing state is advanced by non-enforcing software and then loaded by an enforcing client again.
Reorganizations crossing mandatory-signaling, activation, and expiry boundaries.
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.
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:
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:
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.
The demonstrated divergence does not depend on these follow-ups, but they remain useful:
ReplayBlocks(); the current replay conclusion is source-derived.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
b57f70afd5 and ran the regression locally — all 7 cases passed.Tests successful — including the new phase-specific provenance case.
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.
/** 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;}
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.
Compare against Section 07: each ∅ is now real code. Added lines are green.
A dedicated block-index status bit, set only after successful connection.
The new bit — src/chain.h
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
// 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.
A BIP110 analogue of NeedsRedownload() — the piece with the most nuance.
Chainstate::NeedsReducedDataRevalidation()
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;}
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.
Startup refuses when provenance is missing — mirroring the SegWit guard beside it.
src/node/chainstate.cpp — right after the SegWit check
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.")};}
Error: Blocks subject to BIP110/RDTS require validation.
Please restart with -reindex-chainstate.
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.
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);
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 requirement | How the patch satisfies it | |
|---|---|---|
| Cover every applicable phase | One helper returns a bitmask of both phases — DeploymentActiveAt and DeploymentMustSignalAfter — tracked as separate bits. | done |
| Record successful connection | Marker set after the BLOCK_VALID_SCRIPTS raise in ConnectBlock(). | done |
| Exclude weak paths | Not set on receipt or in non-enforcing builds; the helper returns false when RDTS is inactive at that ancestry. | done |
| Exclude dry runs | Set on the real connect path, not fJustCheck validation. | done |
| Bind to a fixed ruleset | Marker keyed to DEPLOYMENT_REDUCED_DATA; the regression exercises multiple -vbparams states. | done |
| Scan the complete applicable ancestry | NeedsReducedDataRevalidation() walks the mandatory window and the entire ACTIVE range, including post-EXPIRED. | done |
| Fail closed with correct recovery | Startup refuses and names -reindex-chainstate; pruned nodes are steered to full -reindex. | done |
| Distinguish signaling from active rules | Two separate bits; the new test_phase_specific_provenance proves ACTIVE provenance cannot satisfy a signaling check. | done |
| New unambiguous status bit | Uses fresh bits 512 and 1024, not the reserved AssumeUTXO bit. | done |
| AssumeUTXO trust is explicit | Snapshot ancestors marked deliberately alongside the SegWit precedent. | done |
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.
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:
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.
Full suite (657 cases) plus focused versionbits_tests, validation_chainstate_tests, validation_chainstatemanager_tests, txvalidationcache_tests.
feature_rdts.py, the two reduced-data suites, feature_presegwit_node_upgrade.py, and feature_assumeutxo.py.
Draft confined to the fork. It does not open or modify any pull request, issue, or branch in bitcoinknots/bitcoin.