Skip to main content

dig_epoch/
manager.rs

1//! # `manager` — `EpochManager` struct and methods
2//!
3//! **Introduced by:** `STR-002` — Module hierarchy (SPEC §13).
4//!
5//! **Owners:** `MGR-001` (struct) / `MGR-002..MGR-008` (methods).
6//! Phase tracking (PHS-002/003/004) is wired through the same struct.
7//!
8//! **Spec reference:** [`SPEC.md` §6](../../docs/resources/SPEC.md)
9
10/// Sentinel marker proving the module exists and is reachable at
11/// `dig_epoch::manager::STR_002_MODULE_PRESENT`.
12#[doc(hidden)]
13pub const STR_002_MODULE_PRESENT: () = ();
14
15use std::collections::HashMap;
16
17use chia_protocol::Bytes32;
18use parking_lot::RwLock;
19
20use crate::arithmetic::l1_range_for_epoch;
21use crate::constants::{EPOCH_L1_BLOCKS, GENESIS_HEIGHT};
22use crate::error::EpochError;
23use crate::phase::l1_progress_phase_for_network_epoch;
24use crate::types::checkpoint_competition::CheckpointCompetition;
25use crate::types::dfsp::DfspCloseSnapshot;
26use crate::types::epoch_info::EpochInfo;
27use crate::types::epoch_phase::{EpochPhase, PhaseTransition};
28use crate::types::epoch_summary::EpochSummary;
29use crate::types::events::EpochStats;
30use crate::types::reward::RewardDistribution;
31
32// -----------------------------------------------------------------------------
33// MGR-001 — EpochManagerInner
34// -----------------------------------------------------------------------------
35
36/// Private inner state for [`EpochManager`]. All access goes through
37/// [`EpochManager`] methods, which acquire the outer `RwLock`.
38struct EpochManagerInner {
39    network_id: Bytes32,
40    genesis_l1_height: u32,
41    current_epoch: EpochInfo,
42    competition: CheckpointCompetition,
43    summaries: Vec<EpochSummary>,
44    rewards: HashMap<u64, RewardDistribution>,
45}
46
47/// Primary state machine managing the current epoch's lifecycle and
48/// archiving completed epochs.
49///
50/// Uses `parking_lot::RwLock` for interior mutability (start.md Hard
51/// Requirement 12). Read operations allow concurrent access; write
52/// operations block all other access.
53pub struct EpochManager {
54    inner: RwLock<EpochManagerInner>,
55}
56
57impl EpochManager {
58    // -------------------------------------------------------------------------
59    // MGR-001 / SPEC §6.2 — construction
60    // -------------------------------------------------------------------------
61
62    /// Creates an [`EpochManager`] at epoch 0 with empty history and a
63    /// fresh `Pending` competition.
64    ///
65    /// `network_id` and `genesis_l1_height` are immutable for the lifetime
66    /// of this manager. The initial `EpochInfo` starts at
67    /// `GENESIS_HEIGHT` in `BlockProduction` phase.
68    pub fn new(network_id: Bytes32, genesis_l1_height: u32, initial_state_root: Bytes32) -> Self {
69        let current_epoch =
70            EpochInfo::new(0, genesis_l1_height, GENESIS_HEIGHT, initial_state_root);
71        Self {
72            inner: RwLock::new(EpochManagerInner {
73                network_id,
74                genesis_l1_height,
75                current_epoch,
76                competition: CheckpointCompetition::new(0),
77                summaries: Vec::new(),
78                rewards: HashMap::new(),
79            }),
80        }
81    }
82
83    // -------------------------------------------------------------------------
84    // MGR-008 / SPEC §6.3 — accessors
85    // -------------------------------------------------------------------------
86
87    /// Returns the current epoch number.
88    pub fn current_epoch(&self) -> u64 {
89        self.inner.read().current_epoch.epoch
90    }
91
92    /// Returns a clone of the current epoch's full state.
93    pub fn current_epoch_info(&self) -> EpochInfo {
94        self.inner.read().current_epoch.clone()
95    }
96
97    /// Returns the current phase of the current epoch.
98    pub fn current_phase(&self) -> EpochPhase {
99        self.inner.read().current_epoch.phase
100    }
101
102    /// Returns the network's genesis L1 height.
103    pub fn genesis_l1_height(&self) -> u32 {
104        self.inner.read().genesis_l1_height
105    }
106
107    /// Returns the network ID.
108    pub fn network_id(&self) -> Bytes32 {
109        self.inner.read().network_id
110    }
111
112    /// Maps an L1 height to its epoch number using `genesis_l1_height`.
113    ///
114    /// Heights before genesis map to epoch 0.
115    pub fn epoch_for_l1_height(&self, l1_height: u32) -> u64 {
116        let g = self.genesis_l1_height();
117        if l1_height <= g {
118            0
119        } else {
120            ((l1_height - g) / EPOCH_L1_BLOCKS) as u64
121        }
122    }
123
124    /// Returns `(start_l1, end_l1)` for the given epoch.
125    pub fn l1_range_for_epoch(&self, epoch: u64) -> (u32, u32) {
126        l1_range_for_epoch(self.genesis_l1_height(), epoch)
127    }
128
129    // -------------------------------------------------------------------------
130    // PHS-002 / MGR-008 — update_phase
131    // -------------------------------------------------------------------------
132
133    /// Recalculates the phase from `l1_height`. Returns `Some(PhaseTransition)`
134    /// if the phase changed, `None` if unchanged.
135    pub fn update_phase(&self, l1_height: u32) -> Option<PhaseTransition> {
136        let mut inner = self.inner.write();
137        let old_phase = inner.current_epoch.phase;
138        let new_phase = l1_progress_phase_for_network_epoch(
139            inner.genesis_l1_height,
140            inner.current_epoch.epoch,
141            l1_height,
142        );
143        if new_phase != old_phase {
144            inner.current_epoch.phase = new_phase;
145            Some(PhaseTransition {
146                epoch: inner.current_epoch.epoch,
147                from: old_phase,
148                to: new_phase,
149                l1_height,
150            })
151        } else {
152            None
153        }
154    }
155
156    /// Returns `true` when the current phase is `Complete`.
157    pub fn should_advance(&self, _l1_height: u32) -> bool {
158        self.current_phase() == EpochPhase::Complete
159    }
160
161    // -------------------------------------------------------------------------
162    // MGR-002 — record_block (PHS-004 phase-gated)
163    // -------------------------------------------------------------------------
164
165    /// Records a block in the current epoch.
166    ///
167    /// Returns `Err(PhaseMismatch)` if not in `BlockProduction`.
168    pub fn record_block(&self, fees: u64, tx_count: u64) -> Result<(), EpochError> {
169        let mut inner = self.inner.write();
170        if inner.current_epoch.phase != EpochPhase::BlockProduction {
171            return Err(EpochError::PhaseMismatch {
172                expected: EpochPhase::BlockProduction,
173                got: inner.current_epoch.phase,
174            });
175        }
176        inner.current_epoch.record_block(fees, tx_count);
177        Ok(())
178    }
179
180    // -------------------------------------------------------------------------
181    // MGR-003 — set_current_epoch_chain_totals
182    // -------------------------------------------------------------------------
183
184    /// Overwrites the current epoch's block production statistics.
185    ///
186    /// Used for resync / correction. No phase restriction; values are
187    /// replaced, not incremented.
188    pub fn set_current_epoch_chain_totals(&self, blocks: u32, fees: u64, txns: u64) {
189        let mut inner = self.inner.write();
190        inner.current_epoch.blocks_produced = blocks;
191        inner.current_epoch.total_fees = fees;
192        inner.current_epoch.total_transactions = txns;
193    }
194
195    // -------------------------------------------------------------------------
196    // MGR-006 — set_current_epoch_dfsp_close_snapshot
197    // -------------------------------------------------------------------------
198
199    /// Applies DFSP close values to the current epoch before advance.
200    ///
201    /// Returns `Err(PhaseMismatch)` if not in `Finalization`.
202    pub fn set_current_epoch_dfsp_close_snapshot(
203        &self,
204        snap: DfspCloseSnapshot,
205    ) -> Result<(), EpochError> {
206        let mut inner = self.inner.write();
207        if inner.current_epoch.phase != EpochPhase::Finalization {
208            return Err(EpochError::PhaseMismatch {
209                expected: EpochPhase::Finalization,
210                got: inner.current_epoch.phase,
211            });
212        }
213        inner.current_epoch.collateral_registry_root = snap.collateral_registry_root;
214        inner.current_epoch.cid_state_root = snap.cid_state_root;
215        inner.current_epoch.node_registry_root = snap.node_registry_root;
216        inner.current_epoch.namespace_epoch_root = snap.namespace_epoch_root;
217        inner.current_epoch.dfsp_issuance_total = snap.dfsp_issuance_total;
218        inner.current_epoch.active_cid_count = snap.active_cid_count;
219        inner.current_epoch.active_node_count = snap.active_node_count;
220        Ok(())
221    }
222
223    // -------------------------------------------------------------------------
224    // MGR-004 — advance_epoch
225    // -------------------------------------------------------------------------
226
227    /// Archives the current epoch and transitions to `epoch + 1`.
228    ///
229    /// Preconditions:
230    /// - Current phase is `Complete`.
231    /// - Current competition is `Finalized`.
232    ///
233    /// Both preconditions are checked before any state mutation.
234    pub fn advance_epoch(&self, _l1_height: u32, state_root: Bytes32) -> Result<u64, EpochError> {
235        let mut inner = self.inner.write();
236        let current_epoch_num = inner.current_epoch.epoch;
237        if inner.current_epoch.phase != EpochPhase::Complete {
238            return Err(EpochError::EpochNotComplete(current_epoch_num));
239        }
240        if !inner.competition.is_finalized() {
241            return Err(EpochError::NoFinalizedCheckpoint(current_epoch_num));
242        }
243
244        let old_info = inner.current_epoch.clone();
245        let next_epoch = current_epoch_num + 1;
246        let next_start_l1 = inner.genesis_l1_height + (next_epoch as u32 * EPOCH_L1_BLOCKS);
247        let next_start_l2 = old_info.start_l2_height + crate::constants::BLOCKS_PER_EPOCH;
248
249        inner.summaries.push(EpochSummary::from(old_info));
250        inner.current_epoch = EpochInfo::new(next_epoch, next_start_l1, next_start_l2, state_root);
251        inner.competition = CheckpointCompetition::new(next_epoch);
252        Ok(next_epoch)
253    }
254
255    // -------------------------------------------------------------------------
256    // MGR-005 — query methods
257    // -------------------------------------------------------------------------
258
259    /// Returns a clone of the current `EpochInfo`.
260    pub fn get_epoch_info(&self) -> EpochInfo {
261        self.current_epoch_info()
262    }
263
264    /// Returns the `EpochSummary` for a specific completed epoch, or `None`.
265    pub fn get_epoch_summary(&self, epoch: u64) -> Option<EpochSummary> {
266        self.inner
267            .read()
268            .summaries
269            .iter()
270            .find(|s| s.epoch == epoch)
271            .cloned()
272    }
273
274    /// Returns the last `n` summaries from the tail, preserving epoch order.
275    pub fn recent_summaries(&self, n: usize) -> Vec<EpochSummary> {
276        let inner = self.inner.read();
277        let len = inner.summaries.len();
278        let start = len.saturating_sub(n);
279        inner.summaries[start..].to_vec()
280    }
281
282    /// Aggregate statistics across all completed epochs plus the current one.
283    pub fn total_stats(&self) -> EpochStats {
284        let inner = self.inner.read();
285        let mut stats = EpochStats {
286            total_epochs: inner.summaries.len() as u64 + 1,
287            finalized_epochs: 0,
288            total_blocks: 0,
289            total_transactions: 0,
290            total_fees: 0,
291        };
292        for s in &inner.summaries {
293            if s.finalized {
294                stats.finalized_epochs += 1;
295            }
296            stats.total_blocks += s.blocks as u64;
297            stats.total_transactions += s.transactions;
298            stats.total_fees += s.fees;
299        }
300        let cur = &inner.current_epoch;
301        if cur.is_finalized() {
302            stats.finalized_epochs += 1;
303        }
304        stats.total_blocks += cur.blocks_produced as u64;
305        stats.total_transactions += cur.total_transactions;
306        stats.total_fees += cur.total_fees;
307        stats
308    }
309
310    /// Returns the [`RewardDistribution`] for `epoch`, or `None`.
311    pub fn get_rewards(&self, epoch: u64) -> Option<RewardDistribution> {
312        self.inner.read().rewards.get(&epoch).cloned()
313    }
314
315    // -------------------------------------------------------------------------
316    // MGR-008 — store_rewards
317    // -------------------------------------------------------------------------
318
319    /// Archives a [`RewardDistribution`] keyed by its `epoch` field.
320    pub fn store_rewards(&self, distribution: RewardDistribution) {
321        let mut inner = self.inner.write();
322        inner.rewards.insert(distribution.epoch, distribution);
323    }
324
325    // -------------------------------------------------------------------------
326    // Internal accessors for checkpoint competition (used by MGR-004)
327    // -------------------------------------------------------------------------
328
329    /// Returns a clone of the current competition. Read-only.
330    pub fn competition(&self) -> CheckpointCompetition {
331        self.inner.read().competition.clone()
332    }
333
334    /// **Test / bootstrap helper** — directly overwrites the current competition.
335    ///
336    /// Used before CKP-002..005 provide full lifecycle methods. Not part of
337    /// the stable SPEC §6.5 API.
338    #[doc(hidden)]
339    pub fn __set_competition_for_test(&self, competition: CheckpointCompetition) {
340        self.inner.write().competition = competition;
341    }
342
343    /// **Test / bootstrap helper** — forces the current epoch into the given phase,
344    /// bypassing the L1 progress calculation.
345    ///
346    /// Used to exercise phase-gated methods (MGR-003/004/006) before the
347    /// phase machine is wired end-to-end. Not part of the stable SPEC API.
348    #[doc(hidden)]
349    pub fn __force_phase_for_test(&self, phase: EpochPhase) {
350        self.inner.write().current_epoch.phase = phase;
351    }
352
353    // -------------------------------------------------------------------------
354    // CKP-002 — start_checkpoint_competition
355    // -------------------------------------------------------------------------
356
357    /// Transitions the current competition from `Pending` to `Collecting`.
358    ///
359    /// Phase-gated to `Checkpoint`. Delegates to
360    /// [`CheckpointCompetition::start`] for the state transition.
361    pub fn start_checkpoint_competition(&self) -> Result<(), EpochError> {
362        let mut inner = self.inner.write();
363        if inner.current_epoch.phase != EpochPhase::Checkpoint {
364            return Err(EpochError::PhaseMismatch {
365                expected: EpochPhase::Checkpoint,
366                got: inner.current_epoch.phase,
367            });
368        }
369        inner.competition.start()?;
370        Ok(())
371    }
372
373    // -------------------------------------------------------------------------
374    // CKP-003 — submit_checkpoint
375    // -------------------------------------------------------------------------
376
377    /// Records a checkpoint submission against the current epoch's competition.
378    ///
379    /// Phase-gated to `Checkpoint`. Returns `Ok(true)` when the submission
380    /// becomes the new leader, `Ok(false)` is never returned (non-leading
381    /// submissions return `Err(ScoreNotHigher)`). Delegates scoring to
382    /// [`CheckpointCompetition::submit`].
383    pub fn submit_checkpoint(
384        &self,
385        submission: dig_block::CheckpointSubmission,
386    ) -> Result<bool, EpochError> {
387        let mut inner = self.inner.write();
388        if inner.current_epoch.phase != EpochPhase::Checkpoint {
389            return Err(EpochError::PhaseMismatch {
390                expected: EpochPhase::Checkpoint,
391                got: inner.current_epoch.phase,
392            });
393        }
394        Ok(inner.competition.submit(submission)?)
395    }
396
397    // -------------------------------------------------------------------------
398    // CKP-004 — finalize_competition / get_competition
399    // -------------------------------------------------------------------------
400
401    /// Finalizes the competition for `epoch` at `l1_height`, transitioning
402    /// status to `Finalized` and setting the winning checkpoint on the
403    /// current [`EpochInfo`].
404    ///
405    /// Phase-gated to `Finalization` when `epoch` matches the current epoch.
406    /// Returns the winning checkpoint on success, or `Ok(None)` if no winner
407    /// was selected.
408    pub fn finalize_competition(
409        &self,
410        epoch: u64,
411        l1_height: u32,
412    ) -> Result<Option<dig_block::Checkpoint>, EpochError> {
413        let mut inner = self.inner.write();
414        if inner.current_epoch.phase != EpochPhase::Finalization {
415            return Err(EpochError::PhaseMismatch {
416                expected: EpochPhase::Finalization,
417                got: inner.current_epoch.phase,
418            });
419        }
420        if inner.competition.epoch != epoch {
421            return Err(EpochError::EpochMismatch {
422                expected: inner.competition.epoch,
423                got: epoch,
424            });
425        }
426        // No winner: transition to Failed and return None.
427        if inner.competition.current_winner.is_none() {
428            inner.competition.fail()?;
429            return Ok(None);
430        }
431        inner.competition.finalize(l1_height)?;
432        let winner_idx = inner.competition.current_winner.unwrap();
433        let winning_checkpoint = inner.competition.submissions[winner_idx].checkpoint.clone();
434        inner
435            .current_epoch
436            .set_checkpoint(winning_checkpoint.clone());
437        Ok(Some(winning_checkpoint))
438    }
439
440    /// Returns a clone of the competition for `epoch`.
441    ///
442    /// Only the current epoch's competition is tracked; returns `None` for
443    /// past or future epochs.
444    pub fn get_competition(&self, epoch: u64) -> Option<CheckpointCompetition> {
445        let inner = self.inner.read();
446        if inner.competition.epoch == epoch {
447            Some(inner.competition.clone())
448        } else {
449            None
450        }
451    }
452}