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].as_chunks::<8>().0 {
218            let offset = i64::from_le_bytes(*chunk);
219            offsets.push(offset);
220        }
221
222        Ok(Self { starting_number, component_count, offsets })
223    }
224}
225
226/// `ere` file identifier
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct EreId {
229    /// Network configuration name
230    pub network_name: String,
231
232    /// First block number in file
233    pub start_block: BlockNumber,
234
235    /// Number of blocks in the file
236    pub block_count: u32,
237
238    /// Optional hash identifier for this file.
239    /// First 4 bytes of the hash of the last block in the file.
240    pub hash: Option<[u8; 4]>,
241
242    /// Whether to include era count in filename.
243    /// It is used for custom exports when we don't use the max number of items per file.
244    pub include_era_count: bool,
245
246    /// Subset profiles applied to this file.
247    ///
248    /// Kept sorted and deduplicated by the builders so the filename postfix renders in the
249    /// spec-mandated alphabetical order. Empty means the default, fully verifiable profile.
250    pub profiles: Vec<EreProfile>,
251}
252
253impl EreId {
254    /// Create a new [`EreId`]
255    pub fn new(
256        network_name: impl Into<String>,
257        start_block: BlockNumber,
258        block_count: u32,
259    ) -> Self {
260        Self {
261            network_name: network_name.into(),
262            start_block,
263            block_count,
264            hash: None,
265            include_era_count: false,
266            profiles: Vec::new(),
267        }
268    }
269
270    /// Add a hash identifier to [`EreId`]
271    pub const fn with_hash(mut self, hash: [u8; 4]) -> Self {
272        self.hash = Some(hash);
273        self
274    }
275
276    /// Include era count in filename, for custom block-per-file exports
277    pub const fn with_era_count(mut self) -> Self {
278        self.include_era_count = true;
279        self
280    }
281
282    /// Add a subset [`EreProfile`] to this file, keeping profiles sorted and deduplicated.
283    pub fn with_profile(mut self, profile: EreProfile) -> Self {
284        self.profiles.push(profile);
285        self.normalize_profiles();
286        self
287    }
288
289    /// Add several subset profiles to this file, keeping profiles sorted and deduplicated.
290    pub fn with_profiles(mut self, profiles: impl IntoIterator<Item = EreProfile>) -> Self {
291        self.profiles.extend(profiles);
292        self.normalize_profiles();
293        self
294    }
295
296    /// Sort and deduplicate profiles so the filename postfix is deterministic and alphabetical.
297    fn normalize_profiles(&mut self) {
298        self.profiles.sort_unstable();
299        self.profiles.dedup();
300    }
301}
302
303impl EraFileId for EreId {
304    const FILE_TYPE: EraFileType = EraFileType::Ere;
305
306    const ITEMS_PER_ERA: u64 = MAX_BLOCKS_PER_ERE as u64;
307
308    fn network_name(&self) -> &str {
309        &self.network_name
310    }
311
312    fn start_number(&self) -> u64 {
313        self.start_block
314    }
315
316    fn count(&self) -> u32 {
317        self.block_count
318    }
319
320    fn hash(&self) -> Option<[u8; 4]> {
321        self.hash
322    }
323
324    fn include_era_count(&self) -> bool {
325        self.include_era_count
326    }
327
328    /// Render the filename, appending any subset-profile postfixes before the extension.
329    ///
330    /// Default profile: `<network>-<era-number>-<short-block-hash>.ere`.
331    /// With profiles: `<network>-<era-number>-<short-block-hash>-<profile>...-.ere`, in
332    /// alphabetical profile order, e.g. `mainnet-00000-4bb7de2e-noproofs-noreceipts.ere`.
333    ///
334    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/ere.md#file-name>
335    fn to_file_name(&self) -> String {
336        let base = Self::FILE_TYPE.format_filename(
337            self.network_name(),
338            self.era_number(),
339            self.hash(),
340            self.include_era_count(),
341            self.era_count(),
342        );
343
344        if self.profiles.is_empty() {
345            return base;
346        }
347
348        // Insert the `-<profile>` postfixes before the extension. `profiles` is already sorted
349        // and deduplicated, so the order matches the spec's alphabetical requirement.
350        let extension = Self::FILE_TYPE.extension();
351        let stem = base.strip_suffix(extension).unwrap_or(base.as_str());
352        let mut name = String::with_capacity(base.len() + self.profiles.len() * 12);
353        name.push_str(stem);
354        for profile in &self.profiles {
355            name.push('-');
356            name.push_str(profile.as_str());
357        }
358        name.push_str(extension);
359        name
360    }
361}
362
363/// A subset profile for an `ere` file, distinguishing non-default contents from the fully
364/// verifiable default profile.
365///
366/// Variants are ordered so that [`EreId::to_file_name`] renders their postfixes alphabetically,
367/// as required by the spec.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
369pub enum EreProfile {
370    /// Omits `Proof` entries (`noproofs`).
371    NoProofs,
372    /// Omits `CompressedSlimReceipts` entries (`noreceipts`).
373    NoReceipts,
374}
375
376impl EreProfile {
377    /// The lower-case ASCII filename postfix for this profile.
378    pub const fn as_str(self) -> &'static str {
379        match self {
380            Self::NoProofs => "noproofs",
381            Self::NoReceipts => "noreceipts",
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::ere::types::execution::{
390        CompressedBody, CompressedHeader, CompressedSlimReceipts, TotalDifficulty,
391    };
392    use alloy_primitives::{B256, U256};
393
394    /// Build an `ere` block tuple from uncompressed sample bytes; the index/group tests only care
395    /// about the tuple's presence, not its decoded contents.
396    fn sample_block(data_size: usize) -> BlockTuple {
397        BlockTuple::new(
398            CompressedHeader::new(vec![0xAA; data_size]),
399            CompressedBody::new(vec![0xBB; data_size * 2]),
400        )
401        .with_receipts(CompressedSlimReceipts::new(vec![0xCC; data_size]))
402        .with_total_difficulty(TotalDifficulty::new(U256::from(data_size)))
403    }
404
405    #[test]
406    fn test_dynamic_block_index_roundtrip() {
407        let starting_number = 1000;
408        let component_count = 4;
409        // 2 blocks, 4 components each = 8 offsets
410        let offsets = vec![100, 200, 300, 400, 500, 600, 700, 800];
411
412        let block_index = DynamicBlockIndex::new(starting_number, component_count, offsets.clone());
413
414        let entry = block_index.to_entry();
415        assert_eq!(entry.entry_type, DYNAMIC_BLOCK_INDEX);
416
417        let recovered = DynamicBlockIndex::from_entry(&entry).unwrap();
418        assert_eq!(recovered, block_index);
419        assert_eq!(recovered.starting_number(), starting_number);
420        assert_eq!(recovered.component_count(), component_count);
421        assert_eq!(recovered.offsets(), offsets);
422        assert_eq!(recovered.block_count(), 2);
423    }
424
425    #[test]
426    fn test_dynamic_block_index_negative_offsets_roundtrip() {
427        // Offsets point backward from the index to earlier entries, so they are negative in real
428        // files. Cover the full component-count range (2 and 5) to exercise the `i64` LE encoding.
429        for (component_count, offsets) in [
430            (2u64, vec![-2048, -1024, -512, -256]),
431            (5u64, vec![-50, -40, -30, -20, -10, -9, -8, -7, -6, -5]),
432        ] {
433            let index = DynamicBlockIndex::new(1000, component_count, offsets);
434            let recovered = DynamicBlockIndex::from_entry(&index.to_entry()).unwrap();
435            assert_eq!(recovered, index);
436        }
437    }
438
439    #[test]
440    fn test_dynamic_block_index_offset_lookup() {
441        let starting_number = 1000;
442        let component_count = 3;
443        // 3 blocks, 3 components each = 9 offsets
444        let offsets = vec![10, 20, 30, 40, 50, 60, 70, 80, 90];
445
446        let block_index = DynamicBlockIndex::new(starting_number, component_count, offsets);
447
448        // Block 1000: [10, 20, 30]
449        assert_eq!(block_index.offsets_for_block(1000), Some(&[10, 20, 30][..]));
450
451        // Block 1002: [70, 80, 90]
452        assert_eq!(block_index.offsets_for_block(1002), Some(&[70, 80, 90][..]));
453
454        // Out of range below and above
455        assert_eq!(block_index.offsets_for_block(999), None);
456        assert_eq!(block_index.offsets_for_block(1003), None);
457    }
458
459    #[test]
460    fn test_dynamic_block_index_rejects_bad_component_count() {
461        // component-count must be in 2..=5; forge an entry with component-count = 1.
462        let mut data = Vec::new();
463        data.extend_from_slice(&1000u64.to_le_bytes()); // starting-number
464        data.extend_from_slice(&42i64.to_le_bytes()); // single offset
465        data.extend_from_slice(&1u64.to_le_bytes()); // component-count = 1 (invalid)
466        data.extend_from_slice(&1u64.to_le_bytes()); // count
467        let entry = Entry::new(DYNAMIC_BLOCK_INDEX, data);
468
469        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
470    }
471
472    #[test]
473    fn test_dynamic_block_index_rejects_overflowing_count() {
474        // 24-byte entry declaring a count whose offset length overflows usize; must be rejected,
475        // not allocated.
476        let mut data = Vec::new();
477        data.extend_from_slice(&1000u64.to_le_bytes()); // starting-number
478        data.extend_from_slice(&2u64.to_le_bytes()); // component-count = 2
479        data.extend_from_slice(&(1u64 << 60).to_le_bytes()); // count = 2^60
480        let entry = Entry::new(DYNAMIC_BLOCK_INDEX, data);
481
482        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
483    }
484
485    #[test]
486    fn test_dynamic_block_index_rejects_wrong_length() {
487        // Encode a valid index, then drop a trailing byte so the declared count no longer matches.
488        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200, 300, 400]);
489        let mut entry = block_index.to_entry();
490        entry.data.pop();
491
492        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
493    }
494
495    #[test]
496    fn test_dynamic_block_index_rejects_wrong_type() {
497        let entry = Entry::new([0x66, 0x32], vec![0u8; 24]);
498        assert!(DynamicBlockIndex::from_entry(&entry).is_err());
499    }
500
501    #[test]
502    fn test_ere_group_basic_construction() {
503        let blocks = vec![sample_block(10), sample_block(15), sample_block(20)];
504
505        let accumulator = Accumulator::new(B256::from([0xDD; 32]));
506        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200, 300, 400, 500, 600]);
507
508        let group = EreGroup::new(blocks, Some(accumulator.clone()), block_index);
509
510        assert_eq!(group.blocks.len(), 3);
511        assert_eq!(group.other_entries.len(), 0);
512        assert_eq!(group.accumulator.unwrap().root, accumulator.root);
513        assert_eq!(group.index.starting_number(), 1000);
514        assert_eq!(group.index.offsets(), vec![100, 200, 300, 400, 500, 600]);
515    }
516
517    #[test]
518    fn test_ere_group_without_accumulator() {
519        // Post-merge files carry no accumulator.
520        let blocks = vec![sample_block(10)];
521        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200]);
522
523        let group = EreGroup::new(blocks, None, block_index);
524
525        assert!(group.accumulator.is_none());
526        assert_eq!(group.blocks.len(), 1);
527    }
528
529    #[test]
530    fn test_ere_group_add_entries() {
531        let blocks = vec![sample_block(10)];
532        let accumulator = Accumulator::new(B256::from([0xDD; 32]));
533        let block_index = DynamicBlockIndex::new(1000, 2, vec![100, 200]);
534
535        let mut group = EreGroup::new(blocks, Some(accumulator), block_index);
536        assert_eq!(group.other_entries.len(), 0);
537
538        group.add_entry(Entry::new([0x01, 0x01], vec![1, 2, 3, 4]));
539        group.add_entry(Entry::new([0x02, 0x02], vec![5, 6, 7, 8]));
540
541        assert_eq!(group.other_entries.len(), 2);
542        assert_eq!(group.other_entries[0].entry_type, [0x01, 0x01]);
543        assert_eq!(group.other_entries[1].data, vec![5, 6, 7, 8]);
544    }
545
546    #[test]
547    fn test_ere_group_with_mismatched_index() {
548        // The group is a plain container; it does not validate that block count matches the index.
549        let blocks = vec![sample_block(10), sample_block(15)];
550        let index = DynamicBlockIndex::new(2000, 2, vec![100, 200, 300, 400, 500, 600]); // 3 blocks
551        let group = EreGroup::new(blocks, None, index);
552        assert_eq!(group.blocks.len(), 2);
553        assert_eq!(group.index.starting_number(), 2000);
554    }
555
556    #[test_case::test_case(
557        EreId::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]),
558        "mainnet-00000-5ec1ffb8.ere";
559        "Mainnet era 0"
560    )]
561    #[test_case::test_case(
562        EreId::new("mainnet", 8192, 8192).with_hash([0x5e, 0xcb, 0x9b, 0xf9]),
563        "mainnet-00001-5ecb9bf9.ere";
564        "Mainnet era 1"
565    )]
566    #[test_case::test_case(
567        EreId::new("sepolia", 0, 8192).with_hash([0x90, 0x91, 0x84, 0x72]),
568        "sepolia-00000-90918472.ere";
569        "Sepolia era 0"
570    )]
571    #[test_case::test_case(
572        EreId::new("mainnet", 1000, 100),
573        "mainnet-00000-00000000.ere";
574        "ID without hash"
575    )]
576    fn test_ere_id_file_naming(id: EreId, expected_file_name: &str) {
577        assert_eq!(id.to_file_name(), expected_file_name);
578    }
579
580    // File naming with era-count, for custom exports
581    #[test_case::test_case(
582        EreId::new("mainnet", 0, 8192).with_hash([0x5e, 0xc1, 0xff, 0xb8]).with_era_count(),
583        "mainnet-00000-00001-5ec1ffb8.ere";
584        "Mainnet era 0 with count"
585    )]
586    #[test_case::test_case(
587        EreId::new("mainnet", 8000, 500).with_hash([0xab, 0xcd, 0xef, 0x12]).with_era_count(),
588        "mainnet-00000-00002-abcdef12.ere";
589        "Spanning two eras with count"
590    )]
591    fn test_ere_id_file_naming_with_era_count(id: EreId, expected_file_name: &str) {
592        assert_eq!(id.to_file_name(), expected_file_name);
593    }
594
595    // File naming with subset-profile postfixes, in alphabetical order.
596    #[test_case::test_case(
597        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]),
598        "mainnet-00000-4bb7de2e.ere";
599        "Default profile, no postfix"
600    )]
601    #[test_case::test_case(
602        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoProofs),
603        "mainnet-00000-4bb7de2e-noproofs.ere";
604        "noproofs profile"
605    )]
606    #[test_case::test_case(
607        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoReceipts),
608        "mainnet-00000-4bb7de2e-noreceipts.ere";
609        "noreceipts profile"
610    )]
611    #[test_case::test_case(
612        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profiles([EreProfile::NoProofs, EreProfile::NoReceipts]),
613        "mainnet-00000-4bb7de2e-noproofs-noreceipts.ere";
614        "Combined profiles"
615    )]
616    #[test_case::test_case(
617        // Insertion order reversed and duplicated; output must still be alphabetical and deduped.
618        EreId::new("mainnet", 0, 8192).with_hash([0x4b, 0xb7, 0xde, 0x2e]).with_profile(EreProfile::NoReceipts).with_profile(EreProfile::NoProofs).with_profile(EreProfile::NoReceipts),
619        "mainnet-00000-4bb7de2e-noproofs-noreceipts.ere";
620        "Profiles normalized to alphabetical order"
621    )]
622    fn test_ere_id_file_naming_with_profiles(id: EreId, expected_file_name: &str) {
623        assert_eq!(id.to_file_name(), expected_file_name);
624    }
625}