init4_bin_base/utils/
calc.rs

1use crate::utils::from_env::FromEnv;
2use signet_constants::KnownChains;
3use std::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, FromEnv)]
55#[from_env(crate)]
56pub struct SlotCalculator {
57    /// The start timestamp. This is the timestamp of the header to start the
58    /// PoS chain. That header occupies a specific slot (the `slot_offset`). The
59    /// `start_timestamp` is the END of that slot.
60    #[from_env(
61        var = "START_TIMESTAMP",
62        desc = "The start timestamp of the chain in seconds"
63    )]
64    start_timestamp: u64,
65
66    /// This is the number of the slot containing the block which contains the
67    /// `start_timestamp`.
68    ///
69    /// This is needed for chains that contain a merge (like Ethereum Mainnet),
70    /// or for chains with missed slots at the start of the chain (like
71    /// Holesky).
72    #[from_env(
73        var = "SLOT_OFFSET",
74        desc = "The number of the slot containing the start timestamp"
75    )]
76    slot_offset: usize,
77
78    /// The slot duration (in seconds).
79    #[from_env(
80        var = "SLOT_DURATION",
81        desc = "The slot duration of the chain in seconds"
82    )]
83    slot_duration: u64,
84}
85
86impl SlotCalculator {
87    /// Creates a new slot calculator.
88    pub const fn new(start_timestamp: u64, slot_offset: usize, slot_duration: u64) -> Self {
89        Self {
90            start_timestamp,
91            slot_offset,
92            slot_duration,
93        }
94    }
95
96    /// Creates a new slot calculator for Holesky.
97    pub const fn holesky() -> Self {
98        // begin slot calculation for Holesky from block number 1, slot number 2, timestamp 1695902424
99        // because of a strange 324 second gap between block 0 and 1 which
100        // should have been 27 slots, but which is recorded as 2 slots in chain data
101        Self {
102            start_timestamp: 1695902424,
103            slot_offset: 2,
104            slot_duration: 12,
105        }
106    }
107
108    /// Creates a new slot calculator for Pecorino host network.
109    pub const fn pecorino_host() -> Self {
110        Self {
111            start_timestamp: 1754584265,
112            slot_offset: 0,
113            slot_duration: 12,
114        }
115    }
116
117    /// Creates a new slot calculator for Ethereum mainnet.
118    pub const fn mainnet() -> Self {
119        Self {
120            start_timestamp: 1663224179,
121            slot_offset: 4700013,
122            slot_duration: 12,
123        }
124    }
125
126    /// The timestamp of the first PoS block in the chain.
127    pub const fn start_timestamp(&self) -> u64 {
128        self.start_timestamp
129    }
130
131    /// The slot number of the first PoS block in the chain.
132    pub const fn slot_offset(&self) -> usize {
133        self.slot_offset
134    }
135
136    /// The slot duration, usually 12 seconds.
137    pub const fn slot_duration(&self) -> u64 {
138        self.slot_duration
139    }
140
141    /// The offset in seconds between UTC time and slot mining times
142    const fn slot_utc_offset(&self) -> u64 {
143        self.start_timestamp % self.slot_duration
144    }
145
146    /// Calculates the slot that contains a given timestamp.
147    ///
148    /// Returns `None` if the timestamp is before the chain's start timestamp.
149    pub const fn slot_containing(&self, timestamp: u64) -> Option<usize> {
150        let Some(elapsed) = timestamp.checked_sub(self.start_timestamp) else {
151            return None;
152        };
153        let slots = (elapsed / self.slot_duration) + 1;
154        Some(slots as usize + self.slot_offset)
155    }
156
157    /// Calculates how many seconds a given timestamp is into its containing
158    /// slot.
159    ///
160    /// Returns `None` if the timestamp is before the chain's start.
161    pub const fn point_within_slot(&self, timestamp: u64) -> Option<u64> {
162        let Some(offset) = timestamp.checked_sub(self.slot_utc_offset()) else {
163            return None;
164        };
165        Some(offset % self.slot_duration)
166    }
167
168    /// Calculates how many seconds a given timestamp is into a given slot.
169    /// Returns `None` if the timestamp is not within the slot.
170    pub const fn checked_point_within_slot(&self, slot: usize, timestamp: u64) -> Option<u64> {
171        let calculated = self.slot_containing(timestamp);
172        if calculated.is_none() || calculated.unwrap() != slot {
173            return None;
174        }
175        self.point_within_slot(timestamp)
176    }
177
178    /// Calculates the start and end timestamps for a given slot
179    pub const fn slot_window(&self, slot_number: usize) -> std::ops::Range<u64> {
180        let end_of_slot =
181            ((slot_number - self.slot_offset) as u64 * self.slot_duration) + self.start_timestamp;
182        let start_of_slot = end_of_slot - self.slot_duration;
183        start_of_slot..end_of_slot
184    }
185
186    /// Calculates the start timestamp of a given slot.
187    pub const fn slot_start(&self, slot_number: usize) -> u64 {
188        self.slot_window(slot_number).start
189    }
190
191    /// Calculates the end timestamp of a given slot.
192    pub const fn slot_end(&self, slot_number: usize) -> u64 {
193        self.slot_window(slot_number).end
194    }
195
196    /// Calculate the timestamp that will appear in the header of the block at
197    /// the given slot number (if any block is produced). This is an alias for
198    /// [`Self::slot_end`].
199    #[inline(always)]
200    pub const fn slot_timestamp(&self, slot_number: usize) -> u64 {
201        // The timestamp of the slot is the end of the slot window.
202        self.slot_end(slot_number)
203    }
204
205    /// Calculates the slot window for the slot that contains to the given
206    /// timestamp. Slot windows are ranges `start..end`, where `start` is the
207    /// end timestamp of the slot and `end` is `start + slot_duration`.
208    ///
209    /// Returns `None` if the timestamp is before the chain's start timestamp.
210    pub const fn slot_window_for_timestamp(&self, timestamp: u64) -> Option<std::ops::Range<u64>> {
211        let Some(slot) = self.slot_containing(timestamp) else {
212            return None;
213        };
214        Some(self.slot_window(slot))
215    }
216
217    /// Calcuates the start timestamp for the slot that contains the given
218    /// timestamp.
219    pub const fn slot_start_for_timestamp(&self, timestamp: u64) -> Option<u64> {
220        if let Some(window) = self.slot_window_for_timestamp(timestamp) {
221            Some(window.start)
222        } else {
223            None
224        }
225    }
226
227    /// Calculates the end timestamp for the slot that contains to the given
228    /// timestamp.
229    pub const fn slot_end_for_timestamp(&self, timestamp: u64) -> Option<u64> {
230        if let Some(window) = self.slot_window_for_timestamp(timestamp) {
231            Some(window.end)
232        } else {
233            None
234        }
235    }
236
237    /// The current slot number.
238    ///
239    /// Returns `None` if the current time is before the chain's start
240    /// timestamp.
241    pub fn current_slot(&self) -> Option<usize> {
242        self.slot_containing(chrono::Utc::now().timestamp() as u64)
243    }
244
245    /// The current number of seconds into the slot.
246    pub fn current_point_within_slot(&self) -> Option<u64> {
247        self.point_within_slot(chrono::Utc::now().timestamp() as u64)
248    }
249
250    /// Calculates the slot that starts 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_starting_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    }
262
263    /// Calculates the slot that ends at the given timestamp.
264    /// Returns `None` if the timestamp is not a slot boundary.
265    /// Returns `None` if the timestamp is before the chain's start timestamp.
266    pub fn slot_ending_at(&self, timestamp: u64) -> Option<usize> {
267        let elapsed = timestamp.checked_sub(self.start_timestamp)?;
268
269        if elapsed % self.slot_duration != 0 {
270            return None;
271        }
272
273        self.slot_containing(timestamp)
274            .and_then(|slot| slot.checked_sub(1))
275    }
276}
277
278impl From<KnownChains> for SlotCalculator {
279    fn from(value: KnownChains) -> Self {
280        match value {
281            KnownChains::Pecorino => SlotCalculator::pecorino_host(),
282        }
283    }
284}
285
286impl FromStr for SlotCalculator {
287    type Err = signet_constants::ParseChainError;
288
289    fn from_str(s: &str) -> Result<Self, Self::Err> {
290        Ok(SlotCalculator::from(KnownChains::from_str(s)?))
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    impl SlotCalculator {
299        #[track_caller]
300        fn assert_contains(&self, slot: usize, timestamp: u64) {
301            assert_eq!(self.slot_containing(timestamp), Some(slot));
302            assert!(self.slot_window(slot).contains(&timestamp));
303        }
304    }
305
306    #[test]
307    fn test_basic_slot_calculations() {
308        let calculator = SlotCalculator::new(12, 0, 12);
309        assert_eq!(calculator.slot_ending_at(0), None);
310        assert_eq!(calculator.slot_containing(0), None);
311        assert_eq!(calculator.slot_containing(1), None);
312        assert_eq!(calculator.slot_containing(11), None);
313
314        assert_eq!(calculator.slot_ending_at(11), None);
315        assert_eq!(calculator.slot_ending_at(12), Some(0));
316        assert_eq!(calculator.slot_starting_at(12), Some(1));
317        assert_eq!(calculator.slot_containing(12), Some(1));
318        assert_eq!(calculator.slot_containing(13), Some(1));
319        assert_eq!(calculator.slot_starting_at(13), None);
320        assert_eq!(calculator.slot_containing(23), Some(1));
321        assert_eq!(calculator.slot_ending_at(23), None);
322
323        assert_eq!(calculator.slot_ending_at(24), Some(1));
324        assert_eq!(calculator.slot_starting_at(24), Some(2));
325        assert_eq!(calculator.slot_containing(24), Some(2));
326        assert_eq!(calculator.slot_containing(25), Some(2));
327        assert_eq!(calculator.slot_containing(35), Some(2));
328
329        assert_eq!(calculator.slot_containing(36), Some(3));
330    }
331
332    #[test]
333    fn test_holesky_slot_calculations() {
334        let calculator = SlotCalculator::holesky();
335
336        // Just before the start timestamp
337        let just_before = calculator.start_timestamp - 1;
338        assert_eq!(calculator.slot_containing(just_before), None);
339
340        // Timestamp 17
341        assert_eq!(calculator.slot_containing(17), None);
342
343        // block 1 == slot 2 == timestamp 1695902424
344        // timestamp 1695902424 == slot 3 is in slot 3
345        calculator.assert_contains(3, 1695902424);
346
347        // the next second, timestamp 1695902425 == slot 3
348        calculator.assert_contains(3, 1695902425);
349
350        // block 3557085 == slot 3919127 == timestamp 1742931924
351        // timestamp 1742931924 == slot 3919127
352        calculator.assert_contains(3919128, 1742931924);
353        // the next second, timestamp 1742931925 == slot 3919128
354        calculator.assert_contains(3919128, 1742931925);
355    }
356
357    #[test]
358    fn test_holesky_slot_timepoint_calculations() {
359        let calculator = SlotCalculator::holesky();
360        // calculate timepoint in slot
361        assert_eq!(calculator.point_within_slot(1695902424), Some(0));
362        assert_eq!(calculator.point_within_slot(1695902425), Some(1));
363        assert_eq!(calculator.point_within_slot(1695902435), Some(11));
364        assert_eq!(calculator.point_within_slot(1695902436), Some(0));
365    }
366
367    #[test]
368    fn test_holesky_slot_window() {
369        let calculator = SlotCalculator::holesky();
370        // calculate slot window
371        assert_eq!(calculator.slot_window(2), 1695902412..1695902424);
372        assert_eq!(calculator.slot_window(3), 1695902424..1695902436);
373    }
374
375    #[test]
376    fn test_mainnet_slot_calculations() {
377        let calculator = SlotCalculator::mainnet();
378
379        // Just before the start timestamp
380        let just_before = calculator.start_timestamp - 1;
381        assert_eq!(calculator.slot_containing(just_before), None);
382
383        // Timestamp 17
384        assert_eq!(calculator.slot_containing(17), None);
385
386        // 1663224179 - Sep-15-2022 06:42:59 AM +UTC
387        // https://beaconscan.com/slot/4700013
388        calculator.assert_contains(4700014, 1663224179);
389        calculator.assert_contains(4700014, 1663224180);
390
391        // https://beaconscan.com/slot/11003251
392        calculator.assert_contains(11003252, 1738863035);
393        // https://beaconscan.com/slot/11003518
394        calculator.assert_contains(11003519, 1738866239);
395        // https://beaconscan.com/slot/11003517
396        calculator.assert_contains(11003518, 1738866227);
397    }
398
399    #[test]
400    fn test_mainnet_slot_timepoint_calculations() {
401        let calculator = SlotCalculator::mainnet();
402        // calculate timepoint in slot
403        assert_eq!(calculator.point_within_slot(1663224179), Some(0));
404        assert_eq!(calculator.point_within_slot(1663224180), Some(1));
405        assert_eq!(calculator.point_within_slot(1663224190), Some(11));
406        assert_eq!(calculator.point_within_slot(1663224191), Some(0));
407    }
408
409    #[test]
410    fn test_ethereum_slot_window() {
411        let calculator = SlotCalculator::mainnet();
412        // calculate slot window
413        assert_eq!(calculator.slot_window(4700013), (1663224167..1663224179));
414        assert_eq!(calculator.slot_window(4700014), (1663224179..1663224191));
415    }
416
417    #[test]
418    fn slot_boundaries() {
419        let calculator = SlotCalculator::new(0, 0, 2);
420
421        // Check the boundaries of slots
422        calculator.assert_contains(1, 0);
423        calculator.assert_contains(1, 1);
424        calculator.assert_contains(2, 2);
425        calculator.assert_contains(2, 3);
426        calculator.assert_contains(3, 4);
427        calculator.assert_contains(3, 5);
428        calculator.assert_contains(4, 6);
429
430        let calculator = SlotCalculator::new(12, 0, 12);
431
432        // Check the boundaries of slots
433        assert_eq!(calculator.slot_containing(0), None);
434        assert_eq!(calculator.slot_containing(11), None);
435        calculator.assert_contains(1, 12);
436        calculator.assert_contains(1, 13);
437        calculator.assert_contains(1, 23);
438        calculator.assert_contains(2, 24);
439        calculator.assert_contains(2, 25);
440        calculator.assert_contains(2, 35);
441
442        let calculator = SlotCalculator::new(12, 1, 12);
443
444        assert_eq!(calculator.slot_containing(0), None);
445        assert_eq!(calculator.slot_containing(11), None);
446        assert_eq!(calculator.slot_containing(12), Some(2));
447        assert_eq!(calculator.slot_containing(13), Some(2));
448        assert_eq!(calculator.slot_containing(23), Some(2));
449        assert_eq!(calculator.slot_containing(24), Some(3));
450        assert_eq!(calculator.slot_containing(25), Some(3));
451        assert_eq!(calculator.slot_containing(35), Some(3));
452    }
453}