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