Skip to main content

dig_slashing/inactivity/
score.rs

1//! Per-validator inactivity-score tracker (Ethereum Bellatrix
2//! parity).
3//!
4//! Traces to: [SPEC §9.2](../../../docs/resources/SPEC.md),
5//! catalogue rows
6//! [DSL-088..093](../../../docs/requirements/domains/inactivity/specs/).
7//!
8//! # Role
9//!
10//! `InactivityScoreTracker` holds a `u64` score per validator.
11//! `update_for_epoch` drives the Ethereum Bellatrix score
12//! formula at each epoch boundary:
13//!
14//!   - DSL-088: hit → `-1` (saturating).
15//!   - DSL-089: miss + stall → `+4`.
16//!   - DSL-090: out-of-stall → global `-16` recovery.
17//!
18//! Score drives DSL-091 `inactivity_penalty(eff_bal, score)`
19//! on finalisation.
20
21use crate::constants::{
22    INACTIVITY_PENALTY_QUOTIENT, INACTIVITY_SCORE_BIAS, INACTIVITY_SCORE_RECOVERY_RATE,
23};
24use crate::participation::ParticipationTracker;
25use crate::traits::EffectiveBalanceView;
26
27/// Per-validator inactivity-score store.
28///
29/// Implements DSL-088 (+ DSL-089/090 in later commits). Traces
30/// to SPEC §9.2.
31///
32/// # Storage
33///
34/// `Vec<u64>` indexed by validator_index. Size fixed at
35/// construction; caller resizes at validator-set growth via
36/// future `resize` DSL.
37///
38/// # Default
39///
40/// `InactivityScoreTracker::new(n)` zero-initialises all slots.
41#[derive(Debug, Clone)]
42pub struct InactivityScoreTracker {
43    scores: Vec<u64>,
44}
45
46impl InactivityScoreTracker {
47    /// New tracker sized for `validator_count` validators with
48    /// all scores at 0.
49    #[must_use]
50    pub fn new(validator_count: usize) -> Self {
51        Self {
52            scores: vec![0u64; validator_count],
53        }
54    }
55
56    /// Read score at `validator_index`. `None` when out of
57    /// range.
58    #[must_use]
59    pub fn score(&self, validator_index: u32) -> Option<u64> {
60        self.scores.get(validator_index as usize).copied()
61    }
62
63    /// Number of validator slots tracked.
64    #[must_use]
65    pub fn validator_count(&self) -> usize {
66        self.scores.len()
67    }
68
69    /// Mutable score access for tests + DSL-089/090 rollout.
70    pub fn set_score(&mut self, validator_index: u32, score: u64) -> bool {
71        if let Some(s) = self.scores.get_mut(validator_index as usize) {
72            *s = score;
73            true
74        } else {
75            false
76        }
77    }
78
79    /// Apply per-epoch score deltas based on the just-finished
80    /// epoch's participation (read from
81    /// `participation.previous_flags`).
82    ///
83    /// Implements [DSL-088](../../../docs/requirements/domains/inactivity/specs/DSL-088.md).
84    /// DSL-089 (miss + stall → +4) and DSL-090 (global -16 out
85    /// of stall) extend the body in later commits.
86    ///
87    /// # DSL-088 rule (hit decrement)
88    ///
89    /// For every validator whose previous-epoch flags had
90    /// `TIMELY_TARGET` set, decrement the score by 1 saturating
91    /// at 0. Applies in BOTH regimes (stall + no-stall) — timely
92    /// target participation is the canonical signal for reducing
93    /// inactivity score, and the `_in_finality_stall` argument
94    /// is reserved (stored, not read) so future DSLs can pick
95    /// it up without changing the caller signature.
96    ///
97    /// # Iteration
98    ///
99    /// Iterates `0..min(validator_count, participation.validator_count())`.
100    /// A validator that is tracked here but missing from the
101    /// participation tracker receives no delta (defensive: grow
102    /// happens at the tracker boundary, not here).
103    pub fn update_for_epoch(
104        &mut self,
105        participation: &ParticipationTracker,
106        in_finality_stall: bool,
107    ) {
108        let n = self.scores.len().min(participation.validator_count());
109        for i in 0..n {
110            let idx = i as u32;
111            let flags = participation.previous_flags(idx).unwrap_or_default();
112            if flags.is_target_timely() {
113                // DSL-088: TIMELY_TARGET hit → -1 saturating.
114                self.scores[i] = self.scores[i].saturating_sub(1);
115            } else if in_finality_stall {
116                // DSL-089: TIMELY_TARGET miss during finality
117                // stall → += INACTIVITY_SCORE_BIAS (4),
118                // saturating at u64::MAX. Outside a stall,
119                // misses are absorbed by DSL-090 global
120                // recovery instead of accumulating here.
121                self.scores[i] = self.scores[i].saturating_add(INACTIVITY_SCORE_BIAS);
122            }
123        }
124
125        // DSL-090: out-of-stall global recovery. Runs AFTER
126        // the per-validator pass above so the hit decrement
127        // (DSL-088) stacks with this global shrink. In-stall,
128        // no global recovery — only DSL-088 hit decrement
129        // fires.
130        if !in_finality_stall {
131            for score in &mut self.scores {
132                *score = score.saturating_sub(INACTIVITY_SCORE_RECOVERY_RATE);
133            }
134        }
135    }
136
137    /// Compute per-validator inactivity-leak debits for the
138    /// current epoch.
139    ///
140    /// Implements [DSL-091](../../../docs/requirements/domains/inactivity/specs/DSL-091.md).
141    /// DSL-092 lands the in-stall penalty formula; for now the
142    /// in-stall branch returns an empty vec, same as the
143    /// out-of-stall branch.
144    ///
145    /// # Out-of-stall (DSL-091)
146    ///
147    /// `!in_finality_stall` → empty `Vec<(u32, u64)>`. Inactivity
148    /// penalties NEVER charge validators outside a stall — DSL-090
149    /// global recovery handles score decay and that is the only
150    /// no-stall effect.
151    ///
152    /// # In-stall (DSL-092 — stub today)
153    ///
154    /// Returns empty until DSL-092 lands the formula
155    /// `penalty_mojos = eff_bal * score /
156    /// INACTIVITY_PENALTY_QUOTIENT`. Callers that iterate the
157    /// return see zero entries either way; once DSL-092 ships,
158    /// they'll receive one `(validator_index, penalty_mojos)`
159    /// pair per validator whose score contributes.
160    #[must_use]
161    pub fn epoch_penalties(
162        &self,
163        effective_balances: &dyn EffectiveBalanceView,
164        in_finality_stall: bool,
165    ) -> Vec<(u32, u64)> {
166        if !in_finality_stall {
167            return Vec::new();
168        }
169        // DSL-092: in-stall penalty formula.
170        //   penalty = effective_balance * score / INACTIVITY_PENALTY_QUOTIENT
171        // u128 intermediate prevents overflow when eff_bal and
172        // score are both near u64::MAX. Zero-score validators
173        // are filtered out of the output; zero-penalty results
174        // (score so small that the quotient truncates it to 0)
175        // are also dropped so consumers iterate only value-
176        // bearing debits.
177        let mut out: Vec<(u32, u64)> = Vec::new();
178        for (i, &score) in self.scores.iter().enumerate() {
179            if score == 0 {
180                continue;
181            }
182            let idx = i as u32;
183            let eff_bal = effective_balances.get(idx);
184            let penalty = (u128::from(eff_bal) * u128::from(score)
185                / u128::from(INACTIVITY_PENALTY_QUOTIENT)) as u64;
186            if penalty > 0 {
187                out.push((idx, penalty));
188            }
189        }
190        out
191    }
192
193    /// Grow or shrink the score vector to `validator_count`
194    /// slots.
195    ///
196    /// Implements [DSL-093](../../../docs/requirements/domains/inactivity/specs/DSL-093.md).
197    /// Traces to SPEC §9.2, §10.
198    ///
199    /// # Semantics
200    ///
201    /// - Growing: new trailing slots initialise to 0. Matches
202    ///   the activation semantics — freshly-activated
203    ///   validators start with a clean inactivity record.
204    /// - Shrinking: trailing entries are dropped. Rarely used
205    ///   (exits don't reuse indices); provided for symmetry
206    ///   with `ParticipationTracker::rotate_epoch`.
207    /// - Same size: no-op.
208    ///
209    /// Existing scores in the preserved range are unchanged.
210    /// Rewind the tracker on fork-choice reorg.
211    ///
212    /// Implements the inactivity leg of DSL-130
213    /// `rewind_all_on_reorg`. Zeroes every score — the same
214    /// conservative-choice rationale as
215    /// `ParticipationTracker::rewind_on_reorg`: no historical
216    /// snapshots to restore, so fresh accumulation on the new
217    /// canonical tip is safest (no ghost inactivity penalties).
218    ///
219    /// Returns the number of epochs dropped — computed by the
220    /// caller, not the tracker (the tracker does not carry an
221    /// epoch counter). Accepts the value as `depth` so the
222    /// DSL-130 `ReorgReport` can carry it uniformly with the
223    /// participation-side report.
224    pub fn rewind_on_reorg(&mut self, depth: u64) -> u64 {
225        // DSL-155 acceptance: `depth == 0` is a no-op. Orchestrator
226        // occasionally fires rewind_all_on_reorg defensively with
227        // `new_tip_epoch == current_epoch` after a recovery restart;
228        // those callers must observe zero mutation. Vec length
229        // preserved either way (DSL-093 resize_for is the only path
230        // that shrinks/grows the scores vector).
231        if depth == 0 {
232            return 0;
233        }
234        for score in &mut self.scores {
235            *score = 0;
236        }
237        depth
238    }
239
240    pub fn resize_for(&mut self, validator_count: usize) {
241        self.scores.resize(validator_count, 0);
242    }
243}