Skip to main content

reth_era/ere/types/
group.rs

1//! `ere` (era execution) file content group.
2//!
3//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md#specification>
4
5use crate::{
6    common::file_ops::{EraFileId, EraFileType},
7    e2s::{error::E2sError, types::Entry},
8    ere::types::execution::{Accumulator, BlockTuple, MAX_BLOCKS_PER_ERE},
9};
10use alloy_primitives::BlockNumber;
11
12/// `DynamicBlockIndex` record: ['g', '2']
13pub const DYNAMIC_BLOCK_INDEX: [u8; 2] = [0x67, 0x32];
14
15/// Minimum number of index components stored per block (header + body).
16pub const MIN_COMPONENTS_PER_BLOCK: u64 = 2;
17
18/// Maximum number of index components stored per block
19/// (header + body + receipts + difficulty + proof).
20pub const MAX_COMPONENTS_PER_BLOCK: u64 = 5;
21
22/// File content in an `ere` file.
23///
24/// Format:
25/// `CompressedHeader+ | CompressedBody+ | CompressedSlimReceipts* | Proof* | TotalDifficulty* |
26/// other-entries* | Accumulator? | DynamicBlockIndex`
27///
28/// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md#specification>
29#[derive(Debug)]
30pub struct EreGroup {
31    /// Blocks in this `ere` group
32    pub blocks: Vec<BlockTuple>,
33
34    /// Other entries that don't fit into the standard per-block categories
35    pub other_entries: Vec<Entry>,
36
37    /// Accumulator over the block header records.
38    ///
39    /// Optional: it is only present for files that contain pre-merge blocks, since
40    /// `total-difficulty` stops advancing after the merge.
41    pub accumulator: Option<Accumulator>,
42
43    /// Dynamic block index, required
44    pub index: DynamicBlockIndex,
45}
46
47impl EreGroup {
48    /// Create a new [`EreGroup`]
49    pub const fn new(
50        blocks: Vec<BlockTuple>,
51        accumulator: Option<Accumulator>,
52        index: DynamicBlockIndex,
53    ) -> Self {
54        Self { blocks, accumulator, index, other_entries: Vec::new() }
55    }
56
57    /// Add another entry to this group
58    pub fn add_entry(&mut self, entry: Entry) {
59        self.other_entries.push(entry);
60    }
61}
62
63/// `ere` block index with a dynamic per-block component count.
64///
65/// Unlike `era1`'s single-offset-per-block index, an `ere` block can carry a variable number of
66/// components, so the index stores `component_count` offsets for every block.
67///
68/// Format: `starting-number | indexes | indexes | ... | component-count | count`
69///
70/// where each `indexes` group holds the offsets for one block:
71/// `header-index | body-index | receipts-index? | difficulty-index? | proof-index?`
72///
73/// `component-count` is 2-5 depending on which optional components are present. Offsets are `i64`
74/// (they point backward to earlier entries); their little-endian bytes match the spec's `uint64`.
75///
76/// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md#specification>
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct DynamicBlockIndex {
79    /// Starting block number
80    starting_number: BlockNumber,
81
82    /// Number of index components per block (2-5)
83    component_count: u64,
84
85    /// Flattened, block-major offsets: `[h0, b0, (r0)?, (d0)?, (p0)?, h1, b1, ...]`.
86    /// Length is `count * component_count`.
87    offsets: Vec<i64>,
88}
89
90impl DynamicBlockIndex {
91    /// Create a new [`DynamicBlockIndex`].
92    ///
93    /// `offsets` must be block-major with exactly `component_count` entries per block; the encoded
94    /// block count is derived as `offsets.len() / component_count`.
95    pub const fn new(
96        starting_number: BlockNumber,
97        component_count: u64,
98        offsets: Vec<i64>,
99    ) -> Self {
100        Self { starting_number, component_count, offsets }
101    }
102
103    /// Get the starting block number
104    pub const fn starting_number(&self) -> u64 {
105        self.starting_number
106    }
107
108    /// Get the number of index components stored per block
109    pub const fn component_count(&self) -> u64 {
110        self.component_count
111    }
112
113    /// Get the number of blocks covered by this index
114    pub const fn block_count(&self) -> usize {
115        if self.component_count == 0 {
116            return 0;
117        }
118        self.offsets.len() / self.component_count as usize
119    }
120
121    /// Get all offsets in block-major order
122    pub fn offsets(&self) -> &[i64] {
123        &self.offsets
124    }
125
126    /// Get the `component_count` offsets for a specific block number.
127    ///
128    /// Returns a slice ordered as
129    /// `[header, body, (receipts)?, (difficulty)?, (proof)?]`, or `None` when the block is outside
130    /// the range covered by this index.
131    pub fn offsets_for_block(&self, block_number: BlockNumber) -> Option<&[i64]> {
132        if block_number < self.starting_number || self.component_count == 0 {
133            return None;
134        }
135        let index = (block_number - self.starting_number) as usize;
136        let cc = self.component_count as usize;
137        let start = index.checked_mul(cc)?;
138        let end = start.checked_add(cc)?;
139        self.offsets.get(start..end)
140    }
141
142    /// Convert to an [`Entry`] for storage in an e2store file.
143    ///
144    /// Format: `starting-number | offsets... | component-count | count`
145    pub fn to_entry(&self) -> Entry {
146        let block_count = self.block_count();
147        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 16);
148
149        data.extend_from_slice(&self.starting_number.to_le_bytes());
150        data.extend(self.offsets.iter().flat_map(|offset| offset.to_le_bytes()));
151        data.extend_from_slice(&self.component_count.to_le_bytes());
152        data.extend_from_slice(&(block_count as u64).to_le_bytes());
153
154        Entry::new(DYNAMIC_BLOCK_INDEX, data)
155    }
156
157    /// Create from an [`Entry`]
158    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
159        entry.ensure_type(DYNAMIC_BLOCK_INDEX, "DynamicBlockIndex")?;
160
161        // Need at least: starting-number(8) + component-count(8) + count(8) = 24 bytes
162        if entry.data.len() < 24 {
163            return Err(E2sError::Ssz(
164                "DynamicBlockIndex too short: need at least 24 bytes for starting-number, \
165                 component-count and count"
166                    .to_string(),
167            ));
168        }
169
170        let data = &entry.data;
171        let len = data.len();
172
173        // Count is the last 8 bytes, component-count the 8 before it.
174        let count = u64::from_le_bytes(
175            data[len - 8..]
176                .try_into()
177                .map_err(|_| E2sError::Ssz("Failed to read count bytes".to_string()))?,
178        ) as usize;
179
180        let component_count = u64::from_le_bytes(
181            data[len - 16..len - 8]
182                .try_into()
183                .map_err(|_| E2sError::Ssz("Failed to read component-count bytes".to_string()))?,
184        );
185
186        if !(MIN_COMPONENTS_PER_BLOCK..=MAX_COMPONENTS_PER_BLOCK).contains(&component_count) {
187            return Err(E2sError::Ssz(format!(
188                "Invalid component-count for DynamicBlockIndex: expected 2-5, got {component_count}"
189            )));
190        }
191
192        // Derive the offset count from the actual entry length, not the untrusted `count`, so a
193        // crafted `count` can't overflow `* 8` and drive a huge `Vec::with_capacity`.
194        let offsets_bytes = len - 24; // len >= 24 checked above
195        if !offsets_bytes.is_multiple_of(8) {
196            return Err(E2sError::Ssz(
197                "DynamicBlockIndex offset section is not 8-byte aligned".to_string(),
198            ));
199        }
200        let total_offsets = offsets_bytes / 8;
201
202        // The declared `count` and `component-count` must match the stored offsets exactly.
203        if count.checked_mul(component_count as usize) != Some(total_offsets) {
204            return Err(E2sError::Ssz(format!(
205                "DynamicBlockIndex length mismatch: count {count} * component-count \
206                 {component_count} does not equal the {total_offsets} stored offsets"
207            )));
208        }
209
210        let starting_number = u64::from_le_bytes(
211            data[0..8]
212                .try_into()
213                .map_err(|_| E2sError::Ssz("Failed to read starting_number bytes".to_string()))?,
214        );
215
216        let mut offsets = Vec::with_capacity(total_offsets);
217        for chunk in data[8..8 + offsets_bytes].chunks_exact(8) {
218            let offset = i64::from_le_bytes(
219                chunk.try_into().map_err(|_| E2sError::Ssz("Failed to read offset".to_string()))?,
220            );
221            offsets.push(offset);
222        }
223
224        Ok(Self { starting_number, component_count, offsets })
225    }
226}
227
228/// `ere` file identifier
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct EreId {
231    /// Network configuration name
232    pub network_name: String,
233
234    /// First block number in file
235    pub start_block: BlockNumber,
236
237    /// Number of blocks in the file
238    pub block_count: u32,
239
240    /// Optional hash identifier for this file.
241    /// First 4 bytes of the hash of the last block in the file.
242    pub hash: Option<[u8; 4]>,
243
244    /// Whether to include era count in filename.
245    /// It is used for custom exports when we don't use the max number of items per file.
246    pub include_era_count: bool,
247
248    /// Subset profiles applied to this file.
249    ///
250    /// Kept sorted and deduplicated by the builders so the filename postfix renders in the
251    /// spec-mandated alphabetical order. Empty means the default, fully verifiable profile.
252    pub profiles: Vec<EreProfile>,
253}
254
255impl EreId {
256    /// Create a new [`EreId`]
257    pub fn new(
258        network_name: impl Into<String>,
259        start_block: BlockNumber,
260        block_count: u32,
261    ) -> Self {
262        Self {
263            network_name: network_name.into(),
264            start_block,
265            block_count,
266            hash: None,
267            include_era_count: false,
268            profiles: Vec::new(),
269        }
270    }
271
272    /// Add a hash identifier to [`EreId`]
273    pub const fn with_hash(mut self, hash: [u8; 4]) -> Self {
274        self.hash = Some(hash);
275        self
276    }
277
278    /// Include era count in filename, for custom block-per-file exports
279    pub const fn with_era_count(mut self) -> Self {
280        self.include_era_count = true;
281        self
282    }
283
284    /// Add a subset [`EreProfile`] to this file, keeping profiles sorted and deduplicated.
285    pub fn with_profile(mut self, profile: EreProfile) -> Self {
286        self.profiles.push(profile);
287        self.normalize_profiles();
288        self
289    }
290
291    /// Add several subset profiles to this file, keeping profiles sorted and deduplicated.
292    pub fn with_profiles(mut self, profiles: impl IntoIterator<Item = EreProfile>) -> Self {
293        self.profiles.extend(profiles);
294        self.normalize_profiles();
295        self
296    }
297
298    /// Sort and deduplicate profiles so the filename postfix is deterministic and alphabetical.
299    fn normalize_profiles(&mut self) {
300        self.profiles.sort_unstable();
301        self.profiles.dedup();
302    }
303}
304
305impl EraFileId for EreId {
306    const FILE_TYPE: EraFileType = EraFileType::Ere;
307
308    const ITEMS_PER_ERA: u64 = MAX_BLOCKS_PER_ERE as u64;
309
310    fn network_name(&self) -> &str {
311        &self.network_name
312    }
313
314    fn start_number(&self) -> u64 {
315        self.start_block
316    }
317
318    fn count(&self) -> u32 {
319        self.block_count
320    }
321
322    fn hash(&self) -> Option<[u8; 4]> {
323        self.hash
324    }
325
326    fn include_era_count(&self) -> bool {
327        self.include_era_count
328    }
329
330    /// Render the filename, appending any subset-profile postfixes before the extension.
331    ///
332    /// Default profile: `<network>-<era-number>-<short-block-hash>.ere`.
333    /// With profiles: `<network>-<era-number>-<short-block-hash>-<profile>...-.ere`, in
334    /// alphabetical profile order, e.g. `mainnet-00000-4bb7de2e-noproofs-noreceipts.ere`.
335    ///
336    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md#file-name>
337    fn to_file_name(&self) -> String {
338        let base = Self::FILE_TYPE.format_filename(
339            self.network_name(),
340            self.era_number(),
341            self.hash(),
342            self.include_era_count(),
343            self.era_count(),
344        );
345
346        if self.profiles.is_empty() {
347            return base;
348        }
349
350        // Insert the `-<profile>` postfixes before the extension. `profiles` is already sorted
351        // and deduplicated, so the order matches the spec's alphabetical requirement.
352        let extension = Self::FILE_TYPE.extension();
353        let stem = base.strip_suffix(extension).unwrap_or(base.as_str());
354        let mut name = String::with_capacity(base.len() + self.profiles.len() * 12);
355        name.push_str(stem);
356        for profile in &self.profiles {
357            name.push('-');
358            name.push_str(profile.as_str());
359        }
360        name.push_str(extension);
361        name
362    }
363}
364
365/// A subset profile for an `ere` file, distinguishing non-default contents from the fully
366/// verifiable default profile.
367///
368/// Variants are ordered so that [`EreId::to_file_name`] renders their postfixes alphabetically,
369/// as required by the spec.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
371pub enum EreProfile {
372    /// Omits `Proof` entries (`noproofs`).
373    NoProofs,
374    /// Omits `CompressedSlimReceipts` entries (`noreceipts`).
375    NoReceipts,
376}
377
378impl EreProfile {
379    /// The lower-case ASCII filename postfix for this profile.
380    pub const fn as_str(self) -> &'static str {
381        match self {
382            Self::NoProofs => "noproofs",
383            Self::NoReceipts => "noreceipts",
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::ere::types::execution::{
392        CompressedBody, CompressedHeader, CompressedSlimReceipts, TotalDifficulty,
393    };
394    use alloy_primitives::{B256, U256};
395
396    /// Build an `ere` block tuple from uncompressed sample bytes; the index/group tests only care
397    /// about the tuple's presence, not its decoded contents.
398    fn sample_block(data_size: usize) -> BlockTuple {
399        BlockTuple::new(
400            CompressedHeader::new(vec![0xAA; data_size]),
401            CompressedBody::new(vec![0xBB; data_size * 2]),
402        )
403        .with_receipts(CompressedSlimReceipts::new(vec![0xCC; data_size]))
404        .with_total_difficulty(TotalDifficulty::new(U256::from(data_size)))
405    }
406
407    #[test]
408    fn test_dynamic_block_index_roundtrip() {
409        let starting_number = 1000;
410        let component_count = 4;
411        // 2 blocks, 4 components each = 8 offsets
412        let offsets = vec![100, 200, 300, 400, 500, 600, 700, 800];
413
414        let block_index = DynamicBlockIndex::new(starting_number, component_count, offsets.clone());
415
416        let entry = block_index.to_entry();
417        assert_eq!(entry.entry_type, DYNAMIC_BLOCK_INDEX);
418
419        let recovered = DynamicBlockIndex::from_entry(&entry).unwrap();
420        assert_eq!(recovered, block_index);
421        assert_eq!(recovered.starting_number(), starting_number);
422        assert_eq!(recovered.component_count(), component_count);
423        assert_eq!(recovered.offsets(), offsets);
424        assert_eq!(recovered.block_count(), 2);
425    }
426
427    #[test]
428    fn test_dynamic_block_index_negative_offsets_roundtrip() {
429        // Offsets point backward from the index to earlier entries, so they are negative in real
430        // files. Cover the full component-count range (2 and 5) to exercise the `i64` LE encoding.
431        for (component_count, offsets) in [
432            (2u64, vec![-2048, -1024, -512, -256]),
433            (5u64, vec![-50, -40, -30, -20, -10, -9, -8, -7, -6, -5]),
434        ] {
435            let index = DynamicBlockIndex::new(1000, component_count, offsets);
436            let recovered = DynamicBlockIndex::from_entry(&index.to_entry()).unwrap();
437            assert_eq!(recovered, index);
438        }
439    }
440
441    #[test]
442    fn test_dynamic_block_index_offset_lookup() {
443        let starting_number = 1000;
444        let component_count = 3;
445        // 3 blocks, 3 components each = 9 offsets
446        let offsets = vec![10, 20, 30, 40, 50, 60, 70, 80, 90];
447
448        let block_index = DynamicBlockIndex::new(starting_number, component_count, offsets);
449
450        // Block 1000: [10, 20, 30]
451        assert_eq!(block_index.offsets_for_block(1000), Some(&[10, 20, 30][..]));
452
453        // Block 1002: [70, 80, 90]
454        assert_eq!(block_index.offsets_for_block(1002), Some(&[70, 80, 90][..]));
455
456        // Out of range below and above
457        assert_eq!(block_index.offsets_for_block(999), None);
458        assert_eq!(block_index.offsets_for_block(1003), None);
459    }
460
461    #[test]
462    fn test_dynamic_block_index_rejects_bad_component_count() {
463        // component-count must be in 2..=5; forge an entry with component-count = 1.
464        let mut data = Vec::new();
465        data.extend_from_slice(&1000u64.to_le_bytes()); // starting-number
466        data.extend_from_slice(&42i64.to_le_bytes()); // single offset
467        data.extend_from_slice(&1u64.to_le_bytes()); // component-count = 1 (invalid)
468        data.extend_from_slice(&1u64.to_le_bytes()); // count
469        let entry = Entry::new(DYNAMIC_BLOCK_INDEX, data);
470
471        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
472    }
473
474    #[test]
475    fn test_dynamic_block_index_rejects_overflowing_count() {
476        // 24-byte entry declaring a count whose offset length overflows usize; must be rejected,
477        // not allocated.
478        let mut data = Vec::new();
479        data.extend_from_slice(&1000u64.to_le_bytes()); // starting-number
480        data.extend_from_slice(&2u64.to_le_bytes()); // component-count = 2
481        data.extend_from_slice(&(1u64 << 60).to_le_bytes()); // count = 2^60
482        let entry = Entry::new(DYNAMIC_BLOCK_INDEX, data);
483
484        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
485    }
486
487    #[test]
488    fn test_dynamic_block_index_rejects_wrong_length() {
489        // Encode a valid index, then drop a trailing byte so the declared count no longer matches.
490        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200, 300, 400]);
491        let mut entry = block_index.to_entry();
492        entry.data.pop();
493
494        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
495    }
496
497    #[test]
498    fn test_dynamic_block_index_rejects_wrong_type() {
499        let entry = Entry::new([0x66, 0x32], vec![0u8; 24]);
500        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
501    }
502
503    #[test]
504    fn test_ere_group_basic_construction() {
505        let blocks = vec![sample_block(10), sample_block(15), sample_block(20)];
506
507        let accumulator = Accumulator::new(B256::from([0xDD; 32]));
508        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200, 300, 400, 500, 600]);
509
510        let group = EreGroup::new(blocks, Some(accumulator.clone()), block_index);
511
512        assert_eq!(group.blocks.len(), 3);
513        assert_eq!(group.other_entries.len(), 0);
514        assert_eq!(group.accumulator.unwrap().root, accumulator.root);
515        assert_eq!(group.index.starting_number(), 1000);
516        assert_eq!(group.index.offsets(), vec![100, 200, 300, 400, 500, 600]);
517    }
518
519    #[test]
520    fn test_ere_group_without_accumulator() {
521        // Post-merge files carry no accumulator.
522        let blocks = vec![sample_block(10)];
523        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200]);
524
525        let group = EreGroup::new(blocks, None, block_index);
526
527        assert!(group.accumulator.is_none());
528        assert_eq!(group.blocks.len(), 1);
529    }
530
531    #[test]
532    fn test_ere_group_add_entries() {
533        let blocks = vec![sample_block(10)];
534        let accumulator = Accumulator::new(B256::from([0xDD; 32]));
535        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200]);
536
537        let mut group = EreGroup::new(blocks, Some(accumulator), block_index);
538        assert_eq!(group.other_entries.len(), 0);
539
540        group.add_entry(Entry::new([0x01, 0x01], vec![1, 2, 3, 4]));
541        group.add_entry(Entry::new([0x02, 0x02], vec![5, 6, 7, 8]));
542
543        assert_eq!(group.other_entries.len(), 2);
544        assert_eq!(group.other_entries[0].entry_type, [0x01, 0x01]);
545        assert_eq!(group.other_entries[1].data, vec![5, 6, 7, 8]);
546    }
547
548    #[test]
549    fn test_ere_group_with_mismatched_index() {
550        // The group is a plain container; it does not validate that block count matches the index.
551        let blocks = vec![sample_block(10), sample_block(15)];
552        let index = DynamicBlockIndex::new(2000, 2, vec![100, 200, 300, 400, 500, 600]); // 3 blocks
553        let group = EreGroup::new(blocks, None, index);
554        assert_eq!(group.blocks.len(), 2);
555        assert_eq!(group.index.starting_number(), 2000);
556    }
557
558    #[test_case::test_case(
559        EreId::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]),
560        "mainnet-00000-5ec1ffb8.ere";
561        "Mainnet era 0"
562    )]
563    #[test_case::test_case(
564        EreId::new("mainnet", 8192, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]),
565        "mainnet-00001-5ecb9bf9.ere";
566        "Mainnet era 1"
567    )]
568    #[test_case::test_case(
569        EreId::new("sepolia", 0, 8192).with_hash([0x90, 0x91, 0x84, 0x72]),
570        "sepolia-00000-90918472.ere";
571        "Sepolia era 0"
572    )]
573    #[test_case::test_case(
574        EreId::new("mainnet", 1000, 100),
575        "mainnet-00000-00000000.ere";
576        "ID without hash"
577    )]
578    fn test_ere_id_file_naming(id: EreId, expected_file_name: &str) {
579        assert_eq!(id.to_file_name(), expected_file_name);
580    }
581
582    // File naming with era-count, for custom exports
583    #[test_case::test_case(
584        EreId::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]).with_era_count(),
585        "mainnet-00000-00001-5ec1ffb8.ere";
586        "Mainnet era 0 with count"
587    )]
588    #[test_case::test_case(
589        EreId::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(),
590        "mainnet-00000-00002-abcdef12.ere";
591        "Spanning two eras with count"
592    )]
593    fn test_ere_id_file_naming_with_era_count(id: EreId, expected_file_name: &str) {
594        assert_eq!(id.to_file_name(), expected_file_name);
595    }
596
597    // File naming with subset-profile postfixes, in alphabetical order.
598    #[test_case::test_case(
599        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]),
600        "mainnet-00000-4bb7de2e.ere";
601        "Default profile, no postfix"
602    )]
603    #[test_case::test_case(
604        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoProofs),
605        "mainnet-00000-4bb7de2e-noproofs.ere";
606        "noproofs profile"
607    )]
608    #[test_case::test_case(
609        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoReceipts),
610        "mainnet-00000-4bb7de2e-noreceipts.ere";
611        "noreceipts profile"
612    )]
613    #[test_case::test_case(
614        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profiles([EreProfile::NoProofs, EreProfile::NoReceipts]),
615        "mainnet-00000-4bb7de2e-noproofs-noreceipts.ere";
616        "Combined profiles"
617    )]
618    #[test_case::test_case(
619        // Insertion order reversed and duplicated; output must still be alphabetical and deduped.
620        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoReceipts).with_profile(EreProfile::NoProofs).with_profile(EreProfile::NoReceipts),
621        "mainnet-00000-4bb7de2e-noproofs-noreceipts.ere";
622        "Profiles normalized to alphabetical order"
623    )]
624    fn test_ere_id_file_naming_with_profiles(id: EreId, expected_file_name: &str) {
625        assert_eq!(id.to_file_name(), expected_file_name);
626    }
627}