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::{Entry, IndexEntry},
7    era_file_ops::EraFileId,
8    execution_types::{Accumulator, BlockTuple, MAX_BLOCKS_PER_ERA1},
9};
10use alloy_primitives::BlockNumber;
11
12/// `BlockIndex` record: ['i', '2']
13pub const BLOCK_INDEX: [u8; 2] = [0x66, 0x32];
14
15/// File content in an Era1 file
16///
17/// Format: `block-tuple* | other-entries* | Accumulator | BlockIndex`
18#[derive(Debug)]
19pub struct Era1Group {
20    /// Blocks in this era1 group
21    pub blocks: Vec<BlockTuple>,
22
23    /// Other entries that don't fit into the standard categories
24    pub other_entries: Vec<Entry>,
25
26    /// Accumulator is hash tree root of block headers and difficulties
27    pub accumulator: Accumulator,
28
29    /// Block index, optional, omitted for genesis era
30    pub block_index: BlockIndex,
31}
32
33impl Era1Group {
34    /// Create a new [`Era1Group`]
35    pub const fn new(
36        blocks: Vec<BlockTuple>,
37        accumulator: Accumulator,
38        block_index: BlockIndex,
39    ) -> Self {
40        Self { blocks, accumulator, block_index, other_entries: Vec::new() }
41    }
42
43    /// Add another entry to this group
44    pub fn add_entry(&mut self, entry: Entry) {
45        self.other_entries.push(entry);
46    }
47}
48
49/// [`BlockIndex`] records store offsets to data at specific block numbers
50/// from the beginning of the index record to the beginning of the corresponding data.
51///
52/// Format:
53/// `starting-(block)-number | index | index | index ... | count`
54#[derive(Debug, Clone)]
55pub struct BlockIndex {
56    /// Starting block number
57    starting_number: BlockNumber,
58
59    /// Offsets to data at each block number
60    offsets: Vec<i64>,
61}
62
63impl BlockIndex {
64    /// Get the offset for a specific block number
65    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
66        if block_number < self.starting_number {
67            return None;
68        }
69
70        let index = (block_number - self.starting_number) as usize;
71        self.offsets.get(index).copied()
72    }
73}
74
75impl IndexEntry for BlockIndex {
76    fn new(starting_number: u64, offsets: Vec<i64>) -> Self {
77        Self { starting_number, offsets }
78    }
79
80    fn entry_type() -> [u8; 2] {
81        BLOCK_INDEX
82    }
83
84    fn starting_number(&self) -> u64 {
85        self.starting_number
86    }
87
88    fn offsets(&self) -> &[i64] {
89        &self.offsets
90    }
91}
92
93/// Era1 file identifier
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Era1Id {
96    /// Network configuration name
97    pub network_name: String,
98
99    /// First block number in file
100    pub start_block: BlockNumber,
101
102    /// Number of blocks in the file
103    pub block_count: u32,
104
105    /// Optional hash identifier for this file
106    /// First 4 bytes of the last historical root in the last state in the era file
107    pub hash: Option<[u8; 4]>,
108}
109
110impl Era1Id {
111    /// Create a new [`Era1Id`]
112    pub fn new(
113        network_name: impl Into<String>,
114        start_block: BlockNumber,
115        block_count: u32,
116    ) -> Self {
117        Self { network_name: network_name.into(), start_block, block_count, hash: None }
118    }
119
120    /// Add a hash identifier to  [`Era1Id`]
121    pub const fn with_hash(mut self, hash: [u8; 4]) -> Self {
122        self.hash = Some(hash);
123        self
124    }
125
126    // Helper function to calculate the number of eras per era1 file,
127    // If the user can decide how many blocks per era1 file there are, we need to calculate it.
128    // Most of the time it should be 1, but it can never be more than 2 eras per file
129    // as there is a maximum of 8192 blocks per era1 file.
130    const fn calculate_era_count(&self, first_era: u64) -> u64 {
131        // Calculate the actual last block number in the range
132        let last_block = self.start_block + self.block_count as u64 - 1;
133        // Find which era the last block belongs to
134        let last_era = last_block / MAX_BLOCKS_PER_ERA1 as u64;
135        // Count how many eras we span
136        last_era - first_era + 1
137    }
138}
139
140impl EraFileId for Era1Id {
141    fn network_name(&self) -> &str {
142        &self.network_name
143    }
144
145    fn start_number(&self) -> u64 {
146        self.start_block
147    }
148
149    fn count(&self) -> u32 {
150        self.block_count
151    }
152    /// Convert to file name following the era file naming:
153    /// `<config-name>-<era-number>-<era-count>-<short-historical-root>.era(1)`
154    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
155    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
156    fn to_file_name(&self) -> String {
157        // Find which era the first block belongs to
158        let era_number = self.start_block / MAX_BLOCKS_PER_ERA1 as u64;
159        let era_count = self.calculate_era_count(era_number);
160        if let Some(hash) = self.hash {
161            format!(
162                "{}-{:05}-{:05}-{:02x}{:02x}{:02x}{:02x}.era1",
163                self.network_name, era_number, era_count, hash[0], hash[1], hash[2], hash[3]
164            )
165        } else {
166            // era spec format with placeholder hash when no hash available
167            // Format: `<config-name>-<era-number>-<era-count>-00000000.era1`
168            format!("{}-{:05}-{:05}-00000000.era1", self.network_name, era_number, era_count)
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::{
177        test_utils::{create_sample_block, create_test_block_with_compressed_data},
178        DecodeCompressed,
179    };
180    use alloy_consensus::ReceiptWithBloom;
181    use alloy_primitives::{B256, U256};
182
183    #[test]
184    fn test_alloy_components_decode_and_receipt_in_bloom() {
185        // Create a block tuple from compressed data
186        let block: BlockTuple = create_test_block_with_compressed_data(30);
187
188        // Decode and decompress the block header
189        let header: alloy_consensus::Header = block.header.decode().unwrap();
190        assert_eq!(header.number, 30, "Header block number should match");
191        assert_eq!(header.difficulty, U256::from(30 * 1000), "Header difficulty should match");
192        assert_eq!(header.gas_limit, 5000000, "Gas limit should match");
193        assert_eq!(header.gas_used, 21000, "Gas used should match");
194        assert_eq!(header.timestamp, 1609459200 + 30, "Timestamp should match");
195        assert_eq!(header.base_fee_per_gas, Some(10), "Base fee per gas should match");
196        assert!(header.withdrawals_root.is_some(), "Should have withdrawals root");
197        assert!(header.blob_gas_used.is_none(), "Should not have blob gas used");
198        assert!(header.excess_blob_gas.is_none(), "Should not have excess blob gas");
199
200        let body: alloy_consensus::BlockBody<alloy_primitives::Bytes> =
201            block.body.decode().unwrap();
202        assert_eq!(body.ommers.len(), 0, "Should have no ommers");
203        assert!(body.withdrawals.is_some(), "Should have withdrawals field");
204
205        let receipts: Vec<ReceiptWithBloom> = block.receipts.decode().unwrap();
206        assert_eq!(receipts.len(), 1, "Should have exactly 1 receipt");
207    }
208
209    #[test]
210    fn test_block_index_roundtrip() {
211        let starting_number = 1000;
212        let offsets = vec![100, 200, 300, 400, 500];
213
214        let block_index = BlockIndex::new(starting_number, offsets.clone());
215
216        let entry = block_index.to_entry();
217
218        // Validate entry type
219        assert_eq!(entry.entry_type, BLOCK_INDEX);
220
221        // Convert back to block index
222        let recovered = BlockIndex::from_entry(&entry).unwrap();
223
224        // Verify fields match
225        assert_eq!(recovered.starting_number, starting_number);
226        assert_eq!(recovered.offsets, offsets);
227    }
228
229    #[test]
230    fn test_block_index_offset_lookup() {
231        let starting_number = 1000;
232        let offsets = vec![100, 200, 300, 400, 500];
233
234        let block_index = BlockIndex::new(starting_number, offsets);
235
236        // Test valid lookups
237        assert_eq!(block_index.offset_for_block(1000), Some(100));
238        assert_eq!(block_index.offset_for_block(1002), Some(300));
239        assert_eq!(block_index.offset_for_block(1004), Some(500));
240
241        // Test out of range lookups
242        assert_eq!(block_index.offset_for_block(999), None);
243        assert_eq!(block_index.offset_for_block(1005), None);
244    }
245
246    #[test]
247    fn test_era1_group_basic_construction() {
248        let blocks =
249            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
250
251        let root_bytes = [0xDD; 32];
252        let accumulator = Accumulator::new(B256::from(root_bytes));
253        let block_index = BlockIndex::new(1000, vec![100, 200, 300]);
254
255        let era1_group = Era1Group::new(blocks, accumulator.clone(), block_index);
256
257        // Verify initial state
258        assert_eq!(era1_group.blocks.len(), 3);
259        assert_eq!(era1_group.other_entries.len(), 0);
260        assert_eq!(era1_group.accumulator.root, accumulator.root);
261        assert_eq!(era1_group.block_index.starting_number, 1000);
262        assert_eq!(era1_group.block_index.offsets, vec![100, 200, 300]);
263    }
264
265    #[test]
266    fn test_era1_group_add_entries() {
267        let blocks = vec![create_sample_block(10)];
268
269        let root_bytes = [0xDD; 32];
270        let accumulator = Accumulator::new(B256::from(root_bytes));
271
272        let block_index = BlockIndex::new(1000, vec![100]);
273
274        // Create and verify group
275        let mut era1_group = Era1Group::new(blocks, accumulator, block_index);
276        assert_eq!(era1_group.other_entries.len(), 0);
277
278        // Create custom entries with different types
279        let entry1 = Entry::new([0x01, 0x01], vec![1, 2, 3, 4]);
280        let entry2 = Entry::new([0x02, 0x02], vec![5, 6, 7, 8]);
281
282        // Add those entries
283        era1_group.add_entry(entry1);
284        era1_group.add_entry(entry2);
285
286        // Verify entries were added correctly
287        assert_eq!(era1_group.other_entries.len(), 2);
288        assert_eq!(era1_group.other_entries[0].entry_type, [0x01, 0x01]);
289        assert_eq!(era1_group.other_entries[0].data, vec![1, 2, 3, 4]);
290        assert_eq!(era1_group.other_entries[1].entry_type, [0x02, 0x02]);
291        assert_eq!(era1_group.other_entries[1].data, vec![5, 6, 7, 8]);
292    }
293
294    #[test]
295    fn test_era1_group_with_mismatched_index() {
296        let blocks =
297            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
298
299        let root_bytes = [0xDD; 32];
300        let accumulator = Accumulator::new(B256::from(root_bytes));
301
302        // Create block index with different starting number
303        let block_index = BlockIndex::new(2000, vec![100, 200, 300]);
304
305        // This should create a valid Era1Group
306        // even though the block numbers don't match the block index
307        // validation not at the era1 group level
308        let era1_group = Era1Group::new(blocks, accumulator, block_index);
309
310        // Verify the mismatch exists but the group was created
311        assert_eq!(era1_group.blocks.len(), 3);
312        assert_eq!(era1_group.block_index.starting_number, 2000);
313    }
314
315    #[test_case::test_case(
316        Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]),
317        "mainnet-00000-00001-5ec1ffb8.era1";
318        "Mainnet era 0"
319    )]
320    #[test_case::test_case(
321        Era1Id::new("mainnet", 8192, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]),
322        "mainnet-00001-00001-5ecb9bf9.era1";
323        "Mainnet era 1"
324    )]
325    #[test_case::test_case(
326        Era1Id::new("sepolia", 0, 8192).with_hash([0x90, 0x91, 0x84, 0x72]),
327        "sepolia-00000-00001-90918472.era1";
328        "Sepolia era 0"
329    )]
330    #[test_case::test_case(
331        Era1Id::new("sepolia", 155648, 8192).with_hash([0xfa, 0x77, 0x00, 0x19]),
332        "sepolia-00019-00001-fa770019.era1";
333        "Sepolia era 19"
334    )]
335    #[test_case::test_case(
336        Era1Id::new("mainnet", 1000, 100),
337        "mainnet-00000-00001-00000000.era1";
338        "ID without hash"
339    )]
340    #[test_case::test_case(
341        Era1Id::new("sepolia", 101130240, 8192).with_hash([0xab, 0xcd, 0xef, 0x12]),
342        "sepolia-12345-00001-abcdef12.era1";
343        "Large block number era 12345"
344    )]
345    fn test_era1id_file_naming(id: Era1Id, expected_file_name: &str) {
346        let actual_file_name = id.to_file_name();
347        assert_eq!(actual_file_name, expected_file_name);
348    }
349}