From ce8572ec3921084bedd9366c44be5d84b7fb7489 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 16:21:12 -0300 Subject: [PATCH 01/11] Bump leanSpec commit to 8b7636b Updates the pinned leanSpec commit hash to pick up the latest spec changes. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b9d74f58..f4fddc6d 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ docker-build: ## 🐳 Build the Docker image -t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) . @echo -LEAN_SPEC_COMMIT_HASH:=b39472e73f8a7d603cc13d14426eed14c6eff6f1 +LEAN_SPEC_COMMIT_HASH:=8b7636bb8a95fe4bec414cc4c24e74079e6256b6 leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch From 1f69319b2995aaf780310a440ef9600baf0f33ff Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 17:27:33 -0300 Subject: [PATCH 02/11] Fix signature fixture deserialization for new leanSpec hex format leanSpec commit 0340cc1 changed XMSS Signature serialization from structured JSON (path/rho/hashes sub-objects) to hex-encoded SSZ bytes ("0x..."). Replace the old ProposerSignature struct and its SSZ reconstruction logic with a simple hex deserializer (deser_xmss_hex), matching the existing deser_pubkey_hex pattern. --- crates/blockchain/tests/signature_types.rs | 148 +++------------------ 1 file changed, 17 insertions(+), 131 deletions(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index aab99223..1d8dede8 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -7,41 +7,12 @@ use ethlambda_types::block::{ AttestationSignatures, Block as EthBlock, BlockBody as EthBlockBody, BlockSignatures, BlockWithAttestation, SignedBlockWithAttestation, }; -use ethlambda_types::primitives::{ - BitList, H256, VariableList, - ssz::{Decode as SszDecode, Encode as SszEncode}, -}; +use ethlambda_types::primitives::{BitList, H256, VariableList}; use ethlambda_types::state::{Checkpoint as EthCheckpoint, State, ValidatorPubkeyBytes}; use serde::Deserialize; -use ssz_types::FixedVector; -use ssz_types::typenum::{U28, U32}; use std::collections::HashMap; use std::path::Path; -// ============================================================================ -// SSZ Types matching leansig's GeneralizedXMSSSignature structure -// ============================================================================ - -/// A single hash digest (8 field elements = 32 bytes) -pub type HashDigest = FixedVector; - -/// Randomness (7 field elements = 28 bytes) -pub type Rho = FixedVector; - -/// SSZ-compatible HashTreeOpening matching leansig's structure -#[derive(Clone, SszEncode, SszDecode)] -pub struct SszHashTreeOpening { - pub co_path: Vec, -} - -/// SSZ-compatible XMSS Signature matching leansig's GeneralizedXMSSSignature -#[derive(Clone, SszEncode, SszDecode)] -pub struct SszXmssSignature { - pub path: SszHashTreeOpening, - pub rho: Rho, - pub hashes: Vec, -} - /// Root struct for verify signatures test vectors #[derive(Debug, Clone, Deserialize)] pub struct VerifySignaturesTestVector { @@ -217,7 +188,7 @@ impl From for SignedBlockWithAttestation { proposer_attestation: value.message.proposer_attestation.into(), }; - let proposer_signature = value.signature.proposer_signature.to_xmss_signature(); + let proposer_signature = value.signature.proposer_signature; // Convert attestation signatures to AggregatedSignatureProof. // Each proof contains the participants bitfield from the test data. @@ -378,110 +349,12 @@ impl From for EthAttestation { #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct TestSignatureBundle { - #[serde(rename = "proposerSignature")] - pub proposer_signature: ProposerSignature, + #[serde(rename = "proposerSignature", deserialize_with = "deser_xmss_hex")] + pub proposer_signature: XmssSignature, #[serde(rename = "attestationSignatures")] pub attestation_signatures: Container, } -/// XMSS signature structure as it appears in JSON -#[derive(Debug, Clone, Deserialize)] -pub struct ProposerSignature { - pub path: SignaturePath, - pub rho: RhoData, - pub hashes: HashesData, -} - -impl ProposerSignature { - /// Convert to XmssSignature (FixedVector of bytes). - /// - /// Constructs an SSZ-encoded signature matching leansig's GeneralizedXMSSSignature format. - pub fn to_xmss_signature(&self) -> XmssSignature { - // Build SSZ types from JSON data - let ssz_sig = self.to_ssz_signature(); - - // Encode to SSZ bytes - let bytes = ssz_sig.as_ssz_bytes(); - - // Pad to exactly SignatureSize bytes (3112) - let sig_size = 3112; - let mut padded = bytes.clone(); - padded.resize(sig_size, 0); - - XmssSignature::new(padded).expect("signature size mismatch") - } - - /// Convert to SSZ signature type - fn to_ssz_signature(&self) -> SszXmssSignature { - // Convert path siblings to HashDigest (Vec of 32 bytes each) - let co_path: Vec = self - .path - .siblings - .data - .iter() - .map(|sibling| { - let bytes: Vec = sibling - .data - .iter() - .flat_map(|&val| val.to_le_bytes()) - .collect(); - HashDigest::new(bytes).expect("Invalid sibling length") - }) - .collect(); - - // Convert rho (7 field elements = 28 bytes) - let rho_bytes: Vec = self - .rho - .data - .iter() - .flat_map(|&val| val.to_le_bytes()) - .collect(); - let rho = Rho::new(rho_bytes).expect("Invalid rho length"); - - // Convert hashes to HashDigest - let hashes: Vec = self - .hashes - .data - .iter() - .map(|hash| { - let bytes: Vec = hash - .data - .iter() - .flat_map(|&val| val.to_le_bytes()) - .collect(); - HashDigest::new(bytes).expect("Invalid hash length") - }) - .collect(); - - SszXmssSignature { - path: SszHashTreeOpening { co_path }, - rho, - hashes, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct SignaturePath { - pub siblings: Container, -} - -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -pub struct HashElement { - pub data: [u32; 8], -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RhoData { - pub data: [u32; 7], -} - -#[derive(Debug, Clone, Deserialize)] -pub struct HashesData { - pub data: Vec, -} - /// Attestation signature from a validator /// Note: proofData is for future SNARK aggregation, currently just placeholder #[derive(Debug, Clone, Deserialize)] @@ -526,3 +399,16 @@ where .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; Ok(pubkey) } + +pub fn deser_xmss_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let value = String::deserialize(d)?; + let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("XmssSignature value is not valid hex"))?; + XmssSignature::new(bytes) + .map_err(|_| D::Error::custom("XmssSignature length != 3112")) +} From e0b9aafa2d74f21277262272578205bb73dd2296 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 17:33:05 -0300 Subject: [PATCH 03/11] Run cargo fmt --- crates/blockchain/tests/signature_types.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs index 1d8dede8..fab5b43f 100644 --- a/crates/blockchain/tests/signature_types.rs +++ b/crates/blockchain/tests/signature_types.rs @@ -409,6 +409,5 @@ where let value = String::deserialize(d)?; let bytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) .map_err(|_| D::Error::custom("XmssSignature value is not valid hex"))?; - XmssSignature::new(bytes) - .map_err(|_| D::Error::custom("XmssSignature length != 3112")) + XmssSignature::new(bytes).map_err(|_| D::Error::custom("XmssSignature length != 3112")) } From a8779985474697b8cf8a32e35718b0f6bbed064f Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 18:31:54 -0300 Subject: [PATCH 04/11] Add head >= target and head slot consistency checks to attestation validation Port two validation rules from leanSpec 8b7636b: reject attestations where the head checkpoint is older than the target, and reject attestations where the head checkpoint slot doesn't match the actual block slot. Well-formed attestations already satisfy these, but without them we'd accept malformed ones. --- crates/blockchain/src/store.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 13ad8c45..4ccb94f4 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -206,7 +206,9 @@ fn aggregate_committee_signatures(store: &mut Store) { /// Ensures the vote respects the basic laws of time and topology: /// 1. The blocks voted for must exist in our store. /// 2. A vote cannot span backwards in time (source > target). -/// 3. A vote cannot be for a future slot. +/// 3. The head must be at least as recent as source and target. +/// 4. Checkpoint slots must match the actual block slots. +/// 5. A vote cannot be for a future slot. fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<(), StoreError> { let _timing = metrics::time_attestation_validation(); @@ -218,14 +220,20 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() .get_block_header(&data.target.root) .ok_or(StoreError::UnknownTargetBlock(data.target.root))?; - let _ = store + let head_header = store .get_block_header(&data.head.root) .ok_or(StoreError::UnknownHeadBlock(data.head.root))?; - // Topology Check - Source must be older than Target. + // Topology Check - Source must be older than Target, and Head must be at least as recent. if data.source.slot > data.target.slot { return Err(StoreError::SourceExceedsTarget); } + if data.head.slot < data.target.slot { + return Err(StoreError::HeadOlderThanTarget { + head_slot: data.head.slot, + target_slot: data.target.slot, + }); + } // Consistency Check - Validate checkpoint slots match block slots. if source_header.slot != data.source.slot { @@ -240,6 +248,12 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() block_slot: target_header.slot, }); } + if head_header.slot != data.head.slot { + return Err(StoreError::HeadSlotMismatch { + checkpoint_slot: data.head.slot, + block_slot: head_header.slot, + }); + } // Time Check - Validate attestation is not too far in the future. // We allow a small margin for clock disparity (1 slot), but no further. @@ -798,6 +812,9 @@ pub enum StoreError { #[error("Source checkpoint slot exceeds target")] SourceExceedsTarget, + #[error("Head checkpoint slot {head_slot} is older than target slot {target_slot}")] + HeadOlderThanTarget { head_slot: u64, target_slot: u64 }, + #[error("Source checkpoint slot {checkpoint_slot} does not match block slot {block_slot}")] SourceSlotMismatch { checkpoint_slot: u64, @@ -810,6 +827,12 @@ pub enum StoreError { block_slot: u64, }, + #[error("Head checkpoint slot {checkpoint_slot} does not match block slot {block_slot}")] + HeadSlotMismatch { + checkpoint_slot: u64, + block_slot: u64, + }, + #[error( "Attestation slot {attestation_slot} is too far in future (current slot: {current_slot})" )] From f17d086cb0dca27d64fe5c91b8dcaf35842cbbbd Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 18:32:19 -0300 Subject: [PATCH 05/11] Merge known and new attestation pools in update_safe_target At interval 3, the migration step (interval 4) hasn't run yet, so attestations that entered "known" directly (proposer's own attestation in block body, node's self-attestation) were invisible to the safe target calculation. Merge both pools to avoid undercounting support. No double-counting risk since extract_attestations_from_aggregated_payloads deduplicates by validator ID. --- crates/blockchain/src/store.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 4ccb94f4..647f1445 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -82,8 +82,16 @@ fn update_safe_target(store: &mut Store) { let min_target_score = (num_validators * 2).div_ceil(3); let blocks = store.get_live_chain(); + // Merge both attestation pools. At interval 3 the migration (interval 4) hasn't + // run yet, so attestations that entered "known" directly (proposer's own attestation + // in block body, node's self-attestation) would be invisible without this merge. + let mut all_payloads: HashMap> = + store.iter_known_aggregated_payloads().collect(); + for (key, new_proofs) in store.iter_new_aggregated_payloads() { + all_payloads.entry(key).or_default().extend(new_proofs); + } let attestations = - extract_attestations_from_aggregated_payloads(store, store.iter_new_aggregated_payloads()); + extract_attestations_from_aggregated_payloads(store, all_payloads.into_iter()); let safe_target = ethlambda_fork_choice::compute_lmd_ghost_head( store.latest_justified().root, &blocks, From d72c3a3929f4d94c7c931258e96f68a71d9ac379 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Wed, 18 Feb 2026 18:33:23 -0300 Subject: [PATCH 06/11] Broadcast aggregated attestations after committee signature aggregation After aggregating committee signatures at interval 2, broadcast the resulting SignedAggregatedAttestation messages to the network. aggregate_committee_signatures and on_tick now return the new aggregates, and BlockChainServer::on_tick publishes them via P2PMessage::PublishAggregatedAttestation. The variant already existed but was never sent from the aggregation path. --- crates/blockchain/src/lib.rs | 9 ++++++++- crates/blockchain/src/store.rs | 27 +++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 2c7540b0..bcecb57a 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -155,13 +155,20 @@ impl BlockChainServer { .flatten(); // Tick the store first - this accepts attestations at interval 0 if we have a proposal - store::on_tick( + let new_aggregates = store::on_tick( &mut self.store, timestamp_ms, proposer_validator_id.is_some(), self.is_aggregator, ); + for aggregate in new_aggregates { + let _ = self + .p2p_tx + .send(P2PMessage::PublishAggregatedAttestation(aggregate)) + .inspect_err(|err| error!(%err, "Failed to publish aggregated attestation")); + } + // Now build and publish the block (after attestations have been accepted) if let Some(validator_id) = proposer_validator_id { self.propose_block(slot, validator_id); diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 647f1445..abe2b081 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -133,12 +133,14 @@ fn extract_attestations_from_aggregated_payloads( /// /// Collects individual gossip signatures, aggregates them by attestation data, /// and stores the resulting proofs in `LatestNewAggregatedPayloads`. -fn aggregate_committee_signatures(store: &mut Store) { +fn aggregate_committee_signatures(store: &mut Store) -> Vec { let gossip_sigs: Vec<(SignatureKey, _)> = store.iter_gossip_signatures().collect(); if gossip_sigs.is_empty() { - return; + return Vec::new(); } + let mut new_aggregates: Vec = Vec::new(); + let head_state = store.head_state(); let validators = &head_state.validators; @@ -191,6 +193,12 @@ fn aggregate_committee_signatures(store: &mut Store) { let participants = aggregation_bits_from_validator_indices(&ids); let proof = AggregatedSignatureProof::new(participants, proof_data); + + new_aggregates.push(SignedAggregatedAttestation { + data: data.clone(), + proof: proof.clone(), + }); + let payload = StoredAggregatedPayload { slot, proof }; // Store in new aggregated payloads for each covered validator @@ -207,6 +215,8 @@ fn aggregate_committee_signatures(store: &mut Store) { // Delete aggregated entries from gossip_signatures store.delete_gossip_signatures(&keys_to_delete); + + new_aggregates } /// Validate incoming attestation before processing. @@ -282,7 +292,14 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<() /// 800ms interval. Slot and interval-within-slot are derived as: /// slot = store.time() / INTERVALS_PER_SLOT /// interval = store.time() % INTERVALS_PER_SLOT -pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool, is_aggregator: bool) { +pub fn on_tick( + store: &mut Store, + timestamp_ms: u64, + has_proposal: bool, + is_aggregator: bool, +) -> Vec { + let mut new_aggregates: Vec = Vec::new(); + // Convert UNIX timestamp (ms) to interval count since genesis let genesis_time_ms = store.config().genesis_time * 1000; let time_delta_ms = timestamp_ms.saturating_sub(genesis_time_ms); @@ -320,7 +337,7 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool, is_aggr 2 => { // Aggregation interval if is_aggregator { - aggregate_committee_signatures(store); + new_aggregates.extend(aggregate_committee_signatures(store)); } } 3 => { @@ -334,6 +351,8 @@ pub fn on_tick(store: &mut Store, timestamp_ms: u64, has_proposal: bool, is_aggr _ => unreachable!("slots only have 5 intervals"), } } + + new_aggregates } /// Process a gossiped attestation. From ff04494eb1fe706facc405b9fc6927d77f104438 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Fri, 20 Feb 2026 18:32:17 -0300 Subject: [PATCH 07/11] Fix gossip config --- crates/net/p2p/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index 130fdfa8..e8376003 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -85,9 +85,7 @@ pub async fn start_p2p( // Taken from ream .max_transmit_size(MAX_COMPRESSED_PAYLOAD_SIZE) .max_messages_per_rpc(Some(500)) - .validate_messages() .allow_self_origin(true) - .flood_publish(false) .idontwant_message_size_threshold(1000) .build() .expect("invalid gossipsub config"); From a10a4d4b6bff40ef086673f625eb42131a2fe14c Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Fri, 20 Feb 2026 19:02:20 -0300 Subject: [PATCH 08/11] changing devnet3 to devnet0 for network in gossip messages --- crates/net/p2p/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index e8376003..697fa6d7 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -153,7 +153,7 @@ pub async fn start_p2p( .listen_on(addr) .expect("failed to bind gossipsub listening address"); - let network = "devnet3"; + let network = "devnet0"; // Subscribe to block topic (all nodes) let block_topic_str = format!("/leanconsensus/{network}/{BLOCK_TOPIC_KIND}/ssz_snappy"); From ef9b2e1eb3219111eb205add084008857779b3ae Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 23 Feb 2026 16:04:49 -0300 Subject: [PATCH 09/11] Add --attestation-committee-count CLI parameter to make the number of attestation subnets configurable. Previously hardcoded to 1, this allows future devnets to use multiple committees. The value is passed to the P2P layer for subnet subscription (validator_id % committee_count). --- bin/ethlambda/src/main.rs | 4 ++++ crates/blockchain/src/lib.rs | 3 --- crates/net/p2p/src/lib.rs | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index bc5c9a10..3f80d858 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -54,6 +54,9 @@ struct CliOptions { /// Whether this node acts as a committee aggregator #[arg(long, default_value = "false")] is_aggregator: bool, + /// Number of attestation committees (subnets) per slot + #[arg(long, default_value = "1")] + attestation_committee_count: u64, } #[tokio::main] @@ -130,6 +133,7 @@ async fn main() -> eyre::Result<()> { p2p_rx, store.clone(), first_validator_id, + options.attestation_committee_count, )); ethlambda_rpc::start_rpc_server(metrics_socket, store) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index bcecb57a..6172e0b1 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -48,9 +48,6 @@ pub const MILLISECONDS_PER_SLOT: u64 = 4_000; pub const MILLISECONDS_PER_INTERVAL: u64 = 800; /// Number of intervals per slot (5 intervals of 800ms = 4 seconds). pub const INTERVALS_PER_SLOT: u64 = 5; -/// Number of attestation committees per slot. -pub const ATTESTATION_COMMITTEE_COUNT: u64 = 1; - impl BlockChain { pub fn spawn( store: Store, diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index 697fa6d7..0f6c500d 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -4,7 +4,7 @@ use std::{ time::Duration, }; -use ethlambda_blockchain::{ATTESTATION_COMMITTEE_COUNT, BlockChain, P2PMessage}; +use ethlambda_blockchain::{BlockChain, P2PMessage}; use ethlambda_storage::Store; use ethlambda_types::primitives::H256; use ethrex_common::H264; @@ -56,6 +56,7 @@ pub(crate) struct PendingRequest { pub(crate) last_peer: Option, } +#[allow(clippy::too_many_arguments)] pub async fn start_p2p( node_key: Vec, bootnodes: Vec, @@ -64,6 +65,7 @@ pub async fn start_p2p( p2p_rx: mpsc::UnboundedReceiver, store: Store, validator_id: Option, + attestation_committee_count: u64, ) { let config = libp2p::gossipsub::ConfigBuilder::default() // d @@ -175,9 +177,7 @@ pub async fn start_p2p( .unwrap(); // Subscribe to attestation subnet topic (validators subscribe to their committee's subnet) - // ATTESTATION_COMMITTEE_COUNT is 1 for devnet-3 but will increase in future devnets. - #[allow(clippy::modulo_one)] - let subnet_id = validator_id.map(|vid| vid % ATTESTATION_COMMITTEE_COUNT); + let subnet_id = validator_id.map(|vid| vid % attestation_committee_count); let attestation_topic_kind = match subnet_id { Some(id) => format!("{ATTESTATION_SUBNET_TOPIC_PREFIX}_{id}"), // Non-validators subscribe to subnet 0 to receive attestations From ea37b6d57572ac5ec4995ac08456019b43e74349 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 23 Feb 2026 16:12:23 -0300 Subject: [PATCH 10/11] Reject --attestation-committee-count 0 at CLI parse time to prevent division by zero in subnet calculation --- bin/ethlambda/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 3f80d858..f23ea58a 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -55,7 +55,7 @@ struct CliOptions { #[arg(long, default_value = "false")] is_aggregator: bool, /// Number of attestation committees (subnets) per slot - #[arg(long, default_value = "1")] + #[arg(long, default_value = "1", value_parser = clap::value_parser!(u64).range(1..))] attestation_committee_count: u64, } From a6f6a5eb435d3a2c834f9f1c63d4b95698041a6f Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Mon, 23 Feb 2026 16:16:18 -0300 Subject: [PATCH 11/11] Add comment clarifying attestation_committee_count >= 1 is guaranteed by CLI validation --- crates/net/p2p/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index 0f6c500d..4e2a4651 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -177,6 +177,7 @@ pub async fn start_p2p( .unwrap(); // Subscribe to attestation subnet topic (validators subscribe to their committee's subnet) + // attestation_committee_count is validated to be >= 1 by clap at CLI parse time. let subnet_id = validator_id.map(|vid| vid % attestation_committee_count); let attestation_topic_kind = match subnet_id { Some(id) => format!("{ATTESTATION_SUBNET_TOPIC_PREFIX}_{id}"),