From 4259856ab62991246f143f46a60a0841a86c78fa Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 24 Feb 2026 19:34:44 -0300 Subject: [PATCH 01/10] Deduplicate AggregationBits type alias and remove redundant getters AggregationBits was defined identically in both attestation.rs and block.rs. Keep the single definition in attestation.rs and import it in block.rs. Update blockchain/store.rs and signature_types.rs to import from attestation instead of block, removing the now-unnecessary EthAggregationBitsSig alias. Also remove participants() and proof_data() getters on AggregatedSignatureProof since both fields are already public and no code calls the getters. --- crates/blockchain/src/store.rs | 6 ++++-- crates/blockchain/tests/signature_types.rs | 7 +++---- crates/common/types/src/block.rs | 18 +----------------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 04138a91..a7208dac 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -7,9 +7,11 @@ use ethlambda_state_transition::{ use ethlambda_storage::{ForkCheckpoints, SignatureKey, Store}; use ethlambda_types::{ ShortRoot, - attestation::{AggregatedAttestation, Attestation, AttestationData, SignedAttestation}, + attestation::{ + AggregatedAttestation, AggregationBits, Attestation, AttestationData, SignedAttestation, + }, block::{ - AggregatedAttestations, AggregatedSignatureProof, AggregationBits, Block, BlockBody, + AggregatedAttestations, AggregatedSignatureProof, Block, BlockBody, SignedBlockWithAttestation, }, primitives::{H256, ssz::TreeHash}, diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index aab99223..a700ae16 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -3,9 +3,8 @@ use ethlambda_types::attestation::{ Attestation as EthAttestation, AttestationData as EthAttestationData, XmssSignature, }; use ethlambda_types::block::{ - AggregatedAttestations, AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, - AttestationSignatures, Block as EthBlock, BlockBody as EthBlockBody, BlockSignatures, - BlockWithAttestation, SignedBlockWithAttestation, + AggregatedAttestations, AggregatedSignatureProof, AttestationSignatures, Block as EthBlock, + BlockBody as EthBlockBody, BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, }; use ethlambda_types::primitives::{ BitList, H256, VariableList, @@ -229,7 +228,7 @@ impl From for SignedBlockWithAttestation { .into_iter() .map(|att_sig| { // Convert participants bitfield - let participants: EthAggregationBitsSig = att_sig.participants.into(); + let participants: EthAggregationBits = att_sig.participants.into(); // Create proof with participants but empty proof_data AggregatedSignatureProof::empty(participants) }) diff --git a/crates/common/types/src/block.rs b/crates/common/types/src/block.rs index 776dbb1e..634c7953 100644 --- a/crates/common/types/src/block.rs +++ b/crates/common/types/src/block.rs @@ -2,7 +2,7 @@ use serde::Serialize; use ssz_types::typenum::U1048576; use crate::{ - attestation::{AggregatedAttestation, Attestation, XmssSignature}, + attestation::{AggregatedAttestation, AggregationBits, Attestation, XmssSignature}, primitives::{ ByteList, H256, ssz::{Decode, Encode, TreeHash}, @@ -102,24 +102,8 @@ impl AggregatedSignatureProof { proof_data: ByteList::empty(), } } - - /// Get the participants bitfield. - pub fn participants(&self) -> &AggregationBits { - &self.participants - } - - /// Get the proof data. - pub fn proof_data(&self) -> &ByteListMiB { - &self.proof_data - } } -/// Bitlist representing validator participation in an attestation or signature. -/// -/// A general-purpose bitfield for tracking which validators have participated -/// in some collective action (attestation, signature aggregation, etc.). -pub type AggregationBits = ssz_types::BitList; - /// Bundle containing a block and the proposer's attestation. #[derive(Debug, Clone, Encode, Decode, TreeHash)] pub struct BlockWithAttestation { From 76278a6c29fee521cce9f96e1841ba78728719ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:05:50 -0300 Subject: [PATCH 02/10] fix: stop appending endpoint path to checkpoint sync URL (#148) ## Summary - `--checkpoint-sync-url` now expects the full URL (e.g., `http://peer:5052/lean/v0/states/finalized`) instead of just the base URL with `/lean/v0/states/finalized` appended automatically - Decouples the CLI from a specific API path convention, so it works with any checkpoint provider regardless of endpoint layout ## Test plan - [x] `cargo check` passes - [ ] Verify checkpoint sync works end-to-end with the full URL in a devnet --- bin/ethlambda/src/checkpoint_sync.rs | 6 ++---- bin/ethlambda/src/main.rs | 2 +- docs/checkpoint_sync.md | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bin/ethlambda/src/checkpoint_sync.rs b/bin/ethlambda/src/checkpoint_sync.rs index 8b163ad7..c0ce60ad 100644 --- a/bin/ethlambda/src/checkpoint_sync.rs +++ b/bin/ethlambda/src/checkpoint_sync.rs @@ -63,12 +63,10 @@ pub enum CheckpointSyncError { /// disconnect even for valid downloads if the state is simply too large to /// transfer within the time limit. pub async fn fetch_checkpoint_state( - base_url: &str, + url: &str, expected_genesis_time: u64, expected_validators: &[Validator], ) -> Result { - let base_url = base_url.trim_end_matches('/'); - let url = format!("{base_url}/lean/v0/states/finalized"); // Use .read_timeout() to detect stalled downloads (inactivity timer). // This allows large states to complete as long as data keeps flowing. let client = Client::builder() @@ -77,7 +75,7 @@ pub async fn fetch_checkpoint_state( .build()?; let response = client - .get(&url) + .get(url) .header("Accept", "application/octet-stream") .send() .await? diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index c7b54b9a..27b321c2 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -47,7 +47,7 @@ struct CliOptions { /// The node ID to look up in annotated_validators.yaml (e.g., "ethlambda_0") #[arg(long)] node_id: String, - /// URL of a peer to download checkpoint state from (e.g., http://peer:5052) + /// URL to download checkpoint state from (e.g., http://peer:5052/lean/v0/states/finalized) /// When set, skips genesis initialization and syncs from checkpoint. #[arg(long)] checkpoint_sync_url: Option, diff --git a/docs/checkpoint_sync.md b/docs/checkpoint_sync.md index ff018558..b2a6c20d 100644 --- a/docs/checkpoint_sync.md +++ b/docs/checkpoint_sync.md @@ -26,7 +26,7 @@ When `--checkpoint-sync-url` is omitted, the node initializes from genesis. ### Direct peer -Any running node that serves the `/lean/v0/states/finalized` endpoint can be used as a checkpoint source, not just ethlambda. +Any running node that serves the finalized state as SSZ can be used as a checkpoint source, not just ethlambda. For ethlambda nodes, the endpoint is `/lean/v0/states/finalized`. This is the simplest option, with no additional infrastructure needed. The trade-off is that you trust a single peer to provide a correct finalized state. @@ -38,7 +38,7 @@ This is the recommended option for production deployments since it reduces trust ## How It Works -1. **Fetch and verify**: The node sends an HTTP GET to `{url}/lean/v0/states/finalized` requesting the SSZ-encoded finalized state. Once downloaded, the state is decoded and verified against the local genesis config (see [Verification Checks](#verification-checks) below). +1. **Fetch and verify**: The node sends an HTTP GET to the provided URL requesting the SSZ-encoded finalized state. Once downloaded, the state is decoded and verified against the local genesis config (see [Verification Checks](#verification-checks) below). Timeouts: - **Connect**: 15 seconds (fail fast if peer is unreachable) From 432f7e2b8231ccbfff6fcc4d6e401e7b3f4d7337 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 12:49:11 -0300 Subject: [PATCH 03/10] Extract shared test types to common module (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/blockchain/tests/types.rs` (fork choice tests) and `crates/blockchain/tests/signature_types.rs` (signature tests) duplicate ~14 type definitions with identical fields, serde attributes, and `From` trait implementations. This creates maintenance burden and risks divergence — any change to shared types (e.g. adding a new field to `TestState` or `Block`) must be applied in two places. Extract the 14 shared types into a new `common.rs` module that both test files import from: | Type | Purpose | |------|---------| | `Container` | Generic serde wrapper for JSON arrays with `data` field | | `Config` + `From for ChainConfig` | Genesis time config | | `Checkpoint` + `From for DomainCheckpoint` | Root + slot pair | | `BlockHeader` + `From for block::BlockHeader` | Block header fields | | `Validator` + `From for DomainValidator` | Validator index + pubkey | | `TestState` + `From for State` | Full beacon state deserialization | | `Block` + `From for DomainBlock` | Block with body | | `BlockBody` + `From for DomainBlockBody` | Attestation container | | `AggregatedAttestation` + `From` impl | Aggregation bits + data | | `AggregationBits` + `From` impl | Boolean bitfield | | `AttestationData` + `From` impl | Slot + head/target/source checkpoints | | `ProposerAttestation` + `From` impl | Validator ID + attestation data | | `TestInfo` | Test metadata (hash, comment, description) | | `deser_pubkey_hex()` | Hex string → `ValidatorPubkeyBytes` deserializer | **Files changed:** - **`tests/common.rs`** (new) — shared types with `#![allow(dead_code)]` at module level - **`tests/types.rs`** — reduced to fork-choice-specific types only (`ForkChoiceTestVector`, `ForkChoiceTest`, `ForkChoiceStep`, `BlockStepData`, `StoreChecks`, `AttestationCheck`), imports shared types via `super::common` - **`tests/signature_types.rs`** — reduced to signature-specific types only (SSZ types, `VerifySignaturesTestVector`, `TestSignedBlockWithAttestation`, `ProposerSignature`, etc.), imports shared types via `super::common` - **`tests/forkchoice_spectests.rs`** / **`tests/signature_spectests.rs`** — added `mod common;` - **`Cargo.toml`** — added `autotests = false` to prevent `common.rs`, `types.rs`, and `signature_types.rs` from being auto-discovered as standalone test binaries **Net result:** -575 lines added, +330 lines removed = **245 fewer lines** of duplicated code. The `From` impl differed slightly between files: - `types.rs`: `VariableList::new(attestations).expect(...)` - `signature_types.rs`: `collect::>().try_into().expect(...)` Both are equivalent. The common module uses the `VariableList::new()` form. ```bash make fmt # No formatting changes make lint # No warnings make test # All 26 forkchoice spec tests pass ``` Note: `signature_spectests` have pre-existing failures (fixture format mismatch with `ProposerSignature` — the JSON provides a hex string where the code expects a struct). These failures are identical on `main` and are unrelated to this change. --- crates/blockchain/Cargo.toml | 1 + crates/blockchain/tests/common.rs | 310 ++++++++++++++++++ .../blockchain/tests/forkchoice_spectests.rs | 1 + .../blockchain/tests/signature_spectests.rs | 1 + crates/blockchain/tests/signature_types.rs | 297 +---------------- crates/blockchain/tests/types.rs | 294 +---------------- 6 files changed, 330 insertions(+), 574 deletions(-) create mode 100644 crates/blockchain/tests/common.rs diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 440d45de..d8e0825c 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -8,6 +8,7 @@ readme.workspace = true repository.workspace = true rust-version.workspace = true version.workspace = true +autotests = false [features] # To skip signature verification during tests diff --git a/crates/blockchain/tests/common.rs b/crates/blockchain/tests/common.rs new file mode 100644 index 00000000..5d7e804c --- /dev/null +++ b/crates/blockchain/tests/common.rs @@ -0,0 +1,310 @@ +#![allow(dead_code)] + +use ethlambda_types::{ + attestation::{ + AggregatedAttestation as DomainAggregatedAttestation, + AggregationBits as DomainAggregationBits, Attestation as DomainAttestation, + AttestationData as DomainAttestationData, + }, + block::{Block as DomainBlock, BlockBody as DomainBlockBody}, + primitives::{BitList, H256, VariableList}, + state::{ + ChainConfig, Checkpoint as DomainCheckpoint, State, Validator as DomainValidator, + ValidatorPubkeyBytes, + }, +}; +use serde::Deserialize; + +// ============================================================================ +// Generic Container +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Container { + pub data: Vec, +} + +// ============================================================================ +// Config +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(rename = "genesisTime")] + pub genesis_time: u64, +} + +impl From for ChainConfig { + fn from(value: Config) -> Self { + ChainConfig { + genesis_time: value.genesis_time, + } + } +} + +// ============================================================================ +// Checkpoint +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Checkpoint { + pub root: H256, + pub slot: u64, +} + +impl From for DomainCheckpoint { + fn from(value: Checkpoint) -> Self { + Self { + root: value.root, + slot: value.slot, + } + } +} + +// ============================================================================ +// BlockHeader +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockHeader { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + #[serde(rename = "bodyRoot")] + pub body_root: H256, +} + +impl From for ethlambda_types::block::BlockHeader { + fn from(value: BlockHeader) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body_root: value.body_root, + } + } +} + +// ============================================================================ +// Validator +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Validator { + index: u64, + #[serde(deserialize_with = "deser_pubkey_hex")] + pubkey: ValidatorPubkeyBytes, +} + +impl From for DomainValidator { + fn from(value: Validator) -> Self { + Self { + index: value.index, + pubkey: value.pubkey, + } + } +} + +// ============================================================================ +// State +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct TestState { + pub config: Config, + pub slot: u64, + #[serde(rename = "latestBlockHeader")] + pub latest_block_header: BlockHeader, + #[serde(rename = "latestJustified")] + pub latest_justified: Checkpoint, + #[serde(rename = "latestFinalized")] + pub latest_finalized: Checkpoint, + #[serde(rename = "historicalBlockHashes")] + pub historical_block_hashes: Container, + #[serde(rename = "justifiedSlots")] + pub justified_slots: Container, + pub validators: Container, + #[serde(rename = "justificationsRoots")] + pub justifications_roots: Container, + #[serde(rename = "justificationsValidators")] + pub justifications_validators: Container, +} + +impl From for State { + fn from(value: TestState) -> Self { + let historical_block_hashes = + VariableList::new(value.historical_block_hashes.data).unwrap(); + let validators = + VariableList::new(value.validators.data.into_iter().map(Into::into).collect()).unwrap(); + let justifications_roots = VariableList::new(value.justifications_roots.data).unwrap(); + + State { + config: value.config.into(), + slot: value.slot, + latest_block_header: value.latest_block_header.into(), + latest_justified: value.latest_justified.into(), + latest_finalized: value.latest_finalized.into(), + historical_block_hashes, + justified_slots: BitList::with_capacity(0).unwrap(), + validators, + justifications_roots, + justifications_validators: BitList::with_capacity(0).unwrap(), + } + } +} + +// ============================================================================ +// Block Types +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct Block { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + pub body: BlockBody, +} + +impl From for DomainBlock { + fn from(value: Block) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body: value.body.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct BlockBody { + pub attestations: Container, +} + +impl From for DomainBlockBody { + fn from(value: BlockBody) -> Self { + let attestations = value + .attestations + .data + .into_iter() + .map(Into::into) + .collect(); + Self { + attestations: VariableList::new(attestations).expect("too many attestations"), + } + } +} + +// ============================================================================ +// Attestation Types +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, +} + +impl From for DomainAggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for DomainAggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = DomainAggregationBits::with_capacity(value.data.len()).unwrap(); + for (i, &b) in value.data.iter().enumerate() { + bits.set(i, b).unwrap(); + } + bits + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AttestationData { + pub slot: u64, + pub head: Checkpoint, + pub target: Checkpoint, + pub source: Checkpoint, +} + +impl From for DomainAttestationData { + fn from(value: AttestationData) -> Self { + Self { + slot: value.slot, + head: value.head.into(), + target: value.target.into(), + source: value.source.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ProposerAttestation { + #[serde(rename = "validatorId")] + pub validator_id: u64, + pub data: AttestationData, +} + +impl From for DomainAttestation { + fn from(value: ProposerAttestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + } + } +} + +// ============================================================================ +// Metadata +// ============================================================================ + +#[derive(Debug, Clone, Deserialize)] +pub struct TestInfo { + pub hash: String, + pub comment: String, + #[serde(rename = "testId")] + pub test_id: String, + pub description: String, + #[serde(rename = "fixtureFormat")] + pub fixture_format: String, +} + +// ============================================================================ +// Helpers +// ============================================================================ + +pub fn deser_pubkey_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + use serde::de::Error; + + let value = String::deserialize(d)?; + let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? + .try_into() + .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; + Ok(pubkey) +} diff --git a/crates/blockchain/tests/forkchoice_spectests.rs b/crates/blockchain/tests/forkchoice_spectests.rs index e1c69e1b..7a9fd62b 100644 --- a/crates/blockchain/tests/forkchoice_spectests.rs +++ b/crates/blockchain/tests/forkchoice_spectests.rs @@ -17,6 +17,7 @@ use crate::types::{ForkChoiceTestVector, StoreChecks}; const SUPPORTED_FIXTURE_FORMAT: &str = "fork_choice_test"; +mod common; mod types; fn run(path: &Path) -> datatest_stable::Result<()> { diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs index 40ec7aa4..78940d4a 100644 --- a/crates/blockchain/tests/signature_spectests.rs +++ b/crates/blockchain/tests/signature_spectests.rs @@ -9,6 +9,7 @@ use ethlambda_types::{ state::State, }; +mod common; mod signature_types; use signature_types::VerifySignaturesTestVector; diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index a700ae16..b52e2b32 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,16 +1,10 @@ -use ethlambda_types::attestation::{ - AggregatedAttestation as EthAggregatedAttestation, AggregationBits as EthAggregationBits, - Attestation as EthAttestation, AttestationData as EthAttestationData, XmssSignature, -}; +use super::common::{AggregationBits, Block, Container, ProposerAttestation, TestInfo, TestState}; +use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ - AggregatedAttestations, AggregatedSignatureProof, AttestationSignatures, Block as EthBlock, - BlockBody as EthBlockBody, BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, -}; -use ethlambda_types::primitives::{ - BitList, H256, VariableList, - ssz::{Decode as SszDecode, Encode as SszEncode}, + AggregatedSignatureProof, AttestationSignatures, BlockSignatures, BlockWithAttestation, + SignedBlockWithAttestation, }; -use ethlambda_types::state::{Checkpoint as EthCheckpoint, State, ValidatorPubkeyBytes}; +use ethlambda_types::primitives::ssz::{Decode as SszDecode, Encode as SszEncode}; use serde::Deserialize; use ssz_types::FixedVector; use ssz_types::typenum::{U28, U32}; @@ -41,6 +35,10 @@ pub struct SszXmssSignature { pub hashes: Vec, } +// ============================================================================ +// Root Structures +// ============================================================================ + /// Root struct for verify signatures test vectors #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTestVector { @@ -76,131 +74,9 @@ pub struct VerifySignaturesTest { pub info: TestInfo, } -/// Pre-state of the beacon chain for signature tests -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestState { - pub config: Config, - pub slot: u64, - #[serde(rename = "latestBlockHeader")] - pub latest_block_header: BlockHeader, - #[serde(rename = "latestJustified")] - pub latest_justified: Checkpoint, - #[serde(rename = "latestFinalized")] - pub latest_finalized: Checkpoint, - #[serde(rename = "historicalBlockHashes")] - pub historical_block_hashes: Container, - #[serde(rename = "justifiedSlots")] - pub justified_slots: Container, - pub validators: Container, - #[serde(rename = "justificationsRoots")] - pub justifications_roots: Container, - #[serde(rename = "justificationsValidators")] - pub justifications_validators: Container, -} - -impl From for State { - fn from(value: TestState) -> Self { - let historical_block_hashes = - VariableList::new(value.historical_block_hashes.data).unwrap(); - let validators = - VariableList::new(value.validators.data.into_iter().map(Into::into).collect()).unwrap(); - let justifications_roots = VariableList::new(value.justifications_roots.data).unwrap(); - - State { - config: value.config.into(), - slot: value.slot, - latest_block_header: value.latest_block_header.into(), - latest_justified: value.latest_justified.into(), - latest_finalized: value.latest_finalized.into(), - historical_block_hashes, - justified_slots: BitList::with_capacity(0).unwrap(), - validators, - justifications_roots, - justifications_validators: BitList::with_capacity(0).unwrap(), - } - } -} - -/// Configuration for the beacon chain -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - #[serde(rename = "genesisTime")] - pub genesis_time: u64, -} - -impl From for ethlambda_types::state::ChainConfig { - fn from(value: Config) -> Self { - ethlambda_types::state::ChainConfig { - genesis_time: value.genesis_time, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Checkpoint { - pub root: H256, - pub slot: u64, -} - -impl From for EthCheckpoint { - fn from(value: Checkpoint) -> Self { - Self { - root: value.root, - slot: value.slot, - } - } -} - -/// Block header representing the latest block -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct BlockHeader { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - #[serde(rename = "bodyRoot")] - pub body_root: H256, -} - -impl From for ethlambda_types::block::BlockHeader { - fn from(value: BlockHeader) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body_root: value.body_root, - } - } -} - -/// Validator information -#[derive(Debug, Clone, Deserialize)] -pub struct Validator { - pub index: u64, - #[serde(deserialize_with = "deser_pubkey_hex")] - pub pubkey: ValidatorPubkeyBytes, -} - -impl From for ethlambda_types::state::Validator { - fn from(value: Validator) -> Self { - Self { - index: value.index, - pubkey: value.pubkey, - } - } -} - -/// Generic container for arrays -#[derive(Debug, Clone, Deserialize)] -pub struct Container { - pub data: Vec, -} +// ============================================================================ +// Signed Block Types +// ============================================================================ /// Signed block with attestation and signature #[derive(Debug, Clone, Deserialize)] @@ -255,123 +131,9 @@ pub struct TestBlockWithAttestation { pub proposer_attestation: ProposerAttestation, } -/// A block to be processed -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct Block { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - pub body: BlockBody, -} - -impl From for EthBlock { - fn from(value: Block) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body: value.body.into(), - } - } -} - -/// Block body containing attestations -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct BlockBody { - pub attestations: Container, -} - -impl From for EthBlockBody { - fn from(value: BlockBody) -> Self { - let attestations: AggregatedAttestations = value - .attestations - .data - .into_iter() - .map(Into::into) - .collect::>() - .try_into() - .expect("too many attestations"); - Self { attestations } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AggregatedAttestation { - #[serde(rename = "aggregationBits")] - pub aggregation_bits: AggregationBits, - pub data: AttestationData, -} - -impl From for EthAggregatedAttestation { - fn from(value: AggregatedAttestation) -> Self { - Self { - aggregation_bits: value.aggregation_bits.into(), - data: value.data.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AggregationBits { - pub data: Vec, -} - -impl From for EthAggregationBits { - fn from(value: AggregationBits) -> Self { - let mut bits = EthAggregationBits::with_capacity(value.data.len()).unwrap(); - for (i, &b) in value.data.iter().enumerate() { - bits.set(i, b).unwrap(); - } - bits - } -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct AttestationData { - pub slot: u64, - pub head: Checkpoint, - pub target: Checkpoint, - pub source: Checkpoint, -} - -impl From for EthAttestationData { - fn from(value: AttestationData) -> Self { - Self { - slot: value.slot, - head: value.head.into(), - target: value.target.into(), - source: value.source.into(), - } - } -} - -/// Proposer attestation structure -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct ProposerAttestation { - #[serde(rename = "validatorId")] - pub validator_id: u64, - pub data: AttestationData, -} - -impl From for EthAttestation { - fn from(value: ProposerAttestation) -> Self { - Self { - validator_id: value.validator_id, - data: value.data.into(), - } - } -} +// ============================================================================ +// Signature Types +// ============================================================================ /// Bundle of signatures for block and attestations #[derive(Debug, Clone, Deserialize)] @@ -496,32 +258,3 @@ pub struct AttestationSignature { pub struct ProofData { pub data: String, } - -/// Test metadata and information -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestInfo { - pub hash: String, - pub comment: String, - #[serde(rename = "testId")] - pub test_id: String, - pub description: String, - #[serde(rename = "fixtureFormat")] - pub fixture_format: String, -} - -// Helpers - -pub fn deser_pubkey_hex<'de, D>(d: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::de::Error; - - let value = String::deserialize(d)?; - let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) - .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? - .try_into() - .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; - Ok(pubkey) -} diff --git a/crates/blockchain/tests/types.rs b/crates/blockchain/tests/types.rs index edb80f91..e8c089ef 100644 --- a/crates/blockchain/tests/types.rs +++ b/crates/blockchain/tests/types.rs @@ -1,16 +1,5 @@ -use ethlambda_types::{ - attestation::{ - AggregatedAttestation as DomainAggregatedAttestation, - AggregationBits as DomainAggregationBits, Attestation as DomainAttestation, - AttestationData as DomainAttestationData, - }, - block::{Block as DomainBlock, BlockBody as DomainBlockBody}, - primitives::{BitList, H256, VariableList}, - state::{ - ChainConfig, Checkpoint as DomainCheckpoint, State, Validator as DomainValidator, - ValidatorPubkeyBytes, - }, -}; +use super::common::{Block, ProposerAttestation, TestInfo, TestState}; +use ethlambda_types::primitives::H256; use serde::Deserialize; use std::collections::HashMap; use std::path::Path; @@ -126,282 +115,3 @@ pub struct AttestationCheck { pub target_slot: Option, pub location: String, } - -// ============================================================================ -// State Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct TestState { - pub config: Config, - pub slot: u64, - #[serde(rename = "latestBlockHeader")] - pub latest_block_header: BlockHeader, - #[serde(rename = "latestJustified")] - pub latest_justified: Checkpoint, - #[serde(rename = "latestFinalized")] - pub latest_finalized: Checkpoint, - #[serde(rename = "historicalBlockHashes")] - pub historical_block_hashes: Container, - #[serde(rename = "justifiedSlots")] - pub justified_slots: Container, - pub validators: Container, - #[serde(rename = "justificationsRoots")] - pub justifications_roots: Container, - #[serde(rename = "justificationsValidators")] - pub justifications_validators: Container, -} - -impl From for State { - fn from(value: TestState) -> Self { - let historical_block_hashes = - VariableList::new(value.historical_block_hashes.data).unwrap(); - let validators = - VariableList::new(value.validators.data.into_iter().map(Into::into).collect()).unwrap(); - let justifications_roots = VariableList::new(value.justifications_roots.data).unwrap(); - - State { - config: value.config.into(), - slot: value.slot, - latest_block_header: value.latest_block_header.into(), - latest_justified: value.latest_justified.into(), - latest_finalized: value.latest_finalized.into(), - historical_block_hashes, - justified_slots: BitList::with_capacity(0).unwrap(), - validators, - justifications_roots, - justifications_validators: BitList::with_capacity(0).unwrap(), - } - } -} - -// ============================================================================ -// Primitive Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Config { - #[serde(rename = "genesisTime")] - pub genesis_time: u64, -} - -impl From for ChainConfig { - fn from(value: Config) -> Self { - ChainConfig { - genesis_time: value.genesis_time, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Checkpoint { - pub root: H256, - pub slot: u64, -} - -impl From for DomainCheckpoint { - fn from(value: Checkpoint) -> Self { - Self { - root: value.root, - slot: value.slot, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct BlockHeader { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - #[serde(rename = "bodyRoot")] - pub body_root: H256, -} - -impl From for ethlambda_types::block::BlockHeader { - fn from(value: BlockHeader) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body_root: value.body_root, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Validator { - index: u64, - #[serde(deserialize_with = "deser_pubkey_hex")] - pubkey: ValidatorPubkeyBytes, -} - -impl From for DomainValidator { - fn from(value: Validator) -> Self { - Self { - index: value.index, - pubkey: value.pubkey, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Container { - pub data: Vec, -} - -// ============================================================================ -// Block Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct Block { - pub slot: u64, - #[serde(rename = "proposerIndex")] - pub proposer_index: u64, - #[serde(rename = "parentRoot")] - pub parent_root: H256, - #[serde(rename = "stateRoot")] - pub state_root: H256, - pub body: BlockBody, -} - -impl From for DomainBlock { - fn from(value: Block) -> Self { - Self { - slot: value.slot, - proposer_index: value.proposer_index, - parent_root: value.parent_root, - state_root: value.state_root, - body: value.body.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct BlockBody { - pub attestations: Container, -} - -impl From for DomainBlockBody { - fn from(value: BlockBody) -> Self { - let attestations = value - .attestations - .data - .into_iter() - .map(Into::into) - .collect(); - Self { - attestations: VariableList::new(attestations).expect("too many attestations"), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregatedAttestation { - #[serde(rename = "aggregationBits")] - pub aggregation_bits: AggregationBits, - pub data: AttestationData, -} - -impl From for DomainAggregatedAttestation { - fn from(value: AggregatedAttestation) -> Self { - Self { - aggregation_bits: value.aggregation_bits.into(), - data: value.data.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AggregationBits { - pub data: Vec, -} - -impl From for DomainAggregationBits { - fn from(value: AggregationBits) -> Self { - let mut bits = DomainAggregationBits::with_capacity(value.data.len()).unwrap(); - for (i, &b) in value.data.iter().enumerate() { - bits.set(i, b).unwrap(); - } - bits - } -} - -// ============================================================================ -// Attestation Types -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -pub struct ProposerAttestation { - #[serde(rename = "validatorId")] - pub validator_id: u64, - pub data: AttestationData, -} - -impl From for DomainAttestation { - fn from(value: ProposerAttestation) -> Self { - Self { - validator_id: value.validator_id, - data: value.data.into(), - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct AttestationData { - pub slot: u64, - pub head: Checkpoint, - pub target: Checkpoint, - pub source: Checkpoint, -} - -impl From for DomainAttestationData { - fn from(value: AttestationData) -> Self { - Self { - slot: value.slot, - head: value.head.into(), - target: value.target.into(), - source: value.source.into(), - } - } -} - -// ============================================================================ -// Metadata -// ============================================================================ - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct TestInfo { - pub hash: String, - pub comment: String, - #[serde(rename = "testId")] - pub test_id: String, - pub description: String, - #[serde(rename = "fixtureFormat")] - pub fixture_format: String, -} - -// ============================================================================ -// Helpers -// ============================================================================ - -pub fn deser_pubkey_hex<'de, D>(d: D) -> Result -where - D: serde::Deserializer<'de>, -{ - use serde::Deserialize; - use serde::de::Error; - - let value = String::deserialize(d)?; - let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) - .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? - .try_into() - .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; - Ok(pubkey) -} From 4787537386e9633c9061e7e53a6201560dca9c05 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 15:44:39 -0300 Subject: [PATCH 04/10] Add nix flake for dev shell and package build (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Provide a reproducible development environment and build via nix, so contributors can get a working setup with a single command regardless of their system package manager. ## Description Adds a nix flake using [crane](https://github.com/ipetkov/crane) for Rust builds and [rust-overlay](https://github.com/oxalica/rust-overlay) to pin the exact Rust 1.92.0 toolchain. ### Outputs | Output | What it provides | |--------|-----------------| | `devShells.default` | Rust 1.92.0 + clippy + rustfmt + libclang + pkg-config + cargo-watch | | `packages.default` | `ethlambda` release binary | ### Build structure Crane's two-phase build (`buildDepsOnly` → `buildPackage`) caches compiled dependencies separately from source code, giving fast incremental rebuilds similar to cargo-chef in Docker. ### vergen-git2 handling Nix cleans the source tree, so `.git` is absent at build time. The flake sets `VERGEN_GIT_SHA` (from the flake's git rev) and `VERGEN_GIT_BRANCH` (`"nix"`) as env vars. vergen-git2 falls back to these when git info is unavailable. ### Platform support Supports `x86_64-linux`, `aarch64-linux`, `x86_64-darwin`, and `aarch64-darwin`. Darwin builds include `libiconv` and `apple-sdk_15`; these are gated behind `isDarwin` and don't affect Linux. ### Files - `flake.nix` — flake definition - `flake.lock` — pinned input revisions - `.envrc` — direnv integration (`use flake`) ## How to Test ```bash # Validate flake structure nix flake check # Enter dev shell, verify toolchain nix develop rustc --version # 1.92.0 # Build the binary nix build ./result/bin/ethlambda --version # Or with direnv: cd into the repo and the shell activates automatically direnv allow ``` --------- Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com> --- .envrc | 1 + flake.lock | 64 +++++++++++++++++++++++++++++++++++++ flake.nix | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..fb300922 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1771438068, + "narHash": "sha256-nGBbXvEZVe/egCPVPFcu89RFtd8Rf6J+4RFoVCFec0A=", + "owner": "ipetkov", + "repo": "crane", + "rev": "b5090e53e9d68c523a4bb9ad42b4737ee6747597", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771177547, + "narHash": "sha256-trTtk3WTOHz7hSw89xIIvahkgoFJYQ0G43IlqprFoMA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ac055f38c798b0d87695240c7b761b82fc7e5bc2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1771384185, + "narHash": "sha256-KvmjUeA7uODwzbcQoN/B8DCZIbhT/Q/uErF1BBMcYnw=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "23dd7fa91602a68bd04847ac41bc10af1e6e2fd2", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..3a1e95ac --- /dev/null +++ b/flake.nix @@ -0,0 +1,94 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + crane.url = "github:ipetkov/crane"; + }; + + outputs = { self, nixpkgs, rust-overlay, crane }: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + + mkPkgs = system: import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + + rustToolchain = pkgs: pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + + mkCraneLib = pkgs: (crane.mkLib pkgs).overrideToolchain (rustToolchain pkgs); + in + { + packages = forAllSystems (system: + let + pkgs = mkPkgs system; + craneLib = mkCraneLib pkgs; + + commonArgs = { + pname = "ethlambda"; + src = craneLib.cleanCargoSource ./.; + strictDeps = true; + + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + buildInputs = with pkgs; [ + llvmPackages.libclang + ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isDarwin (with pkgs; [ + libiconv + apple-sdk_15 + ]); + + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + + # vergen-git2 falls back to env vars when .git is absent + VERGEN_GIT_SHA = self.shortRev or self.dirtyShortRev or "unknown"; + VERGEN_GIT_BRANCH = "nix"; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + ethlambda = craneLib.buildPackage (commonArgs // { + inherit cargoArtifacts; + # Only install the main binary + cargoExtraArgs = "--bin ethlambda"; + }); + in + { + default = ethlambda; + inherit ethlambda; + } + ); + + devShells = forAllSystems (system: + let + pkgs = mkPkgs system; + in + { + default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + (rustToolchain pkgs) + pkg-config + cargo-watch + ]; + + buildInputs = with pkgs; [ + llvmPackages.libclang + ] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isDarwin (with pkgs; [ + libiconv + apple-sdk_15 + ]); + + LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; + }; + } + ); + }; +} From 287ba38a8bf6000183937f6cccd42963d0db038b Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 16:43:22 -0300 Subject: [PATCH 05/10] Unify duplicated attestation accessors in Store (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `Store` in `crates/storage/src/store.rs` had three pairs of methods for "known" and "new" attestations that were identical except for the `Table` variant they targeted: | Known variant | New variant | |---|---| | `iter_known_attestations()` | `iter_new_attestations()` | | `get_known_attestation()` | `get_new_attestation()` | | `insert_known_attestation()` | `insert_new_attestation()` | Each pair duplicated the same `begin_read → prefix_iterator → decode` (or `begin_write → put_batch → commit`) pattern, differing only in `Table::LatestKnownAttestations` vs `Table::LatestNewAttestations`. ## Description Extract three private helpers parameterized by `Table`: - `iter_attestations(table)` — single implementation of the iterator + decode pattern - `get_attestation(table, validator_id)` — single implementation of point lookup + decode - `insert_attestation(table, validator_id, data)` — single implementation of write batch + commit The six public methods become thin one-line delegates: ```rust pub fn iter_known_attestations(&self) -> ... { self.iter_attestations(Table::LatestKnownAttestations) } pub fn iter_new_attestations(&self) -> ... { self.iter_attestations(Table::LatestNewAttestations) } // same pattern for get_* and insert_* ``` **Public API is fully preserved** — no callers change. ### Left untouched - `remove_new_attestation()` — no "known" counterpart, stays as-is - `promote_new_attestations()` — unique cross-table logic, stays as-is - `iter_gossip_signatures()` / `iter_aggregated_payloads()` — different key/value types, not deduplicated ## How to test ```bash make fmt # formatting make lint # clippy with -D warnings make test # all workspace tests + forkchoice spectests ``` Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com> --- crates/storage/src/store.rs | 67 +++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs index d43a6a02..5054dbae 100644 --- a/crates/storage/src/store.rs +++ b/crates/storage/src/store.rs @@ -624,16 +624,12 @@ impl Store { batch.commit().expect("commit"); } - // ============ Known Attestations ============ - // - // "Known" attestations are included in fork choice weight calculations. - // They're promoted from "new" attestations at specific intervals. + // ============ Attestation Helpers ============ - /// Iterates over all known attestations (validator_id, attestation_data). - pub fn iter_known_attestations(&self) -> impl Iterator + '_ { + fn iter_attestations(&self, table: Table) -> impl Iterator + '_ { let view = self.backend.begin_read().expect("read view"); let entries: Vec<_> = view - .prefix_iterator(Table::LatestKnownAttestations, &[]) + .prefix_iterator(table, &[]) .expect("iterator") .filter_map(|res| res.ok()) .map(|(k, v)| { @@ -645,24 +641,40 @@ impl Store { entries.into_iter() } - /// Returns a validator's latest known attestation. - pub fn get_known_attestation(&self, validator_id: &u64) -> Option { + fn get_attestation(&self, table: Table, validator_id: &u64) -> Option { let view = self.backend.begin_read().expect("read view"); - view.get(Table::LatestKnownAttestations, &validator_id.as_ssz_bytes()) + view.get(table, &validator_id.as_ssz_bytes()) .expect("get") .map(|bytes| AttestationData::from_ssz_bytes(&bytes).expect("valid attestation data")) } - /// Stores a validator's latest known attestation. - pub fn insert_known_attestation(&mut self, validator_id: u64, data: AttestationData) { + fn insert_attestation(&mut self, table: Table, validator_id: u64, data: AttestationData) { let mut batch = self.backend.begin_write().expect("write batch"); let entries = vec![(validator_id.as_ssz_bytes(), data.as_ssz_bytes())]; - batch - .put_batch(Table::LatestKnownAttestations, entries) - .expect("put attestation"); + batch.put_batch(table, entries).expect("put attestation"); batch.commit().expect("commit"); } + // ============ Known Attestations ============ + // + // "Known" attestations are included in fork choice weight calculations. + // They're promoted from "new" attestations at specific intervals. + + /// Iterates over all known attestations (validator_id, attestation_data). + pub fn iter_known_attestations(&self) -> impl Iterator + '_ { + self.iter_attestations(Table::LatestKnownAttestations) + } + + /// Returns a validator's latest known attestation. + pub fn get_known_attestation(&self, validator_id: &u64) -> Option { + self.get_attestation(Table::LatestKnownAttestations, validator_id) + } + + /// Stores a validator's latest known attestation. + pub fn insert_known_attestation(&mut self, validator_id: u64, data: AttestationData) { + self.insert_attestation(Table::LatestKnownAttestations, validator_id, data); + } + // ============ New Attestations ============ // // "New" attestations are pending attestations not yet included in fork choice. @@ -670,36 +682,17 @@ impl Store { /// Iterates over all new (pending) attestations. pub fn iter_new_attestations(&self) -> impl Iterator + '_ { - let view = self.backend.begin_read().expect("read view"); - let entries: Vec<_> = view - .prefix_iterator(Table::LatestNewAttestations, &[]) - .expect("iterator") - .filter_map(|res| res.ok()) - .map(|(k, v)| { - let validator_id = u64::from_ssz_bytes(&k).expect("valid validator_id"); - let data = AttestationData::from_ssz_bytes(&v).expect("valid attestation data"); - (validator_id, data) - }) - .collect(); - entries.into_iter() + self.iter_attestations(Table::LatestNewAttestations) } /// Returns a validator's latest new (pending) attestation. pub fn get_new_attestation(&self, validator_id: &u64) -> Option { - let view = self.backend.begin_read().expect("read view"); - view.get(Table::LatestNewAttestations, &validator_id.as_ssz_bytes()) - .expect("get") - .map(|bytes| AttestationData::from_ssz_bytes(&bytes).expect("valid attestation data")) + self.get_attestation(Table::LatestNewAttestations, validator_id) } /// Stores a validator's new (pending) attestation. pub fn insert_new_attestation(&mut self, validator_id: u64, data: AttestationData) { - let mut batch = self.backend.begin_write().expect("write batch"); - let entries = vec![(validator_id.as_ssz_bytes(), data.as_ssz_bytes())]; - batch - .put_batch(Table::LatestNewAttestations, entries) - .expect("put attestation"); - batch.commit().expect("commit"); + self.insert_attestation(Table::LatestNewAttestations, validator_id, data); } /// Removes a validator's new (pending) attestation. From 43175dc44b7ec4edd73662cc0d74750b45ee54a6 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 12:49:11 -0300 Subject: [PATCH 06/10] Extract shared test types to common module (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/blockchain/tests/types.rs` (fork choice tests) and `crates/blockchain/tests/signature_types.rs` (signature tests) duplicate ~14 type definitions with identical fields, serde attributes, and `From` trait implementations. This creates maintenance burden and risks divergence — any change to shared types (e.g. adding a new field to `TestState` or `Block`) must be applied in two places. Extract the 14 shared types into a new `common.rs` module that both test files import from: | Type | Purpose | |------|---------| | `Container` | Generic serde wrapper for JSON arrays with `data` field | | `Config` + `From for ChainConfig` | Genesis time config | | `Checkpoint` + `From for DomainCheckpoint` | Root + slot pair | | `BlockHeader` + `From for block::BlockHeader` | Block header fields | | `Validator` + `From for DomainValidator` | Validator index + pubkey | | `TestState` + `From for State` | Full beacon state deserialization | | `Block` + `From for DomainBlock` | Block with body | | `BlockBody` + `From for DomainBlockBody` | Attestation container | | `AggregatedAttestation` + `From` impl | Aggregation bits + data | | `AggregationBits` + `From` impl | Boolean bitfield | | `AttestationData` + `From` impl | Slot + head/target/source checkpoints | | `ProposerAttestation` + `From` impl | Validator ID + attestation data | | `TestInfo` | Test metadata (hash, comment, description) | | `deser_pubkey_hex()` | Hex string → `ValidatorPubkeyBytes` deserializer | **Files changed:** - **`tests/common.rs`** (new) — shared types with `#![allow(dead_code)]` at module level - **`tests/types.rs`** — reduced to fork-choice-specific types only (`ForkChoiceTestVector`, `ForkChoiceTest`, `ForkChoiceStep`, `BlockStepData`, `StoreChecks`, `AttestationCheck`), imports shared types via `super::common` - **`tests/signature_types.rs`** — reduced to signature-specific types only (SSZ types, `VerifySignaturesTestVector`, `TestSignedBlockWithAttestation`, `ProposerSignature`, etc.), imports shared types via `super::common` - **`tests/forkchoice_spectests.rs`** / **`tests/signature_spectests.rs`** — added `mod common;` - **`Cargo.toml`** — added `autotests = false` to prevent `common.rs`, `types.rs`, and `signature_types.rs` from being auto-discovered as standalone test binaries **Net result:** -575 lines added, +330 lines removed = **245 fewer lines** of duplicated code. The `From` impl differed slightly between files: - `types.rs`: `VariableList::new(attestations).expect(...)` - `signature_types.rs`: `collect::>().try_into().expect(...)` Both are equivalent. The common module uses the `VariableList::new()` form. ```bash make fmt # No formatting changes make lint # No warnings make test # All 26 forkchoice spec tests pass ``` Note: `signature_spectests` have pre-existing failures (fixture format mismatch with `ProposerSignature` — the JSON provides a hex string where the code expects a struct). These failures are identical on `main` and are unrelated to this change. --- crates/blockchain/tests/signature_types.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index b52e2b32..0690d49a 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,8 +1,8 @@ use super::common::{AggregationBits, Block, Container, ProposerAttestation, TestInfo, TestState}; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ - AggregatedSignatureProof, AttestationSignatures, BlockSignatures, BlockWithAttestation, - SignedBlockWithAttestation, + AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, AttestationSignatures, + BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, }; use ethlambda_types::primitives::ssz::{Decode as SszDecode, Encode as SszEncode}; use serde::Deserialize; From 6495339bc5c48fe786cf010139fc28b0af2a6998 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 17:47:14 -0300 Subject: [PATCH 07/10] Decompose process_attestations() into helper functions (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `process_attestations()` was 183 lines with validation, vote recording, justification, finalization, and serialization all inlined in a single function. This makes it hard to review each phase independently and to reason about which parts mutate state. ## Description Extract three logical phases into well-named helper functions: ### `is_valid_attestation()` — pure predicate, no mutation Consolidates the 6 consecutive `continue` guards into a single function returning `bool`. Checks: 1. Source is already justified 2. Target is not yet justified 3. Neither root is zero hash 4. Both checkpoints exist in `historical_block_hashes` 5. Target slot > source slot 6. Target slot is justifiable after the finalized slot ### `try_finalize()` — finalization attempt after justification Extracts the finalization block that was nested inside the supermajority `if` block, inside the attestation loop (three levels of nesting). Inverts the condition to use early return, removing one nesting level. ### `serialize_justifications()` — post-loop SSZ conversion Extracts the deterministic conversion from the in-memory `HashMap>` vote structure back into SSZ-compatible `state.justifications_roots` and `state.justifications_validators`. ### What stays inline Vote recording and the supermajority check remain in the main loop because they're tightly coupled to the iteration (mutating `justifications` and `attestations_processed` on every pass). ### No behavior change - Public API unchanged (`process_block` → `process_attestations`) - All existing comments preserved - No logic reordering within each extracted block ## How to Test ```bash make fmt make lint make test cargo test -p ethlambda-blockchain --features skip-signature-verification --test forkchoice_spectests --release ``` The forkchoice spectests (26 tests) exercise `process_attestations` end-to-end: justification, finalization, reorgs, and edge cases. All pass. --------- Co-authored-by: Tomás Grüner <47506558+MegaRedHand@users.noreply.github.com> --- crates/blockchain/state_transition/src/lib.rs | 198 +++++++++++------- 1 file changed, 127 insertions(+), 71 deletions(-) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index bb7555d9..8a0ce05b 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -233,42 +233,7 @@ fn process_attestations( let source = attestation_data.source; let target = attestation_data.target; - // Check that the source is already justified - if !justified_slots_ops::is_slot_justified( - &state.justified_slots, - state.latest_finalized.slot, - source.slot, - ) { - // TODO: why doesn't this make the block invalid? - continue; - } - - // Ignore votes for targets that have already reached consensus - if justified_slots_ops::is_slot_justified( - &state.justified_slots, - state.latest_finalized.slot, - target.slot, - ) { - continue; - } - - // Ignore votes that reference zero-hash slots. - if source.root == H256::ZERO || target.root == H256::ZERO { - continue; - } - - // Ensure the vote refers to blocks that actually exist on our chain - if !checkpoint_exists(state, source) || !checkpoint_exists(state, target) { - continue; - } - - // Ensure time flows forward - if target.slot <= source.slot { - continue; - } - - // Ensure the target falls on a slot that can be justified after the finalized one. - if !slot_is_justifiable_after(target.slot, original_finalized_slot) { + if !is_valid_vote(state, source, target, original_finalized_slot) { continue; } @@ -310,43 +275,136 @@ fn process_attestations( justifications.remove(&target.root); - // Consider whether finalization can advance. - // Use ORIGINAL finalized slot for is_justifiable_after check. - if !((source.slot + 1)..target.slot) - .any(|slot| slot_is_justifiable_after(slot, original_finalized_slot)) - { - let old_finalized_slot = state.latest_finalized.slot; - state.latest_finalized = source; - metrics::inc_finalizations("success"); - - let finalized_slot = source.slot; - let previous_finalized = old_finalized_slot; - let justified_slot = state.latest_justified.slot; - info!( - finalized_slot, - finalized_root = %ShortRoot(&source.root.0), - previous_finalized, - justified_slot, - "Checkpoint finalized" - ); - - // Shift window to drop finalized slots from the front - let delta = (state.latest_finalized.slot - old_finalized_slot) as usize; - justified_slots_ops::shift_window(&mut state.justified_slots, delta); - - // Prune justifications whose roots only appear at now-finalized slots - justifications.retain(|root, _| { - let slot = root_to_slot[root]; - slot > state.latest_finalized.slot - }); - } else { - metrics::inc_finalizations("error"); - } + try_finalize( + state, + source, + target, + original_finalized_slot, + &mut justifications, + &root_to_slot, + ); } } - // Convert the vote structure back into SSZ format + serialize_justifications(state, justifications, validator_count); + metrics::inc_attestations_processed(attestations_processed); + Ok(()) +} + +/// Returns whether an attestation should be counted for fork choice. +/// +/// Checks (all must pass): +/// 1. Source is already justified +/// 2. Target is not yet justified +/// 3. Neither root is zero hash +/// 4. Both checkpoints exist in historical_block_hashes +/// 5. Target slot > source slot +/// 6. Target slot is justifiable after the finalized slot +fn is_valid_vote( + state: &State, + source: Checkpoint, + target: Checkpoint, + original_finalized_slot: u64, +) -> bool { + // Check that the source is already justified + if !justified_slots_ops::is_slot_justified( + &state.justified_slots, + state.latest_finalized.slot, + source.slot, + ) { + // TODO: why doesn't this make the block invalid? + return false; + } + + // Ignore votes for targets that have already reached consensus + if justified_slots_ops::is_slot_justified( + &state.justified_slots, + state.latest_finalized.slot, + target.slot, + ) { + return false; + } + // Ignore votes that reference zero-hash slots. + if source.root == H256::ZERO || target.root == H256::ZERO { + return false; + } + + // Ensure the vote refers to blocks that actually exist on our chain + if !checkpoint_exists(state, source) || !checkpoint_exists(state, target) { + return false; + } + + // Ensure time flows forward + if target.slot <= source.slot { + return false; + } + + // Ensure the target falls on a slot that can be justified after the finalized one. + if !slot_is_justifiable_after(target.slot, original_finalized_slot) { + return false; + } + + true +} + +/// Attempt to advance finalization from source to target. +/// +/// Finalization succeeds when there are no justifiable slots between +/// source.slot and target.slot (exclusive). When finalization advances, +/// shifts the justified_slots window and prunes stale justifications. +fn try_finalize( + state: &mut State, + source: Checkpoint, + target: Checkpoint, + original_finalized_slot: u64, + justifications: &mut HashMap>, + root_to_slot: &HashMap, +) { + // Consider whether finalization can advance. + // Use ORIGINAL finalized slot for is_justifiable_after check. + if ((source.slot + 1)..target.slot) + .any(|slot| slot_is_justifiable_after(slot, original_finalized_slot)) + { + metrics::inc_finalizations("error"); + return; + } + + let old_finalized_slot = state.latest_finalized.slot; + state.latest_finalized = source; + metrics::inc_finalizations("success"); + + let finalized_slot = source.slot; + let previous_finalized = old_finalized_slot; + let justified_slot = state.latest_justified.slot; + info!( + finalized_slot, + finalized_root = %ShortRoot(&source.root.0), + previous_finalized, + justified_slot, + "Checkpoint finalized" + ); + + // Shift window to drop finalized slots from the front + let delta = (state.latest_finalized.slot - old_finalized_slot) as usize; + justified_slots_ops::shift_window(&mut state.justified_slots, delta); + + // Prune justifications whose roots only appear at now-finalized slots + justifications.retain(|root, _| { + let slot = root_to_slot[root]; + slot > state.latest_finalized.slot + }); +} + +/// Convert the in-memory vote HashMap back into SSZ-compatible state fields. +/// +/// Sorts roots for deterministic output, then flattens vote bitfields +/// into `state.justifications_roots` and `state.justifications_validators`. +fn serialize_justifications( + state: &mut State, + justifications: HashMap>, + validator_count: usize, +) { // Sorting ensures that every node produces identical state representation. let justification_roots = { let mut roots: Vec = justifications.keys().cloned().collect(); @@ -370,8 +428,6 @@ fn process_attestations( .try_into() .expect("justifications_roots limit exceeded"); state.justifications_validators = justifications_validators; - metrics::inc_attestations_processed(attestations_processed); - Ok(()) } fn checkpoint_exists(state: &State, checkpoint: Checkpoint) -> bool { From 992517594cacc6ced8d7597ad832ee92d803cee3 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Tue, 24 Feb 2026 19:34:44 -0300 Subject: [PATCH 08/10] Deduplicate AggregationBits type alias and remove redundant getters AggregationBits was defined identically in both attestation.rs and block.rs. Keep the single definition in attestation.rs and import it in block.rs. Update blockchain/store.rs and signature_types.rs to import from attestation instead of block, removing the now-unnecessary EthAggregationBitsSig alias. Also remove participants() and proof_data() getters on AggregatedSignatureProof since both fields are already public and no code calls the getters. --- crates/blockchain/tests/signature_types.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index 0690d49a..b52e2b32 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,8 +1,8 @@ use super::common::{AggregationBits, Block, Container, ProposerAttestation, TestInfo, TestState}; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ - AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, AttestationSignatures, - BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, + AggregatedSignatureProof, AttestationSignatures, BlockSignatures, BlockWithAttestation, + SignedBlockWithAttestation, }; use ethlambda_types::primitives::ssz::{Decode as SszDecode, Encode as SszEncode}; use serde::Deserialize; From 8c8e04ab2e9ee8959445eef9b8f02b991d3886ca Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 17:56:30 -0300 Subject: [PATCH 09/10] fmt --- crates/blockchain/tests/signature_types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index 6bc59d65..1bf18a25 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,6 +1,6 @@ use super::common::{AggregationBits, Block, Container, ProposerAttestation, TestInfo, TestState}; -use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::attestation::XmssSignature; +use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, AttestationSignatures, BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, From 850dd832cee83a2e0805fba26db15b33febe0376 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 25 Feb 2026 17:59:38 -0300 Subject: [PATCH 10/10] fix imports --- crates/blockchain/tests/signature_types.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index 1bf18a25..b52e2b32 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -1,9 +1,8 @@ use super::common::{AggregationBits, Block, Container, ProposerAttestation, TestInfo, TestState}; -use ethlambda_types::attestation::XmssSignature; use ethlambda_types::attestation::{AggregationBits as EthAggregationBits, XmssSignature}; use ethlambda_types::block::{ - AggregatedSignatureProof, AggregationBits as EthAggregationBitsSig, AttestationSignatures, - BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, + AggregatedSignatureProof, AttestationSignatures, BlockSignatures, BlockWithAttestation, + SignedBlockWithAttestation, }; use ethlambda_types::primitives::ssz::{Decode as SszDecode, Encode as SszEncode}; use serde::Deserialize;