test: verify real signatures in fork-choice spec tests#304
test: verify real signatures in fork-choice spec tests#304MegaRedHand wants to merge 1 commit intopr-fixture-schemafrom
Conversation
🤖 Kimi Code ReviewOverall Assessment: Correct refactoring that removes test-only bypass helpers in favor of real verification paths. The consensus logic is preserved, but ensure the implicit bounds check via pubkey collection is intentional. 1. Security/Correctness: Validator Bounds Check LocationFile: The bounds check for validator indices has moved from an explicit .map(|vid| {
target_state
.validators
.get(vid as usize)
.map(|v| v.pubkey.clone())
.ok_or(StoreError::InvalidValidatorIndex)
})This correctly rejects out-of-bounds indices with Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Claude Code ReviewNow I have enough context to write the full review. PR #304 —
|
🤖 Codex Code Review
I couldn’t run the test suite here: Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR removes the Confidence Score: 5/5Safe to merge; all remaining findings are P2 style and robustness suggestions that do not affect correctness. No P0 or P1 issues found. The production code changes are mechanical (flag removal, function deletion, inlining), and the test harness now exercises real cryptographic paths. All three concerns flagged are P2: panics instead of structured errors in test code, silently ignored crates/blockchain/tests/forkchoice_spectests.rs — minor harness robustness issues (.expect() panics and is_aggregator ignored for tick steps).
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Removed verify: bool flag from on_block_core; on_block is now a thin verifying wrapper; on_block_without_verification and on_gossip_attestation_without_verification deleted; on_gossip_aggregated_attestation inlined. Minor: on_block fetches parent state twice (once for verification, once inside on_block_core for the transition). |
| crates/blockchain/tests/forkchoice_spectests.rs | Harness now calls real on_gossip_attestation and on_gossip_aggregated_attestation with fixture signatures. Block steps keep using on_block_core (no block signatures in fixtures). Two style issues: .expect() panics instead of datatest_stable::Result errors for missing optional fields; step.is_aggregator silently ignored for tick steps. |
| crates/blockchain/tests/types.rs | Adds deser_opt_xmss_hex wrapper for Option<XmssSignature> deserialization; AttestationStepData.signature now deserialises to the real XMSS bytes; imports deser_xmss_hex from the shared common module. |
| crates/blockchain/tests/signature_types.rs | Drops the local deser_xmss_hex definition; now imports it from super::common (the shared test-fixtures crate). Clean de-duplication. |
| crates/common/test-fixtures/src/lib.rs | Hoists deser_xmss_hex (previously in signature_types.rs) into this shared crate so both forkchoice_spectests and signature_spectests can reuse it; adds XmssSignature and SIGNATURE_SIZE imports. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming block via gossip] --> B[on_block]
B --> C{Duplicate?}
C -- yes --> D[Return Ok early]
C -- no --> E[Fetch parent_state]
E --> F[verify_signatures]
F -- fail --> G[Return Err]
F -- ok --> H[on_block_core]
I[Test harness block step] --> H
H --> J{Duplicate?}
J -- yes --> D2[Return Ok early]
J -- no --> K[Fetch parent_state again]
K --> L[state_transition]
L --> M[Store block + state]
M --> N[update_head fork choice]
P[Incoming attestation via gossip] --> Q[on_gossip_attestation]
Q --> R[validate_attestation_data]
R --> S[verify XMSS signature]
S -- invalid --> T[Return Err]
S -- valid --> U{is_aggregator?}
U -- yes --> V[insert_gossip_signature]
U -- no --> W[Drop - propagation only]
X[Incoming aggregated attestation] --> Y[on_gossip_aggregated_attestation]
Y --> Z[validate_attestation_data]
Z --> AA[verify_aggregated_signature]
AA -- fail --> AB[Return Err]
AA -- ok --> AC[insert_new_aggregated_payload]
style I fill:#f0ad4e,color:#000
style H fill:#5bc0de,color:#000
style B fill:#5cb85c,color:#000
Comments Outside Diff (2)
-
crates/blockchain/tests/forkchoice_spectests.rs, line 109-110 (link)is_aggregatorfrom tick steps silently droppedstore::on_tickis always called withfalseas theis_aggregatorargument forticksteps. Thestep.is_aggregatorfield parsed from the fixture is used only forattestationsteps. If a fixture ever specifies"isAggregator": trueon atickstep to drive aggregation at interval 2, the field would be silently ignored andaggregate_committee_signatureswould never run during that tick, causing the test to produce unexpected state without any warning.Consider using
step.is_aggregator.unwrap_or(false)here to respect the fixture's intent:Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/tests/forkchoice_spectests.rs Line: 109-110 Comment: **`is_aggregator` from tick steps silently dropped** `store::on_tick` is always called with `false` as the `is_aggregator` argument for `tick` steps. The `step.is_aggregator` field parsed from the fixture is used only for `attestation` steps. If a fixture ever specifies `"isAggregator": true` on a `tick` step to drive aggregation at interval 2, the field would be silently ignored and `aggregate_committee_signatures` would never run during that tick, causing the test to produce unexpected state without any warning. Consider using `step.is_aggregator.unwrap_or(false)` here to respect the fixture's intent: How can I resolve this? If you propose a fix, please make it concise.
-
crates/blockchain/src/store.rs, line 679-699 (link)Double parent-state lookup between
on_blockandon_block_coreon_blockfetchesparent_statefrom storage (line 688–695) to pass toverify_signatures, thenon_block_corefetches it again (lines 721–728) for the state transition. Storage reads are cheap for the in-memory backend used in tests, but on RocksDB each call deserializes the full state (which can be large). Sinceon_blockalready owns the state after the lookup, passing it through toon_block_core(or caching it) would avoid the duplicate deserialisation in the production path.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/src/store.rs Line: 679-699 Comment: **Double parent-state lookup between `on_block` and `on_block_core`** `on_block` fetches `parent_state` from storage (line 688–695) to pass to `verify_signatures`, then `on_block_core` fetches it again (lines 721–728) for the state transition. Storage reads are cheap for the in-memory backend used in tests, but on RocksDB each call deserializes the full state (which can be large). Since `on_block` already owns the state after the lookup, passing it through to `on_block_core` (or caching it) would avoid the duplicate deserialisation in the production path. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/tests/forkchoice_spectests.rs
Line: 113-123
Comment:
**`.expect()` panics instead of structured test failures**
The `.expect("attestation step missing signature")` call on line 122 will panic rather than produce a clean `datatest_stable::Result` error if a fixture's `attestation` step is missing the `signature` field (which defaults to `None` via `#[serde(default)]`). A panic in a `datatest_stable` harness shows an unhelpful backtrace rather than a message like `"Step N: attestation step missing signature"`. Similarly, `validator_id.expect(...)` on line 118 has the same risk for fixtures with `valid: false` that omit the field.
Consider replacing these with `ok_or_else(|| "...".into())?` to keep failures within `datatest_stable::Result`:
```suggestion
"attestation" => {
let att_data = step
.attestation
.expect("attestation step missing attestation data");
let signed_attestation = SignedAttestation {
validator_id: att_data
.validator_id
.ok_or_else(|| format!("Step {step_idx}: attestation step missing validator_id"))?,
data: att_data.data.into(),
signature: att_data
.signature
.ok_or_else(|| format!("Step {step_idx}: attestation step missing signature"))?,
};
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/blockchain/tests/forkchoice_spectests.rs
Line: 109-110
Comment:
**`is_aggregator` from tick steps silently dropped**
`store::on_tick` is always called with `false` as the `is_aggregator` argument for `tick` steps. The `step.is_aggregator` field parsed from the fixture is used only for `attestation` steps. If a fixture ever specifies `"isAggregator": true` on a `tick` step to drive aggregation at interval 2, the field would be silently ignored and `aggregate_committee_signatures` would never run during that tick, causing the test to produce unexpected state without any warning.
Consider using `step.is_aggregator.unwrap_or(false)` here to respect the fixture's intent:
```suggestion
store::on_tick(&mut store, timestamp_ms, has_proposal, step.is_aggregator.unwrap_or(false));
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 679-699
Comment:
**Double parent-state lookup between `on_block` and `on_block_core`**
`on_block` fetches `parent_state` from storage (line 688–695) to pass to `verify_signatures`, then `on_block_core` fetches it again (lines 721–728) for the state transition. Storage reads are cheap for the in-memory backend used in tests, but on RocksDB each call deserializes the full state (which can be large). Since `on_block` already owns the state after the lookup, passing it through to `on_block_core` (or caching it) would avoid the duplicate deserialisation in the production path.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "test: exercise real signature verificati..." | Re-trigger Greptile
ae8ca9c to
263a0b2
Compare
07717e1 to
b91193e
Compare
b91193e to
94b1b82
Compare
Swap the fork-choice harness's `_without_verification` shortcuts for real signature verification where it makes sense, and collapse the aggregated-attestation paths back into a single verifying entry point. Changes: - `store.rs`: delete `on_gossip_attestation_without_verification`; the test harness now uses the real verifying `on_gossip_attestation`. `on_block` / `on_block_without_verification` / `on_block_core(verify)` stay as they are — fork-choice fixtures don't ship block signatures, so the test harness still calls `on_block_without_verification`. - `test-fixtures` crate: hoist `deser_xmss_hex` out of `signature_types.rs` so both blockchain test binaries can share it. - `forkchoice_spectests.rs`: `attestation` steps decode the fixture's real XMSS signature and call `on_gossip_attestation(&signed, is_aggregator)`. `gossipAggregatedAttestation` steps now carry the real ~180 KB aggregated proof bytes through to `on_gossip_aggregated_attestation`. Extract `assert_step_outcome` helper to replace three duplicated match blocks. `AttestationStepData::signature` deserializes straight to `Option<XmssSignature>` via a new `deser_opt_xmss_hex`. - `SKIP_TESTS`: replace the previous placeholder skips with the 13 fixtures whose aggregated XMSS proofs are rejected because our verifier pins `leanEthereum/leanMultisig@2eb4b9d` while the fixtures are generated by `anshalshukla/leanMultisig@devnet-4`. The two forks disagree on the Fiat-Shamir `public_input` encoding and the aggregation bytecode program — every proof rejects with `InvalidProof`. Resolving that fork mismatch is a separate, devnet-wide coordination issue. Previously-skipped fixtures that now pass with real verification: - `test_gossip_attestation_with_invalid_signature` (correctly rejected) - `test_equivocating_proposer_with_split_attestations`
263a0b2 to
238daf9
Compare
94b1b82 to
80598d5
Compare
Summary
Swap the fork-choice harness's
_without_verificationshortcuts for real signature verification where it makes sense, and collapse the aggregated-attestation paths back into a single verifying entry point.store.rson_gossip_attestation_without_verification— the test harness now uses the real verifyingon_gossip_attestation.on_block/on_block_without_verification/on_block_core(verify)are unchanged. Fork-choice fixtures don't ship block signatures, so the test harness still callson_block_without_verification.forkchoice_spectests.rsattestationsteps now decode the fixture's real XMSS signature and callon_gossip_attestation(&signed, is_aggregator).gossipAggregatedAttestationsteps now carry the real ~180 KB aggregated proof bytes through toon_gossip_aggregated_attestation.assert_step_outcome(step_idx, expected_valid, result)to replace three near-duplicate match blocks.test-fixturescratedeser_xmss_hexout ofsignature_types.rsinto the sharedethlambda-test-fixturescrate so both test binaries can use it.AttestationStepData::signaturedeserializes straight toOption<XmssSignature>via a newdeser_opt_xmss_hexwrapper.SKIP_TESTSrewriteReplaces the previous placeholder skip list with 13 fixtures whose aggregated XMSS proofs are rejected with
AggregateVerificationFailed(ProofError(InvalidProof)). Root cause: our verifier pinsleanEthereum/leanMultisig@2eb4b9dwhile the fixtures are generated byanshalshukla/leanMultisig@devnet-4. The two forks disagree on:public_input(full vec vs a single 8-field digest).rec_aggregationbytecode program itself (different hash).Either mismatch alone invalidates every proof. Unblocking those 13 tests requires a coordinated devnet decision: repin to
anshalshukla/leanMultisig@devnet-4(matches zeam/ream/grandine/lantern), or upgrade the whole devnet toleanEthereum/leanMultisigmain and regenerate fixtures.Un-skipped now that we verify signatures
test_gossip_attestation_with_invalid_signature— real signature verification correctly rejects the deliberately-bad fixture signature.test_equivocating_proposer_with_split_attestations— real verification accepts the valid attestation path.Stack
Stacked on #303. Merge after that.
Test plan
cargo test --workspace --releasepasses (314 passed, 6 ignored)signature_spectestsstill passes — hoisteddeser_xmss_hexmatches the previous local definition