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}