init4_bin_base/utils/
calc.rs

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