Skip to content

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

Open
MegaRedHand wants to merge 3 commits into
mainfrom
test/assert-rejection-reason
Open

test(spec): assert the rejection reason on expected-failure fixtures#547
MegaRedHand wants to merge 3 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.
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.

1 participant