Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/validator/activation_queue.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion src/validator/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 48 additions & 0 deletions src/validator/validator_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,6 +27,7 @@ pub struct Validator {
#[derive(Clone, Debug, Default)]
pub struct ValidatorSet {
validators: Vec<Validator>,
activation_queue: ActivationQueue,
exit_queue: ExitQueue,
/// Slot at which the last reorganization occurred
last_reorg_slot: Option<u64>,
Expand All @@ -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,
}
Expand All @@ -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<ValidatorIndex> {
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)
Expand Down
66 changes: 66 additions & 0 deletions tests/activation_queue_test.rs
Original file line number Diff line number Diff line change
@@ -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::<u64>::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::<u64>::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);
}
Loading