init4_bin_base/utils/
calc.rs

1use crate::utils::from_env::{EnvItemInfo, FromEnv, FromEnvErr, FromEnvVar};
2use signet_constants::KnownChains;
3use std::{num::ParseIntError, str::FromStr};
4
5/// A slot calculator, which can calculate slot numbers, windows, and offsets
6/// for a given chain.
7///
8/// ## Typing
9///
10/// `slots` are indices, and use `usize` for their type.
11/// `timestamps` are in Unix Epoch seconds, and use `u64` for their type.
12///
13/// It is recommended that literal integers passed to these functions be
14/// explicitly typed, e.g. `0u64`, `12usize`, etc., to avoid confusion in
15/// calling code.
16///
17/// ## Behavior
18///
19/// Chain slot behavior is a bit unintuitive, particularly for chains that
20/// have a merge or chains that have missed slots at the start of the chain
21/// (i.e. Ethereum and its testnets).
22///
23/// Each header occupies a slot, but not all slots contain headers.
24/// Headers contain the timestamp at the END of their respective slot.
25///
26/// Chains _start_ with a first header, which contains a timestamp (the
27/// `start_timestamp`) and occupies the initial slot (the `slot_offset`).
28/// The `start_timestamp` is therefore the END of the initial slot, and the
29/// BEGINNING of the next slot. I.e. if the initial slot is 0, then the start
30/// of slot 1 is the `start_timestamp` and the end of slot 1 is
31/// `start_timestamp + slot_duration`.
32///
33/// For a given slot, we normalize its number to `n` by subtracting the slot
34/// offset. `n` is therefore in the range `1..`.
35///
36/// - `n = normalized(slot) = slot - slot_offset`.
37///
38/// As such, we can define the `slot_start(n)` as
39/// - `slot_start(n) = (n - 1) * slot_duration + start_timestamp`
40///
41/// and `slot_end(n)` as
42/// - `slot_end(n) = n * slot_duration + start_timestamp`
43///
44/// The slot `n` contains the range of timestamps:
45/// - `slot_window(n) = slot_start(n)..slot_end(n)`
46///
47/// To calculate the slot number `n` for a given timestamp `t`, we can use the
48/// following formula:
49/// - `slot_for(t) = ((t - start_timestamp) / slot_duration) + slot_offset + 1`
50///
51/// The `+ 1` is added because the first slot is the slot at `slot_offset`,
52/// which ENDS at `start_timestamp`. I.e. a timestamp at `start_timestamp` is
53/// in slot `slot_offset + 1`.
54#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize)]
55pub struct SlotCalculator {
56    /// The start timestamp. This is the timestamp of the header to start the
57    /// PoS chain. That header occupies a specific slot (the `slot_offset`). The
58    /// `start_timestamp` is the END of that slot.
59    start_timestamp: u64,
60
61    /// This is the number of the slot containing the block which contains the
62    /// `start_timestamp`.
63    ///
64    /// This is needed for chains that contain a merge (like Ethereum Mainnet),
65    /// or for chains with missed slots at the start of the chain (like
66    /// Holesky).
67    slot_offset: usize,
68
69    /// The slot duration (in seconds).
70    slot_duration: u64,
71}
72
73impl SlotCalculator {
74    /// Creates a new slot calculator.
75    pub const fn new(start_timestamp: u64, slot_offset: usize, slot_duration: u64) -> Self {
76        Self {
77            start_timestamp,
78            slot_offset,
79            slot_duration,
80        }
81    }
82
83    /// Creates a new slot calculator for Holesky.
84    pub const fn holesky() -> Self {
85        // begin slot calculation for Holesky from block number 1, slot number 2, timestamp 1695902424
86        // because of a strange 324 second gap between block 0 and 1 which
87        // should have been 27 slots, but which is recorded as 2 slots in chain data
88        Self {
89            start_timestamp: 1695902424,
90            slot_offset: 2,
91            slot_duration: 12,
92        }
93    }
94
95    /// Creates a new slot calculator for Pecorino host network.
96    pub const fn pecorino_host() -> Self {
97        Self {
98            start_timestamp: 1754584265,
99            slot_offset: 0,
100            slot_duration: 12,
101        }
102    }
103
104    /// Creates a new slot calculator for Ethereum mainnet.
105    pub const fn mainnet() -> Self {
106        Self {
107            start_timestamp: 1663224179,
108            slot_offset: 4700013,
109            slot_duration: 12,
110        }
111    }
112
113    /// The timestamp of the first PoS block in the chain.
114    pub const fn start_timestamp(&self) -> u64 {
115        self.start_timestamp
116    }
117
118    /// The slot number of the first PoS block in the chain.
119    pub const fn slot_offset(&self) -> usize {
120        self.slot_offset
121    }
122
123    /// The slot duration, usually 12 seconds.
124    pub const fn slot_duration(&self) -> u64 {
125        self.slot_duration
126    }
127
128    /// The offset in seconds between UTC time and slot mining times
129    const fn slot_utc_offset(&self) -> u64 {
130        self.start_timestamp % self.slot_duration
131    }
132
133    /// Calculates the slot that contains a given timestamp.
134    ///
135    /// Returns `None` if the timestamp is before the chain's start timestamp.
136    pub const fn slot_containing(&self, timestamp: u64) -> Option<usize> {
137        let Some(elapsed) = timestamp.checked_sub(self.start_timestamp) else {
138            return None;
139        };
140        let slots = (elapsed / self.slot_duration) + 1;
141        Some(slots as usize + self.slot_offset)
142    }
143
144    /// Calculates how many seconds a given timestamp is into its containing
145    /// slot.
146    ///
147    /// Returns `None` if the timestamp is before the chain's start.
148    pub const fn point_within_slot(&self, timestamp: u64) -> Option<u64> {
149        let Some(offset) = timestamp.checked_sub(self.slot_utc_offset()) else {
150            return None;
151        };
152        Some(offset % self.slot_duration)
153    }
154
155    /// Calculates how many seconds a given timestamp is into a given slot.
156    /// Returns `None` if the timestamp is not within the slot.
157    pub const fn checked_point_within_slot(&self, slot: usize, timestamp: u64) -> Option<u64> {
158        let calculated = self.slot_containing(timestamp);
159        if calculated.is_none() || calculated.unwrap() != slot {
160            return None;
161        }
162        self.point_within_slot(timestamp)
163    }
164
165    /// Calculates the start and end timestamps for a given slot
166    pub const fn slot_window(&self, slot_number: usize) -> std::ops::Range<u64> {
167        let end_of_slot =
168            ((slot_number - self.slot_offset) as u64 * self.slot_duration) + self.start_timestamp;
169        let start_of_slot = end_of_slot - self.slot_duration;
170        start_of_slot..end_of_slot
171    }
172
173    /// Calculates the start timestamp of a given slot.
174    pub const fn slot_start(&self, slot_number: usize) -> u64 {
175        self.slot_window(slot_number).start
176    }
177
178    /// Calculates the end timestamp of a given slot.
179    pub const fn slot_end(&self, slot_number: usize) -> u64 {
180        self.slot_window(slot_number).end
181    }
182
183    /// Calculate the timestamp that will appear in the header of the block at
184    /// the given slot number (if any block is produced). This is an alias for
185    /// [`Self::slot_end`].
186    #[inline(always)]
187    pub const fn slot_timestamp(&self, slot_number: usize) -> u64 {
188        // The timestamp of the slot is the end of the slot window.
189        self.slot_end(slot_number)
190    }
191
192    /// Calculates the slot window for the slot that contains to the given
193    /// timestamp. Slot windows are ranges `start..end`, where `start` is the
194    /// end timestamp of the slot and `end` is `start + slot_duration`.
195    ///
196    /// Returns `None` if the timestamp is before the chain's start timestamp.
197    pub const fn slot_window_for_timestamp(&self, timestamp: u64) -> Option<std::ops::Range<u64>> {
198        let Some(slot) = self.slot_containing(timestamp) else {
199            return None;
200        };
201        Some(self.slot_window(slot))
202    }
203
204    /// Calcuates the start timestamp for the slot that contains the given
205    /// timestamp.
206    pub const fn slot_start_for_timestamp(&self, timestamp: u64) -> Option<u64> {
207        if let Some(window) = self.slot_window_for_timestamp(timestamp) {
208            Some(window.start)
209        } else {
210            None
211        }
212    }
213
214    /// Calculates the end timestamp for the slot that contains to the given
215    /// timestamp.
216    pub const fn slot_end_for_timestamp(&self, timestamp: u64) -> Option<u64> {
217        if let Some(window) = self.slot_window_for_timestamp(timestamp) {
218            Some(window.end)
219        } else {
220            None
221        }
222    }
223
224    /// The current slot number.
225    ///
226    /// Returns `None` if the current time is before the chain's start
227    /// timestamp.
228    pub fn current_slot(&self) -> Option<usize> {
229        self.slot_containing(chrono::Utc::now().timestamp() as u64)
230    }
231
232    /// The current number of seconds into the slot.
233    pub fn current_point_within_slot(&self) -> Option<u64> {
234        self.point_within_slot(chrono::Utc::now().timestamp() as u64)
235    }
236
237    /// Calculates the slot that starts at the given timestamp.
238    /// Returns `None` if the timestamp is not a slot boundary.
239    /// Returns `None` if the timestamp is before the chain's start timestamp.
240    pub fn slot_starting_at(&self, timestamp: u64) -> Option<usize> {
241        let elapsed = timestamp.checked_sub(self.start_timestamp)?;
242
243        if elapsed % self.slot_duration != 0 {
244            return None;
245        }
246
247        self.slot_containing(timestamp)
248    }
249
250    /// Calculates the slot that ends at the given timestamp.
251    /// Returns `None` if the timestamp is not a slot boundary.
252    /// Returns `None` if the timestamp is before the chain's start timestamp.
253    pub fn slot_ending_at(&self, timestamp: u64) -> Option<usize> {
254        let elapsed = timestamp.checked_sub(self.start_timestamp)?;
255
256        if elapsed % self.slot_duration != 0 {
257            return None;
258        }
259
260        self.slot_containing(timestamp)
261            .and_then(|slot| slot.checked_sub(1))
262    }
263}
264
265impl FromEnv for SlotCalculator {
266    type Error = ParseIntError;
267
268    fn inventory() -> Vec<&'static EnvItemInfo> {
269        vec![
270            &EnvItemInfo {
271                var: "CHAIN_NAME",
272                description: "The name of the chain. If set, the other environment variables are ignored.",
273                optional: true,
274            },
275            &EnvItemInfo {
276                var: "START_TIMESTAMP",
277                description: "The start timestamp of the chain in seconds. Required if CHAIN_NAME is not set.",
278                optional: true,
279            },
280            &EnvItemInfo {
281                var: "SLOT_OFFSET",
282                description: "The number of the slot containing the start timestamp. Required if CHAIN_NAME is not set.",
283                optional: true,
284            },
285            &EnvItemInfo {
286                var: "SLOT_DURATION",
287                description: "The slot duration of the chain in seconds. Required if CHAIN_NAME is not set.",
288                optional: true,
289            },
290        ]
291    }
292
293    fn from_env() -> Result<Self, FromEnvErr<Self::Error>> {
294        if let Ok(slot_calculator) = SlotCalculator::from_env_var("CHAIN_NAME") {
295            return Ok(slot_calculator);
296        }
297
298        let start_timestamp = FromEnvVar::from_env_var("START_TIMESTAMP")?;
299        let slot_offset = FromEnvVar::from_env_var("SLOT_OFFSET")?;
300        let slot_duration = FromEnvVar::from_env_var("SLOT_DURATION")?;
301
302        Ok(Self {
303            start_timestamp,
304            slot_offset,
305            slot_duration,
306        })
307    }
308}
309
310impl From<KnownChains> for SlotCalculator {
311    fn from(value: KnownChains) -> Self {
312        match value {
313            KnownChains::Pecorino => SlotCalculator::pecorino_host(),
314        }
315    }
316}
317
318impl FromStr for SlotCalculator {
319    type Err = signet_constants::ParseChainError;
320
321    fn from_str(s: &str) -> Result<Self, Self::Err> {
322        Ok(SlotCalculator::from(KnownChains::from_str(s)?))
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    impl SlotCalculator {
331        #[track_caller]
332        fn assert_contains(&self, slot: usize, timestamp: u64) {
333            assert_eq!(self.slot_containing(timestamp), Some(slot));
334            assert!(self.slot_window(slot).contains(&timestamp));
335        }
336    }
337
338    #[test]
339    fn test_basic_slot_calculations() {
340        let calculator = SlotCalculator::new(12, 0, 12);
341        assert_eq!(calculator.slot_ending_at(0), None);
342        assert_eq!(calculator.slot_containing(0), None);
343        assert_eq!(calculator.slot_containing(1), None);
344        assert_eq!(calculator.slot_containing(11), None);
345
346        assert_eq!(calculator.slot_ending_at(11), None);
347        assert_eq!(calculator.slot_ending_at(12), Some(0));
348        assert_eq!(calculator.slot_starting_at(12), Some(1));
349        assert_eq!(calculator.slot_containing(12), Some(1));
350        assert_eq!(calculator.slot_containing(13), Some(1));
351        assert_eq!(calculator.slot_starting_at(13), None);
352        assert_eq!(calculator.slot_containing(23), Some(1));
353        assert_eq!(calculator.slot_ending_at(23), None);
354
355        assert_eq!(calculator.slot_ending_at(24), Some(1));
356        assert_eq!(calculator.slot_starting_at(24), Some(2));
357        assert_eq!(calculator.slot_containing(24), Some(2));
358        assert_eq!(calculator.slot_containing(25), Some(2));
359        assert_eq!(calculator.slot_containing(35), Some(2));
360
361        assert_eq!(calculator.slot_containing(36), Some(3));
362    }
363
364    #[test]
365    fn test_holesky_slot_calculations() {
366        let calculator = SlotCalculator::holesky();
367
368        // Just before the start timestamp
369        let just_before = calculator.start_timestamp - 1;
370        assert_eq!(calculator.slot_containing(just_before), None);
371
372        // Timestamp 17
373        assert_eq!(calculator.slot_containing(17), None);
374
375        // block 1 == slot 2 == timestamp 1695902424
376        // timestamp 1695902424 == slot 3 is in slot 3
377        calculator.assert_contains(3, 1695902424);
378
379        // the next second, timestamp 1695902425 == slot 3
380        calculator.assert_contains(3, 1695902425);
381
382        // block 3557085 == slot 3919127 == timestamp 1742931924
383        // timestamp 1742931924 == slot 3919127
384        calculator.assert_contains(3919128, 1742931924);
385        // the next second, timestamp 1742931925 == slot 3919128
386        calculator.assert_contains(3919128, 1742931925);
387    }
388
389    #[test]
390    fn test_holesky_slot_timepoint_calculations() {
391        let calculator = SlotCalculator::holesky();
392        // calculate timepoint in slot
393        assert_eq!(calculator.point_within_slot(1695902424), Some(0));
394        assert_eq!(calculator.point_within_slot(1695902425), Some(1));
395        assert_eq!(calculator.point_within_slot(1695902435), Some(11));
396        assert_eq!(calculator.point_within_slot(1695902436), Some(0));
397    }
398
399    #[test]
400    fn test_holesky_slot_window() {
401        let calculator = SlotCalculator::holesky();
402        // calculate slot window
403        assert_eq!(calculator.slot_window(2), 1695902412..1695902424);
404        assert_eq!(calculator.slot_window(3), 1695902424..1695902436);
405    }
406
407    #[test]
408    fn test_mainnet_slot_calculations() {
409        let calculator = SlotCalculator::mainnet();
410
411        // Just before the start timestamp
412        let just_before = calculator.start_timestamp - 1;
413        assert_eq!(calculator.slot_containing(just_before), None);
414
415        // Timestamp 17
416        assert_eq!(calculator.slot_containing(17), None);
417
418        // 1663224179 - Sep-15-2022 06:42:59 AM +UTC
419        // https://beaconscan.com/slot/4700013
420        calculator.assert_contains(4700014, 1663224179);
421        calculator.assert_contains(4700014, 1663224180);
422
423        // https://beaconscan.com/slot/11003251
424        calculator.assert_contains(11003252, 1738863035);
425        // https://beaconscan.com/slot/11003518
426        calculator.assert_contains(11003519, 1738866239);
427        // https://beaconscan.com/slot/11003517
428        calculator.assert_contains(11003518, 1738866227);
429    }
430
431    #[test]
432    fn test_mainnet_slot_timepoint_calculations() {
433        let calculator = SlotCalculator::mainnet();
434        // calculate timepoint in slot
435        assert_eq!(calculator.point_within_slot(1663224179), Some(0));
436        assert_eq!(calculator.point_within_slot(1663224180), Some(1));
437        assert_eq!(calculator.point_within_slot(1663224190), Some(11));
438        assert_eq!(calculator.point_within_slot(1663224191), Some(0));
439    }
440
441    #[test]
442    fn test_ethereum_slot_window() {
443        let calculator = SlotCalculator::mainnet();
444        // calculate slot window
445        assert_eq!(calculator.slot_window(4700013), (1663224167..1663224179));
446        assert_eq!(calculator.slot_window(4700014), (1663224179..1663224191));
447    }
448
449    #[test]
450    fn slot_boundaries() {
451        let calculator = SlotCalculator::new(0, 0, 2);
452
453        // Check the boundaries of slots
454        calculator.assert_contains(1, 0);
455        calculator.assert_contains(1, 1);
456        calculator.assert_contains(2, 2);
457        calculator.assert_contains(2, 3);
458        calculator.assert_contains(3, 4);
459        calculator.assert_contains(3, 5);
460        calculator.assert_contains(4, 6);
461
462        let calculator = SlotCalculator::new(12, 0, 12);
463
464        // Check the boundaries of slots
465        assert_eq!(calculator.slot_containing(0), None);
466        assert_eq!(calculator.slot_containing(11), None);
467        calculator.assert_contains(1, 12);
468        calculator.assert_contains(1, 13);
469        calculator.assert_contains(1, 23);
470        calculator.assert_contains(2, 24);
471        calculator.assert_contains(2, 25);
472        calculator.assert_contains(2, 35);
473
474        let calculator = SlotCalculator::new(12, 1, 12);
475
476        assert_eq!(calculator.slot_containing(0), None);
477        assert_eq!(calculator.slot_containing(11), None);
478        assert_eq!(calculator.slot_containing(12), Some(2));
479        assert_eq!(calculator.slot_containing(13), Some(2));
480        assert_eq!(calculator.slot_containing(23), Some(2));
481        assert_eq!(calculator.slot_containing(24), Some(3));
482        assert_eq!(calculator.slot_containing(25), Some(3));
483        assert_eq!(calculator.slot_containing(35), Some(3));
484    }
485}