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
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ path = "tests/crypto/dkg_serialization_roundtrip_test.rs"
[[test]]
name = "proof_of_connectivity_epoch_nonce_test"
path = "tests/attestation/proof_of_connectivity_epoch_nonce_test.rs"

[[test]]
name = "balance_underflow_test"
path = "tests/validator/balance_underflow_test.rs"
3 changes: 2 additions & 1 deletion src/slashing/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod cross_chain_relay;
pub mod evidence_verifier;
pub mod mempool;
pub mod penalty_calculator;
pub mod types;
pub mod evidence_verifier;
88 changes: 88 additions & 0 deletions src/slashing/penalty_calculator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Slashing and inactivity-leak penalty calculators.
//!
//! Implements the penalty formulae referenced by issue #241:
//!
//! * **Slashing penalty** – whistleblower reward (`1/32` of effective balance)
//! plus correlation penalty (`1/32` of effective balance), giving `1/16` of
//! the validator's effective balance in total.
//! * **Inactivity leak penalty** – proportional to
//! `epochs_since_finality² × effective_balance / INACTIVITY_PENALTY_QUOTIENT`.
//!
//! All arithmetic is performed in `u64` (Gwei). Overflow-safe helpers use
//! `saturating_*` and `checked_*` where appropriate; the penalty values
//! themselves are returned as `u64` and are never negative.

use crate::validator::balance_tracker::MAX_EFFECTIVE_BALANCE;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Divisor for each of the two components of the slashing penalty
/// (whistleblower + correlation). Each component is `effective_balance / 32`,
/// so the total slashing penalty is `effective_balance / 16`.
pub const SLASHING_PENALTY_QUOTIENT: u64 = 32;

/// Divisor used in the inactivity-leak penalty formula.
/// `penalty = epochs_since_finality² × effective_balance / INACTIVITY_PENALTY_QUOTIENT`.
pub const INACTIVITY_PENALTY_QUOTIENT: u64 = 1 << 24; // 16_777_216

// ---------------------------------------------------------------------------
// Penalty computations
// ---------------------------------------------------------------------------

/// Compute the total slashing penalty for a validator with the given
/// `effective_balance` (Gwei).
///
/// Formula:
/// ```text
/// whistleblower = effective_balance / SLASHING_PENALTY_QUOTIENT (1/32)
/// correlation = effective_balance / SLASHING_PENALTY_QUOTIENT (1/32)
/// total = whistleblower + correlation (1/16)
/// ```
///
/// Integer division floors the result. For a 32 ETH validator the penalty is
/// exactly 2 ETH (2_000_000_000 Gwei). For validators with a balance already
/// below 32 ETH the penalty scales proportionally.
///
/// # Panics
///
/// Does not panic; all arithmetic is safe for any `u64` input.
pub fn compute_slashing_penalty(effective_balance: u64) -> u64 {
let whistleblower = effective_balance / SLASHING_PENALTY_QUOTIENT;
let correlation = effective_balance / SLASHING_PENALTY_QUOTIENT;
whistleblower.saturating_add(correlation)
}

/// Compute the inactivity-leak penalty for a validator.
///
/// Formula:
/// ```text
/// penalty = (epochs_since_finality² × effective_balance) / INACTIVITY_PENALTY_QUOTIENT
/// ```
///
/// `u128` intermediate arithmetic prevents the multiplication from wrapping
/// for realistic `epochs_since_finality` values. The result is then clamped
/// back to `u64` (if it somehow exceeds `u64::MAX` it saturates, but this
/// cannot occur for valid effective balances capped at [`MAX_EFFECTIVE_BALANCE`]
/// within any realistic chain lifetime).
///
/// # Panics
///
/// Does not panic.
pub fn compute_inactivity_penalty(effective_balance: u64, epochs_since_finality: u64) -> u64 {
// Use u128 to avoid intermediate overflow when squaring large epoch counts.
let epochs_sq = (epochs_since_finality as u128).saturating_mul(epochs_since_finality as u128);
let numerator = epochs_sq.saturating_mul(effective_balance as u128);
let penalty_wide = numerator / (INACTIVITY_PENALTY_QUOTIENT as u128);
// Clamp to u64; for any realistic effective_balance <= MAX_EFFECTIVE_BALANCE
// and chain lifetime this will not saturate.
penalty_wide.min(u64::MAX as u128) as u64
}

/// Clamp `balance` so it never exceeds [`MAX_EFFECTIVE_BALANCE`].
/// Used after reward application to enforce the protocol invariant.
#[inline]
pub fn cap_effective_balance(balance: u64) -> u64 {
balance.min(MAX_EFFECTIVE_BALANCE)
}
208 changes: 208 additions & 0 deletions src/validator/balance_tracker.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! Validator balance tracker with underflow-safe penalty application.
//!
//! Fixes issue #241: `apply_penalty()` previously used `saturating_sub`, which
//! silently clamped a balance to zero when the penalty exceeded the balance.
//! This allowed a validator to remain active for one more epoch (bypassing the
//! ejection threshold check) and then re-activate with a zero balance.
//!
//! The fix replaces `saturating_sub` with `checked_sub`. When the penalty
//! exceeds the current balance the function returns
//! [`BalanceError::InsufficientBalance`] instead of clamping, and the caller
//! is responsible for recording a debt and forcing immediate ejection.

extern crate alloc;
use alloc::collections::BTreeMap;

use crate::validator::exit_queue::ValidatorIndex;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// 1 ETH expressed in Gwei (u64). Used as the base unit for balances.
pub const GWEI_PER_ETH: u64 = 1_000_000_000;

/// Maximum effective balance a validator can hold (32 ETH in Gwei).
pub const MAX_EFFECTIVE_BALANCE: u64 = 32 * GWEI_PER_ETH;

/// Ejection threshold: validators whose effective balance falls strictly below
/// this value are ejected at the epoch boundary (16 ETH in Gwei).
pub const EJECTION_THRESHOLD: u64 = 16 * GWEI_PER_ETH;

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// Errors that can be returned by balance-tracker operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BalanceError {
/// The requested penalty exceeds the validator's current balance.
/// The validator must be force-ejected and a debt recorded.
InsufficientBalance {
/// Balance at the time the penalty was applied.
balance: u64,
/// Penalty that was attempted.
penalty: u64,
},
/// No record exists for the given `ValidatorIndex`.
ValidatorNotFound,
}

// ---------------------------------------------------------------------------
// BalanceTracker
// ---------------------------------------------------------------------------

/// Tracks effective balances and outstanding debts for every known validator.
///
/// Balances are stored in Gwei (`u64`). Debts are recorded separately so that
/// a validator whose balance has been driven to zero by a penalty that exceeds
/// the balance is still flagged for forced ejection even though its stored
/// balance reads zero.
#[derive(Clone, Debug, Default)]
pub struct BalanceTracker {
/// Effective balance in Gwei for each validator index.
balances: BTreeMap<ValidatorIndex, u64>,
/// Unpaid debt in Gwei for validators whose penalty exceeded their balance.
/// The presence of any debt, however small, marks the validator for forced
/// ejection regardless of its stored effective balance.
debts: BTreeMap<ValidatorIndex, u64>,
}

impl BalanceTracker {
/// Create an empty tracker.
pub fn new() -> Self {
Self {
balances: BTreeMap::new(),
debts: BTreeMap::new(),
}
}

// -----------------------------------------------------------------------
// Registration
// -----------------------------------------------------------------------

/// Register `validator_index` with the given starting `balance` (Gwei).
/// Silently overwrites any previous entry (useful for test setup).
pub fn register_validator(&mut self, validator_index: ValidatorIndex, balance: u64) {
self.balances.insert(validator_index, balance);
// Clear any stale debt from a previous registration.
self.debts.remove(&validator_index);
}

// -----------------------------------------------------------------------
// Accessors
// -----------------------------------------------------------------------

/// Return the current effective balance in Gwei, or `None` if the validator
/// is not registered.
pub fn effective_balance(&self, validator_index: ValidatorIndex) -> Option<u64> {
self.balances.get(&validator_index).copied()
}

/// Return the outstanding debt in Gwei, or `None` if the validator is not
/// registered / has no debt.
pub fn outstanding_debt(&self, validator_index: ValidatorIndex) -> Option<u64> {
self.debts.get(&validator_index).copied()
}

/// Return `true` if the validator has any outstanding debt.
pub fn has_debt(&self, validator_index: ValidatorIndex) -> bool {
self.debts.get(&validator_index).map_or(false, |&d| d > 0)
}

// -----------------------------------------------------------------------
// Mutations
// -----------------------------------------------------------------------

/// Apply `penalty` (Gwei) to `validator_index`.
///
/// # Behaviour
///
/// * If `penalty <= balance` the balance is reduced and `Ok(new_balance)` is
/// returned.
/// * If `penalty > balance` **no balance change is made**, the excess is
/// recorded as a debt, and
/// [`Err(BalanceError::InsufficientBalance)`] is returned. The caller
/// must mark the validator for forced ejection.
///
/// # Errors
///
/// Returns [`BalanceError::ValidatorNotFound`] when the index is unknown.
pub fn apply_penalty(
&mut self,
validator_index: ValidatorIndex,
penalty: u64,
) -> Result<u64, BalanceError> {
let balance = self
.balances
.get_mut(&validator_index)
.ok_or(BalanceError::ValidatorNotFound)?;

match balance.checked_sub(penalty) {
Some(new_balance) => {
*balance = new_balance;
Ok(new_balance)
}
None => {
// Record the excess as a debt so the ejection logic can detect
// it even after the balance has been zeroed.
let debt = penalty - *balance;
*self.debts.entry(validator_index).or_insert(0) += debt;
// The validator's stored balance is set to zero because the
// full balance has been consumed.
*balance = 0;
Err(BalanceError::InsufficientBalance {
balance: 0,
penalty,
})
}
}
}

/// Apply `reward` (Gwei) to `validator_index`, capped at
/// [`MAX_EFFECTIVE_BALANCE`].
///
/// # Errors
///
/// Returns [`BalanceError::ValidatorNotFound`] when the index is unknown.
pub fn apply_reward(
&mut self,
validator_index: ValidatorIndex,
reward: u64,
) -> Result<u64, BalanceError> {
let balance = self
.balances
.get_mut(&validator_index)
.ok_or(BalanceError::ValidatorNotFound)?;

*balance = balance.saturating_add(reward).min(MAX_EFFECTIVE_BALANCE);
Ok(*balance)
}

// -----------------------------------------------------------------------
// Ejection helpers
// -----------------------------------------------------------------------

/// Return the indices of all validators that should be ejected at the
/// current epoch boundary.
///
/// A validator is ejection-eligible when **either**:
/// 1. Its effective balance has fallen strictly below
/// [`EJECTION_THRESHOLD`], **or**
/// 2. It has an outstanding debt (the penalty exceeded its balance).
pub fn ejection_eligible(&self) -> alloc::vec::Vec<ValidatorIndex> {
let mut eligible = alloc::vec::Vec::new();
for (&idx, &bal) in &self.balances {
if bal < EJECTION_THRESHOLD || self.has_debt(idx) {
eligible.push(idx);
}
}
eligible
}

/// Clear the debt record for a validator (called after ejection is
/// confirmed so the entry does not linger).
pub fn clear_debt(&mut self, validator_index: ValidatorIndex) {
self.debts.remove(&validator_index);
}
}
1 change: 1 addition & 0 deletions src/validator/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Validator lifecycle: the deterministic exit queue and validator set.

pub mod activation_queue;
pub mod balance_tracker;
pub mod committee_assignment;
pub mod exit_queue;
pub mod validator_set;
Loading
Loading