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=H1 and 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 path
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.
- 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
Discovered
2026-05-31 testnet h=5817132 stall investigation. Self-produced blocks have internally inconsistent
block.hash != justification.block_hashbecause:block_hash=H1and precommits all signed for H1.stashed.hash==H1 == action.block_hash==H1✓ passbc.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.block.hash=H2,justification.block_hash=H1,precommits[*].block_hash=H1.This has been latent because:
u64::MAX) → no per-signature verification at receiversBut 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 rewritelast.justification.block_hash = last.hashand eachprecommit.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_rootfield 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