Skip to main content

dig_slashing/participation/
tracker.rs

1//! `ParticipationTracker` — two-epoch attestation-flag state
2//! machine.
3//!
4//! Traces to: [SPEC §8.2](../../../docs/resources/SPEC.md),
5//! catalogue rows
6//! [DSL-078..080](../../../docs/requirements/domains/participation/specs/).
7//!
8//! # Role
9//!
10//! Holds per-validator `ParticipationFlags` for the current
11//! epoch + the previous epoch. Consumers call:
12//!
13//!   - `record_attestation` (DSL-078) per admitted attestation
14//!     to OR the flags into `current_epoch`.
15//!   - `rotate_epoch` (DSL-080) at each epoch boundary to shift
16//!     current → previous and reset current to zero.
17//!
18//! Two epochs of state are retained because Altair-parity
19//! rewards at finalisation (DSL-081..086) read the PREVIOUS
20//! epoch's flags (the "attested" epoch), not the current one.
21//!
22//! # Storage shape
23//!
24//! `Vec<ParticipationFlags>` indexed by validator_index. Size
25//! fixed at construction — consumers resize via
26//! `resize_validator_count` (future DSL) when the validator set
27//! grows.
28
29use crate::participation::error::ParticipationError;
30use crate::participation::flags::ParticipationFlags;
31
32/// Per-validator two-epoch attestation-flag store.
33///
34/// Implements DSL-078 (+ DSL-079/080 in later commits). Traces
35/// to SPEC §8.2.
36///
37/// # Fields
38///
39/// - `current_epoch` — flags accumulated during the in-flight
40///   epoch.
41/// - `previous_epoch` — flags from the just-completed epoch.
42///   Read by reward / penalty delta computation (DSL-082..086).
43/// - `current_epoch_number` — monotonically advancing epoch
44///   counter. Driven forward by `rotate_epoch` (DSL-080).
45///
46/// # Storage size
47///
48/// Both vecs are sized at construction to `validator_count`.
49/// Out-of-range indices return
50/// `ParticipationError::IndexOutOfRange` rather than panicking
51/// — record_attestation is called with adversary-controllable
52/// indices (attesters may be newly registered or slashed
53/// between admission and record), and the tracker is expected
54/// to degrade gracefully.
55#[derive(Debug, Clone)]
56pub struct ParticipationTracker {
57    current_epoch: Vec<ParticipationFlags>,
58    previous_epoch: Vec<ParticipationFlags>,
59    current_epoch_number: u64,
60}
61
62impl ParticipationTracker {
63    /// New tracker sized for `validator_count` slots, starting
64    /// at `initial_epoch`. Both epoch vectors initialise to
65    /// `ParticipationFlags::default()` (all-zero).
66    #[must_use]
67    pub fn new(validator_count: usize, initial_epoch: u64) -> Self {
68        Self {
69            current_epoch: vec![ParticipationFlags::default(); validator_count],
70            previous_epoch: vec![ParticipationFlags::default(); validator_count],
71            current_epoch_number: initial_epoch,
72        }
73    }
74
75    /// Current epoch counter. Advanced by
76    /// `rotate_epoch` (DSL-080).
77    #[must_use]
78    pub fn current_epoch_number(&self) -> u64 {
79        self.current_epoch_number
80    }
81
82    /// Flag bits accumulated for `validator_index` during the
83    /// current epoch. `None` when the index is out of range.
84    #[must_use]
85    pub fn current_flags(&self, validator_index: u32) -> Option<ParticipationFlags> {
86        self.current_epoch.get(validator_index as usize).copied()
87    }
88
89    /// Flag bits from the previous (finalisable) epoch for
90    /// `validator_index`. `None` when the index is out of range.
91    /// Consumed by DSL-082/083 reward-delta math.
92    #[must_use]
93    pub fn previous_flags(&self, validator_index: u32) -> Option<ParticipationFlags> {
94        self.previous_epoch.get(validator_index as usize).copied()
95    }
96
97    /// Number of validator slots the tracker can address.
98    /// `attesting_indices` with values `>= validator_count`
99    /// return `IndexOutOfRange`.
100    #[must_use]
101    pub fn validator_count(&self) -> usize {
102        self.current_epoch.len()
103    }
104
105    /// Advance to a new epoch: swap current → previous, reset
106    /// current to `validator_count` zero-initialised slots, and
107    /// update `current_epoch_number`.
108    ///
109    /// Implements [DSL-080](../../../docs/requirements/domains/participation/specs/DSL-080.md).
110    /// Traces to SPEC §8.2, §10.
111    ///
112    /// # Ordering
113    ///
114    /// 1. `swap(previous, current)` — what was accumulated during
115    ///    the just-finished epoch moves into `previous_epoch`.
116    ///    DSL-082..086 reward / penalty math reads these bits.
117    /// 2. `current.clear(); current.resize(validator_count, 0)` —
118    ///    accept validator-set growth at the boundary. New
119    ///    validators that activated this epoch get zero flags.
120    /// 3. `current_epoch_number = new_epoch`.
121    ///
122    /// # Shrinking validator set
123    ///
124    /// If `validator_count < old.len()`, the trailing entries
125    /// are dropped. This is the correct behaviour for exited
126    /// validators — their previous-epoch flags are preserved
127    /// (in the just-swapped `previous_epoch`), only the current
128    /// -epoch slot is discarded.
129    ///
130    /// # Previous-epoch sizing
131    ///
132    /// `previous_epoch` keeps whatever length `current` had
133    /// before rotation. Downstream reward math reads
134    /// `previous_flags(idx)` via `.get(idx).copied()`, so
135    /// out-of-range reads return `None` rather than panicking.
136    pub fn rotate_epoch(&mut self, new_epoch: u64, validator_count: usize) {
137        std::mem::swap(&mut self.previous_epoch, &mut self.current_epoch);
138        self.current_epoch.clear();
139        self.current_epoch
140            .resize(validator_count, ParticipationFlags::default());
141        self.current_epoch_number = new_epoch;
142    }
143
144    /// Rewind the tracker on fork-choice reorg.
145    ///
146    /// Implements the participation leg of DSL-130
147    /// `rewind_all_on_reorg`. Drops both flag vectors and
148    /// reinstates `new_tip_epoch` as the current epoch with
149    /// zero-initialised flags.
150    ///
151    /// Why zero-fill instead of restoring pre-reorg state: the
152    /// tracker does NOT retain historical snapshots (each
153    /// `rotate_epoch` overwrites in place). Post-rewind, flag
154    /// accumulation resumes fresh from the new canonical tip —
155    /// the reward-delta pass at the NEXT epoch boundary
156    /// observes no activity over the rewound span (conservative;
157    /// no false reward credits from a ghost chain). Also zero-
158    /// fills the `previous_epoch` slot so the first post-rewind
159    /// `compute_flag_deltas` cannot read ghost data.
160    ///
161    /// Returns the number of epochs dropped
162    /// (`old_current_epoch - new_tip_epoch`, saturating at 0
163    /// when the tip is already current or ahead).
164    pub fn rewind_on_reorg(&mut self, new_tip_epoch: u64, validator_count: usize) -> u64 {
165        let dropped = self.current_epoch_number.saturating_sub(new_tip_epoch);
166        // DSL-153 acceptance: `depth == 0` is a genuine no-op — the
167        // orchestrator occasionally fires rewind_all_on_reorg with
168        // `new_tip_epoch == current_epoch_number` for safety after a
169        // recovery restart, and those callers must observe no flag /
170        // epoch-number mutation. Skipping the rotate_epoch call also
171        // avoids an unnecessary Vec resize when the validator count
172        // is unchanged.
173        if dropped == 0 {
174            return 0;
175        }
176        // rotate_epoch zeroes the current slot; the swap in
177        // rotate_epoch would otherwise preserve ghost `previous`
178        // data from the reorged chain, so clear previous too.
179        self.rotate_epoch(new_tip_epoch, validator_count);
180        self.previous_epoch.clear();
181        self.previous_epoch
182            .resize(validator_count, ParticipationFlags::default());
183        dropped
184    }
185
186    /// Record an attestation: apply `flags` to every entry in
187    /// `attesting_indices` via bit-OR into the current epoch's
188    /// per-validator bucket.
189    ///
190    /// Implements [DSL-078](../../../docs/requirements/domains/participation/specs/DSL-078.md).
191    ///
192    /// # Errors
193    ///
194    /// Returns `ParticipationError::IndexOutOfRange(i)` for the
195    /// FIRST offending index; later indices are not touched.
196    /// Structural non-ascending / duplicate checks land in
197    /// DSL-079 and run before this bit-OR pass in that cycle.
198    ///
199    /// # Behaviour
200    ///
201    /// - Bit-OR is additive. `record_attestation(.., TIMELY_SOURCE)`
202    ///   followed by `record_attestation(.., TIMELY_TARGET)` on
203    ///   the same validator leaves both bits set.
204    /// - `current_epoch_number` is NOT mutated — epoch
205    ///   advancement is the sole responsibility of `rotate_epoch`
206    ///   (DSL-080).
207    pub fn record_attestation(
208        &mut self,
209        _data: &crate::evidence::attestation_data::AttestationData,
210        attesting_indices: &[u32],
211        flags: ParticipationFlags,
212    ) -> Result<(), ParticipationError> {
213        // DSL-079: strict-ascending structural check runs BEFORE
214        // the bit-OR pass so a malformed attestation does not
215        // mutate any validator's flags.
216        for w in attesting_indices.windows(2) {
217            if w[0] == w[1] {
218                return Err(ParticipationError::DuplicateIndex(w[0]));
219            }
220            if w[0] > w[1] {
221                return Err(ParticipationError::NonAscendingIndices);
222            }
223        }
224
225        for idx in attesting_indices {
226            let i = *idx as usize;
227            if i >= self.current_epoch.len() {
228                return Err(ParticipationError::IndexOutOfRange(*idx));
229            }
230            self.current_epoch[i].0 |= flags.0;
231        }
232        Ok(())
233    }
234}