Repository navigation
Bug B: block.hash recomputed post-apply doesn't update embedded justification — produces internally inconsistent blocks #751
Description
Discovered
2026-05-31 testnet h=5817132 stall investigation. Self-produced blocks have internally inconsistent block.hash != justification.block_hash because:
- Engine collects supermajority precommits for hash H1 (pre-apply, before state_root computed). Justification embedded in block carries
block_hash=H1and precommits all signed for H1. - main.rs FinalizeBlock arm hash-mismatch guard:
stashed.hash==H1 == action.block_hash==H1✓ pass bc.add_block(blk)runs apply pathblock_executor.rs:1561-1563:last.state_root = Some(computed_root);last.hash = last.calculate_hash(). Block.hash becomes H2 (post-recompute with new state_root). Justification NOT updated.- Stored block:
block.hash=H2,justification.block_hash=H1,precommits[*].block_hash=H1.
This has been latent because:
- Chain progression uses block.hash (H2) consistently — next block's prev_hash references H2
- Strict_justification fork not active by default (
u64::MAX) → no per-signature verification at receivers - Pre-fork justification only sums stake_weights — passes
But strict_justification activation surfaces it, and the broader integrity story is broken.
Fix options
Option B1 — speculative apply at propose time:
Proposer applies block locally to compute state_root, includes state_root in proposal. Other vals verify state_root before voting. They prevote/precommit on hash that INCLUDES correct state_root.
Pros: clean, deterministic, single hash everywhere.
Cons: doubles compute (proposer applies twice — once for proposal, once on commit).
Option B2 — separate block_id from block_hash:
Vote on block_id (deterministic ID computed from block contents excluding state_root). block_hash (with state_root) used only for chain linking. Justification refs block_id.
Pros: avoids double-apply.
Cons: introduces two identifiers, complicates audit story.
Option B3 — recompute justification refs in apply:
After last.hash = last.calculate_hash() at L1563, also rewrite last.justification.block_hash = last.hash and each precommit.block_hash = last.hash.
Pros: smallest code change.
Cons: BREAKS signature verification — precommits were signed over H1, not H2. recover_signer would fail on the new j.block_hash. So this option ALONE is wrong; must combine with re-signing (impossible without keys).
Option B4 — block.hash committed BEFORE state_root stamp:
Stop including state_root in block.hash calculation entirely. Block.hash = hash(index, prev, merkle, ts, validator). State_root is verified separately via last.state_root field against expected value computed at apply.
Pros: simple, single hash from creation.
Cons: requires fork — chain rule change. State_root drift no longer in hash chain.
Related immediate fix
- #(this PR) v2.2.23: reject peer blocks where block.hash != j.block_hash (gated by strict_justification fork). Stops the bleed when fork active.
- Bug A: off-trie consensus state drift (pending_rewards, total_minted, epoch_state) — silent fork-trigger on any consuming tx #750: Bug A (off-trie state drift) — separate concern but related to the 2026-05-31 cascade.
Acceptance criteria
- Design SIP for chosen option (B1/B2/B4)
- Implement
- Testnet bake
- Mainnet fork activation
Activity
docs: add ROADMAP.md at repo root (closes #626) (#753)
satyakwok commentedon Jun 14, 2026
The B1 design (speculative apply at propose, so the block hash commits the post-execution state root and validators verify the state root before voting) is specified in SIP-5, currently
Draft. Remaining to close this: the fork-gated reference implementation + testnet bake.feat(consensus): speculative apply so block hash commits state root (…
feat(consensus): speculative apply so block hash commits state root (…