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