reth_era/
era1_types.rs

1//! Era1 types
2//!
3//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
4
5use crate::{
6    e2s_types::{E2sError, Entry},
7    execution_types::{Accumulator, BlockTuple},
8};
9use alloy_primitives::BlockNumber;
10
11/// `BlockIndex` record: ['i', '2']
12pub const BLOCK_INDEX: [u8; 2] = [0x66, 0x32];
13
14/// File content in an Era1 file
15///
16/// Format: `block-tuple* | other-entries* | Accumulator | BlockIndex`
17#[derive(Debug)]
18pub struct Era1Group {
19    /// Blocks in this era1 group
20    pub blocks: Vec<BlockTuple>,
21
22    /// Other entries that don't fit into the standard categories
23    pub other_entries: Vec<Entry>,
24
25    /// Accumulator is hash tree root of block headers and difficulties
26    pub accumulator: Accumulator,
27
28    /// Block index, optional, omitted for genesis era
29    pub block_index: BlockIndex,
30}
31
32impl Era1Group {
33    /// Create a new [`Era1Group`]
34    pub fn new(blocks: Vec<BlockTuple>, accumulator: Accumulator, block_index: BlockIndex) -> Self {
35        Self { blocks, accumulator, block_index, other_entries: Vec::new() }
36    }
37    /// Add another entry to this group
38    pub fn add_entry(&mut self, entry: Entry) {
39        self.other_entries.push(entry);
40    }
41}
42
43/// [`BlockIndex`] records store offsets to data at specific block numbers
44/// from the beginning of the index record to the beginning of the corresponding data.
45///
46/// Format:
47/// `starting-(block)-number | index | index | index ... | count`
48#[derive(Debug, Clone)]
49pub struct BlockIndex {
50    /// Starting block number
51    pub starting_number: BlockNumber,
52
53    /// Offsets to data at each block number
54    pub offsets: Vec<i64>,
55}
56
57impl BlockIndex {
58    /// Create a new [`BlockIndex`]
59    pub fn new(starting_number: BlockNumber, offsets: Vec<i64>) -> Self {
60        Self { starting_number, offsets }
61    }
62
63    /// Get the offset for a specific block number
64    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
65        if block_number < self.starting_number {
66            return None;
67        }
68
69        let index = (block_number - self.starting_number) as usize;
70        self.offsets.get(index).copied()
71    }
72
73    /// Convert to an [`Entry`] for storage in an e2store file
74    pub fn to_entry(&self) -> Entry {
75        // Format: starting-(block)-number | index | index | index ... | count
76        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 8);
77
78        // Add starting block number
79        data.extend_from_slice(&self.starting_number.to_le_bytes());
80
81        // Add all offsets
82        for offset in &self.offsets {
83            data.extend_from_slice(&offset.to_le_bytes());
84        }
85
86        // Add count
87        data.extend_from_slice(&(self.offsets.len() as i64).to_le_bytes());
88
89        Entry::new(BLOCK_INDEX, data)
90    }
91
92    /// Create from an [`Entry`]
93    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
94        if entry.entry_type != BLOCK_INDEX {
95            return Err(E2sError::Ssz(format!(
96                "Invalid entry type for BlockIndex: expected {:02x}{:02x}, got {:02x}{:02x}",
97                BLOCK_INDEX[0], BLOCK_INDEX[1], entry.entry_type[0], entry.entry_type[1]
98            )));
99        }
100
101        if entry.data.len() < 16 {
102            return Err(E2sError::Ssz(String::from(
103                "BlockIndex entry too short to contain starting block number and count",
104            )));
105        }
106
107        // Extract starting block number = first 8 bytes
108        let mut starting_number_bytes = [0u8; 8];
109        starting_number_bytes.copy_from_slice(&entry.data[0..8]);
110        let starting_number = u64::from_le_bytes(starting_number_bytes);
111
112        // Extract count = last 8 bytes
113        let mut count_bytes = [0u8; 8];
114        count_bytes.copy_from_slice(&entry.data[entry.data.len() - 8..]);
115        let count = u64::from_le_bytes(count_bytes) as usize;
116
117        // Verify that the entry has the correct size
118        let expected_size = 8 + count * 8 + 8;
119        if entry.data.len() != expected_size {
120            return Err(E2sError::Ssz(format!(
121                "BlockIndex entry has incorrect size: expected {}, got {}",
122                expected_size,
123                entry.data.len()
124            )));
125        }
126
127        // Extract all offsets
128        let mut offsets = Vec::with_capacity(count);
129        for i in 0..count {
130            let start = 8 + i * 8;
131            let end = start + 8;
132            let mut offset_bytes = [0u8; 8];
133            offset_bytes.copy_from_slice(&entry.data[start..end]);
134            offsets.push(i64::from_le_bytes(offset_bytes));
135        }
136
137        Ok(Self { starting_number, offsets })
138    }
139}
140
141/// Era1 file identifier
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Era1Id {
144    /// Network configuration name
145    pub network_name: String,
146
147    /// First block number in file
148    pub start_block: BlockNumber,
149
150    /// Number of blocks in the file
151    pub block_count: u32,
152
153    /// Optional hash identifier for this file
154    pub hash: Option<[u8; 4]>,
155}
156
157impl Era1Id {
158    /// Create a new [`Era1Id`]
159    pub fn new(
160        network_name: impl Into<String>,
161        start_block: BlockNumber,
162        block_count: u32,
163    ) -> Self {
164        Self { network_name: network_name.into(), start_block, block_count, hash: None }
165    }
166
167    /// Add a hash identifier to  [`Era1Id`]
168    pub fn with_hash(mut self, hash: [u8; 4]) -> Self {
169        self.hash = Some(hash);
170        self
171    }
172
173    /// Convert to file name following the era1 file naming:
174    /// `<network-name>-<start-block>-<block-count>.era1`
175    /// inspired from era file naming convention in
176    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
177    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
178    pub fn to_file_name(&self) -> String {
179        if let Some(hash) = self.hash {
180            // Format with zero-padded era number and hash:
181            // For example network-00000-5ec1ffb8.era1
182            format!(
183                "{}-{:05}-{:02x}{:02x}{:02x}{:02x}.era1",
184                self.network_name, self.start_block, hash[0], hash[1], hash[2], hash[3]
185            )
186        } else {
187            // Original format without hash
188            format!("{}-{}-{}.era1", self.network_name, self.start_block, self.block_count)
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::execution_types::{
197        CompressedBody, CompressedHeader, CompressedReceipts, TotalDifficulty,
198    };
199    use alloy_primitives::{B256, U256};
200
201    /// Helper function to create a sample block tuple
202    fn create_sample_block(data_size: usize) -> BlockTuple {
203        // Create a compressed header with very sample data
204        let header_data = vec![0xAA; data_size];
205        let header = CompressedHeader::new(header_data);
206
207        // Create a compressed body
208        let body_data = vec![0xBB; data_size * 2];
209        let body = CompressedBody::new(body_data);
210
211        // Create compressed receipts
212        let receipts_data = vec![0xCC; data_size];
213        let receipts = CompressedReceipts::new(receipts_data);
214
215        let difficulty = TotalDifficulty::new(U256::from(data_size));
216
217        // Create and return the block tuple
218        BlockTuple::new(header, body, receipts, difficulty)
219    }
220
221    #[test]
222    fn test_block_index_roundtrip() {
223        let starting_number = 1000;
224        let offsets = vec![100, 200, 300, 400, 500];
225
226        let block_index = BlockIndex::new(starting_number, offsets.clone());
227
228        let entry = block_index.to_entry();
229
230        // Validate entry type
231        assert_eq!(entry.entry_type, BLOCK_INDEX);
232
233        // Convert back to block index
234        let recovered = BlockIndex::from_entry(&entry).unwrap();
235
236        // Verify fields match
237        assert_eq!(recovered.starting_number, starting_number);
238        assert_eq!(recovered.offsets, offsets);
239    }
240
241    #[test]
242    fn test_block_index_offset_lookup() {
243        let starting_number = 1000;
244        let offsets = vec![100, 200, 300, 400, 500];
245
246        let block_index = BlockIndex::new(starting_number, offsets);
247
248        // Test valid lookups
249        assert_eq!(block_index.offset_for_block(1000), Some(100));
250        assert_eq!(block_index.offset_for_block(1002), Some(300));
251        assert_eq!(block_index.offset_for_block(1004), Some(500));
252
253        // Test out of range lookups
254        assert_eq!(block_index.offset_for_block(999), None);
255        assert_eq!(block_index.offset_for_block(1005), None);
256    }
257
258    #[test]
259    fn test_era1_group_basic_construction() {
260        let blocks =
261            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
262
263        let root_bytes = [0xDD; 32];
264        let accumulator = Accumulator::new(B256::from(root_bytes));
265        let block_index = BlockIndex::new(1000, vec![100, 200, 300]);
266
267        let era1_group = Era1Group::new(blocks, accumulator.clone(), block_index);
268
269        // Verify initial state
270        assert_eq!(era1_group.blocks.len(), 3);
271        assert_eq!(era1_group.other_entries.len(), 0);
272        assert_eq!(era1_group.accumulator.root, accumulator.root);
273        assert_eq!(era1_group.block_index.starting_number, 1000);
274        assert_eq!(era1_group.block_index.offsets, vec![100, 200, 300]);
275    }
276
277    #[test]
278    fn test_era1_group_add_entries() {
279        let blocks = vec![create_sample_block(10)];
280
281        let root_bytes = [0xDD; 32];
282        let accumulator = Accumulator::new(B256::from(root_bytes));
283
284        let block_index = BlockIndex::new(1000, vec![100]);
285
286        // Create and verify group
287        let mut era1_group = Era1Group::new(blocks, accumulator, block_index);
288        assert_eq!(era1_group.other_entries.len(), 0);
289
290        // Create custom entries with different types
291        let entry1 = Entry::new([0x01, 0x01], vec![1, 2, 3, 4]);
292        let entry2 = Entry::new([0x02, 0x02], vec![5, 6, 7, 8]);
293
294        // Add those entries
295        era1_group.add_entry(entry1);
296        era1_group.add_entry(entry2);
297
298        // Verify entries were added correctly
299        assert_eq!(era1_group.other_entries.len(), 2);
300        assert_eq!(era1_group.other_entries[0].entry_type, [0x01, 0x01]);
301        assert_eq!(era1_group.other_entries[0].data, vec![1, 2, 3, 4]);
302        assert_eq!(era1_group.other_entries[1].entry_type, [0x02, 0x02]);
303        assert_eq!(era1_group.other_entries[1].data, vec![5, 6, 7, 8]);
304    }
305
306    #[test]
307    fn test_era1_group_with_mismatched_index() {
308        let blocks =
309            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
310
311        let root_bytes = [0xDD; 32];
312        let accumulator = Accumulator::new(B256::from(root_bytes));
313
314        // Create block index with different starting number
315        let block_index = BlockIndex::new(2000, vec![100, 200, 300]);
316
317        // This should create a valid Era1Group
318        // even though the block numbers don't match the block index
319        // validation not at the era1 group level
320        let era1_group = Era1Group::new(blocks, accumulator, block_index);
321
322        // Verify the mismatch exists but the group was created
323        assert_eq!(era1_group.blocks.len(), 3);
324        assert_eq!(era1_group.block_index.starting_number, 2000);
325    }
326
327    #[test]
328    fn test_era1id_file_naming() {
329        // Test with real mainnet examples
330        // See <https://era1.ethportal.net/> or <https://mainnet.era1.nimbus.team/>
331        let mainnet_00000 = Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]);
332        assert_eq!(mainnet_00000.to_file_name(), "mainnet-00000-5ec1ffb8.era1");
333
334        let mainnet_00012 = Era1Id::new("mainnet", 12, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]);
335        assert_eq!(mainnet_00012.to_file_name(), "mainnet-00012-5ecb9bf9.era1");
336
337        // Test with real sepolia examples
338        // See <https://sepolia.era1.nimbus.team/>
339        let sepolia_00005 = Era1Id::new("sepolia", 5, 8192).with_hash([0x90, 0x91, 0x84, 0x72]);
340        assert_eq!(sepolia_00005.to_file_name(), "sepolia-00005-90918472.era1");
341
342        let sepolia_00019 = Era1Id::new("sepolia", 19, 8192).with_hash([0xfa, 0x77, 0x00, 0x19]);
343        assert_eq!(sepolia_00019.to_file_name(), "sepolia-00019-fa770019.era1");
344
345        // Test fallback to original format when no hash is provided
346        let id_without_hash = Era1Id::new("mainnet", 1000, 100);
347        assert_eq!(id_without_hash.to_file_name(), "mainnet-1000-100.era1");
348
349        // Test with larger era numbers to ensure proper zero-padding
350        let large_era = Era1Id::new("sepolia", 12345, 8192).with_hash([0xab, 0xcd, 0xef, 0x12]);
351        assert_eq!(large_era.to_file_name(), "sepolia-12345-abcdef12.era1");
352    }
353}