From 25ab5a34c1472533a6144bc1c6d78882ffce0587 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Fri, 13 Mar 2026 16:04:00 -0300 Subject: [PATCH 1/3] Document zero validator count guard in is_proposer as defensive check The spec does slot % num_validators without checking for zero, which would panic on division by zero. This can't happen in practice since genesis always has at least one validator, but we guard explicitly to avoid undefined behavior from crafted inputs. --- crates/blockchain/state_transition/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index c5e4888..4650955 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -207,6 +207,10 @@ fn current_proposer(slot: u64, num_validators: u64) -> u64 { /// /// Proposer selection uses simple round-robin: `slot % num_validators`. pub fn is_proposer(validator_index: u64, slot: u64, num_validators: u64) -> bool { + // Guard: the spec (validator.py L25) does `slot % num_validators` without + // checking for zero, which would panic on division by zero. This can't + // happen in practice (genesis always has at least one validator), but we + // guard explicitly to avoid UB from crafted inputs. if num_validators == 0 { return false; } From ab8b9b160814318ba3bed98c58bbff09992dc49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Gr=C3=BCner?= <47506558+MegaRedHand@users.noreply.github.com> Date: Fri, 13 Mar 2026 18:09:16 -0300 Subject: [PATCH 2/3] Apply suggestion from @MegaRedHand --- crates/blockchain/state_transition/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 4650955..18b68fb 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -210,7 +210,7 @@ pub fn is_proposer(validator_index: u64, slot: u64, num_validators: u64) -> bool // Guard: the spec (validator.py L25) does `slot % num_validators` without // checking for zero, which would panic on division by zero. This can't // happen in practice (genesis always has at least one validator), but we - // guard explicitly to avoid UB from crafted inputs. + // guard explicitly to avoid panics from crafted inputs. if num_validators == 0 { return false; } From 34fbe1cc83c187100bf5a1a26c862f9839aea1a5 Mon Sep 17 00:00:00 2001 From: Pablo Deymonnaz Date: Fri, 13 Mar 2026 18:32:49 -0300 Subject: [PATCH 3/3] Make current_proposer return Option and add zero-validator fail-fast guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #218: - current_proposer now returns Option, returning None when num_validators is 0 instead of panicking on division by zero. - is_proposer uses the Option naturally (no separate zero guard needed). - process_block_header propagates a new NoValidators error via ok_or. - BlockChainServer.on_tick fails fast with an error log when the head state has no validators, preventing downstream panics. - Fix comment terminology: "UB" → "panics" (safe Rust panics on division by zero, no undefined behavior). --- crates/blockchain/src/lib.rs | 7 +++++ crates/blockchain/state_transition/src/lib.rs | 28 +++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index f6a6ac9..ee6ccfd 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -103,6 +103,13 @@ impl BlockChainServer { let slot = time_since_genesis_ms / MILLISECONDS_PER_SLOT; let interval = (time_since_genesis_ms % MILLISECONDS_PER_SLOT) / MILLISECONDS_PER_INTERVAL; + // Fail fast: a state with zero validators is invalid and would cause + // panics in proposer selection and attestation processing. + if self.store.head_state().validators.is_empty() { + error!("Head state has no validators, skipping tick"); + return; + } + // Update current slot metric metrics::update_current_slot(slot); diff --git a/crates/blockchain/state_transition/src/lib.rs b/crates/blockchain/state_transition/src/lib.rs index 18b68fb..ae82af9 100644 --- a/crates/blockchain/state_transition/src/lib.rs +++ b/crates/blockchain/state_transition/src/lib.rs @@ -24,6 +24,8 @@ pub enum Error { InvalidProposer { expected: u64, found: u64 }, #[error("parent root mismatch: expected {expected}, found {found}")] InvalidParent { expected: H256, found: H256 }, + #[error("no validators in state")] + NoValidators, #[error("state root mismatch: expected {expected}, computed {computed}")] StateRootMismatch { expected: H256, computed: H256 }, #[error("slot gap {gap} would exceed historical roots limit (current: {current}, max: {max})")] @@ -124,7 +126,9 @@ fn process_block_header(state: &mut State, block: &Block) -> Result<(), Error> { block_slot: block.slot, }); } - let expected_proposer = current_proposer(block.slot, state.validators.len() as u64); + let num_validators = state.validators.len() as u64; + let expected_proposer = + current_proposer(block.slot, num_validators).ok_or(Error::NoValidators)?; if block.proposer_index != expected_proposer { return Err(Error::InvalidProposer { expected: expected_proposer, @@ -195,26 +199,22 @@ fn process_block_header(state: &mut State, block: &Block) -> Result<(), Error> { Ok(()) } -/// Determine if a validator is the proposer for a given slot. +/// Determine the proposer for a given slot using round-robin selection. /// -/// Uses round-robin proposer selection based on slot number and total -/// validator count, following the lean protocol specification. -fn current_proposer(slot: u64, num_validators: u64) -> u64 { - slot % num_validators +/// Returns `None` when `num_validators` is zero. The spec (validator.py L25) +/// does `slot % num_validators` without checking for zero, which would panic +/// on division by zero. This can't happen in practice (genesis always has at +/// least one validator), but we guard explicitly to avoid panics from crafted +/// inputs. +fn current_proposer(slot: u64, num_validators: u64) -> Option { + (num_validators > 0).then(|| slot % num_validators) } /// Check if a validator is the proposer for a given slot. /// /// Proposer selection uses simple round-robin: `slot % num_validators`. pub fn is_proposer(validator_index: u64, slot: u64, num_validators: u64) -> bool { - // Guard: the spec (validator.py L25) does `slot % num_validators` without - // checking for zero, which would panic on division by zero. This can't - // happen in practice (genesis always has at least one validator), but we - // guard explicitly to avoid panics from crafted inputs. - if num_validators == 0 { - return false; - } - current_proposer(slot, num_validators) == validator_index + current_proposer(slot, num_validators) == Some(validator_index) } /// Apply attestations and update justification/finalization