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}