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}