Skip to content

fix(lstar): reject malformed justification state with typed errors#1178

Merged
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:eval/issue-1174-partial-functions
Jul 4, 2026
Merged

fix(lstar): reject malformed justification state with typed errors#1178
tcoratger merged 1 commit into
leanEthereum:mainfrom
tcoratger:eval/issue-1174-partial-functions

Conversation

@tcoratger

Copy link
Copy Markdown
Collaborator

What

Turns three incidental crashes on precondition violations into total functions or typed domain rejections, closing #1174. Two of the three are reachable on a state reconstructed from untrusted bytes (sync / database); the third is defence-in-depth.

Framing: robustness, not a consensus change

This does not change which blocks or states any correct client accepts. Every rejection path already funnels to "drop the block" (the sync driver wraps the transition in a catch-all), so an AssertionError, a ValueError, and a SpecRejectionError are consensus-equivalent — they reject the same block. The value is a defined, language-neutral rejection in place of a crash whose type differs per transliterating language. It should not be labelled safety- or liveness-critical.

The problems

  1. Slot.is_justifiable_after guarded self >= finalized_slot with a bare assert. Not reachable with a bad value in the honest flow (all callers guard it), but a public partial function that crashes rather than answering.
  2. process_attestations unpacked the flat justification vote list with batched(..., validator_count) and zip(..., strict=True), plus a bare zero-hash assert. A validly SSZ-decoded State can still violate the cross-field invariant that the vote list is exactly one registry-sized segment per tracked root — the State container has no cross-field length validator — so a state from untrusted bytes could crash here with an untyped ValueError, and a non-multiple length could even produce a silently short final segment.

The fix

  • is_justifiable_after is now total: it returns False for any slot before the finalized boundary. The equal-slot case still returns True via the immediate-justification window. The misleading Raises clause is gone.
  • process_attestations validates the justification bookkeeping before the unpack, with typed rejections:
    • empty validator registry (reuses EMPTY_VALIDATOR_REGISTRY; belt-and-suspenders, since the header stage already rejects this first),
    • vote-list length not equal to tracked-root count times validator count (new JUSTIFICATION_VOTES_LENGTH_MISMATCH),
    • a zero hash among the tracked roots (new ZERO_HASH_JUSTIFICATION_ROOT).
    • zip(strict=True) is kept as a cheap belt-and-suspenders behind the guards.

This follows the existing reference pattern in this repo — ValidatorIndex.proposer_for_slot already rejects an empty registry with a typed SpecRejectionError.

Tests

Consensus vectors only, per the forks-are-vectors rule (no pytests, no tests/spec/forks/ tree):

  • Justifiability gains two cases below the finalized boundary (delta -1 and -10, is_justifiable=false), which the previous assert could not emit at all.
  • Two state_transition rejection vectors craft a decoded State that violates the length invariant and one that carries a zero-hash root, each asserting the full rejection message with string equality.

The empty-registry guard inside attestation processing is unreachable through any vector (the header proposer guard intercepts first) and pytest is disallowed for forks, so it stays as documented belt-and-suspenders; the reachable header path is already covered elsewhere.

uv run fill generates the new fixtures deterministically, and just check passes.

Credit

Found via the Lean 4 formalization of this spec (NyxFoundation/formal-leanSpec): these are the call sites where the proofs needed an explicit side hypothesis to discharge — the tell-tale of an implicit precondition.

Closes #1174

🤖 Generated with Claude Code

Several partial functions enforced their preconditions by crashing with
an untyped exception instead of rejecting cleanly. In the honest block
flow the preconditions hold, but these functions are reachable on a state
reconstructed from untrusted bytes over sync or the database, where the
crash type differs per transliterating language.

This is robustness hardening, not a consensus change: every rejection
path already funnels to dropping the block, so an assertion error, a
value error, and a typed rejection are consensus-equivalent. The value
is a defined, language-neutral rejection in place of an incidental crash.

Changes:

- Slot justifiability is now total. A slot before the finalized boundary
  returns False instead of asserting; the equal-slot case still returns
  True through the immediate-justification window.

- Attestation processing validates the justification bookkeeping before
  unpacking the flat vote list into per-root segments. It rejects an
  empty registry, a vote list whose length is not the tracked-root count
  times the validator count, and a zero hash among the tracked roots.
  Two typed rejection reasons are added for the latter two.

Vectors:

- Justifiability gains two cases below the finalized boundary, which the
  previous assert could not produce.
- Two state-transition rejection vectors craft a decoded state that
  violates the length invariant and one that carries a zero-hash root.

Closes leanEthereum#1174

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tcoratger tcoratger merged commit f48f6bd into leanEthereum:main Jul 4, 2026
13 of 14 checks passed
adust09 added a commit to NyxFoundation/formal-leanSpec that referenced this pull request Jul 5, 2026
… #1177-#1181) (#42)

* feat(cont): totalize isJustifiableAfter below the finalized boundary

Upstream leanEthereum/leanSpec#1178 (issue #1174) made
Slot.is_justifiable_after total: a slot before the finalized boundary
returns False instead of tripping an assert reachable from untrusted
state. Mirror the guard in isJustifiableAfter, discharge it in
justifiable_iff via the finalized <= target hypothesis, and prove the
settled-slot behavior as justifiable_before_finalized (CONT-2).

* feat(st): model justification vote-layout guards as typed rejections

Upstream leanEthereum/leanSpec#1178 and #1180 (issues #1173/#1174)
turned the process_attestations vote-layout asserts into
SpecRejectionError raises with dedicated reasons, and split
SpecRejectionError from AssertionError via a new SpecError base.

Mirror that here: processAttestations now rejects an empty registry
(EMPTY_VALIDATOR_REGISTRY), a flat vote list whose length is not
roots x validators (JUSTIFICATION_VOTES_LENGTH_MISMATCH), and a
zero-hash tracked root (ZERO_HASH_JUSTIFICATION_ROOT), with the two
new STError variants added. noJustifiableBetween carries the same
settled-slot guard is_justifiable_after gained. The ST-3/ST-4/ST-6
preservation proofs are extended over the new error paths.

* docs(cont): note slot-only selection in advanceTo

Upstream leanEthereum/leanSpec#1179 (issue #1176) documented that
Checkpoint.advance_to selects by slot only and that descent from the
finalized block is a separate store invariant. Mirror the note so the
Lean model and the Python spec read the same.
MegaRedHand added a commit to lambdaclass/ethlambda that referenced this pull request Jul 6, 2026
…d errors (leanSpec #1178) (#502)

## What

Ports the reachable robustness guards from leanSpec
[#1178](leanEthereum/leanSpec#1178): reject a
malformed justification-bookkeeping `State` in `process_attestations`
with typed errors instead of silently mis-counting.

## Why

`process_attestations` unpacks the flat `justifications_validators` bit
list as one validator-sized segment per tracked root. A `State`
reconstructed from untrusted bytes (checkpoint sync) satisfies SSZ yet
can still break the cross-field invariant
`len(justifications_validators) == len(justifications_roots) *
validator_count` — SSZ decoding cannot enforce it. ethlambda's
index-range unpack (`.get(j)`) does not crash on a mismatch, but it
silently produces short/wrong vote segments. These guards close that
gap.

## Changes

`crates/blockchain/state_transition/src/lib.rs`, in
`process_attestations`, before the unpack:

- **Empty registry:** reject `validator_count == 0` (`NoValidators`).
Belt-and-suspenders — the header stage rejects this first in the normal
flow — but the unpack relies on a non-zero segment width directly.
- **Vote-list length mismatch:** reject `justifications_validators.len()
!= justifications_roots.len() * validator_count` (new
`JustificationVotesLengthMismatch`).

Checks are ordered to match the spec (registry → length → zero-hash).
The zero-hash-root guard and the totality of `slot_is_justifiable_after`
(the other two items in #1178) were already present in ethlambda, so
they are unchanged.

For a well-formed state none of these fire, so honest operation and
state roots are unaffected.

## Tests

- `process_attestations_rejects_justification_votes_length_mismatch`
- `process_attestations_rejects_empty_validator_registry`

`cargo fmt`, `clippy -D warnings`, and the state-transition lib tests
pass.
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.

Partial functions crash on precondition violations reachable from untrusted state

1 participant