Skip to content

Bug B: block.hash recomputed post-apply doesn't update embedded justification — produces internally inconsistent blocks #751

Description

@satyakwok

Discovered

2026-05-31 testnet h=5817132 stall investigation. Self-produced blocks have internally inconsistent block.hash != justification.block_hash because:

  1. Engine collects supermajority precommits for hash H1 (pre-apply, before state_root computed). Justification embedded in block carries block_hash=H1 and precommits all signed for H1.
  2. main.rs FinalizeBlock arm hash-mismatch guard: stashed.hash==H1 == action.block_hash==H1 ✓ pass
  3. bc.add_block(blk) runs apply path
  4. block_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.
  5. 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

Acceptance criteria

  • Design SIP for chosen option (B1/B2/B4)
  • Implement
  • Testnet bake
  • Mainnet fork activation

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions