fix(lstar): reject malformed justification state with typed errors#1178
Merged
tcoratger merged 1 commit intoJul 4, 2026
Merged
Conversation
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>
2 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, aValueError, and aSpecRejectionErrorare 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
Slot.is_justifiable_afterguardedself >= finalized_slotwith a bareassert. Not reachable with a bad value in the honest flow (all callers guard it), but a public partial function that crashes rather than answering.process_attestationsunpacked the flat justification vote list withbatched(..., validator_count)andzip(..., strict=True), plus a bare zero-hashassert. A validly SSZ-decodedStatecan still violate the cross-field invariant that the vote list is exactly one registry-sized segment per tracked root — theStatecontainer has no cross-field length validator — so a state from untrusted bytes could crash here with an untypedValueError, and a non-multiple length could even produce a silently short final segment.The fix
is_justifiable_afteris now total: it returnsFalsefor any slot before the finalized boundary. The equal-slot case still returnsTruevia the immediate-justification window. The misleadingRaisesclause is gone.process_attestationsvalidates the justification bookkeeping before the unpack, with typed rejections:EMPTY_VALIDATOR_REGISTRY; belt-and-suspenders, since the header stage already rejects this first),JUSTIFICATION_VOTES_LENGTH_MISMATCH),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_slotalready rejects an empty registry with a typedSpecRejectionError.Tests
Consensus vectors only, per the forks-are-vectors rule (no pytests, no
tests/spec/forks/tree):is_justifiable=false), which the previous assert could not emit at all.state_transitionrejection vectors craft a decodedStatethat 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 fillgenerates the new fixtures deterministically, andjust checkpasses.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