Skip to content

Reject blocks with zero hash in justifications_roots#216

Merged
MegaRedHand merged 1 commit intomainfrom
fix/assert-no-zero-hash-in-justification-roots
Mar 13, 2026
Merged

Reject blocks with zero hash in justifications_roots#216
MegaRedHand merged 1 commit intomainfrom
fix/assert-no-zero-hash-in-justification-roots

Conversation

@pablodeymo
Copy link
Collaborator

Motivation

A spec-to-code compliance audit (FINDING-004) identified a missing invariant check. The leanSpec Python reference asserts at the start of process_attestations that no justification root is the zero hash:

# state.py L389-391
assert not any(root == ZERO_HASH for root in self.justifications_roots), (
    "zero hash is not allowed in justifications roots"
)

The Rust code had no equivalent check.

Why this matters

Under normal operation, zero hashes cannot enter justifications_roots because is_valid_vote Rule 3 rejects any attestation with target.root == H256::ZERO before it reaches the justifications HashMap. So the zero hash is filtered at the input boundary and never persisted by serialize_justifications.

However, this assertion guards against corrupt state ingestion — specifically, when a node receives a state via checkpoint sync from a malicious peer that has a zero hash already embedded in justifications_roots. Without this check, the node would silently operate on corrupted state. With this check, it rejects the block immediately, matching the spec's behavior.

This is a defense-in-depth measure: even though the normal code path prevents zero hashes from accumulating, an explicit precondition check catches the case where state arrives from an external source with the invariant already violated.

Description

Adds a precondition check at the top of process_attestations (before deserialization into the HashMap) that returns Error::ZeroHashInJustificationRoots if any root in state.justifications_roots is H256::ZERO.

Changes:

  • Error enum: New variant ZeroHashInJustificationRoots
  • process_attestations: Early return with error if any justification root is zero, placed before the HashMap deserialization to match the spec's assertion ordering

Spec reference

  • leanSpec/src/lean_spec/subspecs/containers/state/state.py L389-391 (commit d39d101)

How to Test

  1. cargo test --workspace --release — all tests pass
  2. cargo clippy --workspace -- -D warnings — clean
  3. cargo fmt --all -- --check — clean
  4. The check is a precondition on incoming state, so it is not exercised by existing spec test fixtures (which produce valid states). A targeted test constructing a state with a zero hash in justifications_roots and verifying the error would further strengthen coverage.

The spec asserts that justifications_roots must not contain zero hashes
at the start of process_attestations (state.py L389). Add the equivalent
check as a hard error to guard against corrupt state ingestion via
checkpoint sync from malicious peers.
@github-actions
Copy link

🤖 Kimi Code Review

Security/Correctness Review

Critical Issue - Line 227-234: The zero hash check is implemented incorrectly. The spec reference mentions "spec state.py L389" which likely refers to checking that justification bits are not set for zero hashes, not that the roots array itself contains zero hashes. This check could incorrectly reject valid states where unused justification slots contain zero hashes.

Performance Issue - Line 227-234: The zero hash check uses .iter().any() which is O(n) on every attestation processing call. This is unnecessary overhead since justification roots are only modified during epoch processing.

Code Quality Issues:

  1. Line 228: The error variant name ZeroHashInJustificationRoots is misleading - it suggests zero hashes are invalid, but they may be valid placeholder values.

  2. Missing context: The comment references "spec state.py L389" but doesn't provide the actual spec requirement. This makes it impossible to verify the implementation correctness.

Recommendations

  1. Remove the zero hash check from process_attestations entirely - this function processes individual attestations, not justification roots.

  2. If zero hash validation is needed, it should be implemented in:

    • process_justification_and_finalization() during epoch processing
    • Or in state initialization/validation functions
  3. Clarify the spec requirement - the actual spec likely requires checking that justification bits are not set for zero hash checkpoints, not that zero hashes are forbidden in the roots array.

The current implementation appears to be addressing the wrong concern at the wrong time, potentially breaking valid state transitions.


Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link

🤖 Codex Code Review

No high-severity correctness or security issues found in this PR. The new guard is a sensible fail-fast invariant check for malformed state.

  1. Low Add a regression test for the new precondition path so it can’t silently regress later.
    Reference: crates/blockchain/state_transition/src/lib.rs:225, crates/blockchain/state_transition/src/lib.rs:35
    Suggestion: add a unit/integration test that seeds state.justifications_roots with H256::ZERO and asserts process_attestations/state_transition returns Error::ZeroHashInJustificationRoots.

Notes:

  • Security: rejecting zero-root justifications prevents ambiguous/corrupt vote state from being processed.
  • Performance: O(n) scan over justifications_roots is negligible relative to attestation processing.
  • I could not run tests in this environment due sandboxed rustup temp-file permissions (/home/runner/.rustup/tmp not writable).

Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link

🤖 Claude Code Review

Here is my review of PR 216.


Review: Reject blocks with zero hash in justifications_roots

Overall: Small, focused, and correctly motivated change. The implementation is clean and idiomatic. A few observations below.


Correctness

The check faithfully mirrors the Python spec assertion at state.py L389-391:

assert not any(root == ZERO_HASH for root in self.justifications_roots)

The Rust translation using .any(|root| root == &H256::ZERO) is correct. Placement before the HashMap deserialization loop matches the spec's ordering.

Error propagation is correct: process_attestationsprocess_blockstate_transition — the block is rejected, which is the intended behavior.


Observations

Placement is correct but only catches the problem at block-processing time

The PR description accurately frames this as a defense against corrupted state arriving via checkpoint sync. However, note that the check only fires when a new block is processed on top of the corrupted state. If a node ingests a corrupted checkpoint state but hasn't yet processed a block, the invariant violation sits silently in memory.

A stronger approach would be validating justifications_roots at state ingestion time (wherever checkpoint-synced states are loaded from external peers). This PR's check is still valuable as a second line of defense, but the primary validation ideally belongs at the trust boundary.

No unit test for the new error path

The PR description acknowledges this gap directly:

"A targeted test constructing a state with a zero hash in justifications_roots and verifying the error would further strengthen coverage."

A test would be straightforward — construct a State with justifications_roots containing H256::ZERO, call process_block, and assert Err(Error::ZeroHashInJustificationRoots) is returned. Given this is a security-relevant invariant, adding the test in this PR would be preferable to leaving it as a follow-up.

Error variant carries no diagnostic context

Other error variants include fields useful for debugging (e.g., StateRootMismatch { expected, computed }). ZeroHashInJustificationRoots carries no information about which index held the zero hash. This is minor — the error message is unambiguous — but an optional index field would help with post-mortem debugging of corrupted peer states.

Style: root == &H256::ZERO vs *root == H256::ZERO

Both are idiomatic Rust; the current form is fine.


Summary

The invariant check is correct, spec-aligned, and the defense-in-depth rationale is sound. The two actionable gaps are:

  • A unit test for the new error variant should be added before merging.
  • Consider whether this check should also be applied at state ingestion time (checkpoint sync boundary), not only during block processing.

Automated review by Claude (Anthropic) · custom prompt

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR adds a spec-compliance guard (FINDING-004) to process_attestations that rejects any state whose justifications_roots contains H256::ZERO, matching the Python reference assertion at leanSpec/state.py L389-391. A new Error::ZeroHashInJustificationRoots variant is added and the check is inserted before the HashMap deserialization step, exactly mirroring the spec ordering.

Key points:

  • Correct placement: The check fires before justifications_roots is expanded into the working HashMap, matching the spec's assertion position.
  • Correct propagation: The new variant flows naturally through the existing StateTransitionFailed(#[from] ethlambda_state_transition::Error) wrapper in store.rs — no downstream changes needed.
  • Defence-in-depth intent: Under normal operation, is_valid_vote Rule 3 already prevents zero hashes from entering justifications_roots; this check guards against corrupt state arriving via checkpoint sync.
  • No test coverage for the new path: The PR author explicitly flags that existing spec fixtures do not exercise this branch. A targeted unit test constructing a state with a zero hash in justifications_roots and asserting Err(Error::ZeroHashInJustificationRoots) would close this gap and confirm the invariant works as intended.
  • Checkpoint sync not updated: verify_checkpoint_state does not perform this check, so a malicious peer state is only caught at first block-processing rather than at ingest. This is within the stated scope of the PR but worth noting as a potential follow-up.

Confidence Score: 4/5

  • Safe to merge — the change is small, correct, and matches the spec; the only gap is the absence of a targeted unit test for the new code path.
  • The implementation is straightforward (a single any() iterator check before an existing code path), the new error variant integrates cleanly with the existing error propagation chain, and the logic faithfully matches the leanSpec assertion. A point is deducted because there is no unit test exercising Err(ZeroHashInJustificationRoots), meaning CI provides no regression coverage for the primary deliverable of this PR.
  • No files require special attention — the single changed file is clean; a follow-up test in the same file or a dedicated test module would be the natural next step.

Important Files Changed

Filename Overview
crates/blockchain/state_transition/src/lib.rs Adds ZeroHashInJustificationRoots error variant and a precondition guard at the top of process_attestations. Implementation is correct and matches the leanSpec assertion at state.py L389-391, but the new code path has no dedicated unit test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[state_transition] --> B[process_slots]
    B --> C[process_block]
    C --> D[process_block_header\nmodifies state]
    D --> E[process_attestations]
    E --> F{any root in\njustifications_roots\n== H256::ZERO?}
    F -- Yes --> G[Err: ZeroHashInJustificationRoots\nPR change - defence-in-depth]
    F -- No --> H[Deserialize justifications\ninto HashMap]
    H --> I[Iterate attestations\nvia is_valid_vote]
    I --> J{supermajority\nthreshold reached?}
    J -- Yes --> K[Justify checkpoint\ntry_finalize]
    J -- No --> I
    K --> L[serialize_justifications\nback to state]
    L --> M[Ok]
    G --> N[Block rejected]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/state_transition/src/lib.rs
Line: 222-232

Comment:
**New error path has no test coverage**

The PR description explicitly acknowledges that no existing spec fixture exercises this branch. Since the invariant is the main deliverable of this PR (FINDING-004), the absence of a targeted unit test means the check is not verified to fire correctly. A regression (e.g., wrong field name, off-by-one in the iterator) would be silently missed.

Consider adding a unit test similar to the pattern used in `checkpoint_sync.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use ethlambda_types::{
        block::{AggregatedAttestations, Block, BlockBody, BlockHeader},
        checkpoint::Checkpoint,
        primitives::{H256, VariableList},
        state::{ChainConfig, JustificationValidators, JustifiedSlots, State},
    };

    fn make_minimal_state_with_zero_root() -> State {
        // Construct a state whose justifications_roots contains H256::ZERO
        // ... (fill in required fields) ...
    }

    #[test]
    fn process_attestations_rejects_zero_hash_in_justifications_roots() {
        let mut state = make_minimal_state_with_zero_root();
        let block = /* a valid next block */;
        let result = state_transition(&mut state, &block);
        assert!(matches!(result, Err(Error::ZeroHashInJustificationRoots)));
    }
}
```

Without this, CI gives no signal that the defence-in-depth measure is actually reachable.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 7b004c9

Copy link
Collaborator

@MegaRedHand MegaRedHand left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add a test vector for this in leanSpec

@MegaRedHand MegaRedHand merged commit 9d435ee into main Mar 13, 2026
7 checks passed
@MegaRedHand MegaRedHand deleted the fix/assert-no-zero-hash-in-justification-roots branch March 13, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants