Skip to content

test(spec): assert the rejection reason on expected-failure fixtures - #547

Merged
MegaRedHand merged 10 commits into
mainfrom
test/assert-rejection-reason
Aug 3, 2026
Merged

test(spec): assert the rejection reason on expected-failure fixtures#547
MegaRedHand merged 10 commits into
mainfrom
test/assert-rejection-reason

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Negative leanSpec fixtures name why their input must be rejected, in a
rejectionReason field. The spec-test runners parsed that field only so
deny_unknown_fields would accept it and asserted nothing more than "some error
came back", so a fixture could pass on a failure unrelated to the rule it
exercises.

This wires the reason into the assertion for all three offline runners
(fork choice, state transition, verify signatures).

What changed

Piece Where
RejectionReason: typed mirror of leanSpec's RejectionReason StrEnum (36 reasons) crates/common/test-fixtures/src/rejection.rs
StoreError → reason classifier, exhaustive so new variants must be classified spec_test_runner::rejection_reason
state_transition::Error → reason, as a total From impl test-fixtures/src/rejection.rs
apply_fork_choice_step now returns a typed StepError (store rejection vs harness failure) instead of String spec_test_runner
Shared comparison + failure wording used by all three runners rejection::check_rejection_reason

Two failure modes are treated as test failures, not passes, since accepting
either restores "any error will do":

  • the client error carries no spec counterpart (unclassified), and
  • the fixture names a reason this build does not know.

Unknown reasons still deserialize (as Unknown(_)) so the Hive test driver
keeps answering a step over HTTP rather than 422-ing the request when leanSpec
adds a reason name; only the offline runners fail on them.

StoreError::AggregateVerificationFailed was raised both for an attestation
aggregate (INVALID_SIGNATURE) and for a block's merged multi-message proof
(INVALID_BLOCK_PROOF). Since the spec separates those, block-proof
verification now returns its own BlockProofVerificationFailed variant, which
also makes production logs say which proof failed.

Results

  • fork choice: 122/122 pass — all 35 negative steps already failed for
    exactly the reason their fixture names.
  • verify signatures: 3/3 pass.
  • state transition: 72 pass, 2 fail, 2 skipped.

The 2 remaining failures are real divergences

They passed before for the wrong reason; the new assertion is what surfaces
them. Left failing rather than allowlisted, so the divergence stays visible:

fixture expects ethlambda raises diagnosis
test_block_exceeding_distinct_attestation_data_cap_rejects_block TOO_MANY_ATTESTATION_DATA STATE_ROOT_MISMATCH the MAX_ATTESTATIONS_DATA cap is enforced on block import (store.rs), not in the STF, so replaying the STF alone misses it
test_source_slot_beyond_tracked_range_rejects_block JUSTIFIED_SLOT_OUT_OF_RANGE STATE_ROOT_MISMATCH is_valid_vote treats a source past the tracked justification window as "not justified" and skips the vote, where the spec rejects the block

The first is a layering gap rather than live exposure: a node still rejects the
capped block at import. Both want follow-up PRs.

The 2 skips are unreplayable fixtures, not client bugs

test_block_with_wrong_slot and
test_block_at_parent_slot_rejected_when_slot_processing_skipped are authored
with leanSpec's BlockSpec.skip_slot_processing (block_spec.py:71), which
makes the filler call process_block alone. That flag never reaches the emitted
fixture — StateTransitionFixture carries only pre, blocks, post,
postStateRoot, rejectionReason — and the failing block is written with a
placeholder zero stateRoot. Replaying state_transition(), which is what the
fixture format's own description prescribes, therefore advances the slot first
and then dies on the state root instead of reaching the rule under test.

Nothing in the JSON marks the entry point, so no client can reproduce these
two
. Verified against fresh clones: ream, zeam, gean, lantern and grandine all
"pass" them on the same state-root mismatch because they assert only that some
error occurred (grandine's negative path counts a state-root mismatch as the
expected failure outright). qlean's 2-month-old vendored copy has the identical
shape under the older expectException: AssertionError spelling, so this is not
a regression: sharpening the fixture's claim to a specific reason is what made
it checkable, and therefore visibly unsatisfiable.

Upstream fix would be to emit the entry point per block (skipSlotProcessing,
or entryPoint: state_transition | process_block), or to drop these two vectors
since they pin a Python-level API contract rather than cross-client behaviour.

Testing

make fmt && make lint                       # clean
cargo test --workspace --profile release-fast
# stf_spectests: 72 passed / 2 failed (table above); all other suites pass

Three unit tests cover reason parsing, the unknown-reason fallback and JSON
deserialization.

Negative leanSpec fixtures name *why* their input must be rejected in a
`rejectionReason` field, but the runners only asserted that some error
came back. A fixture could therefore pass on a failure unrelated to the
rule it exercises, which is what four state-transition fixtures were
doing: a late state-root mismatch stood in for the attestation-data cap,
the justification window and the block-slot check.

Mirror leanSpec's `RejectionReason` vocabulary as a typed enum, classify
client errors into it, and compare the two on every expected failure.
An unclassified error and a reason string this build does not know both
fail the fixture: accepting either would restore "any error will do".

Unknown reasons stay deserializable as `Unknown(_)` so the Hive test
driver still answers such a step over HTTP instead of rejecting the
request; only the offline runners treat them as failures.

`AggregateVerificationFailed` covered both an attestation aggregate and a
block's merged proof, which the spec separates into `INVALID_SIGNATURE`
and `INVALID_BLOCK_PROOF`, so block-proof verification now has its own
variant.
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This PR significantly improves the spec-test infrastructure by introducing structured rejection reasons, ensuring tests fail for the correct reasons rather than any error. The implementation is sound and follows Rust best practices.

Summary of Changes

  • Introduces RejectionReason enum with wire-spelling synchronization via macro
  • Adds StepError to distinguish client rejections from harness failures
  • Implements exhaustive error classification for StoreError and StateTransitionError
  • Updates fork-choice, STF, and signature test runners to assert specific rejection reasons

Detailed Review

crates/common/test-fixtures/src/rejection.rs (New)

Line 20-55: The rejection_reasons! macro is well-designed—it prevents drift between enum variants and their JSON wire spellings. The Unknown(String) variant gracefully handles forward compatibility with newer spec fixtures.

Line 180-211: The From<&ethlambda_state_transition::Error> implementation is correctly exhaustive. Note the subtle mapping of AggregationBitsOutOfBounds to ValidatorIndexOutOfRange (line 202-204)—the comment explaining this is valuable.

Line 157-168: The check_rejection_reason helper correctly treats unknown expected reasons as failures, preventing silent acceptance of "any error will do."

crates/blockchain/src/spec_test_runner.rs

Line 62-63: When classifying StateTransitionFailed, the code delegates to the From impl in rejection.rs. This maintains single responsibility for error classification.

Line 67-95: The rejection_reason function exhaustively matches StoreError. The comment at lines 70-76 correctly documents the context-dependent nature of InvalidValidatorIndex (gossip vs. block validation paths).

Line 98-102: Explicitly returning None for internal failures (PubkeyDecodingFailed, SignatureAggregationFailed, etc.) is correct—these have no spec counterpart and should not satisfy fixture assertions.

crates/blockchain/src/store.rs

Line 1061-1062: Adding BlockProofVerificationFailed distinct from AggregateVerificationFailed is important for correct rejection reason classification in tests. The change at line 1176 to use this new variant ensures block proof failures map to InvalidBlockProof rather than InvalidSignature.

crates/blockchain/tests/forkchoice_spectests.rs

Line 64-81: The anchor rejection handling correctly validates that the only expressible anchor failure is AnchorStateRootMismatch, failing loudly if the fixture expects other reasons the runner cannot yet assert.

Line 173-196: The assert_step_outcome function correctly handles the transition from "any failure passes" to "specific reason required." The match arm at line 190 handles legacy fixtures without rejectionReason.

crates/net/rpc/src/test_driver.rs

Line 234: Converting to String here loses structured error information, but this is acceptable for the Hive test driver interface which expects string errors.

Minor Suggestions

  1. crates/blockchain/src/spec_test_runner.rs:58: Consider making rejection_reason a method on StoreError directly (via impl StoreError), rather than a free function, for better discoverability.

  2. crates/common/test-fixtures/src/rejection.rs:215: The unit tests are good. Consider adding a test for the Unknown round-trip case to ensure serialization/deserialization symmetry.

  3. Documentation: The PR description mentions "3SF-mini" fork choice—ensure the rejection reasons cover 3SF-specific failures (e.g., sync committee-related) if applicable in future work.

Consensus Safety

No concerns. This PR only affects test infrastructure and error classification. No changes to:

  • State transition logic
  • Signature verification algorithms
  • Fork choice weight calculations
  • SSZ encoding/decoding

The production code change (new BlockProofVerificationFailed variant) is purely additive for error specificity.

Verdict: Approve. The PR is well-structured, exhaustively handles error variants, and prevents the test suite from accepting false positives.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

Comment on lines 82 to +84
}
(Err(_), None) => {
// Expected failure
// Expected failure. When the fixture names why, the transition must
// have failed for that reason: a state-root mismatch standing in for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 STF fixtures now fail deterministically

When the existing negative fixtures exercise attestation limits, justification ranges, or skipped slot processing, this unconditional reason comparison receives a different transition error and fails four stf_spectests, leaving the required CI target red.

Knowledge Base Used: Blockchain core: fork choice, state transition, block building, sync

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/state_transition/tests/stf_spectests.rs
Line: 82-84

Comment:
**STF fixtures now fail deterministically**

When the existing negative fixtures exercise attestation limits, justification ranges, or skipped slot processing, this unconditional reason comparison receives a different transition error and fails four `stf_spectests`, leaving the required CI target red.

**Knowledge Base Used:** [Blockchain core: fork choice, state transition, block building, sync](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethlambda/-/docs/blockchain-core.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

version.workspace = true

[dependencies]
ethlambda-state-transition.workspace = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Common crate crosses dependency layers

Adding ethlambda-state-transition as a regular dependency couples the common fixture model to a blockchain implementation crate, violating the repository's documented dependency direction and expanding the build surface for every fixture consumer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/test-fixtures/Cargo.toml
Line: 13

Comment:
**Common crate crosses dependency layers**

Adding `ethlambda-state-transition` as a regular dependency couples the common fixture model to a blockchain implementation crate, violating the repository's documented dependency direction and expanding the build surface for every fixture consumer.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds typed rejection-reason assertions across the offline spec runners.

  • Introduces a shared rejection-reason vocabulary and classifiers for store and state-transition errors.
  • Separates block-proof verification failures from attestation aggregate failures.
  • Distinguishes fork-choice harness errors from store rejections.
  • Updates fork-choice, STF, and signature runners to compare expected and actual rejection reasons.

Confidence Score: 4/5

This PR should not merge until the four deterministic STF spec-test failures are resolved or handled without weakening the new reason assertions.

The new comparison correctly exposes rejection mismatches, but it is enabled unconditionally for fixtures the current STF replay path cannot satisfy, leaving a required test target red; the added common-to-blockchain dependency is also an actionable layering regression.

Files Needing Attention: crates/blockchain/state_transition/tests/stf_spectests.rs and crates/common/test-fixtures/Cargo.toml

Important Files Changed

Filename Overview
crates/common/test-fixtures/src/rejection.rs Adds the shared rejection-reason enum, comparison helper, and state-transition error classifier.
crates/blockchain/src/spec_test_runner.rs Adds typed fork-choice step errors and classifies store failures for fixture assertions.
crates/blockchain/state_transition/tests/stf_spectests.rs Enforces rejection-reason equality but deterministically leaves four existing fixtures failing.
crates/common/test-fixtures/Cargo.toml Adds a higher-layer state-transition dependency to a common crate, contrary to the documented dependency direction.
crates/blockchain/src/store.rs Splits block-proof verification failures into a dedicated StoreError variant.
crates/blockchain/tests/forkchoice_spectests.rs Validates negative fork-choice steps against their expected typed rejection reasons.
crates/blockchain/tests/signature_spectests.rs Validates signature-test failures against their expected rejection reasons.
crates/net/rpc/src/test_driver.rs Converts typed StepError values to strings while preserving the existing HTTP response shape.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Fixture["Negative fixture<br/>rejectionReason"] --> Runner["Offline spec runner"]
  Runner --> Operation["Fork choice / STF / signature verification"]
  Operation --> Error["Typed client error"]
  Error --> Classifier["RejectionReason classifier"]
  Fixture --> Compare["check_rejection_reason"]
  Classifier --> Compare
  Compare -->|match| Pass["Fixture passes"]
  Compare -->|mismatch / unknown| Fail["Fixture fails"]
Loading
Prompt To Fix All With AI
### Issue 1
crates/blockchain/state_transition/tests/stf_spectests.rs:82-84
**STF fixtures now fail deterministically**

When the existing negative fixtures exercise attestation limits, justification ranges, or skipped slot processing, this unconditional reason comparison receives a different transition error and fails four `stf_spectests`, leaving the required CI target red.

### Issue 2
crates/common/test-fixtures/Cargo.toml:13
**Common crate crosses dependency layers**

Adding `ethlambda-state-transition` as a regular dependency couples the common fixture model to a blockchain implementation crate, violating the repository's documented dependency direction and expanding the build surface for every fixture consumer.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "test(spec): assert the rejection reason ..." | Re-trigger Greptile

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR #547 — assert rejection reason on expected-failure fixtures

Overall: Solid, well-scoped test-infrastructure change. The core idea — mirroring leanSpec's RejectionReason vocabulary and asserting it instead of "any error will do" — closes a real gap (the four now-failing stf_spectests fixtures prove it). Two things stood out as correctness safeguards done right:

  1. Both classifier functions (spec_test_runner::rejection_reason over StoreError, and RejectionReason::from(&state_transition::Error)) are exhaustive matches with no wildcard arm, so the compiler forces a decision whenever a new error variant is added — exactly the guarantee the doc comments claim. I checked both against the current variant lists in store.rs and state_transition/src/lib.rs and they're complete and correctly mapped.
  2. Splitting AggregateVerificationFailed into AggregateVerificationFailed (attestation aggregate → INVALID_SIGNATURE) and BlockProofVerificationFailed (block merged proof → INVALID_BLOCK_PROOF) is a genuine production bug fix, not just a test change — the two call sites (store.rs:534 and store.rs:1176) were verified to map to the right paths, and no other code exhaustively matches StoreError in a way this would break.

Minor observations (not blocking):

  • crates/common/test-fixtures/src/state_transition.rs (the StateTransitionRunRequest used by the online Hive test-driver path, untouched by this PR) still carries expect_exception: Option<String> — a stringly-typed, unvalidated sibling of the newly-typed rejection_reason used by the offline runners. That's consistent with the PR's stated scope (only the three offline runners), but it means the same concept now has two different representations in the codebase. Worth a tracking note/follow-up if there's appetite to unify, but not a defect in this PR.
  • The four newly-red stf_spectests fixtures are left failing by design (per the PR description) rather than allowlisted, which is the right call for visibility — just flagging that CI will show red on stf_spectests until the underlying STF gaps (attestation-data cap enforcement, justified-slot-range handling) are fixed, so reviewers/CI dashboards should expect that rather than treat it as this PR's own regression.
  • RejectionReason::Unknown(String) correctly stays deserializable (satisfying deny_unknown_fields on all three fixture structs) while still failing offline-runner assertions via check_rejection_reason — this dual behavior (permissive for Hive, strict for spec tests) is a subtle but correctly-implemented distinction, worth keeping in mind if this code is touched again.

No security, memory-safety, or fork-choice/state-transition-logic issues found — this PR only changes how test failures are classified and reported, plus the one legitimate StoreError variant split.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. InvalidValidatorIndex is classified incorrectly for the block-signature path. The shared mapper in spec_test_runner.rs always turns StoreError::InvalidValidatorIndex into VALIDATOR_NOT_IN_STATE, but verify_block_signatures emits that same store error for out-of-range block participants and proposer indices in store.rs. The new verify_signatures runner then consumes that generic mapping in signature_spectests.rs. Result: a fixture expecting VALIDATOR_INDEX_OUT_OF_RANGE will now fail for the wrong reason. This needs either separate store variants or a path-aware classifier.

  2. DECODE_ERROR is added to the fixture vocabulary, but the offline runners still cannot produce it. verify_signatures eagerly converts signedBlock with From<TestSignedBlock> in signature_spectests.rs, and that conversion still panics on bad hex / oversized proof bytes in verify_signatures.rs. Fork-choice aggregated proof decoding also still expects on invalid hex in fork_choice.rs and reports oversized proof blobs as Harness errors in spec_test_runner.rs. So malformed-wire negative fixtures will crash or be treated as harness failures instead of matching DECODE_ERROR.

  3. The backward-compatibility arm in forkchoice_spectests.rs still accepts any Err(_) for valid: false steps that do not name a rejectionReason. That includes StepError::Harness, which this PR introduced specifically to mean “runner cannot replay this fixture step.” In practice, an unsupported or malformed step can still pass as a valid negative fixture, which preserves the same false-positive class this change is supposed to remove. That arm should only accept StepError::Store(_).

Verification

Static review only. I could not run Cargo checks in this environment because dependency resolution needs network access and a writable Cargo home.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

Both are authored with leanSpec's `BlockSpec.skip_slot_processing`, which
makes the filler call `process_block` alone, but the flag never reaches
the emitted fixture: `StateTransitionFixture` carries only pre, blocks,
post, postStateRoot and rejectionReason. Replaying `state_transition()`
as the fixture format prescribes therefore runs `process_slots` first and
dies on the placeholder zero state root instead of the rule under test,
and nothing in the JSON marks the intended entry point.

ream, zeam, gean, lantern and grandine all "pass" these two on that same
state-root mismatch because they assert only that some error occurred;
grandine's negative path even counts a state-root mismatch as the
expected failure outright. Skipping states the limitation instead of
pretending the assertion holds.

The two remaining state-transition failures are real divergences, not
replay artifacts, and stay red.
The macro saved one copy of the 36-entry table at the cost of making the
enum invisible to readers, rustdoc and go-to-definition. Write the enum,
`as_str` and `From<&str>` out directly.

Drift between the two tables cannot pass silently: `as_str` is exhaustive,
so a new variant fails to compile until it has a wire spelling, and a
missing `From` arm lands the reason in `Unknown`, which every runner
reports as a failure naming the string to add.

@pablodeymo pablodeymo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the branch at cb9bd7d and reproduced the results locally in a worktree off the PR head.

Verification

Suite Claimed Measured
forkchoice_spectests 122/122 122 passed
signature_spectests 3/3 3 passed
stf_spectests 72 pass / 2 fail / 2 skip 72 passed, 2 failed, both skips confirmed firing ✅
test-fixtures unit tests 3 3 passed

CI on the PR: Lint pass, Verify License pass, Test fail (the two STF fixtures).

The core design is good. Both classifiers (spec_test_runner::rejection_reason over StoreError, From<&Error> over the STF error) are wildcard-free, so a new error variant is a compile error until someone classifies it; Unknown(_) still deserializes so the Hive driver answers a step instead of 422-ing the request; and splitting BlockProofVerificationFailed out of AggregateVerificationFailed is a genuine fix that also makes production logs say which proof failed. Because negative fixtures ship a zero placeholder stateRoot, any unenforced rule surfaces as STATE_ROOT_MISMATCH — that is what makes the new assertion sharp.

Findings

1. CI is red by design, and the two known-bad classes are handled inconsistently. Two STF fixtures fail; two others are silently skipped. Both groups are "known non-conformance", handled two different ways, and the red one makes make test unusable as a gate. Suggest a single mechanism: an expected-divergence table (fixture → reason the fixture names, reason we actually produce) that fails if the mismatch changes or disappears. That keeps the divergence visible, stops it rotting, and self-clears when it's fixed. Plus tracking issues for both.

2. InvalidValidatorIndex is knowingly misclassified for the block path (crates/blockchain/src/spec_test_runner.rs:69). It maps unconditionally to VALIDATOR_NOT_IN_STATE, but verify_block_signatures raises the same variant for out-of-range attestation participants (store.rs:1122, store.rs:1147 → spec VALIDATOR_INDEX_OUT_OF_RANGE) and for an out-of-range proposer_index (store.rs:1127, store.rs:1160PROPOSER_INDEX_OUT_OF_RANGE). Consequence: both of those reasons are unreachable from the store classifier, so the moment leanSpec adds a verify_signatures fixture for either, it fails for the wrong reason. The doc comment acknowledges the ambiguity, but the fix is the same shape this PR already applies to the block proof — split the variant. Worth doing here, since "one error ⇒ one reason" is the PR's thesis.

3. A harness error can satisfy an expected rejection. In crates/blockchain/tests/forkchoice_spectests.rs, the arm (Err(_), false) if expected_reason.is_none() => Ok(()) accepts a StepError::Harness — e.g. "unknown step type: X" on a valid: false step passes. That contradicts StepError's documented invariant that a harness failure "never satisfies an expected rejectionReason". I checked the pinned fixtures: all 34 invalid fork-choice steps carry a reason, so the arm is unreachable today — either drop it (strictly stronger) or match Harness first and always fail.

Nits

  • crates/common/test-fixtures/src/verify_signatures.rs:50 keeps alias = "expectException" but now parses it as a RejectionReason. A legacy value like "AssertionError" becomes Unknown(_) and fails with "add it to RejectionReason and classify the error that must produce it" — misleading advice for a field that never carried a spec reason. Either drop the alias or keep the old field as presence-only.
  • Commit 3 inlined the macro, so as_str and From<&str> are now two parallel 37-arm tables. Drift fails loudly (Unknown) rather than silently, but a const ALL: &[RejectionReason] round-trip test pins it cheaply; the current tests cover one variant per direction.
  • crates/common/test-fixtures/src/state_transition.rs:22 still spells the field expectException as Option<String>. That's fine — the Hive driver only checks is_some() — but it leaves three spellings in-tree; one line of doc would settle why.
  • SKIP_TESTS matches by substring with no upstream link. Linking the leanSpec issue would give the skip an expiry path. Alternative worth considering instead of skipping: when a negative fixture's state_transition() yields STATE_ROOT_MISMATCH against a different expected reason, retry with process_block alone. test_block_at_parent_slot_rejected_when_slot_processing_skipped carries two blocks at slot 1 with pre-state slot 1, so that path would actually reach BLOCK_OLDER_THAN_LATEST_HEADER — recovering the coverage rather than dropping it.

The StoreError classifier living in blockchain/src/ while the STF one is a From impl in test-fixtures is forced by the dependency direction (blockchain → test-fixtures), not sloppiness — no action needed.

MegaRedHand added a commit that referenced this pull request Aug 3, 2026
…ion (#555)

## What

Enforce the per-block cap on distinct `AttestationData` inside
`process_attestations`, where leanSpec has it, in addition to the
existing check at the import boundary in `on_block`.

## Why

leanSpec puts the bound in the transition
(`state_transition.process_attestations`) and `fork_choice.on_block`
defers to it explicitly:

> The transition itself bounds the distinct-data count. Only the
wire-level duplicate prohibition lives here.

We only had it in `on_block` (`store.rs`), so `state_transition()`
accepted an over-cap block and then failed on the state root instead.
Two callers reach the transition without passing through `on_block`:

| caller | before | after |
|---|---|---|
| `build_block` -> `process_block` | unbounded (the proposer-side clamp
is the only guard) | fails loudly instead of publishing an unimportable
block |
| spec-fixture replay / Hive `state_transition/run` | over-cap block
accepted, then `STATE_ROOT_MISMATCH` | rejected with the cap error |

The check goes first in `process_attestations`, ahead of the
justification-bookkeeping guards, matching the spec's order when a block
violates two rules at once.

## The duplicate check in `on_block` stays

Deliberately, for now: it runs before `verify_block_signatures`, so an
over-cap block is still rejected without paying for proof verification.
Whether we collapse the two sites into one is a follow-up decision.

## Testing

- `cargo test --workspace --profile release-fast`: green, no new tests
added here.
- The behavior is covered by the existing fixture
`test_block_exceeding_distinct_attestation_data_cap_rejects_block`,
which asserts failure but not yet the reason. Cross-checked against
#547, which adds the reason assertion to the fixture runners: with both
branches applied, that fixture passes for the right reason
(`TOO_MANY_ATTESTATION_DATA`, previously `STATE_ROOT_MISMATCH`).

Whichever of the two lands first, the other's exhaustive `From<&Error>
for RejectionReason` match makes the missing mapping arm a compile
error, so the reason cannot be silently dropped.

(leanSpec #536)
Merging main brought two state-transition error variants the reason
classifier does not know, and its match is exhaustive on purpose, so the
merge itself does not build: `JustifiedSlotOutOfRange` (#549) and
`TooManyAttestationData` (#555). Both have a spec counterpart, so they map
straight through.

That also closes the two divergences this branch documented as failing:
the fixtures expecting JUSTIFIED_SLOT_OUT_OF_RANGE and
TOO_MANY_ATTESTATION_DATA now fail for exactly those reasons.

Pin the two parallel spelling tables while here, with `RejectionReason::ALL`
and a round-trip test over it: an arm present on only one side, or a
spelling claimed by two variants, now fails in the crate's own tests
instead of silently making a fixture unmatchable.
One `StoreError` stood for three distinct spec rejections:
VALIDATOR_NOT_IN_STATE for a gossiped vote naming a validator the target
state never knew (`fork_choice.py`), and VALIDATOR_INDEX_OUT_OF_RANGE or
PROPOSER_INDEX_OUT_OF_RANGE for the block-proof bounds checks
(`signatures.py`). The classifier had to pick one, so the other two reasons
were unreachable from a store rejection and a fixture naming either would
have failed for the wrong reason. leanSpec already carries such vectors
(`verify_signatures/test_index_out_of_range.py`,
`test_proposer_index_bounds.py`); they are only absent from the released
bundle.

Split the variant three ways, mirroring the spec's own split, and carry the
offending index and registry size so production logs name them too.
`StepError::Harness` means the runner never replayed the step at all, yet
the arm accepting a reasonless rejection took any error for a
`valid: false` step, so an unknown step type could satisfy an expected
rejection. Reject harness failures before matching on what the fixture
expected, which is the invariant `StepError`'s own docs already claimed.

Unreachable with the pinned fixtures, where all 34 invalid fork-choice
steps name a reason, so this guards the next fixture bump rather than
fixing a live hole.
Two STF fixtures are authored with leanSpec's
`BlockSpec.skip_slot_processing` (PR #161), which makes the filler call
`process_block` alone and write the failing block with a placeholder zero
`stateRoot`. That entry point never reaches the emitted fixture, so
replaying `state_transition()` runs `process_slots` first and the run dies
on the state root, or on the slot check, before reaching the rule under
test. Both were skipped for that reason, asserting nothing.

Supply the entry point instead, since it is the one piece of information
the JSON omits: `process_block` alone does enforce both rules and reports
exactly the reason each fixture names, BLOCK_SLOT_MISMATCH and
BLOCK_OLDER_THAN_LATEST_HEADER. Scoped to the two names rather than a
general "retry under another entry point" fallback, which would let any
negative fixture pass on whichever path happened to produce the expected
reason.

A listed fixture that carries a `post` is an error, because skipping slot
processing also skips the post-state root check.
`expectException` is gone from leanSpec: zero occurrences in the spec repo
and in all 561 fixtures of the latest bundle, which name `rejectionReason`
instead. Drop the compatibility alias rather than let a Python exception
name ("AssertionError") parse as a spec reason and ask for that name to be
added to `RejectionReason`, and rename the Hive driver's field, which still
declared the retired spelling as its primary name with `rejectionReason`
merely aliased onto it.

Document the rule at the crate root: these types mirror the format the
released bundle carries and nothing older, so a retired field is removed
rather than aliased and a pinned old bundle fails loudly instead of
half-parsing. `RejectionReason::Unknown` stays the deliberate exception,
since the Hive driver must keep answering a step when leanSpec adds a
reason name.
@MegaRedHand
MegaRedHand merged commit 4e76291 into main Aug 3, 2026
3 checks passed
@MegaRedHand
MegaRedHand deleted the test/assert-rejection-reason branch August 3, 2026 21:46
MegaRedHand added a commit that referenced this pull request Aug 4, 2026
The test helpers main brought in (#547, #554) build validators with
hardcoded 52-byte pubkeys, which no longer compile on this branch:
tracking leanVM main shrinks XMSS pubkeys to 32 bytes. Reference the
constant so the size follows the type.
MegaRedHand added a commit that referenced this pull request Aug 4, 2026
…proof

The proposer signature moved out of the attestation aggregate but stayed
inside `BlockProof`, so a failure there is still the spec's
INVALID_BLOCK_PROOF rejection, not INVALID_SIGNATURE. Reusing the
attestation-signature variants classified it as the latter, which the
`test_corrupt_proof_rejected` and
`test_proof_reused_under_different_message_rejected` fixtures reject now
that the runner asserts the reason (#547).

Separate variants keep the gossip attestation path on
INVALID_SIGNATURE.
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