Skip to main content

amaru_kernel/cardano/
era_history.rs

1// Copyright 2026 PRAGMA
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16    fs::File,
17    io::BufReader,
18    path::Path,
19    sync::LazyLock,
20    time::{Duration, SystemTime},
21};
22
23use crate::{
24    Epoch, EraBound, EraName, EraSummary, MAINNET_GLOBAL_PARAMETERS, PREPROD_GLOBAL_PARAMETERS, Slot,
25    TESTNET_GLOBAL_PARAMETERS,
26    cardano::{era_params::EraParams, slot::SlotArithmeticError},
27    cbor,
28};
29
30// A complete history of eras that have taken place.
31#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
32pub struct EraHistory {
33    /// Number of slots for which the chain growth property guarantees at least k blocks.
34    ///
35    /// This is defined as 3 * k / f, where:
36    ///
37    /// - k is the network security parameter (mainnet = 2160);
38    /// - f is the active slot coefficient (mainnet = 0.05);
39    stability_window: Slot,
40
41    /// EraSummary of each era boundaries.
42    eras: Vec<EraSummary>,
43}
44
45/// Era history for Mainnet retrieved with:
46///
47/// ```bash
48/// curl -X POST "https://mainnet.koios.rest/api/v1/ogmios"
49///  -H 'accept: application/json'
50///  -H 'content-type: application/json'
51///  -d '{"jsonrpc":"2.0","method":"queryLedgerState/eraSummaries"}' | jq -c '.result'
52/// ```
53///
54pub static MAINNET_ERA_HISTORY: LazyLock<EraHistory> = LazyLock::new(|| {
55    let eras: [EraSummary; 7] = [
56        EraSummary {
57            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
58            end: Some(EraBound {
59                time: Duration::from_secs(89856000),
60                slot: Slot::from(4492800),
61                epoch: Epoch::from(208),
62            }),
63            params: EraParams {
64                epoch_size_slots: 21600,
65                slot_length: Duration::from_secs(20),
66                era_name: EraName::Byron,
67            },
68        },
69        EraSummary {
70            start: EraBound { time: Duration::from_secs(89856000), slot: Slot::from(4492800), epoch: Epoch::from(208) },
71            end: Some(EraBound {
72                time: Duration::from_secs(101952000),
73                slot: Slot::from(16588800),
74                epoch: Epoch::from(236),
75            }),
76            params: EraParams {
77                epoch_size_slots: 432000,
78                slot_length: Duration::from_secs(1),
79                era_name: EraName::Shelley,
80            },
81        },
82        EraSummary {
83            start: EraBound {
84                time: Duration::from_secs(101952000),
85                slot: Slot::from(16588800),
86                epoch: Epoch::from(236),
87            },
88            end: Some(EraBound {
89                time: Duration::from_secs(108432000),
90                slot: Slot::from(23068800),
91                epoch: Epoch::from(251),
92            }),
93            params: EraParams {
94                epoch_size_slots: 432000,
95                slot_length: Duration::from_secs(1),
96                era_name: EraName::Allegra,
97            },
98        },
99        EraSummary {
100            start: EraBound {
101                time: Duration::from_secs(108432000),
102                slot: Slot::from(23068800),
103                epoch: Epoch::from(251),
104            },
105            end: Some(EraBound {
106                time: Duration::from_secs(125280000),
107                slot: Slot::from(39916800),
108                epoch: Epoch::from(290),
109            }),
110            params: EraParams {
111                epoch_size_slots: 432000,
112                slot_length: Duration::from_secs(1),
113                era_name: EraName::Mary,
114            },
115        },
116        EraSummary {
117            start: EraBound {
118                time: Duration::from_secs(125280000),
119                slot: Slot::from(39916800),
120                epoch: Epoch::from(290),
121            },
122            end: Some(EraBound {
123                time: Duration::from_secs(157680000),
124                slot: Slot::from(72316800),
125                epoch: Epoch::from(365),
126            }),
127            params: EraParams {
128                epoch_size_slots: 432000,
129                slot_length: Duration::from_secs(1),
130                era_name: EraName::Alonzo,
131            },
132        },
133        EraSummary {
134            start: EraBound {
135                time: Duration::from_secs(157680000),
136                slot: Slot::from(72316800),
137                epoch: Epoch::from(365),
138            },
139            end: Some(EraBound {
140                time: Duration::from_secs(219024000),
141                slot: Slot::from(133660800),
142                epoch: Epoch::from(507),
143            }),
144            params: EraParams {
145                epoch_size_slots: 432000,
146                slot_length: Duration::from_secs(1),
147                era_name: EraName::Babbage,
148            },
149        },
150        EraSummary {
151            start: EraBound {
152                time: Duration::from_secs(219024000),
153                slot: Slot::from(133660800),
154                epoch: Epoch::from(507),
155            },
156            end: None,
157            params: EraParams {
158                epoch_size_slots: 432000,
159                slot_length: Duration::from_secs(1),
160                era_name: EraName::Conway,
161            },
162        },
163    ];
164    EraHistory::new(&eras, MAINNET_GLOBAL_PARAMETERS.stability_window)
165});
166
167/// Era history for Preprod retrieved with:
168///
169/// ```bash
170/// curl -X POST "https://preprod.koios.rest/api/v1/ogmios"
171///  -H 'accept: application/json'
172///  -H 'content-type: application/json'
173///  -d '{"jsonrpc":"2.0","method":"queryLedgerState/eraSummaries"}' | jq -c '.result'
174/// ```
175///
176pub static PREPROD_ERA_HISTORY: LazyLock<EraHistory> = LazyLock::new(|| {
177    let eras: [EraSummary; 7] = [
178        EraSummary {
179            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
180            end: Some(EraBound { time: Duration::from_secs(1728000), slot: Slot::from(86400), epoch: Epoch::from(4) }),
181            params: EraParams {
182                epoch_size_slots: 21600,
183                slot_length: Duration::from_secs(20),
184                era_name: EraName::Byron,
185            },
186        },
187        EraSummary {
188            start: EraBound { time: Duration::from_secs(1728000), slot: Slot::from(86400), epoch: Epoch::from(4) },
189            end: Some(EraBound { time: Duration::from_secs(2160000), slot: Slot::from(518400), epoch: Epoch::from(5) }),
190            params: EraParams {
191                epoch_size_slots: 432000,
192                slot_length: Duration::from_secs(1),
193                era_name: EraName::Shelley,
194            },
195        },
196        EraSummary {
197            start: EraBound { time: Duration::from_secs(2160000), slot: Slot::from(518400), epoch: Epoch::from(5) },
198            end: Some(EraBound { time: Duration::from_secs(2592000), slot: Slot::from(950400), epoch: Epoch::from(6) }),
199
200            params: EraParams {
201                epoch_size_slots: 432000,
202                slot_length: Duration::from_secs(1),
203                era_name: EraName::Allegra,
204            },
205        },
206        EraSummary {
207            start: EraBound { time: Duration::from_secs(2592000), slot: Slot::from(950400), epoch: Epoch::from(6) },
208            end: Some(EraBound {
209                time: Duration::from_secs(3024000),
210                slot: Slot::from(1382400),
211                epoch: Epoch::from(7),
212            }),
213
214            params: EraParams {
215                epoch_size_slots: 432000,
216                slot_length: Duration::from_secs(1),
217                era_name: EraName::Mary,
218            },
219        },
220        EraSummary {
221            start: EraBound { time: Duration::from_secs(3024000), slot: Slot::from(1382400), epoch: Epoch::from(7) },
222            end: Some(EraBound {
223                time: Duration::from_secs(5184000),
224                slot: Slot::from(3542400),
225                epoch: Epoch::from(12),
226            }),
227
228            params: EraParams {
229                epoch_size_slots: 432000,
230                slot_length: Duration::from_secs(1),
231                era_name: EraName::Alonzo,
232            },
233        },
234        EraSummary {
235            start: EraBound { time: Duration::from_secs(5184000), slot: Slot::from(3542400), epoch: Epoch::from(12) },
236            end: Some(EraBound {
237                time: Duration::from_secs(70416000),
238                slot: Slot::from(68774400),
239                epoch: Epoch::from(163),
240            }),
241
242            params: EraParams {
243                epoch_size_slots: 432000,
244                slot_length: Duration::from_secs(1),
245                era_name: EraName::Babbage,
246            },
247        },
248        EraSummary {
249            start: EraBound {
250                time: Duration::from_secs(70416000),
251                slot: Slot::from(68774400),
252                epoch: Epoch::from(163),
253            },
254            end: None,
255            params: EraParams {
256                epoch_size_slots: 432000,
257                slot_length: Duration::from_secs(1),
258                era_name: EraName::Conway,
259            },
260        },
261    ];
262
263    EraHistory::new(&eras, PREPROD_GLOBAL_PARAMETERS.stability_window)
264});
265
266/// Era history for Preview retrieved with:
267///
268/// ```bash
269/// curl -X POST "https://preview.koios.rest/api/v1/ogmios"
270///  -H 'accept: application/json'
271///  -H 'content-type: application/json'
272///  -d '{"jsonrpc":"2.0","method":"queryLedgerState/eraSummaries"}' | jq -c '.result'
273/// ```
274///
275pub static PREVIEW_ERA_HISTORY: LazyLock<EraHistory> = LazyLock::new(|| {
276    let eras: [EraSummary; 7] = [
277        EraSummary {
278            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
279            end: Some(EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) }),
280            params: EraParams {
281                epoch_size_slots: 4320,
282                slot_length: Duration::from_secs(20),
283                era_name: EraName::Byron,
284            },
285        },
286        EraSummary {
287            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
288            end: Some(EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) }),
289            params: EraParams {
290                epoch_size_slots: 86400,
291                slot_length: Duration::from_secs(1),
292                era_name: EraName::Shelley,
293            },
294        },
295        EraSummary {
296            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(5) },
297            end: Some(EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) }),
298
299            params: EraParams {
300                epoch_size_slots: 86400,
301                slot_length: Duration::from_secs(1),
302                era_name: EraName::Allegra,
303            },
304        },
305        EraSummary {
306            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
307            end: Some(EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) }),
308
309            params: EraParams { epoch_size_slots: 86400, slot_length: Duration::from_secs(1), era_name: EraName::Mary },
310        },
311        EraSummary {
312            start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
313            end: Some(EraBound { time: Duration::from_secs(259200), slot: Slot::from(259200), epoch: Epoch::from(3) }),
314
315            params: EraParams {
316                epoch_size_slots: 86400,
317                slot_length: Duration::from_secs(1),
318                era_name: EraName::Alonzo,
319            },
320        },
321        EraSummary {
322            start: EraBound { time: Duration::from_secs(259200), slot: Slot::from(259200), epoch: Epoch::from(3) },
323            end: Some(EraBound {
324                time: Duration::from_secs(55814400),
325                slot: Slot::from(55814400),
326                epoch: Epoch::from(646),
327            }),
328
329            params: EraParams {
330                epoch_size_slots: 86400,
331                slot_length: Duration::from_secs(1),
332                era_name: EraName::Babbage,
333            },
334        },
335        EraSummary {
336            start: EraBound {
337                time: Duration::from_secs(55814400),
338                slot: Slot::from(55814400),
339                epoch: Epoch::from(646),
340            },
341            end: None,
342
343            params: EraParams {
344                epoch_size_slots: 86400,
345                slot_length: Duration::from_secs(1),
346                era_name: EraName::Conway,
347            },
348        },
349    ];
350
351    EraHistory::new(&eras, Slot::from(25920))
352});
353
354/// A default era history for testnets
355///
356/// This default `EraHistory` contains a single era which covers 1000 epochs,
357/// with a slot length of 1 second and epoch size of 86400 slots.
358pub static TESTNET_ERA_HISTORY: LazyLock<EraHistory> = LazyLock::new(|| {
359    let eras: [EraSummary; 1] = [EraSummary {
360        start: EraBound { time: Duration::from_secs(0), slot: Slot::from(0), epoch: Epoch::from(0) },
361        end: None,
362
363        params: EraParams {
364            epoch_size_slots: 86400, // one day
365            slot_length: Duration::from_secs(1),
366            era_name: EraName::Conway,
367        },
368    }];
369
370    EraHistory::new(&eras, TESTNET_GLOBAL_PARAMETERS.stability_window)
371});
372
373/// Error type for era history file operations
374#[derive(Debug, thiserror::Error)]
375pub enum EraHistoryFileError {
376    #[error("Failed to open era history file: {0}")]
377    FileOpenError(#[from] std::io::Error),
378    #[error("Failed to parse era history JSON: {0}")]
379    JsonParseError(#[from] serde_json::Error),
380}
381
382/// Load an `EraHistory` from a JSON file.
383///
384/// # Arguments
385///
386/// * `path` - Path to the JSON file containing era history data
387///
388/// # Returns
389///
390/// Returns a Result containing the `EraHistory` if successful, or an `EraHistoryFileError` if the file
391/// cannot be read or parsed.
392///
393/// # Example
394///
395/// ```no_run
396/// use amaru_kernel::load_era_history_from_file;
397/// use std::path::Path;
398///
399/// let era_history = load_era_history_from_file(Path::new("era_history.json")).unwrap();
400/// ```
401pub fn load_era_history_from_file(path: &Path) -> Result<EraHistory, EraHistoryFileError> {
402    let file = File::open(path).map_err(EraHistoryFileError::FileOpenError)?;
403    let reader = BufReader::new(file);
404
405    serde_json::from_reader(reader).map_err(EraHistoryFileError::JsonParseError)
406}
407
408impl<C> cbor::Encode<C> for EraHistory {
409    fn encode<W: cbor::encode::Write>(
410        &self,
411        e: &mut cbor::Encoder<W>,
412        ctx: &mut C,
413    ) -> Result<(), cbor::encode::Error<W::Error>> {
414        e.begin_array()?;
415        for s in &self.eras {
416            s.encode(e, ctx)?;
417        }
418        e.end()?;
419        Ok(())
420    }
421}
422
423impl<'b> cbor::Decode<'b, Slot> for EraHistory {
424    fn decode(d: &mut cbor::Decoder<'b>, ctx: &mut Slot) -> Result<Self, cbor::decode::Error> {
425        let mut eras = vec![];
426        let eras_iter: cbor::decode::ArrayIter<'_, '_, EraSummary> = d.array_iter()?;
427        for era in eras_iter {
428            eras.push(era?);
429        }
430        Ok(EraHistory { stability_window: *ctx, eras })
431    }
432}
433
434#[derive(Debug, PartialEq, Eq, thiserror::Error, serde::Serialize, serde::Deserialize)]
435pub enum EraHistoryError {
436    #[error("slot past time horizon")]
437    PastTimeHorizon,
438    #[error("invalid era history")]
439    InvalidEraHistory,
440    #[error("slot is too far in the future; computation cannot complete")]
441    SlotTooFar,
442    #[error("{0}")]
443    SlotArithmetic(#[from] SlotArithmeticError),
444}
445
446#[derive(Clone, Debug, PartialEq, Eq)]
447pub struct EpochEraBounds {
448    pub start: Slot,
449    pub end: Option<Slot>,
450}
451
452// The last era in the provided EraHistory must end at the time horizon for accurate results. The
453// horizon is the end of the epoch containing the end of the current era's safe zone relative to
454// the current tip. Returns number of milliseconds elapsed since the system start time.
455impl EraHistory {
456    pub fn new(eras: &[EraSummary], stability_window: Slot) -> EraHistory {
457        #[expect(clippy::panic)]
458        if eras.is_empty() {
459            panic!("EraHistory cannot be empty");
460        }
461        // TODO ensures only last era ends with Option
462        EraHistory { stability_window, eras: eras.to_vec() }
463    }
464
465    pub fn slot_to_posix_time(
466        &self,
467        slot: Slot,
468        tip: Slot,
469        system_start: SystemTime,
470    ) -> Result<SystemTime, EraHistoryError> {
471        let relative_time = self.slot_to_relative_time(slot, tip)?;
472
473        Ok(system_start + relative_time)
474    }
475
476    pub fn slot_to_relative_time(&self, slot: Slot, tip: Slot) -> Result<Duration, EraHistoryError> {
477        for era in &self.eras {
478            if era.start.slot > slot {
479                return Err(EraHistoryError::InvalidEraHistory);
480            }
481
482            if era.contains_slot(&slot, &tip, &self.stability_window) {
483                return slot_to_relative_time(&slot, era);
484            }
485        }
486
487        Err(EraHistoryError::PastTimeHorizon)
488    }
489
490    /// Unsafe version of `slot_to_relative_time` which doesn't check whether the slot is within a
491    /// foreseeable horizon. Only use this when the slot is guaranteed to be in the past.
492    pub fn slot_to_relative_time_unchecked_horizon(&self, slot: Slot) -> Result<Duration, EraHistoryError> {
493        for era in &self.eras {
494            if era.start.slot > slot {
495                return Err(EraHistoryError::InvalidEraHistory);
496            }
497
498            if era.contains_slot_unchecked_horizon(&slot) {
499                return slot_to_relative_time(&slot, era);
500            }
501        }
502
503        Err(EraHistoryError::InvalidEraHistory)
504    }
505
506    pub fn slot_to_epoch(&self, slot: Slot, tip: Slot) -> Result<Epoch, EraHistoryError> {
507        for era in &self.eras {
508            if era.start.slot > slot {
509                return Err(EraHistoryError::InvalidEraHistory);
510            }
511
512            if era.contains_slot(&slot, &tip, &self.stability_window) {
513                return slot_to_epoch(&slot, era);
514            }
515        }
516
517        Err(EraHistoryError::PastTimeHorizon)
518    }
519
520    pub fn slot_to_epoch_unchecked_horizon(&self, slot: Slot) -> Result<Epoch, EraHistoryError> {
521        for era in &self.eras {
522            if era.start.slot > slot {
523                return Err(EraHistoryError::InvalidEraHistory);
524            }
525
526            if era.contains_slot_unchecked_horizon(&slot) {
527                return slot_to_epoch(&slot, era);
528            }
529        }
530
531        Err(EraHistoryError::InvalidEraHistory)
532    }
533
534    pub fn next_epoch_first_slot(&self, epoch: Epoch, tip: &Slot) -> Result<Slot, EraHistoryError> {
535        for era in &self.eras {
536            if era.start.epoch > epoch {
537                return Err(EraHistoryError::InvalidEraHistory);
538            }
539
540            if era.contains_epoch(&epoch, tip, &self.stability_window) {
541                let start_of_next_epoch = (epoch.as_u64() - era.start.epoch.as_u64() + 1) * era.params.epoch_size_slots
542                    + era.start.slot.as_u64();
543                return Ok(Slot::new(start_of_next_epoch));
544            }
545        }
546        Err(EraHistoryError::PastTimeHorizon)
547    }
548
549    /// Find the first epoch of the era from which this epoch belongs.
550    pub fn era_first_epoch(&self, epoch: Epoch) -> Result<Epoch, EraHistoryError> {
551        for era in &self.eras {
552            // NOTE: This is okay. If there's no upper-bound to the era and the slot is after the
553            // start of it, then necessarily the era's lower bound is what we're looking for.
554            if era.contains_epoch_unchecked_horizon(&epoch) {
555                return Ok(era.start.epoch);
556            }
557        }
558
559        Err(EraHistoryError::InvalidEraHistory)
560    }
561
562    pub fn epoch_bounds(&self, epoch: Epoch) -> Result<EpochEraBounds, EraHistoryError> {
563        for era in &self.eras {
564            if era.start.epoch > epoch {
565                return Err(EraHistoryError::InvalidEraHistory);
566            }
567
568            // NOTE: Unchecked horizon is okay here since in case there's no upper-bound, we'll
569            // simply return a `None` epoch bounds as well.
570            if era.contains_epoch_unchecked_horizon(&epoch) {
571                let epochs_elapsed = epoch - era.start.epoch;
572                let offset = era.start.slot;
573                let slots_elapsed = epochs_elapsed * era.params.epoch_size_slots;
574                let start = offset.offset_by(slots_elapsed);
575                let end = offset.offset_by(era.params.epoch_size_slots + slots_elapsed);
576                return Ok(EpochEraBounds { start, end: era.end.as_ref().map(|_| end) });
577            }
578        }
579
580        Err(EraHistoryError::InvalidEraHistory)
581    }
582
583    /// Computes the relative slot in the epoch given an absolute slot.
584    ///
585    /// Returns the number of slots since the start of the epoch containing the given slot.
586    ///
587    /// # Errors
588    ///
589    /// Returns `EraHistoryError::PastTimeHorizon` if the slot is beyond the time horizon.
590    /// Returns `EraHistoryError::InvalidEraHistory` if the era history is invalid.
591    pub fn slot_in_epoch(&self, slot: Slot, tip: Slot) -> Result<Slot, EraHistoryError> {
592        let epoch = self.slot_to_epoch(slot, tip)?;
593        let bounds = self.epoch_bounds(epoch)?;
594        let elapsed = slot.elapsed_from(bounds.start)?;
595        Ok(Slot::new(elapsed))
596    }
597
598    /// Returns the era index (0-based) for the given slot.
599    ///
600    /// The era index should correspond to the position in the era history vector:
601    /// - 0 = Byron
602    /// - 1 = Shelley
603    /// - 2 = Allegra
604    /// - 3 = Mary
605    /// - 4 = Alonzo
606    /// - 5 = Babbage
607    /// - 6 = Conway
608    pub fn slot_to_era_index(&self, slot: Slot) -> Result<usize, EraHistoryError> {
609        for (index, era) in self.eras.iter().enumerate() {
610            if era.start.slot > slot {
611                return Err(EraHistoryError::InvalidEraHistory);
612            }
613
614            // Check if slot is in this era: start <= slot < end (end is exclusive)
615            let in_era = match &era.end {
616                Some(end) => slot < end.slot,
617                None => true, // Last era has no end bound
618            };
619
620            if in_era {
621                return Ok(index);
622            }
623        }
624
625        Err(EraHistoryError::InvalidEraHistory)
626    }
627
628    /// Compute the era tag (used for serializating) from a slot using the era history.
629    pub fn slot_to_era_tag(&self, slot: Slot) -> Result<EraName, EraHistoryError> {
630        let era_index = self.slot_to_era_index(slot)?;
631        Ok(self.eras[era_index].params.era_name)
632    }
633}
634
635/// Compute the time in milliseconds between the start of the system and the given slot.
636///
637/// **pre-condition**: the given summary must be the era containing that slot.
638fn slot_to_relative_time(slot: &Slot, era: &EraSummary) -> Result<Duration, EraHistoryError> {
639    let slots_elapsed: u32 = slot
640        .elapsed_from(era.start.slot)
641        .map_err(|_| EraHistoryError::InvalidEraHistory)?
642        .try_into()
643        .map_err(|_| EraHistoryError::SlotTooFar)?;
644
645    let time_elapsed = era.params.slot_length * slots_elapsed;
646
647    let relative_time = era.start.time + time_elapsed;
648
649    Ok(relative_time)
650}
651
652/// Compute the epoch corresponding to the given slot.
653///
654/// **pre-condition**: the given summary must be the era containing that slot.
655fn slot_to_epoch(slot: &Slot, era: &EraSummary) -> Result<Epoch, EraHistoryError> {
656    let slots_elapsed = slot.elapsed_from(era.start.slot).map_err(|_| EraHistoryError::InvalidEraHistory)?;
657    let epochs_elapsed = slots_elapsed / era.params.epoch_size_slots;
658    let epoch_number = era.start.epoch + epochs_elapsed;
659    Ok(epoch_number)
660}
661
662#[cfg(test)]
663mod tests {
664    use std::{env, fs::File, io::Write, path::Path, str::FromStr};
665
666    use proptest::{prelude::*, proptest};
667    use test_case::test_case;
668
669    use super::*;
670    use crate::{
671        Epoch, PREPROD_ERA_HISTORY, Slot, any_era_params, any_network_name, from_cbor_no_leftovers_with,
672        load_era_history_from_file, to_cbor,
673    };
674
675    prop_compose! {
676        // Generate an arbitrary list of ordered epochs where we might have a new era
677        fn any_boundaries()(
678            first_epoch in any::<u16>(),
679            era_lengths in prop::collection::vec(1u64..1000, 1usize..32usize),
680        ) -> Vec<u64> {
681            let mut boundaries = vec![first_epoch as u64];
682            for era_length in era_lengths {
683                boundaries.push(boundaries.last().unwrap() + era_length);
684            }
685            boundaries
686        }
687    }
688
689    prop_compose! {
690        fn any_era_history()(
691            boundaries in any_boundaries()
692        )(
693            stability_window in any::<u64>(),
694            era_params in prop::collection::vec(any_era_params(), boundaries.len()),
695            boundaries in Just(boundaries),
696        ) -> EraHistory {
697            let genesis = EraBound {
698                time: Duration::from_secs(0),
699                slot: Slot::new(0),
700                epoch: Epoch::new(0),
701            };
702
703            let mut prev_bound = genesis;
704
705            // For each boundary, compute the time and slot for that epoch based on the era params and
706            // construct a summary from the boundary pair
707            let mut summaries = vec![];
708            for (boundary, prev_era_params) in boundaries.iter().zip(era_params.iter()) {
709                let epochs_elapsed = boundary - prev_bound.epoch.as_u64();
710                let slots_elapsed = epochs_elapsed * prev_era_params.epoch_size_slots;
711                let time_elapsed = prev_era_params.slot_length * slots_elapsed as u32;
712                let new_bound = EraBound {
713                    time: prev_bound.time + time_elapsed,
714                    slot: prev_bound.slot.offset_by(slots_elapsed),
715                    epoch: Epoch::new(*boundary),
716                };
717
718                summaries.push(EraSummary {
719                    start: prev_bound,
720                    end: if *boundary as usize == boundaries.len() {
721                        None
722                    } else {
723                        Some(new_bound.clone())
724                    },
725                    params: prev_era_params.clone(),
726                });
727
728                prev_bound = new_bound;
729            }
730
731            EraHistory::new(&summaries, Slot::from(stability_window))
732        }
733    }
734
735    fn default_params() -> EraParams {
736        EraParams::new(86400, Duration::from_secs(1), EraName::Conway).unwrap()
737    }
738
739    fn one_era() -> EraHistory {
740        EraHistory {
741            stability_window: Slot::new(25920),
742            eras: vec![EraSummary {
743                start: EraBound { time: Duration::from_secs(0), slot: Slot::new(0), epoch: Epoch::new(0) },
744                end: None,
745                params: default_params(),
746            }],
747        }
748    }
749
750    fn two_eras() -> EraHistory {
751        EraHistory {
752            stability_window: Slot::new(25920),
753            eras: vec![
754                EraSummary {
755                    start: EraBound { time: Duration::from_secs(0), slot: Slot::new(0), epoch: Epoch::new(0) },
756                    end: Some(EraBound {
757                        time: Duration::from_secs(86400),
758                        slot: Slot::new(86400),
759                        epoch: Epoch::new(1),
760                    }),
761                    params: default_params(),
762                },
763                EraSummary {
764                    start: EraBound { time: Duration::from_secs(86400), slot: Slot::new(86400), epoch: Epoch::new(1) },
765                    end: None,
766                    params: default_params(),
767                },
768            ],
769        }
770    }
771
772    #[test]
773    fn slot_to_relative_time_within_horizon() {
774        let eras = two_eras();
775        assert_eq!(eras.slot_to_relative_time(Slot::new(172800), Slot::new(172800)), Ok(Duration::from_secs(172800)));
776    }
777
778    #[test]
779    fn slot_to_time_fails_after_time_horizon() {
780        let eras = two_eras();
781        assert_eq!(
782            eras.slot_to_relative_time(Slot::new(172800), Slot::new(100000)),
783            Ok(Duration::from_secs(172800)),
784            "point is right at the end of the epoch, tip is somewhere"
785        );
786        assert_eq!(
787            eras.slot_to_relative_time(Slot::new(172801), Slot::new(86400)),
788            Err(EraHistoryError::PastTimeHorizon),
789            "point in the next epoch, but tip is way before the stability window (first slot)"
790        );
791        assert_eq!(
792            eras.slot_to_relative_time(Slot::new(172801), Slot::new(100000)),
793            Err(EraHistoryError::PastTimeHorizon),
794            "point in the next epoch, but tip is way before the stability window (somewhere)"
795        );
796        assert_eq!(
797            eras.slot_to_relative_time(Slot::new(172801), Slot::new(146880)),
798            Ok(Duration::from_secs(172801)),
799            "point in the next epoch, and tip right at the stability window limit"
800        );
801        assert_eq!(
802            eras.slot_to_relative_time(Slot::new(172801), Slot::new(146879)),
803            Err(EraHistoryError::PastTimeHorizon),
804            "point in the next epoch, but tip right *before* the stability window limit"
805        );
806    }
807
808    #[test]
809    fn epoch_bounds_epoch_0() {
810        let bounds = two_eras().epoch_bounds(Epoch::new(0)).unwrap();
811        assert_eq!(bounds.start, Slot::new(0));
812        assert_eq!(bounds.end, Some(Slot::new(86400)));
813    }
814
815    #[test]
816    fn epoch_bounds_epoch_1() {
817        let bounds = two_eras().epoch_bounds(Epoch::new(1)).unwrap();
818        assert_eq!(bounds.start, Slot::new(86400));
819        assert_eq!(bounds.end, None);
820    }
821
822    static MAINNET_SYSTEM_START: LazyLock<SystemTime> =
823        LazyLock::new(|| SystemTime::UNIX_EPOCH + Duration::from_secs(1506203091));
824
825    #[test_case(0, 42, *MAINNET_SYSTEM_START
826        => Ok(*MAINNET_SYSTEM_START);
827        "first slot in the system, tip is irrelevant"
828    )]
829    #[test_case(1000, 42, *MAINNET_SYSTEM_START
830        => Ok(*MAINNET_SYSTEM_START + Duration::from_secs(1000));
831        "one thousand slots after genesis, tip is irrelevant"
832    )]
833    #[test_case(172801, 0, *MAINNET_SYSTEM_START
834        => Err(EraHistoryError::PastTimeHorizon);
835        "slot is at the next epcoh, but tip is at genesis"
836    )]
837    fn slot_to_posix(slot: u64, tip: u64, system_start: SystemTime) -> Result<SystemTime, EraHistoryError> {
838        two_eras().slot_to_posix_time(slot.into(), tip.into(), system_start)
839    }
840
841    #[test_case(0,          42 => Ok(0);
842        "first slot in first epoch, tip irrelevant"
843    )]
844    #[test_case(48272,      42 => Ok(0);
845        "slot anywhere in first epoch, tip irrelevant"
846    )]
847    #[test_case(86400,      42 => Ok(1);
848        "first slot in second epoch, tip irrelevant"
849    )]
850    #[test_case(105437,     42 => Ok(1);
851        "slot anywhere in second epoch, tip irrelevant"
852    )]
853    #[test_case(172801, 146879 => Err(EraHistoryError::PastTimeHorizon);
854        "slot beyond first epoch (at the frontier), tip before stable area"
855    )]
856    #[test_case(200000, 146879 => Err(EraHistoryError::PastTimeHorizon);
857        "slot beyond first epoch (anywhere), tip before stable area"
858    )]
859    #[test_case(200000, 146880 => Ok(2);
860        "slot within third epoch, tip in stable area (lower frontier)"
861    )]
862    #[test_case(200000, 153129 => Ok(2);
863        "slot within third epoch, tip in stable area (anywhere)"
864    )]
865    #[test_case(200000, 172800 => Ok(2);
866        "slot within third epoch, tip in stable area (upper frontier)"
867    )]
868    #[test_case(260000, 146880 => Err(EraHistoryError::PastTimeHorizon);
869        "slot far far away, tip in stable area (lower frontier)"
870    )]
871    #[test_case(260000, 153129 => Err(EraHistoryError::PastTimeHorizon);
872        "slot far far away, tip in stable area (anywhere)"
873    )]
874    #[test_case(260000, 172800 => Err(EraHistoryError::PastTimeHorizon);
875        "slot far far away, tip in stable area (upper frontier)"
876    )]
877    fn slot_to_epoch(slot: u64, tip: u64) -> Result<u64, EraHistoryError> {
878        two_eras().slot_to_epoch(Slot::new(slot), Slot::new(tip)).map(|epoch| epoch.into())
879    }
880
881    #[test_case(0 => Ok(0); "first slot in first epoch")]
882    #[test_case(48272 => Ok(0); "slot anywhere in first epoch")]
883    #[test_case(86400 => Ok(1); "first slot in second epoch")]
884    #[test_case(105437 => Ok(1); "slot anywhere in second epoch")]
885    #[test_case(172801 => Ok(2); "slot beyond first epoch (at the frontier)")]
886    #[test_case(200000 => Ok(2); "slot within third epoch")]
887    #[test_case(260000 => Ok(3); "slot far far away")]
888    fn slot_to_epoch_unchecked_horizon(slot: u64) -> Result<u64, EraHistoryError> {
889        two_eras().slot_to_epoch_unchecked_horizon(Slot::new(slot)).map(|epoch| epoch.into())
890    }
891
892    #[test_case(0, 42 => Ok(86400); "fully known forecast (1), tip irrelevant")]
893    #[test_case(1, 42 => Ok(172800); "fully known forecast (2), tip irrelevant")]
894    #[test_case(2, 42 => Err(EraHistoryError::PastTimeHorizon);
895        "far away forecast, tip before stable window (well before)"
896    )]
897    #[test_case(2, 146879 => Err(EraHistoryError::PastTimeHorizon);
898        "far away forecast, tip before stable window (lower frontier)"
899    )]
900    #[test_case(2, 146880 => Ok(259200);
901        "far away forecast, tip within stable window (lower frontier)"
902    )]
903    #[test_case(2, 201621 => Ok(259200);
904        "far away forecast, tip within stable window (anywhere)"
905    )]
906    #[test_case(2, 259199 => Ok(259200);
907        "far away forecast, tip within stable window (upper frontier)"
908    )]
909    fn next_epoch_first_slot(epoch: u64, tip: u64) -> Result<u64, EraHistoryError> {
910        two_eras().next_epoch_first_slot(Epoch::new(epoch), &Slot::new(tip)).map(|slot| slot.into())
911    }
912
913    #[test]
914    fn slot_in_epoch_invalid_era_history() {
915        // Create an invalid era history where the second era starts at a slot
916        // that is earlier than the first era's start slot
917        let invalid_eras = EraHistory {
918            stability_window: Slot::new(129600),
919            eras: vec![
920                EraSummary {
921                    start: EraBound { time: Duration::from_secs(100), slot: Slot::new(100), epoch: Epoch::new(1) },
922                    end: Some(EraBound {
923                        time: Duration::from_secs(186400),
924                        slot: Slot::new(86500),
925                        epoch: Epoch::new(2),
926                    }),
927                    params: default_params(),
928                },
929                EraSummary {
930                    start: EraBound {
931                        time: Duration::from_secs(186400),
932                        slot: Slot::new(50), // This is invalid - earlier than first era's start
933                        epoch: Epoch::new(2),
934                    },
935                    end: Some(EraBound {
936                        time: Duration::from_secs(272800),
937                        slot: Slot::new(86450),
938                        epoch: Epoch::new(3),
939                    }),
940                    params: default_params(),
941                },
942            ],
943        };
944
945        let relative_slot = invalid_eras.slot_in_epoch(Slot::new(60), Slot::new(42));
946        assert_eq!(relative_slot, Err(EraHistoryError::InvalidEraHistory));
947    }
948
949    #[test]
950    fn slot_in_epoch_underflows_given_era_history_with_gaps() {
951        // Create a custom era history with a gap between epochs
952        let invalid_eras = EraHistory {
953            stability_window: Slot::new(129600),
954            eras: vec![
955                EraSummary {
956                    start: EraBound { time: Duration::from_secs(0), slot: Slot::new(0), epoch: Epoch::new(0) },
957                    end: Some(EraBound {
958                        time: Duration::from_secs(86400),
959                        slot: Slot::new(86400),
960                        epoch: Epoch::new(1),
961                    }),
962                    params: default_params(),
963                },
964                EraSummary {
965                    start: EraBound {
966                        time: Duration::from_secs(86400),
967                        slot: Slot::new(186400), // Gap of 100000 slots
968                        epoch: Epoch::new(1),
969                    },
970                    end: Some(EraBound {
971                        time: Duration::from_secs(172800),
972                        slot: Slot::new(272800),
973                        epoch: Epoch::new(2),
974                    }),
975                    params: default_params(),
976                },
977            ],
978        };
979
980        // A slot in epoch 1 but before the start of epoch 1's slots
981        let problematic_slot = Slot::new(100000);
982
983        let result = invalid_eras.slot_in_epoch(problematic_slot, Slot::new(42));
984        assert_eq!(result, Err(EraHistoryError::InvalidEraHistory));
985    }
986
987    #[test]
988    fn encode_era_history() {
989        let eras = one_era();
990        let buffer = cbor::to_vec(&eras).unwrap();
991        assert_eq!(hex::encode(buffer), "9f9f83000000f6831a000151801903e807ffff");
992    }
993
994    #[test]
995    fn can_decode_bounds_with_unbounded_integer_slot() {
996        // CBOR encoding for
997        // [[[0, 0, 0], [0, 0, 0]],
998        //  [[0, 0, 0], [0, 0, 0]],
999        //  [[0, 0, 0], [0, 0, 0]],
1000        //  [[0, 0, 0], [0, 0, 0]],
1001        //  [[0, 0, 0], [259200000000000000, 259200, 3]],
1002        //  [[259200000000000000, 259200, 3], [55814400000000000000, 55814400, 646]]]
1003        let buffer = hex::decode("868283000000830000008283000000830000008283000000830000008283000000830000008283000000831b0398dd06d5c800001a0003f4800382831b0398dd06d5c800001a0003f4800383c2490306949515279000001a0353a900190286").unwrap();
1004        let eras: Vec<(EraBound, EraBound)> = cbor::decode(&buffer).unwrap();
1005
1006        assert_eq!(eras[5].1.time, Duration::from_secs(55814400));
1007    }
1008
1009    #[test]
1010    fn scales_encoded_bounds_to_ms_precision() {
1011        // CBOR encoding for
1012        //   [259200000000000000, 259200, 3]
1013        let buffer = hex::decode("831b0398dd06d5c800001a0003f48003").unwrap();
1014        let bound: EraBound =
1015            cbor::decode(&buffer).expect("cannot decode '831b0398dd06d5c800001a0003f48003' as a EraBound");
1016
1017        assert_eq!(bound.time, Duration::from_secs(259200));
1018    }
1019
1020    #[test]
1021    fn cannot_decode_bounds_with_too_large_integer_value() {
1022        // CBOR encoding for
1023        // [558144000000000000001234567890000000000, 55814400, 646]
1024        let buffer = hex::decode("83c25101a3e69fd156bd141cccb9fb74768db4001a0353a900190286").unwrap();
1025        let result = cbor::decode::<EraBound>(&buffer);
1026        assert!(result.is_err());
1027    }
1028
1029    #[test]
1030    fn cannot_decode_bounds_with_invalid_tag() {
1031        // CBOR encoding for
1032        // [-558144000000000000001234567890000000001, 55814400, 646]
1033        let buffer = hex::decode("83c35101a3e69fd156bd141cccb9fb74768db4001a0353a900190286").unwrap();
1034        let result = cbor::decode::<EraBound>(&buffer);
1035        assert!(result.is_err());
1036    }
1037
1038    #[test]
1039    fn era_index_from_slot() {
1040        let eras = two_eras();
1041        assert_eq!(eras.slot_to_era_index(Slot::new(0)), Ok(0), "first slot in first era");
1042        assert_eq!(eras.slot_to_era_index(Slot::new(48272)), Ok(0), "slot anywhere in first era");
1043        assert_eq!(eras.slot_to_era_index(Slot::new(86399)), Ok(0), "last slot in first era");
1044        assert_eq!(eras.slot_to_era_index(Slot::new(86400)), Ok(1), "first slot in second era");
1045        assert_eq!(eras.slot_to_era_index(Slot::new(105437)), Ok(1), "slot anywhere in second era");
1046        assert_eq!(eras.slot_to_era_index(Slot::new(172801)), Ok(1), "slot beyond first epoch in second era");
1047        assert_eq!(eras.slot_to_era_index(Slot::new(200000)), Ok(1), "slot well into second era");
1048    }
1049
1050    proptest! {
1051        #[test]
1052        fn roundtrip_era_history(era_history in any_era_history()) {
1053            let buffer = to_cbor(&era_history);
1054            let decoded = from_cbor_no_leftovers_with(&buffer, &mut era_history.stability_window.clone()).unwrap();
1055            assert_eq!(era_history, decoded);
1056        }
1057    }
1058
1059    proptest! {
1060        #[test]
1061        fn prop_can_parse_pretty_print_network_name(network in any_network_name()) {
1062            let name = format!("{}", network);
1063            assert_eq!(
1064                FromStr::from_str(&name),
1065                Ok(network),
1066            )
1067        }
1068    }
1069
1070    #[test]
1071    fn can_compute_slot_to_epoch_for_preprod() {
1072        let era_history = &*PREPROD_ERA_HISTORY;
1073        assert_eq!(Epoch::from(4), era_history.slot_to_epoch_unchecked_horizon(Slot::from(86400)).unwrap());
1074        assert_eq!(Epoch::from(11), era_history.slot_to_epoch_unchecked_horizon(Slot::from(3542399)).unwrap());
1075        assert_eq!(Epoch::from(12), era_history.slot_to_epoch_unchecked_horizon(Slot::from(3542400)).unwrap());
1076    }
1077
1078    #[test]
1079    fn can_compute_next_epoch_first_slot_for_preprod() {
1080        let era_history = &*PREPROD_ERA_HISTORY;
1081        let some_tip = Slot::from(96486650);
1082        assert_eq!(era_history.next_epoch_first_slot(Epoch::from(3), &some_tip), Ok(Slot::from(86400)));
1083        assert_eq!(era_history.next_epoch_first_slot(Epoch::from(114), &some_tip), Ok(Slot::from(48038400)));
1084        assert_eq!(era_history.next_epoch_first_slot(Epoch::from(150), &some_tip), Ok(Slot::from(63590400)));
1085    }
1086
1087    #[test]
1088    fn test_era_history_json_serialization() {
1089        let original_era_history = &*PREPROD_ERA_HISTORY;
1090
1091        let mut temp_file_path = env::temp_dir();
1092        temp_file_path.push("test_era_history.json");
1093
1094        let json_data =
1095            serde_json::to_string_pretty(original_era_history).expect("Failed to serialize EraHistory to JSON");
1096
1097        let mut file = File::create(&temp_file_path).expect("Failed to create temporary file");
1098
1099        file.write_all(json_data.as_bytes()).expect("Failed to write JSON data to file");
1100
1101        let loaded_era_history =
1102            load_era_history_from_file(temp_file_path.as_path()).expect("Failed to load EraHistory from file");
1103
1104        assert_eq!(*original_era_history, loaded_era_history, "Era histories don't match");
1105
1106        std::fs::remove_file(temp_file_path).ok();
1107    }
1108
1109    #[test]
1110    fn test_era_history_file_open_error() {
1111        let non_existent_path = Path::new("non_existent_file.json");
1112
1113        let result = load_era_history_from_file(non_existent_path);
1114
1115        match result {
1116            Err(EraHistoryFileError::FileOpenError(_)) => {
1117                // This is the expected error type
1118            }
1119            _ => panic!("Expected FileOpenError, got {:?}", result),
1120        }
1121    }
1122
1123    #[test]
1124    fn test_era_history_json_parse_error() {
1125        let mut temp_file_path = env::temp_dir();
1126        temp_file_path.push("invalid_era_history.json");
1127
1128        let invalid_json = r#"{ "eras": [invalid json] }"#;
1129
1130        let mut file = File::create(&temp_file_path).expect("Failed to create temporary file");
1131
1132        file.write_all(invalid_json.as_bytes()).expect("Failed to write invalid JSON data to file");
1133
1134        let result = load_era_history_from_file(temp_file_path.as_path());
1135
1136        match result {
1137            Err(EraHistoryFileError::JsonParseError(_)) => {
1138                // This is the expected error type
1139            }
1140            _ => panic!("Expected JsonParseError, got {:?}", result),
1141        }
1142
1143        std::fs::remove_file(temp_file_path).ok();
1144    }
1145}