test(spec): harden fixture parsing and assert new fork-choice checks (leanSpec #1189)#511
Conversation
…ranch (leanSpec #1179) leanSpec PR #1179 (commit 9033157c7) added a HEAD_NOT_DESCENDANT_OF_FINALIZED rejection rule to attestation validation. Fork choice only ever descends from the latest finalized block, so a vote whose head sits on a branch orphaned by finalization carries no fork-choice weight. Without this rule a vote that finalization pruning has already dropped can be re-admitted when the same aggregate is re-gossiped, instead of being rejected. ethlambda's shared attestation validation (validate_attestation_data, used by both on_gossip_attestation and on_gossip_aggregated_attestation_core) ran availability, topology, consistency, ancestry, and timing checks but never verified the head descends from the finalized block. Add that check mirroring the spec: after the target-ancestor-of-head check, reuse the existing checkpoint_is_ancestor helper against store.latest_finalized(). This is sound because the store re-derives the finalized checkpoint from the head on each update, so it is always an ancestor of the head. Fixes the failing fork-choice spec test test_re_gossip_of_pruned_orphaned_vote_is_rejected.
…(leanSpec #1189) The spec-test fixture types silently dropped any JSON field they did not model, so when leanSpec adds a fixture check the client keeps passing while verifying nothing new. That masked drift is what let PR #1189's `canonicalEquivocationHeadAmong` (and a dozen other fields already shipped) land in the 07-11 fixtures without ethlambda asserting any of them. Add `#[serde(deny_unknown_fields)]` to every fixture-facing struct so future schema drift fails loudly at parse time instead of passing unnoticed, and model every field the current fixtures carry (previously-dropped ones include `_info.keySetDigest`, top-level `proofSetting`/`rejectionReason`/`postStateRoot`, per-step `storeSnapshot`/`rejectionReason`, `attestationChecks[].sourceRootLabel`, and the whole batch of new `StoreChecks` fields). The fixture types are a production dependency of the RPC Hive test-driver, so this also hardens the binary's parsing of simulator-delivered JSON; the driver returns a store snapshot and applies no `StoreChecks` itself, so no driver logic changes. Assert the checks that map onto the offline runner's store view: - `canonicalEquivocationHeadAmong` (leanSpec #1189): the head must sit on the fork whose targeting attestation in the accepted pool carries the largest `hash_tree_root`. Scheme-independent, mirroring `lexicographicHeadAmong`. - `latestKnownAggregatedTargetSlots`, `attestationTargetRootLabel`, `attestationChecks[].sourceRootLabel`, `labelsInStore`, `reorgDepth`, `blockAttestations`, `blockAttestationCount`. - Seed the block registry with the anchor under the label "genesis", matching leanSpec's harness, so label-based checks resolve. `latestNewAggregatedTargetSlots`, `attestationSignatureTargetSlots`, `newPoolProofParticipants`, `storeSnapshot` and `filledBlockRootLabel` are parsed but not asserted (each has a TODO): the offline runner advances ticks without the aggregator role and folds block-borne votes straight into the known pool, so the pending/raw-signature pools and built-block identity do not track leanSpec's harness. The accepted (known) pool, which the runner does populate, is asserted. Stacked on #510 (the HEAD_NOT_DESCENDANT_OF_FINALIZED fix); retarget to main once that merges.
Both helpers had exactly one call site: the latestKnownAggregatedTargetSlots check. Fold the sorted-unique collect and the sort/dedup comparison into that site and drop the two functions; the logic is unchanged.
PR #500 widened Store::head() and Store::get_live_chain() to return Result. The call sites this branch added to forkchoice_spectests still consumed them as bare values, breaking compilation after the main merge. Propagate the storage error with `?` at those five sites so the spec test builds and runs again.
🤖 Kimi Code ReviewThe PR is a high-quality update to the consensus test harness that improves schema strictness and adds comprehensive fork-choice validation. No critical issues found. Correctness & Consensus Safety
Rust Best Practices
Minor Suggestions
Security Notes
Summary The code is idiomatic, well-documented, and correctly implements the consensus validation logic. The addition of Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
No production-path security issues stand out in this PR; the risk is mostly incorrect spec-test assertions reducing confidence in fork-choice coverage. I couldn’t run the affected cargo tests here because the environment blocks writes to the default Rust/Cargo cache locations under Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR hardens fixture parsing and adds new fork-choice fixture checks.
Confidence Score: 4/5The fork-choice test runner has a contained matching bug in the new block-attestation check.
crates/blockchain/tests/forkchoice_spectests.rs
|
| Filename | Overview |
|---|---|
| crates/blockchain/state_transition/tests/types.rs | Adds strict parsing and optional metadata fields for state-transition fixtures. |
| crates/common/test-fixtures/src/common.rs | Adds strict parsing to shared fixture structs and models _info.keySetDigest. |
| crates/common/test-fixtures/src/fork_choice.rs | Adds strict fork-choice fixture schemas and new store/check fields. |
| crates/common/test-fixtures/src/verify_signatures.rs | Adds strict parsing and models proofSetting for verify-signatures fixtures. |
| crates/blockchain/tests/forkchoice_spectests.rs | Adds new fork-choice assertions; block-attestation matching can reuse one actual aggregate for repeated participant sets. |
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
crates/blockchain/tests/forkchoice_spectests.rs:610-612
**Repeated Aggregate Match Reuse**
When two expected `blockAttestations` entries have the same participant set, this lookup can match both expectations against the first actual aggregate. A fixture that is trying to check distinct aggregates for the same participants can then validate the wrong slot/target data or let one aggregate satisfy multiple expected entries.
Reviews (1): Last reviewed commit: "test(forkchoice): propagate Result from ..." | Re-trigger Greptile
🤖 Claude Code ReviewReview: PR 511 — harden fixture parsing, assert new fork-choice checksThis PR is test/fixture-only in terms of source it touches, but one of those "test" crates is a stated production dependency of the RPC Hive test-driver, which is where the one substantive issue lives. 1.
|
…nStore The reorgDepth and labelsInStore checks read Store::get_live_chain(), whose LiveChain index drops entries below the finalized slot. A reorg that also advances finalization can prune the abandoned branch from that index, so ancestor_set() would stop early and undercount the depth (and labelsInStore could spuriously fail on a finalized-but-still-labeled block). leanSpec's harness walks its never-pruned store.blocks for exactly this reason. Accumulate a cumulative (root -> (slot, parent_root)) block tree in the runner, seeded from the anchor's live chain and extended with each step's snapshot, and have both checks walk that instead of re-reading the pruned index. Current fixtures never finalize far enough to prune, so behavior is unchanged (122 fork-choice fixtures still pass); the runner is now robust to future fixtures whose reorg crosses the finalized boundary.
Why
The spec-test fixture types silently dropped any JSON field they did not model. When leanSpec adds a new fixture check, ethlambda kept passing while verifying nothing new. That masked drift is exactly how PR #1189's
canonicalEquivocationHeadAmong(and a dozen other already-shipped fields) landed in the 07-11 fixtures without ethlambda asserting any of them.What
1.
deny_unknown_fieldshardening (fail loudly on drift)Added
#[serde(deny_unknown_fields)]to every fixture-facing struct and modeled every field the current (07-11) fixtures actually carry, so a future upstream schema addition fails at parse time instead of being silently ignored.Structs that got
deny_unknown_fields:common.rs:Container<T>,Config,Checkpoint,BlockHeader,Validator,TestState,Block,BlockBody,AggregatedAttestation,AggregationBits,AttestationData,TestInfo.fork_choice.rs:ForkChoiceTest,ForkChoiceStep,AttestationStepData,ProofStepData,HexByteList,BlockStepData,StoreChecks,AttestationCheck, plus newBlockAttestationCheck,StoreSnapshot,BlockWeightEntry,AggregatedPayloadEntry,AttestationSignatureEntry.verify_signatures.rs:VerifySignaturesTest,TestSignedBlock,MergedProof,HexBytes.state_transition/tests/types.rs:StateTransitionTest(PostStatealready had it).The flatten wrapper vectors (
ForkChoiceTestVector, etc.) are intentionally left without the attribute (serde forbidsdeny_unknown_fields+flatten). The Hive driver's own request wrappers (StateTransitionRunRequest/InitForkChoiceRequest/VerifySignaturesRequest) are also left without it, so a stray top-level field (e.g._info) is still tolerated. Note, however, that these wrappers embed the shared fixture types (TestState,Block,TestSignedBlock,ForkChoiceStep, …), which this PR does harden: any unknown field nested insideanchorState/anchorBlock/pre/blocks/signedBlock(or a fork-choice step body) now fails deserialization at the Axum handler rather than being silently dropped. This is intentional: silently dropping a replay-relevant field would make the driver report misleading results, so failing loudly on simulator-side schema drift is the safer boundary for a conformance driver.Previously-dropped fields now modeled include:
_info.keySetDigest, top-levelproofSetting/rejectionReason/postStateRoot, per-stepstoreSnapshot/rejectionReason,attestationChecks[].sourceRootLabel, and the full newStoreChecksbatch.The fixture crate is a production dependency of the RPC Hive test-driver, so this also hardens the binary's parsing of simulator-delivered JSON. The driver returns a store snapshot and applies no
StoreChecksitself (the leanSpec simulator does the comparisons), so no driver logic changed.2. Newly asserted checks in the fork-choice runner
canonicalEquivocationHeadAmonghash_tree_root. Scheme-independent; mirrorslexicographicHeadAmong. Covers the 3 equivocation fixtures.latestKnownAggregatedTargetSlotsattestationTargetRootLabelattestationChecks[].sourceRootLabellabelsInStorereorgDepthancestors(old_head) \ ancestors(new_head).blockAttestations/blockAttestationCountAlso seeds the block registry with the anchor under the label
"genesis", matching leanSpec's harness, so label-based checks resolve (true even for checkpoint-sync anchors past slot 0).3. Parsed but not asserted (each carries a
TODO)latestNewAggregatedTargetSlots,attestationSignatureTargetSlots,newPoolProofParticipants,storeSnapshot,filledBlockRootLabel.These read the pending (new) aggregated pool, the raw gossip-signature pool, or the identity of a freshly-built block. The offline runner advances ticks without the aggregator role and folds block-borne votes straight into the known pool, so those pools/built-block do not track leanSpec's harness. The accepted (known) pool — which the runner does populate — is asserted.
storeSnapshotis a whole-store snapshot (692 steps) that would need substantial new Store plumbing to compare.Verification
cargo test --release -p ethlambda-blockchain --test forkchoice_spectests→ 122 passedcargo test --release -p ethlambda-state-transition --test stf_spectests→ 74 passedcargo test --release -p ethlambda-blockchain --test signature_spectests→ 3 passedmake test→ fully greenmake fmt/make lint(clippy-D warnings) clean; whole workspace (incl. Hive driver binary) builds.canonicalEquivocationHeadAmongassertion executes and is meaningful: inverting the winner selection (max→min) fails exactly the 3 equivocation fixtures; poisoning the computedreorgDepth/blockAttestationCountactuals fails exactly the fixtures carrying those checks.