Skip to content

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

@satyakwok

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions