Skip to content

Devnet4: dual-key validators, block signing, and type migration#233

Open
pablodeymo wants to merge 22 commits intodevnet4from
devnet4-phase4-network
Open

Devnet4: dual-key validators, block signing, and type migration#233
pablodeymo wants to merge 22 commits intodevnet4from
devnet4-phase4-network

Conversation

@pablodeymo
Copy link
Copy Markdown
Collaborator

@pablodeymo pablodeymo commented Mar 16, 2026

Motivation

Implements devnet4 (leanSpec#449): separate attestation and proposal keys for validators, replacing the single-key model and removing the proposer attestation wrapper.

Description

Phase 1: Types (#230)

  • Validator gains attestation_pubkey + proposal_pubkey (replaces single pubkey)
  • SignedBlockWithAttestationSignedBlock, BlockWithAttestation deleted
  • Genesis config updated to dual-key YAML format

Phase 2: Key manager + block proposal (#231)

  • ValidatorKeyPair with separate attestation/proposal secret keys
  • Block proposal signs hash_tree_root(block) with proposal key (no proposer attestation)
  • All validators now attest at interval 1 (proposer no longer skipped)

Phase 3: Store + verification (#232)

  • Signature verification uses get_attestation_pubkey() / get_proposal_pubkey() as appropriate
  • Removed ~40 lines of proposer attestation handling from on_block_core
  • Storage reads/writes SignedBlock directly

Phase 4: Network + tests (#233)

  • Type rename cascade across all network layer files
  • Test harness updated: removed ProposerAttestation, simplified build_signed_block()
  • Added proposal signing metrics
  • leanSpec bumped to 9c30436 (dual-key test fixtures)
  • Skipped test_reorg_with_slot_gaps (fixture relies on gossip proposer attestation state)

Supersedes

Closes #230, closes #231, closes #232

Blockers

  • lean-quickstart: needs devnet4 genesis config support for manual devnet testing

@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

Review Summary

This PR introduces dual XMSS key support for validators (separate attestation and proposal keys) and removes the proposer attestation wrapper from blocks. The changes are extensive but generally well-structured. Below are the key findings:


Correctness & Consensus Safety

  • Validator key separation logic is sound: The split into attestation_pubkey and proposal_pubkey prevents OTS reuse when signing both blocks and attestations in the same slot.
  • Checkpoint sync verification updated correctly: verify_checkpoint_state now checks both keys (line 147 in checkpoint_sync.rs).
  • Genesis config parsing updated: The new GenesisValidatorEntry struct properly handles dual keys.

⚠️ Potential Issues

1. Metrics Mislabeling (crates/blockchain/src/key_manager.rs:82)

  • Line 82: time_pq_sig_attestation_signing() is used for block signing with the proposal key. This should use a separate metric (e.g., time_pq_sig_block_signing()).

2. Missing Proposal Key Verification in Tests

  • Fork choice tests (forkchoice_spectests.rs) and signature tests (signature_spectests.rs) use Default::default() for proposer signatures. These should ideally verify the proposal key signature, even in test setups.

3. Checkpoint Sync Test Data

  • **Line 249 in checkpoint_sync.rs**: The test validator generation uses proposal_pubkey: [i as u8 + 101; 52], which could overflow u8for largei. Use ((i + 101) as u8)` or a safer pattern.

4. Error Handling in read_validator_keys

  • Line 287-291: The load_key closure calls std::process::exit(1) on failure. While acceptable for a binary, this is harsh for a library function. Consider propagating errors instead.

🔒 Security

  • No direct security vulnerabilities introduced. The dual-key design improves security by preventing OTS reuse.
  • Key file paths are resolved correctly (absolute vs. relative).

🧹 Code Quality & Maintainability

  • Redundant types removed: BlockWithAttestation, BlockSignaturesWithAttestation, and ProposerAttestation are correctly eliminated.
  • Consistent naming: SignedBlockWithAttestationSignedBlock is a clean rename.
  • SSZ compatibility: The Validator struct’s new layout is SSZ-compatible (fields are fixed-size).

📌 Minor Suggestions

  • Line 230 in main.rs: The AnnotatedValidator struct has unused fields (_attestation_pubkey, _proposal_pubkey). Consider removing them or adding #[allow(dead_code)].
  • Line 135 in key_manager.rs: The test test_sign_message_validator_not_found should be renamed to test_sign_attestation_validator_not_found for clarity.

Conclusion

The PR is well-executed and aligns with the devnet4 spec. The dual-key design is correctly implemented, and the removal of the proposer attestation wrapper simplifies the block structure. Address the minor issues above (especially the metrics mislabeling) before merging.


Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: panic-on-input in block signature verification (remote DoS).
    crates/blockchain/src/store.rs:1175 and crates/blockchain/src/store.rs:1214 use try_into().expect("slot exceeds u32") on untrusted block data. A malicious peer can send a block/attestation with slot > u32::MAX and crash the node.
    Suggested fix: return a typed StoreError on conversion failure instead of expect.

  2. High: silent slot truncation in proposer signing path.
    crates/blockchain/src/lib.rs:216 casts slot as u32 before signing, and crates/blockchain/src/key_manager.rs:65 bakes u32 into the API. After u32::MAX slots, signatures are produced for truncated slots and will fail verification / diverge behavior.
    Suggested fix: plumb u64 through KeyManager and perform checked conversion at the XMSS boundary with explicit error handling.

  3. Medium: genesis root regression test lost its invariant check.
    crates/common/types/src/genesis.rs:137 replaced pinned root assertions with non-zero checks. This no longer detects SSZ layout/hash regressions, which is risky for consensus-critical types.
    Suggested fix: restore deterministic expected roots (or compute from fixed fixtures) and assert exact hashes.

  4. Medium: validator key config is parsed but not validated against loaded secrets.
    bin/ethlambda/src/main.rs:229 / bin/ethlambda/src/main.rs:233 parse attestation/proposal pubkeys, but bin/ethlambda/src/main.rs:287-bin/ethlambda/src/main.rs:306 never checks the loaded secret keys derive those pubkeys. Misconfigured files will start normally but produce invalid signatures.
    Suggested fix: derive pubkeys from secret keys at load time and hard-fail on mismatch.

Overall direction (dual keys + block-root proposer signature) looks coherent, but the two slot-handling issues are blocking for safety/correctness.


Automated review by OpenAI Codex · custom prompt

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Here is my review of PR #233:


PR #233 — Devnet4 Phase 4: Network Type Cascade and Test Harness Updates

Overall this is a clean, well-scoped mechanical migration. The type rename cascade is handled consistently across all 22 files, and the architectural change (proposer signs block root with dedicated proposal key rather than embedding a proposer attestation) is correctly threaded through the full pipeline: key manager → proposal → verification → storage → network. A few issues worth addressing:


Wrong metrics label in sign_with_proposal_key

crates/blockchain/src/key_manager.rs, new sign_with_proposal_key:

let _timing = metrics::time_pq_sig_attestation_signing();

This reuses the attestation signing timing metric for proposal signing. The label is semantically wrong and will inflate lean_pq_sig_attestation_signing_* metrics with block proposal latency. A time_pq_sig_proposal_signing() metric should be added (or at minimum a separate counter inc_pq_sig_proposal_signatures()), mirroring what sign_with_attestation_key does.


Missing serialize_with for proposal_pubkey in Validator

crates/common/types/src/state.rs:

#[serde(serialize_with = "serialize_pubkey_hex")]
pub attestation_pubkey: ValidatorPubkeyBytes,

// no serialize_with here ↓
pub proposal_pubkey: ValidatorPubkeyBytes,

attestation_pubkey gets serialize_pubkey_hex but proposal_pubkey does not. This means JSON serialization of Validator (e.g., via the /lean/v0/fork_choice or /lean/v0/states/finalized endpoints) will produce inconsistent output — attestation_pubkey as hex but proposal_pubkey as raw bytes/array. Should be:

#[serde(serialize_with = "serialize_pubkey_hex")]
pub proposal_pubkey: ValidatorPubkeyBytes,

Inconsistent slot cast in block proposal vs. signature verification

crates/blockchain/src/lib.rs, propose_block:

.sign_block_root(validator_id, slot as u32, &block_root)

vs. crates/blockchain/src/store.rs, verify_signatures:

let slot: u32 = block.slot.try_into().expect("slot exceeds u32");

The proposal path silently truncates an overflowing u64 slot to u32, while the verification path panics. For any slot ≥ 2³², the signature would be created with a different slot than what verification expects, causing verification failure. The as u32 cast should be replaced with try_into().expect("slot exceeds u32") to match the verification side.


ValidatorPubkeyMismatch error loses which key failed

bin/ethlambda/src/checkpoint_sync.rs:

if state_val.attestation_pubkey != expected_val.attestation_pubkey
    || state_val.proposal_pubkey != expected_val.proposal_pubkey
{
    return Err(CheckpointSyncError::ValidatorPubkeyMismatch { index: i });
}

The error doesn't say whether the attestation or proposal key mismatched. For operational debugging this is a minor usability regression. Could either add a field to the error variant or split into two checks with distinct variants/messages.


Weakened genesis state root test (acknowledged, temporary)

crates/common/types/src/genesis.rs, test_genesis_state_root_computation now only asserts != ZERO instead of checking the exact hash. The comment correctly explains this is intentional pending fixture regeneration from leansig-test-keys. This is acceptable given the PR chain context, but should be tracked to restore exact-hash assertions once the dual-key test fixtures are published.


Storage format is a breaking change (expected)

The BlockSignatures type is now stored directly instead of BlockSignaturesWithAttestation. Any existing database with the old encoding will fail to deserialize at startup. This is expected for a devnet breaking change, but it may be worth a log message at startup that instructs operators to wipe the database when migrating from devnet3 to devnet4.


Minor: deser_pubkey_hex error messages lost field context

Old: "GENESIS_VALIDATORS[{idx}] is not valid hex: {s}" (reported the validator index)
New: "pubkey is not valid hex: {s}" (no context which validator entry or which key field failed)

Since deser_pubkey_hex is now called once per field via #[serde(deserialize_with)], the outer serde error will include the field name path, so this is acceptably compensated — but the old index hint was helpful for large genesis configs.


Summary

Severity Item
Bug (latent) slot as u32 truncating cast in propose_block vs. try_into().expect() in verify_signatures
Bug proposal_pubkey missing serialize_with on Validator
Metrics sign_with_proposal_key uses attestation timing metric
Minor ValidatorPubkeyMismatch doesn't identify which key type failed
Expected/tracked Weakened state root test, storage format break

The core logic — dual-key signing, block root verification using proposal_pubkey, proposer now attesting independently via attestation_key at interval 1 — looks correct and consistent across all layers.


Automated review by Claude (Anthropic) · custom prompt

Introduce the devnet4 type-level changes:

- Validator: single pubkey → attestation_pubkey + proposal_pubkey with
  get_attestation_pubkey() and get_proposal_pubkey() methods
- SignedBlockWithAttestation → SignedBlock (message is Block directly)
- Delete BlockWithAttestation and BlockSignaturesWithAttestation wrappers
- Genesis config: GENESIS_VALIDATORS changes from list of hex strings to
  list of {attestation_pubkey, proposal_pubkey} objects
- Test fixtures: Validator deserialization updated for dual pubkeys

NOTE: This is SSZ-breaking. Downstream crates will not compile until
subsequent phases update all call sites.
- KeyManager: introduce ValidatorKeyPair with attestation_key + proposal_key
- sign_attestation() routes to attestation key, new sign_block_root()
  uses proposal key
- propose_block(): sign block root with proposal key, remove proposer
  attestation from block (proposer now attests at interval 1 via gossip)
- produce_attestations(): remove proposer skip, all validators attest
- main.rs: read dual key files per validator (attestation_privkey_file +
  proposal_privkey_file)
- checkpoint_sync: validate both attestation_pubkey and proposal_pubkey
- Type cascade: SignedBlockWithAttestation → SignedBlock, fix field access

NOTE: store.rs and network layer still reference old types — fixed in
subsequent phases.
…r attestation

- verify_signatures(): attestation proofs use get_attestation_pubkey(),
  proposer signature verified with get_proposal_pubkey() over block root
- on_block_core(): remove proposer attestation processing (~40 lines) —
  no more gossip signature insert or dummy proof for proposer
- Gossip attestation/aggregation verification: get_attestation_pubkey()
- Remove StoreError::ProposerAttestationMismatch variant
- Storage: write/read BlockSignatures directly (no wrapper), fix field
  access .message.block → .message
- Table docs: BlockSignatures stores BlockSignatures (not WithAttestation)
- Network API: SignedBlockWithAttestation → SignedBlock in all protocols
- Gossipsub handler: update block decode/encode and field access
- Req/resp codec + handlers: update BlocksByRoot type and field access
- Gossipsub encoding test: update ignored test for new type name
- Fork choice spec tests: remove proposer_attestation from BlockStepData,
  simplify build_signed_block()
- Signature spec tests: update to TestSignedBlock (no attestation wrapper)
- Common test types: remove ProposerAttestation (no longer in block)

This completes the devnet4 type migration. All 34 unit tests pass,
cargo clippy clean with -D warnings.
  - Add dedicated proposal signing metrics (lean_pq_sig_proposal_signatures_total
    counter and lean_pq_sig_proposal_signing_time_seconds histogram) so proposal
    key signing is tracked separately from attestation signing.
  - Pin exact state root and block root hashes in the genesis test instead of
    the weak != H256::ZERO check, catching any future SSZ layout regressions.
  - Update the update_safe_target comment to reflect devnet4 attestation flow
    (proposer no longer embeds its own attestation in the block body).
  - CLAUDE.md: update SignedBlockWithAttestation to SignedBlock, fix genesis
    config format to dual-key YAML, update BlockSignatures table type, fix
    tick interval 1 description (all validators now attest).
  - Architecture HTML: update type name in two places.
  - checkpoint_sync: clarify pubkey mismatch error to mention both keys.
  - SSZ test fixture: rename signed_block_with_attestation.ssz to signed_block.ssz.
  - Genesis test: add proposal pubkey assertions for validators B and C.
  - store.rs: fix stale proof wording in build_block docstring.
  - Devnet log patterns: remove stale 'Skipping attestation for proposer' entry.
  The previous pin (ad9a3226) added dual-key ValidatorKeyPair.from_dict()
  but still pointed KEY_DOWNLOAD_URLS at the old single-key release
  (leanSpec-bbbbf62-v2), causing CI fixture generation to fail with
  KeyError: 'attestation_public'. Commit 488518c updates the URLs to
  the leanSpec-ad9a3226 release which has the dual-key format keys.
… format

  The new leanSpec fixture format flattens BlockStepData - block fields
  (slot, proposerIndex, etc.) are now directly in the step's block object
  instead of nested under a second block wrapper.

  Changes:
  - Bump LEAN_SPEC_COMMIT_HASH to 9c30436 (leanSpec HEAD)
  - Flatten BlockStepData to match the new fixture JSON layout
  - Add to_block() helper for converting BlockStepData to domain Block
  - Skip test_reorg_with_slot_gaps: fixture relies on gossip proposer
    attestation state not serialized into the JSON fixture
@pablodeymo pablodeymo force-pushed the devnet4-phase4-network branch from 39f9fa2 to c8ea8d9 Compare April 1, 2026 22:17
@pablodeymo pablodeymo changed the title Devnet4 Phase 4: network type cascade and test harness updates Devnet4: dual-key validators, block signing, and type migration Apr 7, 2026
@pablodeymo pablodeymo marked this pull request as ready for review April 7, 2026 20:53
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

🤖 Kimi Code Review

This PR introduces a dual-key validator model (separate XMSS keys for attestation vs. block proposal), simplifying the block structure by removing the proposer attestation wrapper and having proposers sign block roots directly.

Security & Correctness

Critical: Key Usage Separation

  • Attestation key used for attestations (sign_attestationget_attestation_pubkey)
  • Proposal key used for block signing (sign_block_rootget_proposal_pubkey)
  • Prevents OTS (one-time signature) exhaustion attacks by isolating signature domains

Block Verification Logic

  • verify_signatures (store.rs:1129-1177) correctly verifies proposer signature over block_root using proposal key
  • Removed ProposerAttestationMismatch check is safe: signature verification implicitly binds proposer_index to the signing key; invalid indices will fail signature verification

Fork Choice Impact

  • Removing proposer attestation from block weight calculation is correct (proposer attestations were excluded from fork choice weight anyway, as noted in deleted comment: "proposer's own attestation...would be invisible without this merge")

Code Quality

Type Safety

  • SignedBlockWithAttestationSignedBlock is a clean simplification
  • ValidatorKeyPair struct (key_manager.rs:27-31) properly encapsulates dual keys

Error Handling

  • checkpoint_sync.rs:147-149: Updated error message correctly identifies which key mismatched
  • main.rs:283-304: Properly handles missing key files with unwrap_or_else + process exit

Tests

  • Genesis state root updated correctly (expected SSZ layout change from dual pubkeys)
  • SKIP_TESTS list in forkchoice_spectests.rs appropriately handles fixtures requiring gossip simulation

Minor Suggestions

Item 1: Remove stale comment in store.rs:608-617
The comment about "proposer attestation as pending" is obsolete since proposer attestations are removed. Delete lines 608-617 comments.

Item 2: Explicit proposer validation (defense in depth)
While signature verification implicitly validates the proposer, consider adding an explicit is_proposer check in on_block_core before signature verification to fail fast with a clear error message (e.g., NotProposer error already defined but unused).

Item 3: Storage migration note
The BlockSignatures table schema changed (removed proposer_attestation wrapper). If this client has been deployed to any persistent testnets, a migration strategy is needed for existing BlockSignaturesWithAttestation entries.

Documentation

  • CLAUDE.md correctly updated: Interval 1 now includes all validators
  • Architecture diagram updated to SignedBlock

Verdict

Approve — The refactoring is architecturally sound, maintains XMSS OTS security guarantees, and properly separates cryptographic domains. The SSZ encoding changes are consistent and tested.


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

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

🤖 Codex Code Review

  1. genesis.rs and lib.rs: the new safety model assumes attestation and proposal keys are different, because the proposer now signs both an attestation and a block in the same slot. But the PR accepts any two keys and never rejects equality. If an operator configures the same XMSS key for both roles, you reintroduce the exact OTS reuse this change is meant to avoid. This should be rejected at config/startup time.

  2. main.rs: AnnotatedValidator parses attestation_pubkey_hex and proposal_pubkey_hex, but those fields are discarded and never checked against the loaded secret keys in read_validator_keys(). A swapped/stale key file will let the node boot and only fail later by producing invalid attestations/blocks on the network. Derive both public keys from the loaded secret keys and compare them to the annotated values before inserting the ValidatorKeyPair.

  3. main.rs vs genesis.rs: bin/ethlambda::deser_pubkey_hex does not accept an optional 0x prefix, while the genesis parser does. That makes the validator-config path stricter than the rest of the repo and is an easy boot-time regression if the YAML is generated by the same tooling. Normalize it with strip_prefix("0x").

Aside from those points, the SignedBlock/BlockSignatures split and the updated verification path looked internally consistent.

I couldn’t run cargo test here because the sandbox blocks rustup from creating temp files under the default home directory.


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

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

🤖 Claude Code Review

Now I have enough context to write a thorough review. Let me compile the findings.


Code Review: PR #233 — Devnet4: dual-key validators, block signing, and type migration

Overview

Large, clean migration introducing separate attestation/proposal XMSS keys per validator. Removes the BlockWithAttestation/SignedBlockWithAttestation wrapper hierarchy in favor of a flat SignedBlock { message: Block, signature: BlockSignatures }. All 34 unit tests pass; clippy clean.


Security & Correctness

Dual-key verification routingverify_signatures() (store.rs) correctly uses get_proposal_pubkey() for the proposer signature (over block.tree_hash_root()) and get_attestation_pubkey() for all attestation proofs. This is the critical security boundary and it is correct.

Attestation key path: on_gossip_attestation, on_gossip_aggregated_attestation, and aggregate_committee_signatures all now call get_attestation_pubkey(). No leftover calls to the removed get_pubkey(). ✓

XMSS OTS slot separation: the proposal key and attestation key advance their OTS indices independently, so signing both in slot N is safe. ✓

Unused pubkey fields in AnnotatedValidator (pre-existing, now doubled)
main.rs reads _attestation_pubkey and _proposal_pubkey from the YAML purely for format validation but never cross-checks them against the loaded private key files. A misconfigured operator who swaps key files would get no startup error — the node would sign with the wrong key.
This pattern existed before (_pubkey), so not a regression, but worth tracking as a hardening item now that there are two keys to get wrong.

checkpoint_sync.rs dual-key validationverify_checkpoint_state() correctly validates both keys independently:

if state_val.attestation_pubkey != expected_val.attestation_pubkey
    || state_val.proposal_pubkey != expected_val.proposal_pubkey


Potential Bugs / Issues

deser_pubkey_hex loses validator index in error messages (genesis.rs, new deser_pubkey_hex)

Old:

D::Error::custom(format!("GENESIS_VALIDATORS[{idx}] is not valid hex: {s}"))

New:

D::Error::custom(format!("pubkey is not valid hex: {s}"))

When a genesis config has a malformed key, the new message no longer tells the operator which validator entry is broken. This regression is a consequence of switching from a Vec<String> deserializer to per-field deserializers, but it makes production diagnosis harder.

Suggested fix: pass the field name through context or keep the key prefix in the error string:

D::Error::custom(format!("attestation_pubkey/proposal_pubkey is not valid hex: {s}"))

Skipped test test_reorg_with_slot_gaps (forkchoice_spectests.rs)

The skip reason is that the fixture relies on gossip attestation state not serialized into JSON. This is a latent correctness gap in reorg handling with slot gaps. There is no tracking issue referenced in the skip comment. At minimum, a link to an issue or a TODO noting what needs to happen for this test to be re-enabled would make it less likely to be forgotten.

SSZ fixture signed_block.ssz is a renamed devnet3 file

crates/net/p2p/test_data/signed_block.ssz is 100% identical to the old signed_block_with_attestation.ssz (devnet3 layout). The test decoding it remains #[ignore] with a comment acknowledging it needs a devnet4 block. This is acceptable short-term but means the SSZ encoding/decoding integration test for SignedBlock is entirely absent. Regenerating the fixture would close this gap.


Minor Observations

ValidatorKeyPair public fields (key_manager.rs:248–250)
Both attestation_key and proposal_key are pub. For an internal type whose only consumer outside the crate is main.rs (which constructs the struct), pub(crate) would be slightly more defensive. Low severity since there's no pub use re-export of the secret keys.

load_key closure uses process::exit(1) after logging (main.rs:194–203)
Pattern is consistent with the rest of startup code. No issue; just noting the approach differs from normal Rust error propagation — intentional for startup failures.

to_block() call at two call sites (forkchoice_spectests.rs:34, 55)
block_data.to_block() is called once for the label registration and once for build_signed_block, meaning to_block() runs twice on the same block_data in the block_root_label branch. The second call clones nothing expensive (all fields are Copy or small), but extracting the block once and sharing it would be cleaner:

let block = block_data.to_block();
if let Some(ref label) = block_data.block_root_label {
    block_registry.insert(label.clone(), H256::from(block.tree_hash_root()));
}
let signed_block = build_signed_block_from(block, &block_data);

Not a bug; minor style point.


Positive Highlights

  • Metrics coverage: lean_pq_sig_proposal_signatures_total counter and lean_pq_sig_proposal_signing_time_seconds histogram are correctly added, forced in init(), and follow the existing LazyLock pattern exactly.
  • Pinned genesis root hashes (genesis.rs test state_from_genesis_root) catch any future SSZ layout regressions — a meaningful improvement over the previous != H256::ZERO check.
  • Proposer-skip removal from produce_attestations is clean; the now-unnecessary head_state load is gone with it.
  • update_safe_target comment accurately describes the devnet4 flow.
  • No leftover references to BlockWithAttestation, BlockSignaturesWithAttestation, or ProposerAttestationMismatch.

Summary

No critical bugs. The dual-key routing through the signing and verification paths is correct, which is the most security-sensitive aspect of this change. The three items worth addressing before merge are (in order of priority):

  1. Error message regression in deser_pubkey_hex — easy fix.
  2. test_reorg_with_slot_gaps skip needs a tracking issue reference.
  3. SSZ fixture regeneration (or at minimum remove the devnet3 binary and leave a placeholder comment until a devnet4 block is available).

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

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 7, 2026

Greptile Summary

This PR completes the devnet4 migration from a single-key validator model to a dual-key model (separate attestation and proposal keys), cascading the type rename from SignedBlockWithAttestationSignedBlock across the full network layer and test harness. The core logic changes are correct: the proposal key signs hash_tree_root(block), all validators now attest at interval 1, and signature verification correctly dispatches to get_attestation_pubkey() / get_proposal_pubkey() as appropriate. All remaining findings are P2 style/quality suggestions.

Confidence Score: 5/5

Safe to merge — dual-key migration is logically consistent and correct across all layers; only minor style inconsistencies remain.

All findings are P2: a missing 0x-prefix strip in a startup helper and test-only proof_data replaced with empty proofs. No correctness, data-integrity, or security issues found in the consensus, signing, or storage paths.

bin/ethlambda/src/main.rs (deser_pubkey_hex 0x handling) and crates/blockchain/tests/signature_types.rs (proof_data discarded silently).

Important Files Changed

Filename Overview
crates/blockchain/src/key_manager.rs Dual-key validator implementation with separate attestation and proposal keys; signing functions use correct keys with metrics; unit tests cover the error paths.
crates/blockchain/src/lib.rs Block proposal correctly signs hash_tree_root(block) with the proposal key; all validators attest at interval 1 (proposer no longer skipped); logic is clean.
crates/common/types/src/block.rs BlockSignatures now carries both attestation_signatures and proposer_signature; clean removal of the old proposer attestation wrapper.
crates/common/types/src/state.rs Validator now has dual attestation_pubkey and proposal_pubkey; helper methods get_attestation_pubkey()/get_proposal_pubkey() added; state root pinned in tests.
crates/common/types/src/genesis.rs GenesisValidatorEntry updated to dual-key YAML format; deser_pubkey_hex correctly strips 0x prefix; pinned genesis root hash test.
bin/ethlambda/src/main.rs Loads dual attestation/proposal key pairs from annotated_validators.yaml; local deser_pubkey_hex lacks 0x prefix stripping unlike genesis.rs version, risking startup failure.
crates/blockchain/tests/signature_types.rs Deserialises devnet4 SignedBlock from test fixtures; proof_data is silently discarded and replaced with empty proofs, so aggregated attestation proof verification is not exercised in spec tests.
crates/blockchain/tests/forkchoice_spectests.rs test_reorg_with_slot_gaps skipped with clear explanation; build_signed_block uses default signatures correctly for verification-bypass path.
bin/ethlambda/src/checkpoint_sync.rs Now validates both attestation_pubkey and proposal_pubkey in checkpoint state; error variant ValidatorPubkeyMismatch updated with clear message.
crates/blockchain/src/metrics.rs New proposal-signing metrics added (counter + histogram); all metrics correctly registered in init(); naming follows lean_ prefix convention.

Sequence Diagram

sequenceDiagram
    participant BC as BlockChainServer
    participant KM as KeyManager
    participant ST as Store
    participant P2P as P2P

    Note over BC: Interval 0 (slot > 0)
    BC->>ST: produce_block_with_signatures(slot, validator_id)
    ST-->>BC: (block, attestation_signatures)
    BC->>KM: sign_block_root(validator_id, slot, block_root)
    KM-->>BC: proposer_signature via proposal_key
    BC->>BC: assemble SignedBlock
    BC->>ST: on_block(signed_block)
    BC->>P2P: publish_block(signed_block)

    Note over BC: Interval 1 (all validators)
    BC->>KM: sign_attestation(validator_id, data)
    KM-->>BC: signature via attestation_key
    BC->>P2P: publish_attestation(SignedAttestation)

    Note over P2P: On receiving gossip block
    P2P->>BC: new_block(SignedBlock)
    BC->>ST: on_block - verify proposer_signature via get_proposal_pubkey()
Loading

Comments Outside Diff (1)

  1. bin/ethlambda/src/main.rs, line 251-263 (link)

    P2 Missing 0x prefix stripping in deser_pubkey_hex

    The local deser_pubkey_hex calls hex::decode(&value) directly, while the equivalent function in genesis.rs strips the 0x prefix first with s.strip_prefix("0x").unwrap_or(&s). If annotated_validators.yaml keys are formatted with a 0x prefix, startup will panic inside serde_yaml_ng::from_str(...).expect("Failed to parse validators file"). Consider aligning both functions or extracting a shared helper.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: bin/ethlambda/src/main.rs
    Line: 251-263
    
    Comment:
    **Missing `0x` prefix stripping in `deser_pubkey_hex`**
    
    The local `deser_pubkey_hex` calls `hex::decode(&value)` directly, while the equivalent function in `genesis.rs` strips the `0x` prefix first with `s.strip_prefix("0x").unwrap_or(&s)`. If `annotated_validators.yaml` keys are formatted with a `0x` prefix, startup will panic inside `serde_yaml_ng::from_str(...).expect("Failed to parse validators file")`. Consider aligning both functions or extracting a shared helper.
    
    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/signature_types.rs
Line: 61-72

Comment:
**Attestation `proof_data` silently discarded in test conversion**

The `From<TestSignedBlock> for SignedBlock` conversion drops the actual `proof_data` from each `AttestationSignature` and replaces it with `AggregatedSignatureProof::empty(participants)`. The `ProofData { data: String }` field in the fixture is read but never used, so the signature spec tests do not exercise aggregated attestation proof verification — only the proposer XMSS signature is actually checked.

If the intent is a known limitation while aggregation proof format is stabilised, a comment explaining this would help future readers avoid mistaking the empty proofs for correct data.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: bin/ethlambda/src/main.rs
Line: 251-263

Comment:
**Missing `0x` prefix stripping in `deser_pubkey_hex`**

The local `deser_pubkey_hex` calls `hex::decode(&value)` directly, while the equivalent function in `genesis.rs` strips the `0x` prefix first with `s.strip_prefix("0x").unwrap_or(&s)`. If `annotated_validators.yaml` keys are formatted with a `0x` prefix, startup will panic inside `serde_yaml_ng::from_str(...).expect("Failed to parse validators file")`. Consider aligning both functions or extracting a shared helper.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "Bump leanSpec to HEAD (9c30436) and adap..." | Re-trigger Greptile

Comment on lines 61 to 72
let attestation_signatures: AttestationSignatures = value
.signature
.attestation_signatures
.data
.into_iter()
.map(|att_sig| {
// Convert participants bitfield
let participants: EthAggregationBits = att_sig.participants.into();
// Create proof with participants but empty proof_data
AggregatedSignatureProof::empty(participants)
})
.collect::<Vec<_>>()
.try_into()
.expect("too many attestation signatures");
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 Attestation proof_data silently discarded in test conversion

The From<TestSignedBlock> for SignedBlock conversion drops the actual proof_data from each AttestationSignature and replaces it with AggregatedSignatureProof::empty(participants). The ProofData { data: String } field in the fixture is read but never used, so the signature spec tests do not exercise aggregated attestation proof verification — only the proposer XMSS signature is actually checked.

If the intent is a known limitation while aggregation proof format is stabilised, a comment explaining this would help future readers avoid mistaking the empty proofs for correct data.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/tests/signature_types.rs
Line: 61-72

Comment:
**Attestation `proof_data` silently discarded in test conversion**

The `From<TestSignedBlock> for SignedBlock` conversion drops the actual `proof_data` from each `AttestationSignature` and replaces it with `AggregatedSignatureProof::empty(participants)`. The `ProofData { data: String }` field in the fixture is read but never used, so the signature spec tests do not exercise aggregated attestation proof verification — only the proposer XMSS signature is actually checked.

If the intent is a known limitation while aggregation proof format is stabilised, a comment explaining this would help future readers avoid mistaking the empty proofs for correct data.

How can I resolve this? If you propose a fix, please make it concise.

#[serde(deserialize_with = "deser_pubkey_hex")]
_pubkey: ValidatorPubkeyBytes,
privkey_file: PathBuf,
_attestation_pubkey: ValidatorPubkeyBytes,
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.

Is this unused?

std::fs::read(&secret_key_path).expect("Failed to read validator secret key file");
let load_key = |path: &Path, purpose: &str| -> ValidatorSecretKey {
let bytes = std::fs::read(path).unwrap_or_else(|err| {
error!(node_id=%node_id, index=validator_index, file=?path, %err, "Failed to read {purpose} key file");
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.

Suggested change
error!(node_id=%node_id, index=validator_index, file=?path, %err, "Failed to read {purpose} key file");
error!(%node_id, index=validator_index, file=?path, %err, "Failed to read {purpose} key file");

std::process::exit(1);
});
ValidatorSecretKey::from_bytes(&bytes).unwrap_or_else(|err| {
error!(node_id=%node_id, index=validator_index, file=?path, ?err, "Failed to parse {purpose} key");
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.

Suggested change
error!(node_id=%node_id, index=validator_index, file=?path, ?err, "Failed to parse {purpose} key");
error!(%node_id, index=validator_index, file=?path, ?err, "Failed to parse {purpose} key");

let load_key = |path: &Path, purpose: &str| -> ValidatorSecretKey {
let bytes = std::fs::read(path).unwrap_or_else(|err| {
error!(node_id=%node_id, index=validator_index, file=?path, %err, "Failed to read {purpose} key file");
std::process::exit(1);
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.

This should return an error

});
ValidatorSecretKey::from_bytes(&bytes).unwrap_or_else(|err| {
error!(node_id=%node_id, index=validator_index, file=?path, ?err, "Failed to parse {purpose} key");
std::process::exit(1);
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.

Same


// Tests where the fixture relies on gossip attestation behavior not serialized into the JSON.
// These pass in the Python spec but fail in our runner because we don't simulate gossip.
const SKIP_TESTS: &[&str] = &["test_reorg_with_slot_gaps"];
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.

Why skip this test? Can't we add gossip simulation?

pablodeymo and others added 8 commits April 8, 2026 19:14
  Resolve merge conflicts from the single-AttestationData-per-block commit (2d5b273):
  - Makefile: keep phase4's leanSpec hash (9c30436) for dual-key fixtures
  - store.rs: keep DuplicateAttestationData error, drop ProposerAttestationMismatch,
    fix type access path (signed_block.message instead of signed_block.block.block)
  - key_manager.rs: fix secret_key references to key_pair.attestation_key for
    XMSS preparation window, add same preparation logic to sign_with_proposal_key
  The auto-merged single-AttestationData tests (2d5b273) used pre-phase4 types:
  - SignedBlockWithAttestation/BlockWithAttestation -> SignedBlock with .message
  - Validator { pubkey } -> Validator { attestation_pubkey, proposal_pubkey }
  - Removed proposer_attestation fields from test block construction
…rt to test harness

  Resolve merge conflict in lib.rs: keep phase4's block-root signing (proposer
  attestation removed in devnet4), drop devnet4's post-block checkpoint attestation
  (PR #268) since the feature it improves no longer exists.

  Add attestation and gossipAggregatedAttestation step handlers to the fork choice
  test harness, with on_gossip_attestation_without_verification() that validates
  data, checks validator index, inserts into known payloads, and recalculates head.

  Patch leanSpec's test_lexicographic_tiebreaker to use single-depth forks
  (backport of upstream fix e545e14) and remove same-slot constraint from
  the lexicographicHeadAmong validation.

  Skip test_gossip_attestation_with_invalid_signature (no sig verification in tests).

  7 headSlot-mismatch failures remain from fixture expectation changes between
  leanSpec d39d101 and 9c30436 - to be investigated separately.
## Summary

- Aggregator now calls `on_gossip_attestation()` locally before
publishing to gossip, ensuring its own validator's attestation enters
`gossip_signatures` and is included in aggregated proofs
- Adds `publish_best_effort()` to suppress the expected
`NoPeersSubscribedToTopic` warning when the aggregator publishes to
attestation subnets it subscribes to
- Threads `is_aggregator` from `SwarmConfig` through `BuiltSwarm` into
`P2PServer` so the gossipsub handler can use best-effort publishing

## Context

Gossipsub does not deliver messages back to the sender. The aggregator
subscribes to the attestation subnet and publishes via mesh, but in
small devnets no other node subscribes to attestation subnets. This
causes the aggregator's own attestation publish to fail silently with
`NoPeersSubscribedToTopic`, meaning its vote never enters
`gossip_signatures`, is never aggregated, and is never included in
blocks.

The reference spec handles this explicitly in `_produce_attestations()`:

```python
# Process attestation locally before publishing.
#
# Gossipsub does not deliver messages back to the sender.
# Without local processing, the aggregator node never sees its own
# validator's attestation in gossip_signatures, reducing the
# aggregation count below the 2/3 safe-target threshold.
self.sync_service.store = self.sync_service.store.on_gossip_attestation(
    signed_attestation=signed_attestation,
    is_aggregator=is_aggregator_role,
)
```

## Test plan

- [x] `cargo build` passes
- [x] `cargo clippy --workspace -- -D warnings` passes
- [x] `cargo test --workspace --release --lib` passes (all 32 unit
tests)
- [ ] Deploy to devnet and verify:
- Validator 3's attestation appears in "Attestation processed" logs on
the aggregator
- Blocks from all proposers include aggregator's vote in aggregated
proofs (higher attestation counts)
  - No more `NoPeersSubscribedToTopic` warnings on the aggregator node
  - Finalization continues normally
  build_signed_block was creating an empty attestation_signatures list,
  so the zip in on_block_core discarded all in-block attestations before
  they reached fork choice. Now builds one empty AggregatedSignatureProof
  per attestation, matching each attestation's aggregation_bits.

  This fixes all 7 headSlot-mismatch failures in fork choice spec tests.
  All 48 forkchoice spec tests now pass.
… of exit

  - Remove unused pubkey fields from AnnotatedValidator (already validated
    during GenesisConfig parsing)
  - Replace process::exit(1) with proper error returns in read_validator_keys
  - Use tracing shorthand %node_id
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