Skip to main content

reth_era/era/types/
group.rs

1//! Era types for `.era` file content
2//!
3//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md>
4
5use crate::{
6    common::file_ops::{EraFileId, EraFileType},
7    e2s::types::{Entry, IndexEntry, SLOT_INDEX},
8    era::types::consensus::{CompressedBeaconState, CompressedSignedBeaconBlock},
9};
10
11/// Number of slots per historical root in ERA files.
12///
13/// Equals [`crate::common::MAX_ENTRIES_PER_ERA`].
14pub const SLOTS_PER_HISTORICAL_ROOT: u64 = crate::common::MAX_ENTRIES_PER_ERA;
15
16/// Era file content group
17///
18/// Format: `Version | block* | era-state | other-entries* | slot-index(block)? | slot-index(state)`
19/// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#structure>
20#[derive(Debug)]
21pub struct EraGroup {
22    /// Group including all blocks leading up to the era transition in slot order
23    pub blocks: Vec<CompressedSignedBeaconBlock>,
24
25    /// State in the era transition slot
26    pub era_state: CompressedBeaconState,
27
28    /// Other entries that don't fit into standard categories
29    pub other_entries: Vec<Entry>,
30
31    /// Block slot index, omitted for genesis era
32    pub slot_index: Option<SlotIndex>,
33
34    /// State slot index
35    pub state_slot_index: SlotIndex,
36}
37
38impl EraGroup {
39    /// Create a new era group
40    pub const fn new(
41        blocks: Vec<CompressedSignedBeaconBlock>,
42        era_state: CompressedBeaconState,
43        state_slot_index: SlotIndex,
44    ) -> Self {
45        Self { blocks, era_state, other_entries: Vec::new(), slot_index: None, state_slot_index }
46    }
47
48    /// Create a new era group with block slot index
49    pub const fn with_block_index(
50        blocks: Vec<CompressedSignedBeaconBlock>,
51        era_state: CompressedBeaconState,
52        slot_index: SlotIndex,
53        state_slot_index: SlotIndex,
54    ) -> Self {
55        Self {
56            blocks,
57            era_state,
58            other_entries: Vec::new(),
59            slot_index: Some(slot_index),
60            state_slot_index,
61        }
62    }
63
64    /// Check if this is a genesis era - no blocks yet
65    pub const fn is_genesis(&self) -> bool {
66        self.blocks.is_empty() && self.slot_index.is_none()
67    }
68
69    /// Add another entry to this group
70    pub fn add_entry(&mut self, entry: Entry) {
71        self.other_entries.push(entry);
72    }
73
74    /// Get the starting slot and slot count.
75    pub const fn slot_range(&self) -> (u64, u32) {
76        if let Some(ref block_index) = self.slot_index {
77            // Non-genesis era: use block slot index
78            (block_index.starting_slot, block_index.slot_count() as u32)
79        } else {
80            // Genesis era: use state slot index, it should be slot 0
81            // Genesis has only the genesis state, no blocks
82            (self.state_slot_index.starting_slot, 0)
83        }
84    }
85
86    /// Get the starting slot number
87    pub const fn starting_slot(&self) -> u64 {
88        self.slot_range().0
89    }
90
91    /// Get the number of slots
92    pub const fn slot_count(&self) -> u32 {
93        self.slot_range().1
94    }
95}
96
97/// [`SlotIndex`] records store offsets to data at specific slots
98/// from the beginning of the index record to the beginning of the corresponding data.
99///
100/// Format: `starting-slot | index | index | index ... | count`
101///
102/// See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md#slotindex>.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SlotIndex {
105    /// Starting slot number
106    pub starting_slot: u64,
107
108    /// Offsets to data at each slot
109    /// 0 indicates no data for that slot
110    pub offsets: Vec<i64>,
111}
112
113impl SlotIndex {
114    /// Create a new slot index
115    pub const fn new(starting_slot: u64, offsets: Vec<i64>) -> Self {
116        Self { starting_slot, offsets }
117    }
118
119    /// Get the number of slots covered by this index
120    pub const fn slot_count(&self) -> usize {
121        self.offsets.len()
122    }
123
124    /// Get the offset for a specific slot
125    pub fn get_offset(&self, slot_index: usize) -> Option<i64> {
126        self.offsets.get(slot_index).copied()
127    }
128
129    /// Check if a slot has data - non-zero offset
130    pub fn has_data_at_slot(&self, slot_index: usize) -> bool {
131        self.get_offset(slot_index).is_some_and(|offset| offset != 0)
132    }
133}
134
135impl IndexEntry for SlotIndex {
136    fn new(starting_number: u64, offsets: Vec<i64>) -> Self {
137        Self { starting_slot: starting_number, offsets }
138    }
139
140    fn entry_type() -> [u8; 2] {
141        SLOT_INDEX
142    }
143
144    fn starting_number(&self) -> u64 {
145        self.starting_slot
146    }
147
148    fn offsets(&self) -> &[i64] {
149        &self.offsets
150    }
151}
152
153/// Era file identifier
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct EraId {
156    /// Network configuration name
157    pub network_name: String,
158
159    /// First slot number in file
160    pub start_slot: u64,
161
162    /// Number of slots in the file
163    pub slot_count: u32,
164
165    /// Optional hash identifier for this file
166    /// First 4 bytes of the last historical root in the last state in the era file
167    pub hash: Option<[u8; 4]>,
168
169    /// Whether to include era count in filename
170    /// It is used for custom exports when we don't use the max number of items per file
171    include_era_count: bool,
172}
173
174impl EraId {
175    /// Create a new [`EraId`]
176    pub fn new(network_name: impl Into<String>, start_slot: u64, slot_count: u32) -> Self {
177        Self {
178            network_name: network_name.into(),
179            start_slot,
180            slot_count,
181            hash: None,
182            include_era_count: false,
183        }
184    }
185
186    /// Add a hash identifier to  [`EraId`]
187    pub const fn with_hash(mut self, hash: [u8; 4]) -> Self {
188        self.hash = Some(hash);
189        self
190    }
191
192    /// Include era count in filename, for custom slot-per-file exports
193    pub const fn with_era_count(mut self) -> Self {
194        self.include_era_count = true;
195        self
196    }
197}
198
199impl EraFileId for EraId {
200    const FILE_TYPE: EraFileType = EraFileType::Era;
201
202    const ITEMS_PER_ERA: u64 = SLOTS_PER_HISTORICAL_ROOT;
203
204    fn network_name(&self) -> &str {
205        &self.network_name
206    }
207
208    fn start_number(&self) -> u64 {
209        self.start_slot
210    }
211
212    fn count(&self) -> u32 {
213        self.slot_count
214    }
215
216    fn hash(&self) -> Option<[u8; 4]> {
217        self.hash
218    }
219
220    fn include_era_count(&self) -> bool {
221        self.include_era_count
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::{
229        e2s::types::{Entry, IndexEntry},
230        test_utils::{create_beacon_block, create_beacon_state},
231    };
232
233    #[test]
234    fn test_slot_index_roundtrip() {
235        let starting_slot = 1000;
236        let offsets = vec![100, 200, 300, 400, 500];
237
238        let slot_index = SlotIndex::new(starting_slot, offsets.clone());
239
240        let entry = slot_index.to_entry();
241
242        // Validate entry type
243        assert_eq!(entry.entry_type, SLOT_INDEX);
244
245        // Convert back to slot index
246        let recovered = SlotIndex::from_entry(&entry).unwrap();
247
248        // Verify fields match
249        assert_eq!(recovered.starting_slot, starting_slot);
250        assert_eq!(recovered.offsets, offsets);
251    }
252    #[test]
253    fn test_slot_index_basic_operations() {
254        let starting_slot = 2000;
255        let offsets = vec![100, 200, 300];
256
257        let slot_index = SlotIndex::new(starting_slot, offsets);
258
259        assert_eq!(slot_index.slot_count(), 3);
260        assert_eq!(slot_index.starting_slot, 2000);
261    }
262
263    #[test]
264    fn test_slot_index_empty_slots() {
265        let starting_slot = 1000;
266        let offsets = vec![100, 0, 300, 0, 500];
267
268        let slot_index = SlotIndex::new(starting_slot, offsets);
269
270        // Test that empty slots return false for has_data_at_slot
271        // slot 1000: offset 100
272        assert!(slot_index.has_data_at_slot(0));
273        // slot 1001: offset 0 - empty
274        assert!(!slot_index.has_data_at_slot(1));
275        // slot 1002: offset 300
276        assert!(slot_index.has_data_at_slot(2));
277        // slot 1003: offset 0 - empty
278        assert!(!slot_index.has_data_at_slot(3));
279        // slot 1004: offset 500
280        assert!(slot_index.has_data_at_slot(4));
281    }
282
283    #[test]
284    fn test_era_group_basic_construction() {
285        let blocks =
286            vec![create_beacon_block(10), create_beacon_block(15), create_beacon_block(20)];
287        let era_state = create_beacon_state(50);
288        let state_slot_index = SlotIndex::new(1000, vec![100, 200, 300]);
289
290        let era_group = EraGroup::new(blocks, era_state, state_slot_index);
291
292        // Verify initial state
293        assert_eq!(era_group.blocks.len(), 3);
294        assert_eq!(era_group.other_entries.len(), 0);
295        assert_eq!(era_group.slot_index, None);
296        assert_eq!(era_group.state_slot_index.starting_slot, 1000);
297        assert_eq!(era_group.state_slot_index.offsets, vec![100, 200, 300]);
298    }
299
300    #[test]
301    fn test_era_group_with_block_index() {
302        let blocks = vec![create_beacon_block(10), create_beacon_block(15)];
303        let era_state = create_beacon_state(50);
304        let block_slot_index = SlotIndex::new(500, vec![50, 100]);
305        let state_slot_index = SlotIndex::new(1000, vec![200, 300]);
306
307        let era_group =
308            EraGroup::with_block_index(blocks, era_state, block_slot_index, state_slot_index);
309
310        // Verify state with block index
311        assert_eq!(era_group.blocks.len(), 2);
312        assert_eq!(era_group.other_entries.len(), 0);
313        assert!(era_group.slot_index.is_some());
314
315        let block_index = era_group.slot_index.as_ref().unwrap();
316        assert_eq!(block_index.starting_slot, 500);
317        assert_eq!(block_index.offsets, vec![50, 100]);
318
319        assert_eq!(era_group.state_slot_index.starting_slot, 1000);
320        assert_eq!(era_group.state_slot_index.offsets, vec![200, 300]);
321    }
322
323    #[test]
324    fn test_era_group_genesis_check() {
325        // Genesis era - no blocks, no block slot index
326        let era_state = create_beacon_state(50);
327        let state_slot_index = SlotIndex::new(0, vec![100]);
328
329        let genesis_era = EraGroup::new(vec![], era_state, state_slot_index);
330        assert!(genesis_era.is_genesis());
331
332        // Non-genesis era - has blocks
333        let blocks = vec![create_beacon_block(10)];
334        let era_state = create_beacon_state(50);
335        let state_slot_index = SlotIndex::new(1000, vec![100]);
336
337        let normal_era = EraGroup::new(blocks, era_state, state_slot_index);
338        assert!(!normal_era.is_genesis());
339
340        // Non-genesis era - has block slot index
341        let era_state = create_beacon_state(50);
342        let block_slot_index = SlotIndex::new(500, vec![50]);
343        let state_slot_index = SlotIndex::new(1000, vec![100]);
344
345        let era_with_index =
346            EraGroup::with_block_index(vec![], era_state, block_slot_index, state_slot_index);
347        assert!(!era_with_index.is_genesis());
348    }
349
350    #[test]
351    fn test_era_group_add_entries() {
352        let blocks = vec![create_beacon_block(10)];
353        let era_state = create_beacon_state(50);
354        let state_slot_index = SlotIndex::new(1000, vec![100]);
355
356        // Create and verify group
357        let mut era_group = EraGroup::new(blocks, era_state, state_slot_index);
358        assert_eq!(era_group.other_entries.len(), 0);
359
360        // Create custom entries with different types
361        let entry1 = Entry::new([0x01, 0x01], vec![1, 2, 3, 4]);
362        let entry2 = Entry::new([0x02, 0x02], vec![5, 6, 7, 8]);
363
364        // Add those entries
365        era_group.add_entry(entry1);
366        era_group.add_entry(entry2);
367
368        // Verify entries were added correctly
369        assert_eq!(era_group.other_entries.len(), 2);
370        assert_eq!(era_group.other_entries[0].entry_type, [0x01, 0x01]);
371        assert_eq!(era_group.other_entries[0].data, vec![1, 2, 3, 4]);
372        assert_eq!(era_group.other_entries[1].entry_type, [0x02, 0x02]);
373        assert_eq!(era_group.other_entries[1].data, vec![5, 6, 7, 8]);
374    }
375
376    #[test]
377    fn test_index_with_negative_offset() {
378        let mut data = Vec::new();
379        data.extend_from_slice(&0u64.to_le_bytes());
380        data.extend_from_slice(&(-1024i64).to_le_bytes());
381        data.extend_from_slice(&0i64.to_le_bytes());
382        data.extend_from_slice(&2i64.to_le_bytes());
383
384        let entry = Entry::new(SLOT_INDEX, data);
385        let index = SlotIndex::from_entry(&entry).unwrap();
386        let parsed_offset = index.offsets[0];
387        assert_eq!(parsed_offset, -1024);
388    }
389
390    #[test_case::test_case(
391        EraId::new("mainnet", 0, 8192).with_hash([0x4b, 0x36, 0x3d, 0xb9]),
392        "mainnet-00000-4b363db9.era";
393        "Mainnet era 0"
394    )]
395    #[test_case::test_case(
396        EraId::new("mainnet", 8192, 8192).with_hash([0x40, 0xcf, 0x2f, 0x3c]),
397        "mainnet-00001-40cf2f3c.era";
398        "Mainnet era 1"
399    )]
400    #[test_case::test_case(
401        EraId::new("mainnet", 0, 8192),
402        "mainnet-00000-00000000.era";
403        "Without hash"
404    )]
405    fn test_era_id_file_naming(id: EraId, expected_file_name: &str) {
406        let actual_file_name = id.to_file_name();
407        assert_eq!(actual_file_name, expected_file_name);
408    }
409
410    // File naming with era-count, for custom exports
411    #[test_case::test_case(
412        EraId::new("mainnet", 0, 8192).with_hash([0x4b, 0x36, 0x3d, 0xb9]).with_era_count(),
413        "mainnet-00000-00001-4b363db9.era";
414        "Mainnet era 0 with count"
415    )]
416    #[test_case::test_case(
417        EraId::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(),
418        "mainnet-00000-00002-abcdef12.era";
419        "Spanning two eras with count"
420    )]
421    fn test_era_id_file_naming_with_era_count(id: EraId, expected_file_name: &str) {
422        let actual_file_name = id.to_file_name();
423        assert_eq!(actual_file_name, expected_file_name);
424    }
425}