reth_era/era1/types/
group.rs

1//! Era1 group for era1 file content
2//!
3//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
4
5use crate::{
6    common::file_ops::{EraFileId, EraFileType},
7    e2s::types::{Entry, IndexEntry},
8    era1::types::execution::{Accumulator, BlockTuple, MAX_BLOCKS_PER_ERA1},
9};
10use alloy_primitives::BlockNumber;
11
12/// `BlockIndex` record: ['f', '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, required
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    /// Whether to include era count in filename
110    /// It is used for custom exports when we don't use the max number of items per file
111    pub include_era_count: bool,
112}
113
114impl Era1Id {
115    /// Create a new [`Era1Id`]
116    pub fn new(
117        network_name: impl Into<String>,
118        start_block: BlockNumber,
119        block_count: u32,
120    ) -> Self {
121        Self {
122            network_name: network_name.into(),
123            start_block,
124            block_count,
125            hash: None,
126            include_era_count: false,
127        }
128    }
129
130    /// Add a hash identifier to  [`Era1Id`]
131    pub const fn with_hash(mut self, hash: [u8; 4]) -> Self {
132        self.hash = Some(hash);
133        self
134    }
135
136    /// Include era count in filename, for custom block-per-file exports
137    pub const fn with_era_count(mut self) -> Self {
138        self.include_era_count = true;
139        self
140    }
141}
142
143impl EraFileId for Era1Id {
144    const FILE_TYPE: EraFileType = EraFileType::Era1;
145
146    const ITEMS_PER_ERA: u64 = MAX_BLOCKS_PER_ERA1 as u64;
147    fn network_name(&self) -> &str {
148        &self.network_name
149    }
150
151    fn start_number(&self) -> u64 {
152        self.start_block
153    }
154
155    fn count(&self) -> u32 {
156        self.block_count
157    }
158
159    fn hash(&self) -> Option<[u8; 4]> {
160        self.hash
161    }
162
163    fn include_era_count(&self) -> bool {
164        self.include_era_count
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::{
172        common::decode::DecodeCompressedRlp,
173        test_utils::{create_sample_block, create_test_block_with_compressed_data},
174    };
175    use alloy_consensus::ReceiptWithBloom;
176    use alloy_primitives::{B256, U256};
177
178    #[test]
179    fn test_alloy_components_decode_and_receipt_in_bloom() {
180        // Create a block tuple from compressed data
181        let block: BlockTuple = create_test_block_with_compressed_data(30);
182
183        // Decode and decompress the block header
184        let header: alloy_consensus::Header = block.header.decode().unwrap();
185        assert_eq!(header.number, 30, "Header block number should match");
186        assert_eq!(header.difficulty, U256::from(30 * 1000), "Header difficulty should match");
187        assert_eq!(header.gas_limit, 5000000, "Gas limit should match");
188        assert_eq!(header.gas_used, 21000, "Gas used should match");
189        assert_eq!(header.timestamp, 1609459200 + 30, "Timestamp should match");
190        assert_eq!(header.base_fee_per_gas, Some(10), "Base fee per gas should match");
191        assert!(header.withdrawals_root.is_some(), "Should have withdrawals root");
192        assert!(header.blob_gas_used.is_none(), "Should not have blob gas used");
193        assert!(header.excess_blob_gas.is_none(), "Should not have excess blob gas");
194
195        let body: alloy_consensus::BlockBody<alloy_primitives::Bytes> =
196            block.body.decode().unwrap();
197        assert_eq!(body.ommers.len(), 0, "Should have no ommers");
198        assert!(body.withdrawals.is_some(), "Should have withdrawals field");
199
200        let receipts: Vec<ReceiptWithBloom> = block.receipts.decode().unwrap();
201        assert_eq!(receipts.len(), 1, "Should have exactly 1 receipt");
202    }
203
204    #[test]
205    fn test_block_index_roundtrip() {
206        let starting_number = 1000;
207        let offsets = vec![100, 200, 300, 400, 500];
208
209        let block_index = BlockIndex::new(starting_number, offsets.clone());
210
211        let entry = block_index.to_entry();
212
213        // Validate entry type
214        assert_eq!(entry.entry_type, BLOCK_INDEX);
215
216        // Convert back to block index
217        let recovered = BlockIndex::from_entry(&entry).unwrap();
218
219        // Verify fields match
220        assert_eq!(recovered.starting_number, starting_number);
221        assert_eq!(recovered.offsets, offsets);
222    }
223
224    #[test]
225    fn test_block_index_offset_lookup() {
226        let starting_number = 1000;
227        let offsets = vec![100, 200, 300, 400, 500];
228
229        let block_index = BlockIndex::new(starting_number, offsets);
230
231        // Test valid lookups
232        assert_eq!(block_index.offset_for_block(1000), Some(100));
233        assert_eq!(block_index.offset_for_block(1002), Some(300));
234        assert_eq!(block_index.offset_for_block(1004), Some(500));
235
236        // Test out of range lookups
237        assert_eq!(block_index.offset_for_block(999), None);
238        assert_eq!(block_index.offset_for_block(1005), None);
239    }
240
241    #[test]
242    fn test_era1_group_basic_construction() {
243        let blocks =
244            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
245
246        let root_bytes = [0xDD; 32];
247        let accumulator = Accumulator::new(B256::from(root_bytes));
248        let block_index = BlockIndex::new(1000, vec![100, 200, 300]);
249
250        let era1_group = Era1Group::new(blocks, accumulator.clone(), block_index);
251
252        // Verify initial state
253        assert_eq!(era1_group.blocks.len(), 3);
254        assert_eq!(era1_group.other_entries.len(), 0);
255        assert_eq!(era1_group.accumulator.root, accumulator.root);
256        assert_eq!(era1_group.block_index.starting_number, 1000);
257        assert_eq!(era1_group.block_index.offsets, vec![100, 200, 300]);
258    }
259
260    #[test]
261    fn test_era1_group_add_entries() {
262        let blocks = vec![create_sample_block(10)];
263
264        let root_bytes = [0xDD; 32];
265        let accumulator = Accumulator::new(B256::from(root_bytes));
266
267        let block_index = BlockIndex::new(1000, vec![100]);
268
269        // Create and verify group
270        let mut era1_group = Era1Group::new(blocks, accumulator, block_index);
271        assert_eq!(era1_group.other_entries.len(), 0);
272
273        // Create custom entries with different types
274        let entry1 = Entry::new([0x01, 0x01], vec![1, 2, 3, 4]);
275        let entry2 = Entry::new([0x02, 0x02], vec![5, 6, 7, 8]);
276
277        // Add those entries
278        era1_group.add_entry(entry1);
279        era1_group.add_entry(entry2);
280
281        // Verify entries were added correctly
282        assert_eq!(era1_group.other_entries.len(), 2);
283        assert_eq!(era1_group.other_entries[0].entry_type, [0x01, 0x01]);
284        assert_eq!(era1_group.other_entries[0].data, vec![1, 2, 3, 4]);
285        assert_eq!(era1_group.other_entries[1].entry_type, [0x02, 0x02]);
286        assert_eq!(era1_group.other_entries[1].data, vec![5, 6, 7, 8]);
287    }
288
289    #[test]
290    fn test_era1_group_with_mismatched_index() {
291        let blocks =
292            vec![create_sample_block(10), create_sample_block(15), create_sample_block(20)];
293
294        let root_bytes = [0xDD; 32];
295        let accumulator = Accumulator::new(B256::from(root_bytes));
296
297        // Create block index with different starting number
298        let block_index = BlockIndex::new(2000, vec![100, 200, 300]);
299
300        // This should create a valid Era1Group
301        // even though the block numbers don't match the block index
302        // validation not at the era1 group level
303        let era1_group = Era1Group::new(blocks, accumulator, block_index);
304
305        // Verify the mismatch exists but the group was created
306        assert_eq!(era1_group.blocks.len(), 3);
307        assert_eq!(era1_group.block_index.starting_number, 2000);
308    }
309
310    #[test_case::test_case(
311        Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]),
312        "mainnet-00000-5ec1ffb8.era1";
313        "Mainnet era 0"
314    )]
315    #[test_case::test_case(
316        Era1Id::new("mainnet", 8192, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]),
317        "mainnet-00001-5ecb9bf9.era1";
318        "Mainnet era 1"
319    )]
320    #[test_case::test_case(
321        Era1Id::new("sepolia", 0, 8192).with_hash([0x90, 0x91, 0x84, 0x72]),
322        "sepolia-00000-90918472.era1";
323        "Sepolia era 0"
324    )]
325    #[test_case::test_case(
326        Era1Id::new("sepolia", 155648, 8192).with_hash([0xfa, 0x77, 0x00, 0x19]),
327        "sepolia-00019-fa770019.era1";
328        "Sepolia era 19"
329    )]
330    #[test_case::test_case(
331        Era1Id::new("mainnet", 1000, 100),
332        "mainnet-00000-00000000.era1";
333        "ID without hash"
334    )]
335    #[test_case::test_case(
336        Era1Id::new("sepolia", 101130240, 8192).with_hash([0xab, 0xcd, 0xef, 0x12]),
337        "sepolia-12345-abcdef12.era1";
338        "Large block number era 12345"
339    )]
340    fn test_era1_id_file_naming(id: Era1Id, expected_file_name: &str) {
341        let actual_file_name = id.to_file_name();
342        assert_eq!(actual_file_name, expected_file_name);
343    }
344
345    // File naming with era-count, for custom exports
346    #[test_case::test_case(
347        Era1Id::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]).with_era_count(),
348        "mainnet-00000-00001-5ec1ffb8.era1";
349        "Mainnet era 0 with count"
350    )]
351    #[test_case::test_case(
352        Era1Id::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(),
353        "mainnet-00000-00002-abcdef12.era1";
354        "Spanning two eras with count"
355    )]
356    fn test_era1_id_file_naming_with_era_count(id: Era1Id, expected_file_name: &str) {
357        let actual_file_name = id.to_file_name();
358        assert_eq!(actual_file_name, expected_file_name);
359    }
360}