diff --git a/src/validator/activation_queue.rs b/src/validator/activation_queue.rs new file mode 100644 index 0000000..88cf4ca --- /dev/null +++ b/src/validator/activation_queue.rs @@ -0,0 +1,99 @@ +//! Deterministic validator activation queue. +//! +//! Pending validators are released at epoch boundaries once their +//! `activation_epoch` is less than or equal to the current epoch. Boundary +//! equality is intentional: a validator scheduled for epoch `N` must activate +//! during the epoch-`N` transition, including after a mid-epoch reorg rebuilds +//! or updates the queue. + +extern crate alloc; +use alloc::collections::BTreeSet; +use alloc::vec::Vec; + +use crate::validator::exit_queue::{Epoch, ValidatorIndex}; + +/// Spec-mandated maximum number of queued validator activations. +pub const MAX_PENDING_VALIDATORS: usize = 8_192; + +/// Epoch delay before a newly pending validator becomes eligible for activation. +pub const MIN_VALIDATOR_WITHDRAWABILITY_DELAY: Epoch = 4; + +/// Errors returned when enqueuing an activation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActivationQueueError { + /// The queue already holds [`MAX_PENDING_VALIDATORS`] entries. + QueueFull, + /// An identical `(activation_epoch, validator_index)` activation is already queued. + DuplicateActivation, +} + +/// A validator activation queue ordered by `(activation_epoch, validator_index)`. +#[derive(Clone, Debug, Default)] +pub struct ActivationQueue { + entries: BTreeSet<(Epoch, ValidatorIndex)>, +} + +impl ActivationQueue { + /// Create an empty queue. + pub fn new() -> Self { + Self { + entries: BTreeSet::new(), + } + } + + /// Number of queued activations. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the queue is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Compute the epoch at which a validator pending at `current_epoch` + /// becomes eligible for activation. + pub fn compute_activation_epoch(current_epoch: Epoch) -> Epoch { + current_epoch.saturating_add(MIN_VALIDATOR_WITHDRAWABILITY_DELAY) + } + + /// Enqueue an activation request. + pub fn push_activation( + &mut self, + activation_epoch: Epoch, + validator_index: ValidatorIndex, + ) -> Result<(), ActivationQueueError> { + let entry = (activation_epoch, validator_index); + if self.entries.contains(&entry) { + return Err(ActivationQueueError::DuplicateActivation); + } + if self.entries.len() >= MAX_PENDING_VALIDATORS { + return Err(ActivationQueueError::QueueFull); + } + self.entries.insert(entry); + Ok(()) + } + + /// Inspect the next activation without removing it. + pub fn peek_activation(&self) -> Option<(Epoch, ValidatorIndex)> { + self.entries.first().copied() + } + + /// Remove and return the next activation in canonical order. + pub fn pop_activation(&mut self) -> Option<(Epoch, ValidatorIndex)> { + self.entries.pop_first() + } + + /// Drain every activation whose `activation_epoch <= current_epoch`. + pub fn drain_eligible(&mut self, current_epoch: Epoch) -> Vec<(Epoch, ValidatorIndex)> { + let mut drained = Vec::new(); + while let Some(&(activation_epoch, _)) = self.entries.first() { + if activation_epoch > current_epoch { + break; + } + // Safe: `first()` just confirmed an element exists. + drained.push(self.entries.pop_first().unwrap()); + } + drained + } +} diff --git a/src/validator/mod.rs b/src/validator/mod.rs index 61d5a76..783c5c8 100644 --- a/src/validator/mod.rs +++ b/src/validator/mod.rs @@ -1,5 +1,6 @@ //! Validator lifecycle: the deterministic exit queue and validator set. +pub mod activation_queue; +pub mod committee_assignment; pub mod exit_queue; pub mod validator_set; -pub mod committee_assignment; diff --git a/src/validator/validator_set.rs b/src/validator/validator_set.rs index 624e5ba..0255be5 100644 --- a/src/validator/validator_set.rs +++ b/src/validator/validator_set.rs @@ -3,11 +3,13 @@ extern crate alloc; use alloc::vec::Vec; +use crate::validator::activation_queue::{ActivationQueue, ActivationQueueError}; use crate::validator::exit_queue::{Epoch, ExitQueue, ExitQueueError, ValidatorIndex}; /// Lifecycle status of a validator. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ValidatorStatus { + Pending, Active, ExitQueued, Exited, @@ -25,6 +27,7 @@ pub struct Validator { #[derive(Clone, Debug, Default)] pub struct ValidatorSet { validators: Vec, + activation_queue: ActivationQueue, exit_queue: ExitQueue, /// Slot at which the last reorganization occurred last_reorg_slot: Option, @@ -35,6 +38,7 @@ impl ValidatorSet { pub fn new() -> Self { Self { validators: Vec::new(), + activation_queue: ActivationQueue::new(), exit_queue: ExitQueue::new(), last_reorg_slot: None, } @@ -49,6 +53,50 @@ impl ValidatorSet { }); } + /// Register a new pending validator and queue it for activation. + pub fn add_pending_validator( + &mut self, + index: ValidatorIndex, + activation_epoch: Epoch, + ) -> Result<(), ActivationQueueError> { + self.activation_queue + .push_activation(activation_epoch, index)?; + self.validators.push(Validator { + index, + status: ValidatorStatus::Pending, + exit_epoch: None, + }); + Ok(()) + } + + /// Number of activations currently queued. + pub fn queued_activations(&self) -> usize { + self.activation_queue.len() + } + + /// Activate a pending validator by index. + pub fn activate_validator(&mut self, index: ValidatorIndex) -> bool { + if let Some(v) = self.validators.iter_mut().find(|v| v.index == index) { + if v.status == ValidatorStatus::Pending { + v.status = ValidatorStatus::Active; + return true; + } + } + false + } + + /// Process all activations eligible at or before `current_epoch`. + pub fn process_activation_queue(&mut self, current_epoch: Epoch) -> Vec { + let drained = self.activation_queue.drain_eligible(current_epoch); + let mut processed = Vec::with_capacity(drained.len()); + for (_, index) in drained { + if self.activate_validator(index) { + processed.push(index); + } + } + processed + } + /// Look up a validator by index. pub fn get(&self, index: ValidatorIndex) -> Option<&Validator> { self.validators.iter().find(|v| v.index == index) diff --git a/tests/activation_queue_test.rs b/tests/activation_queue_test.rs new file mode 100644 index 0000000..c9160df --- /dev/null +++ b/tests/activation_queue_test.rs @@ -0,0 +1,66 @@ +//! Regression tests for validator activation queue boundary handling (#16). + +use sorosusu_contracts::validator::activation_queue::{ + ActivationQueue, ActivationQueueError, MAX_PENDING_VALIDATORS, + MIN_VALIDATOR_WITHDRAWABILITY_DELAY, +}; +use sorosusu_contracts::validator::validator_set::{ValidatorSet, ValidatorStatus}; + +#[test] +fn boundary_epoch_activation_is_drained() { + let mut q = ActivationQueue::new(); + q.push_activation(10, 42).unwrap(); + + assert_eq!(q.drain_eligible(10), vec![(10, 42)]); + assert!(q.is_empty()); +} + +#[test] +fn validator_at_current_epoch_is_activated() { + let mut set = ValidatorSet::new(); + set.add_pending_validator(7, 10).unwrap(); + + let activated = set.process_activation_queue(10); + + assert_eq!(activated, vec![7]); + assert_eq!(set.get(7).unwrap().status, ValidatorStatus::Active); + assert_eq!(set.queued_activations(), 0); +} + +#[test] +fn multiple_validators_at_boundary_activate_once_in_order() { + let mut set = ValidatorSet::new(); + set.add_pending_validator(9, 10).unwrap(); + set.add_pending_validator(3, 10).unwrap(); + set.add_pending_validator(5, 10).unwrap(); + + assert_eq!(set.process_activation_queue(10), vec![3, 5, 9]); + assert_eq!(set.process_activation_queue(10), Vec::::new()); + + for idx in [3u64, 5, 9] { + assert_eq!(set.get(idx).unwrap().status, ValidatorStatus::Active); + } +} + +#[test] +fn future_epoch_remains_pending_and_activation_epoch_uses_delay() { + let mut set = ValidatorSet::new(); + let activation_epoch = ActivationQueue::compute_activation_epoch(10); + assert_eq!(activation_epoch, 10 + MIN_VALIDATOR_WITHDRAWABILITY_DELAY); + set.add_pending_validator(1, activation_epoch).unwrap(); + + assert_eq!(set.process_activation_queue(13), Vec::::new()); + assert_eq!(set.get(1).unwrap().status, ValidatorStatus::Pending); + assert_eq!(set.process_activation_queue(14), vec![1]); +} + +#[test] +fn rejects_duplicates_and_respects_capacity_constant() { + let mut q = ActivationQueue::new(); + q.push_activation(10, 1).unwrap(); + assert_eq!( + q.push_activation(10, 1), + Err(ActivationQueueError::DuplicateActivation) + ); + assert_eq!(MAX_PENDING_VALIDATORS, 8_192); +}