Repository: paradigmxyz/reth
Pull request #15396: feat:  introduce `reth-era` crate with `era1` file types
Author: login: lean-apple
Labels: name: C-enhancement
description: New feature or request
name: A-networking
description: Related to networking in general
Created: 2025-03-30T11:11:39Z | Merged: 2025-04-04T19:01:48Z

Closes https://github.com/paradigmxyz/reth/issues/15371.

Doc Ref : 
- `e2store` : https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md, 
- `era1` : https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md

Still TODO: 
- [x] add `CompressedSignedBeaconBlock`, `CompressedBeaconState` maybe in a beacon module
- [x] add era file module with first era types module
- [x] add impl for SlotIndex
- [x] refactor era types to match era1 according to eth specs  https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md


Diff:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e176c30cd3b..1596d90b30f 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -11,6 +11,7 @@ crates/engine               @rkrasiuk @mattsse @Rjected
 crates/e2e-test-utils/      @mattsse @Rjected
 crates/engine/              @rkrasiuk @mattsse @Rjected @fgimenez
 crates/errors/              @mattsse
+crates/era/                 @mattsse
 crates/ethereum/            @mattsse @Rjected
 crates/ethereum-forks/      @mattsse @Rjected
 crates/etl/                 @joshieDo @shekhirin
diff --git a/Cargo.lock b/Cargo.lock
index e00f9aece58..00a91db6c46 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -7903,6 +7903,20 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "reth-era"
+version = "1.3.7"
+dependencies = [
+ "alloy-consensus",
+ "alloy-eips",
+ "alloy-primitives",
+ "alloy-rlp",
+ "ethereum_ssz",
+ "ethereum_ssz_derive",
+ "snap",
+ "thiserror 2.0.12",
+]
+
 [[package]]
 name = "reth-errors"
 version = "1.3.7"
diff --git a/Cargo.toml b/Cargo.toml
index f69cc403520..d9ee5d5ff5d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -28,6 +28,7 @@ members = [
     "crates/engine/service",
     "crates/engine/tree/",
     "crates/engine/util/",
+    "crates/era",
     "crates/errors/",
     "crates/ethereum-forks/",
     "crates/ethereum/cli/",
@@ -342,6 +343,7 @@ reth-engine-tree = { path = "crates/engine/tree" }
 reth-engine-service = { path = "crates/engine/service" }
 reth-engine-util = { path = "crates/engine/util" }
 reth-errors = { path = "crates/errors" }
+reth-era = { path = "crates/era" }
 reth-eth-wire = { path = "crates/net/eth-wire" }
 reth-eth-wire-types = { path = "crates/net/eth-wire-types" }
 reth-ethereum-cli = { path = "crates/ethereum/cli" }
@@ -621,6 +623,10 @@ tempfile = "3.8"
 test-fuzz = "7"
 rstest = "0.24.0"
 
+# ssz encoding
+ethereum_ssz = "0.8.3"
+ethereum_ssz_derive = "0.8.3"
+
 # allocators
 tikv-jemalloc-ctl = "0.6"
 tikv-jemallocator = "0.6"
@@ -670,7 +676,7 @@ rmp-serde = "1.3"
 roaring = "0.10.2"
 rolling-file = "0.2.0"
 sha3 = "0.10.5"
-snap = "1.0.5"
+snap = "1.1.1"
 socket2 = { version = "0.5", default-features = false }
 sysinfo = { version = "0.33", default-features = false }
 tracing-journald = "0.3"
diff --git a/crates/era/Cargo.toml b/crates/era/Cargo.toml
new file mode 100644
index 00000000000..f0da55b8dfd
--- /dev/null
+++ b/crates/era/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "reth-era"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+exclude.workspace = true
+
+[dependencies]
+thiserror.workspace = true
+
+# alloy
+alloy-consensus.workspace = true
+alloy-eips.workspace = true
+alloy-primitives.workspace = true
+alloy-rlp.workspace = true
+
+# compression and decompression
+snap.workspace = true
+
+# ssz encoding and decoding
+ethereum_ssz.workspace = true
+ethereum_ssz_derive.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/era/src/e2s_file.rs b/crates/era/src/e2s_file.rs
new file mode 100644
index 00000000000..be0cf167ffe
--- /dev/null
+++ b/crates/era/src/e2s_file.rs
@@ -0,0 +1,95 @@
+//! `E2Store` file reader
+//!
+//! See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use std::io::{BufReader, Read, Seek, SeekFrom};
+
+/// A reader for `E2Store` files that wraps a [`BufReader`]
+#[derive(Debug)]
+pub struct E2StoreReader<R: Read> {
+    reader: BufReader<R>,
+}
+
+impl<R: Read + Seek> E2StoreReader<R> {
+    /// Create a new [`E2StoreReader`]
+    pub fn new(reader: R) -> Self {
+        Self { reader: BufReader::new(reader) }
+    }
+
+    /// Read and validate the version record
+    pub fn read_version(&mut self) -> Result<Option<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        match Entry::read(&mut self.reader)? {
+            Some(entry) if entry.is_version() => Ok(Some(entry)),
+            Some(_) => Err(E2sError::Ssz("First entry must be a Version entry".to_string())),
+            None => Ok(None),
+        }
+    }
+
+    /// Iterate through all entries, including the version entry
+    pub fn entries(&mut self) -> Result<Vec<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        let mut entries = Vec::new();
+
+        while let Some(entry) = Entry::read(&mut self.reader)? {
+            entries.push(entry);
+        }
+
+        Ok(entries)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::e2s_types::{BLOCK_INDEX, VERSION};
+    use std::io::Cursor;
+
+    #[test]
+    fn test_e2store_reader() -> Result<(), E2sError> {
+        // Create a mock e2store file in memory
+        let mut mock_file = Vec::new();
+
+        // Write version entry
+        let version_entry = Entry::new(VERSION, Vec::new());
+        version_entry.write(&mut mock_file)?;
+
+        // Write multiple entries of different types
+        let block_index_entry1 = Entry::new(BLOCK_INDEX, vec![1, 2, 3, 4]);
+        block_index_entry1.write(&mut mock_file)?;
+
+        let block_index_entry2 = Entry::new(BLOCK_INDEX, vec![5, 6, 7, 8, 9]);
+        block_index_entry2.write(&mut mock_file)?;
+
+        let custom_type = [0x99, 0x99]; // Application-specific type
+        let custom_entry = Entry::new(custom_type, vec![10, 11, 12]);
+        custom_entry.write(&mut mock_file)?;
+
+        // Create reader and process entries
+        let cursor = Cursor::new(mock_file);
+        let mut e2store_reader = E2StoreReader::new(cursor);
+
+        let version = e2store_reader.read_version()?;
+        assert!(version.is_some());
+
+        let entries = e2store_reader.entries()?;
+
+        // Validate entries
+        assert_eq!(entries.len(), 4);
+        // First entry should be version
+        assert!(entries[0].is_version());
+        // Second entry should be block index
+        assert!(entries[1].is_block_index());
+        // Third entry is block index
+        assert!(entries[2].is_block_index());
+        // Fourth entry is custom type
+        assert!(entries[3].entry_type == [0x99, 0x99]);
+
+        Ok(())
+    }
+}
diff --git a/crates/era/src/e2s_types.rs b/crates/era/src/e2s_types.rs
new file mode 100644
index 00000000000..f6bfad0b47e
--- /dev/null
+++ b/crates/era/src/e2s_types.rs
@@ -0,0 +1,167 @@
+//! Types to build e2store files
+//! ie. with .e2s extension
+//!
+//! e2store contains header and entry
+//!
+//! The [`Header`] is an 8-byte structure at the beginning of each record in the file
+//!
+//! An [`Entry`] is a complete record in the file, consisting of both a [`Header`] and its
+//! associated data
+
+use ssz_derive::{Decode, Encode};
+use std::io::{self, Read, Write};
+use thiserror::Error;
+
+/// [`Version`] record: ['e', '2']
+pub const VERSION: [u8; 2] = [0x65, 0x32];
+
+/// Empty record
+pub const EMPTY: [u8; 2] = [0x00, 0x00];
+
+/// `BlockIndex` record: ['i', '2']
+pub const BLOCK_INDEX: [u8; 2] = [0x69, 0x32];
+
+/// Error types for e2s file operations
+#[derive(Error, Debug)]
+pub enum E2sError {
+    /// IO error during file operations
+    #[error("IO error: {0}")]
+    Io(#[from] io::Error),
+
+    /// Error during SSZ encoding/decoding
+    #[error("SSZ error: {0}")]
+    Ssz(String),
+
+    /// Reserved field in header not zero
+    #[error("Reserved field in header not zero")]
+    ReservedNotZero,
+
+    /// Error during snappy compression
+    #[error("Snappy compression error: {0}")]
+    SnappyCompression(String),
+
+    /// Error during snappy decompression
+    #[error("Snappy decompression error: {0}")]
+    SnappyDecompression(String),
+
+    /// Error during RLP encoding/decoding
+    #[error("RLP error: {0}")]
+    Rlp(String),
+}
+
+/// Header for TLV records in e2store files
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct Header {
+    /// Record type identifier
+    pub header_type: [u8; 2],
+
+    /// Length of data following the header
+    pub length: u32,
+
+    /// Reserved field, must be zero
+    pub reserved: u16,
+}
+
+impl Header {
+    /// Create a new header with the specified type and length
+    pub fn new(header_type: [u8; 2], length: u32) -> Self {
+        Self { header_type, length, reserved: 0 }
+    }
+
+    /// Read header from a reader
+    pub fn read<R: Read>(reader: &mut R) -> Result<Option<Self>, E2sError> {
+        let mut header_bytes = [0u8; 8];
+        match reader.read_exact(&mut header_bytes) {
+            Ok(_) => {}
+            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
+            Err(e) => return Err(e.into()),
+        }
+
+        let header: Self = match ssz::Decode::from_ssz_bytes(&header_bytes) {
+            Ok(h) => h,
+            Err(_) => return Err(E2sError::Ssz(String::from("Failed to decode SSZ header"))),
+        };
+
+        if header.reserved != 0 {
+            return Err(E2sError::ReservedNotZero);
+        }
+
+        Ok(Some(header))
+    }
+
+    /// Writes the header to the given writer.
+    pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
+        let encoded = ssz::Encode::as_ssz_bytes(self);
+        writer.write_all(&encoded)
+    }
+}
+
+/// The [`Version`] record must be the first record in an e2store file
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct Version;
+
+impl Version {
+    /// Encode this record to the given writer
+    pub fn encode<W: Write>(&self, writer: &mut W) -> io::Result<()> {
+        let header = Header::new(VERSION, 0);
+        header.write(writer)
+    }
+}
+
+/// Complete record in an e2store file, consisting of a type, length, and associated data
+#[derive(Debug, Clone)]
+pub struct Entry {
+    /// Record type identifier
+    pub entry_type: [u8; 2],
+
+    /// Data contained in the entry
+    pub data: Vec<u8>,
+}
+
+impl Entry {
+    /// Create a new entry
+    pub fn new(entry_type: [u8; 2], data: Vec<u8>) -> Self {
+        Self { entry_type, data }
+    }
+
+    /// Read an entry from a reader
+    pub fn read<R: Read>(reader: &mut R) -> Result<Option<Self>, E2sError> {
+        // Read the header first
+        let header = match Header::read(reader)? {
+            Some(h) => h,
+            None => return Ok(None),
+        };
+
+        // Read the data
+        let mut data = vec![0u8; header.length as usize];
+        match reader.read_exact(&mut data) {
+            Ok(_) => {}
+            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
+                return Err(E2sError::Io(io::Error::new(
+                    io::ErrorKind::UnexpectedEof,
+                    "Unexpected EOF while reading entry data",
+                )));
+            }
+            Err(e) => return Err(e.into()),
+        }
+
+        Ok(Some(Self { entry_type: header.header_type, data }))
+    }
+
+    /// Write the entry to [`Entry`] writer
+    pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
+        let header = Header::new(self.entry_type, self.data.len() as u32);
+        header.write(writer)?;
+        writer.write_all(&self.data)
+    }
+
+    /// Check if this is a [`Version`] entry
+    pub fn is_version(&self) -> bool {
+        self.entry_type == VERSION
+    }
+
+    /// Check if this is a `BlockIndex` entry
+    pub fn is_block_index(&self) -> bool {
+        self.entry_type == BLOCK_INDEX
+    }
+}
diff --git a/crates/era/src/era1_file.rs b/crates/era/src/era1_file.rs
new file mode 100644
index 00000000000..2076caa9a67
--- /dev/null
+++ b/crates/era/src/era1_file.rs
@@ -0,0 +1,185 @@
+//! Represents a complete Era1 file
+//!
+//! The structure of an Era1 file follows the specification:
+//! `Version | block-tuple* | other-entries* | Accumulator | BlockIndex`
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::{
+    e2s_file::E2StoreReader,
+    e2s_types::{E2sError, Version, BLOCK_INDEX},
+    era1_types::{BlockIndex, Era1Group, Era1Id},
+    execution_types::{
+        self, Accumulator, BlockTuple, CompressedBody, CompressedHeader, CompressedReceipts,
+        TotalDifficulty,
+    },
+};
+use alloy_primitives::BlockNumber;
+use std::{
+    fs::File,
+    io::{Read, Seek},
+    path::Path,
+};
+
+/// Era1 file interface
+#[derive(Debug)]
+pub struct Era1File {
+    /// Version record, must be the first record in the file
+    pub version: Version,
+
+    /// Main content group of the Era1 file
+    pub group: Era1Group,
+
+    /// File identifier
+    pub id: Era1Id,
+}
+
+impl Era1File {
+    /// Create a new [`Era1File`]
+    pub fn new(group: Era1Group, id: Era1Id) -> Self {
+        Self { version: Version, group, id }
+    }
+
+    /// Get a block by its number, if present in this file
+    pub fn get_block_by_number(&self, number: BlockNumber) -> Option<&BlockTuple> {
+        let index = (number - self.group.block_index.starting_number) as usize;
+        (index < self.group.blocks.len()).then(|| &self.group.blocks[index])
+    }
+
+    /// Get the range of block numbers contained in this file
+    pub fn block_range(&self) -> std::ops::RangeInclusive<BlockNumber> {
+        let start = self.group.block_index.starting_number;
+        let end = start + (self.group.blocks.len() as u64) - 1;
+        start..=end
+    }
+
+    /// Check if this file contains a specific block number
+    pub fn contains_block(&self, number: BlockNumber) -> bool {
+        self.block_range().contains(&number)
+    }
+}
+/// Reader for Era1 files that builds on top of [`E2StoreReader`]
+#[derive(Debug)]
+pub struct Era1Reader<R: Read> {
+    reader: E2StoreReader<R>,
+}
+
+impl<R: Read + Seek> Era1Reader<R> {
+    /// Create a new [`Era1Reader`]
+    pub fn new(reader: R) -> Self {
+        Self { reader: E2StoreReader::new(reader) }
+    }
+
+    /// Reads and parses an Era1 file from the underlying reader, assembling all components
+    /// into a complete [`Era1File`] with an [`Era1Id`] that includes the provided network name.
+    pub fn read(&mut self, network_name: String) -> Result<Era1File, E2sError> {
+        // Validate version entry
+        let _version_entry = match self.reader.read_version()? {
+            Some(entry) if entry.is_version() => entry,
+            Some(_) => return Err(E2sError::Ssz("First entry is not a Version entry".to_string())),
+            None => return Err(E2sError::Ssz("Empty Era1 file".to_string())),
+        };
+
+        // Read all entries
+        let entries = self.reader.entries()?;
+
+        // Skip the first entry since it's the version
+        let entries = entries.into_iter().skip(1).collect::<Vec<_>>();
+
+        // Temporary storage for block components
+        let mut headers = Vec::new();
+        let mut bodies = Vec::new();
+        let mut receipts = Vec::new();
+        let mut difficulties = Vec::new();
+        let mut other_entries = Vec::new();
+        let mut accumulator = None;
+        let mut block_index = None;
+
+        // Process entries in the order they appear
+        for entry in entries {
+            match entry.entry_type {
+                execution_types::COMPRESSED_HEADER => {
+                    headers.push(CompressedHeader::from_entry(&entry)?);
+                }
+                execution_types::COMPRESSED_BODY => {
+                    bodies.push(CompressedBody::from_entry(&entry)?);
+                }
+                execution_types::COMPRESSED_RECEIPTS => {
+                    receipts.push(CompressedReceipts::from_entry(&entry)?);
+                }
+                execution_types::TOTAL_DIFFICULTY => {
+                    difficulties.push(TotalDifficulty::from_entry(&entry)?);
+                }
+                execution_types::ACCUMULATOR => {
+                    if accumulator.is_some() {
+                        return Err(E2sError::Ssz("Multiple accumulator entries found".to_string()));
+                    }
+                    accumulator = Some(Accumulator::from_entry(&entry)?);
+                }
+                BLOCK_INDEX => {
+                    if block_index.is_some() {
+                        return Err(E2sError::Ssz("Multiple block index entries found".to_string()));
+                    }
+                    block_index = Some(BlockIndex::from_entry(&entry)?);
+                }
+                _ => {
+                    other_entries.push(entry);
+                }
+            }
+        }
+
+        // Ensure we have matching counts for block components
+        if headers.len() != bodies.len() ||
+            headers.len() != receipts.len() ||
+            headers.len() != difficulties.len()
+        {
+            return Err(E2sError::Ssz(format!(
+            "Mismatched block component counts: headers={}, bodies={}, receipts={}, difficulties={}",
+            headers.len(), bodies.len(), receipts.len(), difficulties.len()
+        )));
+        }
+
+        let mut blocks = Vec::with_capacity(headers.len());
+        for i in 0..headers.len() {
+            blocks.push(BlockTuple::new(
+                headers[i].clone(),
+                bodies[i].clone(),
+                receipts[i].clone(),
+                difficulties[i].clone(),
+            ));
+        }
+
+        let accumulator = accumulator
+            .ok_or_else(|| E2sError::Ssz("Era1 file missing accumulator entry".to_string()))?;
+
+        let block_index = block_index
+            .ok_or_else(|| E2sError::Ssz("Era1 file missing block index entry".to_string()))?;
+
+        let mut group = Era1Group::new(blocks, accumulator, block_index.clone());
+
+        // Add other entries
+        for entry in other_entries {
+            group.add_entry(entry);
+        }
+
+        let id = Era1Id::new(
+            network_name,
+            block_index.starting_number,
+            block_index.offsets.len() as u32,
+        );
+
+        Ok(Era1File::new(group, id))
+    }
+}
+
+impl Era1Reader<File> {
+    /// Opens and reads an Era1 file from the given path
+    pub fn open<P: AsRef<Path>>(
+        path: P,
+        network_name: impl Into<String>,
+    ) -> Result<Era1File, E2sError> {
+        let file = File::open(path).map_err(E2sError::Io)?;
+        let mut reader = Self::new(file);
+        reader.read(network_name.into())
+    }
+}
diff --git a/crates/era/src/era1_types.rs b/crates/era/src/era1_types.rs
new file mode 100644
index 00000000000..22376add0bc
--- /dev/null
+++ b/crates/era/src/era1_types.rs
@@ -0,0 +1,212 @@
+//! Era1 types
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::{
+    e2s_types::{E2sError, Entry, BLOCK_INDEX},
+    execution_types::{Accumulator, BlockTuple},
+};
+use alloy_primitives::BlockNumber;
+
+/// File content in an Era1 file
+///
+/// Format: `block-tuple* | other-entries* | Accumulator | BlockIndex`
+#[derive(Debug)]
+pub struct Era1Group {
+    /// Blocks in this era1 group
+    pub blocks: Vec<BlockTuple>,
+
+    /// Other entries that don't fit into the standard categories
+    pub other_entries: Vec<Entry>,
+
+    /// Accumulator is hash tree root of block headers and difficulties
+    pub accumulator: Accumulator,
+
+    /// Block index, optional, omitted for genesis era
+    pub block_index: BlockIndex,
+}
+
+impl Era1Group {
+    /// Create a new [`Era1Group`]
+    pub fn new(blocks: Vec<BlockTuple>, accumulator: Accumulator, block_index: BlockIndex) -> Self {
+        Self { blocks, accumulator, block_index, other_entries: Vec::new() }
+    }
+    /// Add another entry to this group
+    pub fn add_entry(&mut self, entry: Entry) {
+        self.other_entries.push(entry);
+    }
+}
+
+/// [`BlockIndex`] records store offsets to data at specific block numbers
+/// from the beginning of the index record to the beginning of the corresponding data.
+///
+/// Format:
+/// `starting-(block)-number | index | index | index ... | count`
+#[derive(Debug, Clone)]
+pub struct BlockIndex {
+    /// Starting block number
+    pub starting_number: BlockNumber,
+
+    /// Offsets to data at each block number
+    pub offsets: Vec<i64>,
+}
+
+impl BlockIndex {
+    /// Create a new [`BlockIndex`]
+    pub fn new(starting_number: BlockNumber, offsets: Vec<i64>) -> Self {
+        Self { starting_number, offsets }
+    }
+
+    /// Get the offset for a specific block number
+    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
+        if block_number < self.starting_number {
+            return None;
+        }
+
+        let index = (block_number - self.starting_number) as usize;
+        self.offsets.get(index).copied()
+    }
+
+    /// Convert to an [`Entry`] for storage in an e2store file
+    pub fn to_entry(&self) -> Entry {
+        // Format: starting-(block)-number | index | index | index ... | count
+        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 8);
+
+        // Add starting block number
+        data.extend_from_slice(&self.starting_number.to_le_bytes());
+
+        // Add all offsets
+        for offset in &self.offsets {
+            data.extend_from_slice(&offset.to_le_bytes());
+        }
+
+        // Add count
+        data.extend_from_slice(&(self.offsets.len() as i64).to_le_bytes());
+
+        Entry::new(BLOCK_INDEX, data)
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != BLOCK_INDEX {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for BlockIndex: expected {:02x}{:02x}, got {:02x}{:02x}",
+                BLOCK_INDEX[0], BLOCK_INDEX[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() < 16 {
+            return Err(E2sError::Ssz(String::from(
+                "BlockIndex entry too short to contain starting block number and count",
+            )));
+        }
+
+        // Extract starting block number = first 8 bytes
+        let mut starting_number_bytes = [0u8; 8];
+        starting_number_bytes.copy_from_slice(&entry.data[0..8]);
+        let starting_number = u64::from_le_bytes(starting_number_bytes);
+
+        // Extract count = last 8 bytes
+        let mut count_bytes = [0u8; 8];
+        count_bytes.copy_from_slice(&entry.data[entry.data.len() - 8..]);
+        let count = u64::from_le_bytes(count_bytes) as usize;
+
+        // Verify that the entry has the correct size
+        let expected_size = 8 + count * 8 + 8;
+        if entry.data.len() != expected_size {
+            return Err(E2sError::Ssz(format!(
+                "BlockIndex entry has incorrect size: expected {}, got {}",
+                expected_size,
+                entry.data.len()
+            )));
+        }
+
+        // Extract all offsets
+        let mut offsets = Vec::with_capacity(count);
+        for i in 0..count {
+            let start = 8 + i * 8;
+            let end = start + 8;
+            let mut offset_bytes = [0u8; 8];
+            offset_bytes.copy_from_slice(&entry.data[start..end]);
+            offsets.push(i64::from_le_bytes(offset_bytes));
+        }
+
+        Ok(Self { starting_number, offsets })
+    }
+}
+
+/// Era1 file identifier
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Era1Id {
+    /// Network configuration name
+    pub network_name: String,
+
+    /// First block number in file
+    pub start_block: BlockNumber,
+
+    /// Number of blocks in the file
+    pub block_count: u32,
+}
+
+impl Era1Id {
+    /// Create a new [`Era1Id`]
+    pub fn new(
+        network_name: impl Into<String>,
+        start_block: BlockNumber,
+        block_count: u32,
+    ) -> Self {
+        Self { network_name: network_name.into(), start_block, block_count }
+    }
+
+    /// Convert to file name following the era1 file naming:
+    /// `<network-name>-<start-block>-<block-count>.era1`
+    /// inspired from era file naming convention in
+    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
+    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+    pub fn to_file_name(&self) -> String {
+        format!("{}-{}-{}.era1", self.network_name, self.start_block, self.block_count)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::e2s_types::BLOCK_INDEX;
+
+    #[test]
+    fn test_block_index_roundtrip() {
+        let starting_number = 1000;
+        let offsets = vec![100, 200, 300, 400, 500];
+
+        let block_index = BlockIndex::new(starting_number, offsets.clone());
+
+        let entry = block_index.to_entry();
+
+        // Validate entry type
+        assert_eq!(entry.entry_type, BLOCK_INDEX);
+
+        // Convert back to block index
+        let recovered = BlockIndex::from_entry(&entry).unwrap();
+
+        // Verify fields match
+        assert_eq!(recovered.starting_number, starting_number);
+        assert_eq!(recovered.offsets, offsets);
+    }
+
+    #[test]
+    fn test_block_index_offset_lookup() {
+        let starting_number = 1000;
+        let offsets = vec![100, 200, 300, 400, 500];
+
+        let block_index = BlockIndex::new(starting_number, offsets);
+
+        // Test valid lookups
+        assert_eq!(block_index.offset_for_block(1000), Some(100));
+        assert_eq!(block_index.offset_for_block(1002), Some(300));
+        assert_eq!(block_index.offset_for_block(1004), Some(500));
+
+        // Test out of range lookups
+        assert_eq!(block_index.offset_for_block(999), None);
+        assert_eq!(block_index.offset_for_block(1005), None);
+    }
+}
diff --git a/crates/era/src/execution_types.rs b/crates/era/src/execution_types.rs
new file mode 100644
index 00000000000..e0092c396f8
--- /dev/null
+++ b/crates/era/src/execution_types.rs
@@ -0,0 +1,588 @@
+//! Execution layer specific types for era1 files
+//!
+//! Contains implementations for compressed execution layer data structures:
+//! - [`CompressedHeader`] - Block header
+//! - [`CompressedBody`] - Block body
+//! - [`CompressedReceipts`] - Block receipts
+//! - [`TotalDifficulty`] - Block total difficulty
+//!
+//! These types use Snappy compression to match the specification.
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use alloy_consensus::{Block, BlockBody, Header};
+use alloy_primitives::{B256, U256};
+use alloy_rlp::{Decodable, Encodable};
+use snap::raw::{Decoder, Encoder};
+use std::marker::PhantomData;
+
+// Era1-specific constants
+/// `CompressedHeader` record type
+pub const COMPRESSED_HEADER: [u8; 2] = [0x03, 0x00];
+
+/// `CompressedBody` record type
+pub const COMPRESSED_BODY: [u8; 2] = [0x04, 0x00];
+
+/// `CompressedReceipts` record type
+pub const COMPRESSED_RECEIPTS: [u8; 2] = [0x05, 0x00];
+
+/// `TotalDifficulty` record type
+pub const TOTAL_DIFFICULTY: [u8; 2] = [0x06, 0x00];
+
+/// `Accumulator` record type
+pub const ACCUMULATOR: [u8; 2] = [0x07, 0x00];
+
+/// Maximum number of blocks in an Era1 file, limited by accumulator size
+pub const MAX_BLOCKS_PER_ERA1: usize = 8192;
+
+/// Generic codec for Snappy-compressed RLP data
+#[derive(Debug, Clone, Default)]
+pub struct SnappyRlpCodec<T> {
+    _phantom: PhantomData<T>,
+}
+
+impl<T> SnappyRlpCodec<T> {
+    /// Create a new codec for the given type
+    pub fn new() -> Self {
+        Self { _phantom: PhantomData }
+    }
+}
+
+impl<T: Decodable> SnappyRlpCodec<T> {
+    /// Decode compressed data into the target type
+    pub fn decode(&self, compressed_data: &[u8]) -> Result<T, E2sError> {
+        let mut decoder = Decoder::new();
+        let decompressed = decoder.decompress_vec(compressed_data).map_err(|e| {
+            E2sError::SnappyDecompression(format!("Failed to decompress data: {}", e))
+        })?;
+
+        let mut slice = decompressed.as_slice();
+        T::decode(&mut slice)
+            .map_err(|e| E2sError::Rlp(format!("Failed to decode RLP data: {}", e)))
+    }
+}
+
+impl<T: Encodable> SnappyRlpCodec<T> {
+    /// Encode data into compressed format
+    pub fn encode(&self, data: &T) -> Result<Vec<u8>, E2sError> {
+        let mut rlp_data = Vec::new();
+        data.encode(&mut rlp_data);
+
+        let mut encoder = Encoder::new();
+        encoder
+            .compress_vec(&rlp_data)
+            .map_err(|e| E2sError::SnappyCompression(format!("Failed to compress data: {}", e)))
+    }
+}
+
+/// Compressed block header using `snappyFramed(rlp(header))`
+#[derive(Debug, Clone)]
+pub struct CompressedHeader {
+    /// The compressed data
+    pub data: Vec<u8>,
+}
+
+/// Extension trait for generic decoding from compressed data
+pub trait DecodeCompressed {
+    /// Decompress and decode the data into the given type
+    fn decode<T: Decodable>(&self) -> Result<T, E2sError>;
+}
+
+impl CompressedHeader {
+    /// Create a new [`CompressedHeader`] from compressed data
+    pub fn new(data: Vec<u8>) -> Self {
+        Self { data }
+    }
+
+    /// Create from RLP-encoded header by compressing it with Snappy
+    pub fn from_rlp(rlp_data: &[u8]) -> Result<Self, E2sError> {
+        let mut encoder = Encoder::new();
+        let compressed = encoder.compress_vec(rlp_data).map_err(|e| {
+            E2sError::SnappyCompression(format!("Failed to compress header: {}", e))
+        })?;
+
+        Ok(Self { data: compressed })
+    }
+
+    /// Decompress to get the original RLP-encoded header
+    pub fn decompress(&self) -> Result<Vec<u8>, E2sError> {
+        let mut decoder = Decoder::new();
+        let decompressed = decoder.decompress_vec(&self.data).map_err(|e| {
+            E2sError::SnappyDecompression(format!("Failed to decompress header: {}", e))
+        })?;
+
+        Ok(decompressed)
+    }
+
+    /// Convert to an [`Entry`]
+    pub fn to_entry(&self) -> Entry {
+        Entry::new(COMPRESSED_HEADER, self.data.clone())
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != COMPRESSED_HEADER {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for CompressedHeader: expected {:02x}{:02x}, got {:02x}{:02x}",
+                COMPRESSED_HEADER[0],
+                COMPRESSED_HEADER[1],
+                entry.entry_type[0],
+                entry.entry_type[1]
+            )));
+        }
+
+        Ok(Self { data: entry.data.clone() })
+    }
+
+    /// Decode this compressed header into an `alloy_consensus::Header`
+    pub fn decode_header(&self) -> Result<Header, E2sError> {
+        self.decode()
+    }
+
+    /// Create a [`CompressedHeader`] from an `alloy_consensus::Header`
+    pub fn from_header(header: &Header) -> Result<Self, E2sError> {
+        let encoder = SnappyRlpCodec::<Header>::new();
+        let compressed = encoder.encode(header)?;
+        Ok(Self::new(compressed))
+    }
+}
+
+impl DecodeCompressed for CompressedHeader {
+    fn decode<T: Decodable>(&self) -> Result<T, E2sError> {
+        let decoder = SnappyRlpCodec::<T>::new();
+        decoder.decode(&self.data)
+    }
+}
+
+/// Compressed block body using `snappyFramed(rlp(body))`
+#[derive(Debug, Clone)]
+pub struct CompressedBody {
+    /// The compressed data
+    pub data: Vec<u8>,
+}
+
+impl CompressedBody {
+    /// Create a new [`CompressedBody`] from compressed data
+    pub fn new(data: Vec<u8>) -> Self {
+        Self { data }
+    }
+
+    /// Create from RLP-encoded body by compressing it with Snappy
+    pub fn from_rlp(rlp_data: &[u8]) -> Result<Self, E2sError> {
+        let mut encoder = Encoder::new();
+        let compressed = encoder
+            .compress_vec(rlp_data)
+            .map_err(|e| E2sError::SnappyCompression(format!("Failed to compress body: {}", e)))?;
+
+        Ok(Self { data: compressed })
+    }
+
+    /// Decompress to get the original RLP-encoded body
+    pub fn decompress(&self) -> Result<Vec<u8>, E2sError> {
+        let mut decoder = Decoder::new();
+        let decompressed = decoder.decompress_vec(&self.data).map_err(|e| {
+            E2sError::SnappyDecompression(format!("Failed to decompress body: {}", e))
+        })?;
+
+        Ok(decompressed)
+    }
+
+    /// Convert to an [`Entry`]
+    pub fn to_entry(&self) -> Entry {
+        Entry::new(COMPRESSED_BODY, self.data.clone())
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != COMPRESSED_BODY {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for CompressedBody: expected {:02x}{:02x}, got {:02x}{:02x}",
+                COMPRESSED_BODY[0], COMPRESSED_BODY[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        Ok(Self { data: entry.data.clone() })
+    }
+
+    /// Decode this [`CompressedBody`] into an `alloy_consensus::BlockBody`
+    pub fn decode_body<T: Decodable, H: Decodable>(&self) -> Result<BlockBody<T, H>, E2sError> {
+        self.decode()
+    }
+
+    /// Create a [`CompressedBody`] from an `alloy_consensus::BlockBody`
+    pub fn from_body<T: Encodable, H: Encodable>(body: &BlockBody<T, H>) -> Result<Self, E2sError> {
+        let encoder = SnappyRlpCodec::<BlockBody<T, H>>::new();
+        let compressed = encoder.encode(body)?;
+        Ok(Self::new(compressed))
+    }
+}
+
+impl DecodeCompressed for CompressedBody {
+    fn decode<T: Decodable>(&self) -> Result<T, E2sError> {
+        let decoder = SnappyRlpCodec::<T>::new();
+        decoder.decode(&self.data)
+    }
+}
+
+/// Compressed receipts using snappyFramed(rlp(receipts))
+#[derive(Debug, Clone)]
+pub struct CompressedReceipts {
+    /// The compressed data
+    pub data: Vec<u8>,
+}
+
+impl CompressedReceipts {
+    /// Create a new [`CompressedReceipts`] from compressed data
+    pub fn new(data: Vec<u8>) -> Self {
+        Self { data }
+    }
+
+    /// Create from RLP-encoded receipts by compressing it with Snappy
+    pub fn from_rlp(rlp_data: &[u8]) -> Result<Self, E2sError> {
+        let mut encoder = Encoder::new();
+        let compressed = encoder.compress_vec(rlp_data).map_err(|e| {
+            E2sError::SnappyCompression(format!("Failed to compress receipts: {}", e))
+        })?;
+
+        Ok(Self { data: compressed })
+    }
+
+    /// Decompress to get the original RLP-encoded receipts
+    pub fn decompress(&self) -> Result<Vec<u8>, E2sError> {
+        let mut decoder = Decoder::new();
+        let decompressed = decoder.decompress_vec(&self.data).map_err(|e| {
+            E2sError::SnappyDecompression(format!("Failed to decompress receipts: {}", e))
+        })?;
+
+        Ok(decompressed)
+    }
+
+    /// Convert to an [`Entry`]
+    pub fn to_entry(&self) -> Entry {
+        Entry::new(COMPRESSED_RECEIPTS, self.data.clone())
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != COMPRESSED_RECEIPTS {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for CompressedReceipts: expected {:02x}{:02x}, got {:02x}{:02x}",
+                COMPRESSED_RECEIPTS[0], COMPRESSED_RECEIPTS[1],
+                entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        Ok(Self { data: entry.data.clone() })
+    }
+
+    /// Decode this [`CompressedReceipts`] into the given type
+    pub fn decode<T: Decodable>(&self) -> Result<T, E2sError> {
+        let decoder = SnappyRlpCodec::<T>::new();
+        decoder.decode(&self.data)
+    }
+
+    /// Create [`CompressedReceipts`] from an encodable type
+    pub fn from_encodable<T: Encodable>(data: &T) -> Result<Self, E2sError> {
+        let encoder = SnappyRlpCodec::<T>::new();
+        let compressed = encoder.encode(data)?;
+        Ok(Self::new(compressed))
+    }
+}
+
+impl DecodeCompressed for CompressedReceipts {
+    fn decode<T: Decodable>(&self) -> Result<T, E2sError> {
+        let decoder = SnappyRlpCodec::<T>::new();
+        decoder.decode(&self.data)
+    }
+}
+
+/// Total difficulty for a block
+#[derive(Debug, Clone)]
+pub struct TotalDifficulty {
+    /// The total difficulty as U256
+    pub value: U256,
+}
+
+impl TotalDifficulty {
+    /// Create a new [`TotalDifficulty`] from a U256 value
+    pub fn new(value: U256) -> Self {
+        Self { value }
+    }
+
+    /// Convert to an [`Entry`]
+    pub fn to_entry(&self) -> Entry {
+        let mut data = [0u8; 32];
+
+        let be_bytes = self.value.to_be_bytes_vec();
+
+        if be_bytes.len() <= 32 {
+            data[32 - be_bytes.len()..].copy_from_slice(&be_bytes);
+        } else {
+            data.copy_from_slice(&be_bytes[be_bytes.len() - 32..]);
+        }
+
+        Entry::new(TOTAL_DIFFICULTY, data.to_vec())
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != TOTAL_DIFFICULTY {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for TotalDifficulty: expected {:02x}{:02x}, got {:02x}{:02x}",
+                TOTAL_DIFFICULTY[0], TOTAL_DIFFICULTY[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() != 32 {
+            return Err(E2sError::Ssz(format!(
+                "Invalid data length for TotalDifficulty: expected 32, got {}",
+                entry.data.len()
+            )));
+        }
+
+        // Convert 32-byte array to U256
+        let value = U256::from_be_slice(&entry.data);
+
+        Ok(Self { value })
+    }
+}
+
+/// Accumulator is computed by constructing an SSZ list of header-records
+/// and calculating the `hash_tree_root`
+#[derive(Debug, Clone)]
+pub struct Accumulator {
+    /// The accumulator root hash
+    pub root: B256,
+}
+
+impl Accumulator {
+    /// Create a new [`Accumulator`] from a root hash
+    pub fn new(root: B256) -> Self {
+        Self { root }
+    }
+
+    /// Convert to an [`Entry`]
+    pub fn to_entry(&self) -> Entry {
+        Entry::new(ACCUMULATOR, self.root.to_vec())
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != ACCUMULATOR {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for Accumulator: expected {:02x}{:02x}, got {:02x}{:02x}",
+                ACCUMULATOR[0], ACCUMULATOR[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() != 32 {
+            return Err(E2sError::Ssz(format!(
+                "Invalid data length for Accumulator: expected 32, got {}",
+                entry.data.len()
+            )));
+        }
+
+        let mut root = [0u8; 32];
+        root.copy_from_slice(&entry.data);
+
+        Ok(Self { root: B256::from(root) })
+    }
+}
+
+/// A block tuple in an Era1 file, containing all components for a single block
+#[derive(Debug, Clone)]
+pub struct BlockTuple {
+    /// Compressed block header
+    pub header: CompressedHeader,
+
+    /// Compressed block body
+    pub body: CompressedBody,
+
+    /// Compressed receipts
+    pub receipts: CompressedReceipts,
+
+    /// Total difficulty
+    pub total_difficulty: TotalDifficulty,
+}
+
+impl BlockTuple {
+    /// Create a new [`BlockTuple`]
+    pub fn new(
+        header: CompressedHeader,
+        body: CompressedBody,
+        receipts: CompressedReceipts,
+        total_difficulty: TotalDifficulty,
+    ) -> Self {
+        Self { header, body, receipts, total_difficulty }
+    }
+
+    /// Convert to an `alloy_consensus::Block`
+    pub fn to_alloy_block<T: Decodable>(&self) -> Result<Block<T>, E2sError> {
+        let header: Header = self.header.decode()?;
+        let body: BlockBody<T> = self.body.decode()?;
+
+        Ok(Block::new(header, body))
+    }
+
+    /// Create from an `alloy_consensus::Block`
+    pub fn from_alloy_block<T: Encodable, R: Encodable>(
+        block: &Block<T>,
+        receipts: &R,
+        total_difficulty: U256,
+    ) -> Result<Self, E2sError> {
+        let header = CompressedHeader::from_header(&block.header)?;
+        let body = CompressedBody::from_body(&block.body)?;
+
+        let compressed_receipts = CompressedReceipts::from_encodable(receipts)?;
+
+        let difficulty = TotalDifficulty::new(total_difficulty);
+
+        Ok(Self::new(header, body, compressed_receipts, difficulty))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use alloy_eips::eip4895::Withdrawals;
+    use alloy_primitives::{Address, Bytes, B64};
+
+    #[test]
+    fn test_header_conversion_roundtrip() {
+        let header = Header {
+            parent_hash: B256::default(),
+            ommers_hash: B256::default(),
+            beneficiary: Address::default(),
+            state_root: B256::default(),
+            transactions_root: B256::default(),
+            receipts_root: B256::default(),
+            logs_bloom: Default::default(),
+            difficulty: U256::from(123456u64),
+            number: 100,
+            gas_limit: 5000000,
+            gas_used: 21000,
+            timestamp: 1609459200,
+            extra_data: Bytes::default(),
+            mix_hash: B256::default(),
+            nonce: B64::default(),
+            base_fee_per_gas: Some(10),
+            withdrawals_root: None,
+            blob_gas_used: None,
+            excess_blob_gas: None,
+            parent_beacon_block_root: None,
+            requests_hash: None,
+        };
+
+        let compressed_header = CompressedHeader::from_header(&header).unwrap();
+
+        let decoded_header = compressed_header.decode_header().unwrap();
+
+        assert_eq!(header.number, decoded_header.number);
+        assert_eq!(header.difficulty, decoded_header.difficulty);
+        assert_eq!(header.timestamp, decoded_header.timestamp);
+        assert_eq!(header.gas_used, decoded_header.gas_used);
+        assert_eq!(header.parent_hash, decoded_header.parent_hash);
+        assert_eq!(header.base_fee_per_gas, decoded_header.base_fee_per_gas);
+    }
+
+    #[test]
+    fn test_block_body_conversion() {
+        let block_body: BlockBody<Bytes> =
+            BlockBody { transactions: vec![], ommers: vec![], withdrawals: None };
+
+        let compressed_body = CompressedBody::from_body(&block_body).unwrap();
+
+        let decoded_body: BlockBody<Bytes> = compressed_body.decode_body().unwrap();
+
+        assert_eq!(decoded_body.transactions.len(), 0);
+        assert_eq!(decoded_body.ommers.len(), 0);
+        assert_eq!(decoded_body.withdrawals, None);
+    }
+
+    #[test]
+    fn test_total_difficulty_roundtrip() {
+        let value = U256::from(123456789u64);
+
+        let total_difficulty = TotalDifficulty::new(value);
+
+        let entry = total_difficulty.to_entry();
+
+        assert_eq!(entry.entry_type, TOTAL_DIFFICULTY);
+
+        let recovered = TotalDifficulty::from_entry(&entry).unwrap();
+
+        assert_eq!(recovered.value, value);
+    }
+
+    #[test]
+    fn test_compression_roundtrip() {
+        let rlp_data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+        // Test header compression/decompression
+        let compressed_header = CompressedHeader::from_rlp(&rlp_data).unwrap();
+        let decompressed = compressed_header.decompress().unwrap();
+        assert_eq!(decompressed, rlp_data);
+
+        // Test body compression/decompression
+        let compressed_body = CompressedBody::from_rlp(&rlp_data).unwrap();
+        let decompressed = compressed_body.decompress().unwrap();
+        assert_eq!(decompressed, rlp_data);
+
+        // Test receipts compression/decompression
+        let compressed_receipts = CompressedReceipts::from_rlp(&rlp_data).unwrap();
+        let decompressed = compressed_receipts.decompress().unwrap();
+        assert_eq!(decompressed, rlp_data);
+    }
+
+    #[test]
+    fn test_block_tuple_with_data() {
+        // Create block with transactions and withdrawals
+        let header = Header {
+            parent_hash: B256::default(),
+            ommers_hash: B256::default(),
+            beneficiary: Address::default(),
+            state_root: B256::default(),
+            transactions_root: B256::default(),
+            receipts_root: B256::default(),
+            logs_bloom: Default::default(),
+            difficulty: U256::from(123456u64),
+            number: 100,
+            gas_limit: 5000000,
+            gas_used: 21000,
+            timestamp: 1609459200,
+            extra_data: Bytes::default(),
+            mix_hash: B256::default(),
+            nonce: B64::default(),
+            base_fee_per_gas: Some(10),
+            withdrawals_root: Some(B256::default()),
+            blob_gas_used: None,
+            excess_blob_gas: None,
+            parent_beacon_block_root: None,
+            requests_hash: None,
+        };
+
+        let transactions = vec![Bytes::from(vec![1, 2, 3, 4]), Bytes::from(vec![5, 6, 7, 8])];
+
+        let withdrawals = Some(Withdrawals(vec![]));
+
+        let block_body = BlockBody { transactions, ommers: vec![], withdrawals };
+
+        let block = Block::new(header, block_body);
+
+        let receipts: Vec<u8> = Vec::new();
+
+        let block_tuple =
+            BlockTuple::from_alloy_block(&block, &receipts, U256::from(123456u64)).unwrap();
+
+        // Convert back to Block
+        let decoded_block: Block<Bytes> = block_tuple.to_alloy_block().unwrap();
+
+        // Verify block components
+        assert_eq!(decoded_block.header.number, 100);
+        assert_eq!(decoded_block.body.transactions.len(), 2);
+        assert_eq!(decoded_block.body.transactions[0], Bytes::from(vec![1, 2, 3, 4]));
+        assert_eq!(decoded_block.body.transactions[1], Bytes::from(vec![5, 6, 7, 8]));
+        assert!(decoded_block.body.withdrawals.is_some());
+    }
+}
diff --git a/crates/era/src/lib.rs b/crates/era/src/lib.rs
new file mode 100644
index 00000000000..6007da18738
--- /dev/null
+++ b/crates/era/src/lib.rs
@@ -0,0 +1,19 @@
+//! Era and Era1 files support for Ethereum history expiry.
+//!
+//!
+//! Era files are special instances of .e2s files with a strict content format
+//! optimized for reading and long-term storage and distribution.
+//!
+//! Era1 files use the same e2store foundation but are specialized for
+//! execution layer block history, following the format:
+//! Version | block-tuple* | other-entries* | Accumulator | `BlockIndex`
+//!
+//! See also:
+//! - E2store format: <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md>
+//! - Era1 format: <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+pub mod e2s_file;
+pub mod e2s_types;
+pub mod era1_file;
+pub mod era1_types;
+pub mod execution_types;


Reviews:
user: login: mattsse
body: this is looking great,

we can either continue on this pr but I'm also okay with merging this as is and doing more incremental changes next
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T13:16:52Z
user: login: mattsse
body: this looks fantastic,

only have some nits
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T09:58:19Z
user: login: lean-apple
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T05:23:38Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Files not reviewed (1)</summary>

* **.github/CODEOWNERS**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**crates/era/src/era1_types.rs:99**
* The error message refers to 'SlotIndex' instead of 'BlockIndex', which is inconsistent with the struct name. Consider updating the message to 'BlockIndex entry too short to contain starting block and count'.
```
return Err(E2sError::Ssz(String::from("SlotIndex entry too short to contain starting slot and count")));
```
**crates/era/src/era1_types.rs:117**
* The error message still uses 'SlotIndex' instead of 'BlockIndex'. Update the message for clarity and consistency with the data structure's naming.
```
return Err(E2sError::Ssz(format!("SlotIndex entry has incorrect size: expected {}, got {}", expected_size, entry.data.len())));
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T10:50:13Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

<details>
<summary>Files not reviewed (1)</summary>

* **.github/CODEOWNERS**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**crates/era/src/era1_types.rs:99**
* Consider updating the error message to use consistent terminology, e.g. change 'starting slot' to 'starting block number' to match the struct field naming.
```
return Err(E2sError::Ssz(String::from("BlockIndex entry too short to contain starting slot and count")));
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:58:17Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

<details>
<summary>Files not reviewed (1)</summary>

* **.github/CODEOWNERS**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**crates/era/src/era1_types.rs:99**
* The error message refers to a "starting slot" while the field is named "starting_number" (or "starting block number"). Consider updating the error message for clarity and consistency.
```
return Err(E2sError::Ssz(String::from("BlockIndex entry too short to contain starting slot and count")));
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:59:47Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Files not reviewed (1)</summary>

* **.github/CODEOWNERS**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**crates/era/src/era1_types.rs:72**
* [nitpick] The comment refers to 'starting-slot' while the field is named 'starting_number'. Consider updating the comment to use 'starting_number' or 'starting block' for consistency.
```
        // Format: starting-slot | index | index | index ... | count
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T18:09:01Z
user: login: copilot-pull-request-reviewer[bot]
body: 

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

<details>
<summary>Files not reviewed (1)</summary>

* **.github/CODEOWNERS**: Language not supported
</details>

<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**crates/era/src/e2s_file.rs:63**
* [nitpick] Consider using valid BlockIndex entry data that meets the minimum length requirement (at least 16 bytes) to ensure the test accurately reflects the parsing behavior in production.
```
let block_index_entry1 = Entry::new(BLOCK_INDEX, vec![1, 2, 3, 4]);
```
**crates/era/src/e2s_file.rs:66**
* [nitpick] Consider updating this test entry for BLOCK_INDEX with correctly formatted data to mirror realistic scenarios and prevent false positives during BlockIndex parsing.
```
let block_index_entry2 = Entry::new(BLOCK_INDEX, vec![5, 6, 7, 8, 9]);
```
**crates/era/src/era1_file.rs:99**
* [nitpick] Consider adding tests to cover error paths in the read method, such as handling multiple accumulator entries or mismatched block component counts.
```
for entry in entries {
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T18:16:42Z
user: login: mattsse
body: cool, tysm!

I think we should include this now and do smaller more incremental changes with this because this is a very solid foundation
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-04T18:31:58Z
user: login: lean-apple
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T17:59:30Z

Review comments:
diff_hunk: @@ -0,0 +1,167 @@
+//! Types to build e2store files
+//! ie. with .e2s extension
+//!
+//! e2store contains header and entry
+//!
+//! The [`Header`] is an 8-byte structure at the beginning of each record in the file
+//!
+//! An [`Entry`] is a complete record in the file, consisting of both a [`Header`] and its
+//! associated data
+
+use ssz_derive::{Decode, Encode};
+use std::io::{self, Read, Write};
+use thiserror::Error;
+
+/// [`Version`] record: ['e', '2']
+pub const VERSION: [u8; 2] = [0x65, 0x32];
+
+/// Empty record
+pub const EMPTY: [u8; 2] = [0x00, 0x00];
+
+/// `BlockIndex` record: ['i', '2']
+pub const BLOCK_INDEX: [u8; 2] = [0x66, 0x32];
+
+/// Error types for e2s file operations
+#[derive(Error, Debug)]
+pub enum E2sError {
+    /// IO error during file operations
+    #[error("IO error: {0}")]
+    Io(#[from] io::Error),
+
+    /// Error during SSZ encoding/decoding
+    #[error("SSZ error")]
+    Ssz(String),
+
+    /// Reserved field in header not zero
+    #[error("Reserved field in header not zero")]
+    ReservedNotZero,
+
+    /// Error during snappy compression
+    #[error("Snappy compression error: {0}")]
+    SnappyCompression(String),
+
+    /// Error during snappy decompression
+    #[error("Snappy decompression error: {0}")]
+    SnappyDecompression(String),
+
+    /// Error during RLP encoding/decoding
+    #[error("RLP error: {0}")]
+    Rlp(String),
+}
+
+/// Header for TLV records in e2store files
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct Header {
+    /// Record type identifier
+    pub header_type: [u8; 2],
+
+    /// Length of data following the header
+    pub length: u32,
+
+    /// Reserved field, must be zero
+    pub reserved: u16,
+}
+
+impl Header {
+    /// Create a new header with the specified type and length
+    pub fn new(header_type: [u8; 2], length: u32) -> Self {
+        Self { header_type, length, reserved: 0 }
+    }
+
+    /// Read header from a reader
+    pub fn read<R: Read>(reader: &mut R) -> Result<Option<Self>, E2sError> {
+        let mut header_bytes = [0u8; 8];
+        match reader.read_exact(&mut header_bytes) {
+            Ok(_) => {}
+            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
+            Err(e) => return Err(e.into()),
+        }
+
+        let header: Self = match ssz::Decode::from_ssz_bytes(&header_bytes) {
+            Ok(h) => h,
+            Err(_) => return Err(E2sError::Ssz(String::from("Failed to decode SSZ header"))),
+        };
+
+        if header.reserved != 0 {
+            return Err(E2sError::ReservedNotZero);
+        }
+
+        Ok(Some(header))
+    }
+
+    /// Writes the header to the given writer.
+    pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
path: crates/era/src/e2s_types.rs
user: login: mattsse
body: these are quite simple and easy to follow
created_at: 2025-04-03T09:44:32Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,219 @@
+//! Era1 types
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::{
+    e2s_types::{E2sError, Entry, BLOCK_INDEX},
+    execution_types::{Accumulator, BlockTuple},
+};
+use alloy_primitives::{BlockNumber, ChainId};
+
+/// [`BlockIndex`] records store offsets to data at specific block numbers
+/// from the beginning of the index record to the beginning of the corresponding data.
+///
+/// Format:
+/// `starting-number | index | index | index ... | count`
+#[derive(Debug, Clone)]
+pub struct BlockIndex {
+    /// Starting block number
+    pub starting_number: BlockNumber,
+
+    /// Offsets to data at each block number
+    pub offsets: Vec<i64>,
+}
+
+impl BlockIndex {
+    /// Create a new [`BlockIndex`]
+    pub fn new(starting_number: BlockNumber, offsets: Vec<i64>) -> Self {
+        Self { starting_number, offsets }
+    }
+
+    /// Get the offset for a specific slot
+    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
+        if block_number < self.starting_number {
+            return None;
+        }
+
+        let index = (block_number - self.starting_number) as usize;
+        self.offsets.get(index).copied()
+    }
+
+    /// Convert to an [`Entry`] for storage in an e2store file
+    pub fn to_entry(&self) -> Entry {
+        // Format: starting-slot | index | index | index ... | count
+        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 8);
+
+        // Add starting block number
+        data.extend_from_slice(&self.starting_number.to_le_bytes());
+
+        // Add all offsets
+        for offset in &self.offsets {
+            data.extend_from_slice(&offset.to_le_bytes());
+        }
+
+        // Add count
+        data.extend_from_slice(&(self.offsets.len() as i64).to_le_bytes());
+
+        Entry::new(BLOCK_INDEX, data)
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != BLOCK_INDEX {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for BlockIndex: expected {:02x}{:02x}, got {:02x}{:02x}",
+                BLOCK_INDEX[0], BLOCK_INDEX[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() < 16 {
+            return Err(E2sError::Ssz(String::from(
+                "SlotIndex entry too short to contain starting slot and count",
+            )));
+        }
+
+        // Extract starting slot = first 8 bytes
+        let mut starting_number_bytes = [0u8; 8];
+        starting_number_bytes.copy_from_slice(&entry.data[0..8]);
+        let starting_number = u64::from_le_bytes(starting_number_bytes);
+
+        // Extract count = last 8 bytes
+        let mut count_bytes = [0u8; 8];
+        count_bytes.copy_from_slice(&entry.data[entry.data.len() - 8..]);
+        let count = u64::from_le_bytes(count_bytes) as usize;
+
+        // Verify that the entry has the correct size
+        let expected_size = 8 + count * 8 + 8;
+        if entry.data.len() != expected_size {
+            return Err(E2sError::Ssz(format!(
+                "SlotIndex entry has incorrect size: expected {}, got {}",
+                expected_size,
+                entry.data.len()
+            )));
+        }
+
+        // Extract all offsets
+        let mut offsets = Vec::with_capacity(count);
+        for i in 0..count {
+            let start = 8 + i * 8;
+            let end = start + 8;
+            let mut offset_bytes = [0u8; 8];
+            offset_bytes.copy_from_slice(&entry.data[start..end]);
+            offsets.push(i64::from_le_bytes(offset_bytes));
+        }
+
+        Ok(Self { starting_number, offsets })
+    }
+}
+
+/// Era1 file identifier
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Era1Id {
+    /// Network configuration name
+    pub config_name: String,
+
+    /// First block number in file
+    pub start_block: BlockNumber,
+
+    /// Number of blocks in the file
+    pub block_count: u32,
+
+    /// Chain identifier
+    pub chain_id: ChainId,
+}
+
+impl Era1Id {
+    /// Create a new [`Era1Id`]
+    pub fn new(
+        config_name: impl Into<String>,
+        start_block: BlockNumber,
+        block_count: u32,
+        chain_id: ChainId,
+    ) -> Self {
+        Self { config_name: config_name.into(), start_block, block_count, chain_id }
+    }
+
+    /// Convert to file name following the era1 file naming:
+    /// `<config-name>-<start-block>-<block-count>-<chain-id>.era1`
+    /// inspired from era file naming convention in
+    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
+    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+    pub fn to_file_name(&self) -> String {
+        format!(
+            "{}-{:10}-{:02}-{}.era1",
+            self.config_name, self.start_block, self.block_count, self.chain_id
+        )
+    }
+}
+
+/// Group in an Era1 file
+///
+/// Format: `Version | block-tuple* | other-entries* | Accumulator | BlockIndex`
+#[derive(Debug)]
+pub struct Era1Group {
path: crates/era/src/era1_types.rs
user: login: mattsse
body: this is the main type, right?

can we move this top of file?
created_at: 2025-04-03T09:50:55Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,219 @@
+//! Era1 types
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::{
+    e2s_types::{E2sError, Entry, BLOCK_INDEX},
+    execution_types::{Accumulator, BlockTuple},
+};
+use alloy_primitives::{BlockNumber, ChainId};
+
+/// [`BlockIndex`] records store offsets to data at specific block numbers
+/// from the beginning of the index record to the beginning of the corresponding data.
+///
+/// Format:
+/// `starting-number | index | index | index ... | count`
+#[derive(Debug, Clone)]
+pub struct BlockIndex {
+    /// Starting block number
+    pub starting_number: BlockNumber,
+
+    /// Offsets to data at each block number
+    pub offsets: Vec<i64>,
+}
+
+impl BlockIndex {
+    /// Create a new [`BlockIndex`]
+    pub fn new(starting_number: BlockNumber, offsets: Vec<i64>) -> Self {
+        Self { starting_number, offsets }
+    }
+
+    /// Get the offset for a specific slot
+    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
+        if block_number < self.starting_number {
+            return None;
+        }
+
+        let index = (block_number - self.starting_number) as usize;
+        self.offsets.get(index).copied()
+    }
+
+    /// Convert to an [`Entry`] for storage in an e2store file
+    pub fn to_entry(&self) -> Entry {
+        // Format: starting-slot | index | index | index ... | count
+        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 8);
+
+        // Add starting block number
+        data.extend_from_slice(&self.starting_number.to_le_bytes());
+
+        // Add all offsets
+        for offset in &self.offsets {
+            data.extend_from_slice(&offset.to_le_bytes());
+        }
+
+        // Add count
+        data.extend_from_slice(&(self.offsets.len() as i64).to_le_bytes());
+
+        Entry::new(BLOCK_INDEX, data)
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != BLOCK_INDEX {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for BlockIndex: expected {:02x}{:02x}, got {:02x}{:02x}",
+                BLOCK_INDEX[0], BLOCK_INDEX[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() < 16 {
+            return Err(E2sError::Ssz(String::from(
+                "SlotIndex entry too short to contain starting slot and count",
+            )));
+        }
+
+        // Extract starting slot = first 8 bytes
+        let mut starting_number_bytes = [0u8; 8];
+        starting_number_bytes.copy_from_slice(&entry.data[0..8]);
+        let starting_number = u64::from_le_bytes(starting_number_bytes);
+
+        // Extract count = last 8 bytes
+        let mut count_bytes = [0u8; 8];
+        count_bytes.copy_from_slice(&entry.data[entry.data.len() - 8..]);
+        let count = u64::from_le_bytes(count_bytes) as usize;
+
+        // Verify that the entry has the correct size
+        let expected_size = 8 + count * 8 + 8;
+        if entry.data.len() != expected_size {
+            return Err(E2sError::Ssz(format!(
+                "SlotIndex entry has incorrect size: expected {}, got {}",
+                expected_size,
+                entry.data.len()
+            )));
+        }
+
+        // Extract all offsets
+        let mut offsets = Vec::with_capacity(count);
+        for i in 0..count {
+            let start = 8 + i * 8;
+            let end = start + 8;
+            let mut offset_bytes = [0u8; 8];
+            offset_bytes.copy_from_slice(&entry.data[start..end]);
+            offsets.push(i64::from_le_bytes(offset_bytes));
+        }
+
+        Ok(Self { starting_number, offsets })
+    }
+}
+
+/// Era1 file identifier
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Era1Id {
+    /// Network configuration name
+    pub config_name: String,
+
+    /// First block number in file
+    pub start_block: BlockNumber,
+
+    /// Number of blocks in the file
+    pub block_count: u32,
+
+    /// Chain identifier
+    pub chain_id: ChainId,
+}
+
+impl Era1Id {
+    /// Create a new [`Era1Id`]
+    pub fn new(
+        config_name: impl Into<String>,
+        start_block: BlockNumber,
+        block_count: u32,
+        chain_id: ChainId,
+    ) -> Self {
+        Self { config_name: config_name.into(), start_block, block_count, chain_id }
+    }
+
+    /// Convert to file name following the era1 file naming:
+    /// `<config-name>-<start-block>-<block-count>-<chain-id>.era1`
+    /// inspired from era file naming convention in
+    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
+    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+    pub fn to_file_name(&self) -> String {
+        format!(
+            "{}-{:10}-{:02}-{}.era1",
+            self.config_name, self.start_block, self.block_count, self.chain_id
+        )
+    }
+}
+
+/// Group in an Era1 file
+///
+/// Format: `Version | block-tuple* | other-entries* | Accumulator | BlockIndex`
+#[derive(Debug)]
+pub struct Era1Group {
+    /// Blocks in this era1 group
+    pub blocks: Vec<BlockTuple>,
+
+    /// Other entries that don't fit into the standard categories
+    pub other_entries: Vec<Entry>,
+
+    /// Accumulator is hash tree root of block headers and difficulties
+    pub accumulator: Accumulator,
+
+    /// Block index, optional, omitted for genesis era
+    pub block_index: BlockIndex,
+}
+
+impl Era1Group {
path: crates/era/src/era1_types.rs
user: login: mattsse
body: I think here we also want a helper type that reads this from a given file for convenience
created_at: 2025-04-03T09:51:47Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,597 @@
+//! Execution layer specific types for era1 files
+//!
+//! Contains implementations for compressed execution layer data structures:
+//! - [`CompressedHeader`] - Block header
+//! - [`CompressedBody`] - Block body
+//! - [`CompressedReceipts`] - Block receipts
+//! - [`TotalDifficulty`] - Block total difficulty
+//!
+//! These types use Snappy compression to match the specification.
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use alloy_consensus::{Block, BlockBody, Header};
+use alloy_primitives::{B256, U256};
+use alloy_rlp::{Decodable, Encodable};
+use snap::raw::{Decoder, Encoder};
+use std::marker::PhantomData;
+
+// Era1-specific constants
+/// `CompressedHeader` record type
+pub const COMPRESSED_HEADER: [u8; 2] = [0x03, 0x00];
+
+/// `CompressedBody` record type
+pub const COMPRESSED_BODY: [u8; 2] = [0x04, 0x00];
+
+/// `CompressedReceipts` record type
+pub const COMPRESSED_RECEIPTS: [u8; 2] = [0x05, 0x00];
+
+/// `TotalDifficulty` record type
+pub const TOTAL_DIFFICULTY: [u8; 2] = [0x06, 0x00];
+
+/// `Accumulator` record type
+pub const ACCUMULATOR: [u8; 2] = [0x07, 0x00];
+
+/// Maximum number of blocks in an Era1 file, limited by accumulator size
+pub const MAX_BLOCKS_PER_ERA1: usize = 8192;
+
+/// Generic decoder for Snappy-compressed RLP data
+#[derive(Debug, Clone, Default)]
+pub struct SnappyRlpDecoder<T> {
+    _phantom: PhantomData<T>,
+}
+
+impl<T: Decodable> SnappyRlpDecoder<T> {
+    /// Create a new decoder for the given type
+    pub fn new() -> Self {
+        Self { _phantom: PhantomData }
+    }
+
+    /// Decode compressed data into the target type
+    pub fn decode(&self, compressed_data: &[u8]) -> Result<T, E2sError> {
+        let mut decoder = Decoder::new();
path: crates/era/src/execution_types.rs
user: login: mattsse
body: this is fine because the default encoder is just a ZST
created_at: 2025-04-03T09:52:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,597 @@
+//! Execution layer specific types for era1 files
+//!
+//! Contains implementations for compressed execution layer data structures:
+//! - [`CompressedHeader`] - Block header
+//! - [`CompressedBody`] - Block body
+//! - [`CompressedReceipts`] - Block receipts
+//! - [`TotalDifficulty`] - Block total difficulty
+//!
+//! These types use Snappy compression to match the specification.
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use alloy_consensus::{Block, BlockBody, Header};
+use alloy_primitives::{B256, U256};
+use alloy_rlp::{Decodable, Encodable};
+use snap::raw::{Decoder, Encoder};
+use std::marker::PhantomData;
+
+// Era1-specific constants
+/// `CompressedHeader` record type
+pub const COMPRESSED_HEADER: [u8; 2] = [0x03, 0x00];
+
+/// `CompressedBody` record type
+pub const COMPRESSED_BODY: [u8; 2] = [0x04, 0x00];
+
+/// `CompressedReceipts` record type
+pub const COMPRESSED_RECEIPTS: [u8; 2] = [0x05, 0x00];
+
+/// `TotalDifficulty` record type
+pub const TOTAL_DIFFICULTY: [u8; 2] = [0x06, 0x00];
+
+/// `Accumulator` record type
+pub const ACCUMULATOR: [u8; 2] = [0x07, 0x00];
+
+/// Maximum number of blocks in an Era1 file, limited by accumulator size
+pub const MAX_BLOCKS_PER_ERA1: usize = 8192;
+
+/// Generic decoder for Snappy-compressed RLP data
+#[derive(Debug, Clone, Default)]
+pub struct SnappyRlpDecoder<T> {
+    _phantom: PhantomData<T>,
+}
+
+impl<T: Decodable> SnappyRlpDecoder<T> {
+    /// Create a new decoder for the given type
+    pub fn new() -> Self {
+        Self { _phantom: PhantomData }
+    }
+
+    /// Decode compressed data into the target type
+    pub fn decode(&self, compressed_data: &[u8]) -> Result<T, E2sError> {
+        let mut decoder = Decoder::new();
+        let decompressed = decoder.decompress_vec(compressed_data).map_err(|e| {
+            E2sError::SnappyDecompression(format!("Failed to decompress data: {}", e))
+        })?;
+
+        let mut slice = decompressed.as_slice();
+        T::decode(&mut slice)
+            .map_err(|e| E2sError::Rlp(format!("Failed to decode RLP data: {}", e)))
+    }
+}
+
+/// Generic encoder for RLP data to be Snappy-compressed
+#[derive(Debug, Clone, Default)]
+pub struct SnappyRlpEncoder<T> {
path: crates/era/src/execution_types.rs
user: login: mattsse
body: should we combine both types into a single `SnappyRlpCodec` type instead?
created_at: 2025-04-03T09:53:54Z
author_association: COLLABORATOR
path: crates/era/src/e2s_types.rs
user: login: mattsse
body: I think for E2store we need some kind of stream like type that wraps a `Read` with a BufReader and can pop all entries from the reader

like 

```
struct E2StoreReader<R:Read> {reader: BufReader<R>}
```

and same thing for a writer, but we can also tackle this next
created_at: 2025-04-03T09:58:16Z
author_association: COLLABORATOR
path: crates/era/src/e2s_types.rs
user: login: lean-apple
body: Added, do we want also in this PR the impl to read the file, 
 
```
impl E2StoreReader<File> {}
```

given a path, or we want to wait to do it from a http client and download like mentioned in this issue https://github.com/paradigmxyz/reth/issues/15496 ? 
created_at: 2025-04-04T05:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+//! `E2Store` file reader
+//!
+//! See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use std::io::{BufReader, Read, Seek, SeekFrom};
+
+/// A reader for `E2Store` files that wraps a [`BufReader`]
+#[derive(Debug)]
+pub struct E2StoreReader<R: Read> {
+    reader: BufReader<R>,
+}
+
+impl<R: Read + Seek> E2StoreReader<R> {
+    /// Create a new [`E2StoreReader`]
+    pub fn new(reader: R) -> Self {
+        Self { reader: BufReader::new(reader) }
+    }
+
+    /// Read and validate the version record
+    pub fn read_version(&mut self) -> Result<Option<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        match Entry::read(&mut self.reader)? {
+            Some(entry) if entry.is_version() => Ok(Some(entry)),
+            Some(_) => Err(E2sError::Ssz("First entry must be a Version entry".to_string())),
+            None => Ok(None),
+        }
+    }
+
+    /// Iterate through all entries, including the version entry
+    pub fn entries(&mut self) -> Result<Vec<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        let mut entries = Vec::new();
+
+        while let Some(entry) = Entry::read(&mut self.reader)? {
+            entries.push(entry);
+        }
+
+        Ok(entries)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::e2s_types::{BLOCK_INDEX, VERSION};
+    use std::io::Cursor;
+
+    #[test]
+    fn test_e2store_reader() -> Result<(), E2sError> {
+        // Create a mock e2store file in memory
+        let mut mock_file = Vec::new();
+
+        // Write version entry
+        let version_entry = Entry::new(VERSION, Vec::new());
+        version_entry.write(&mut mock_file)?;
+
+        // Write multiple entries of different types
+        let block_index_entry1 = Entry::new(BLOCK_INDEX, vec![1, 2, 3, 4]);
+        block_index_entry1.write(&mut mock_file)?;
+
+        let block_index_entry2 = Entry::new(BLOCK_INDEX, vec![5, 6, 7, 8, 9]);
+        block_index_entry2.write(&mut mock_file)?;
+
+        let custom_type = [0x99, 0x99]; // Application-specific type
+        let custom_entry = Entry::new(custom_type, vec![10, 11, 12]);
+        custom_entry.write(&mut mock_file)?;
+
+        // Create reader and process entries
+        let cursor = Cursor::new(mock_file);
+        let mut e2store_reader = E2StoreReader::new(cursor);
+
+        let version = e2store_reader.read_version()?;
+        assert!(version.is_some());
+
+        let entries = e2store_reader.entries()?;
+
+        // Validate entries
+        assert_eq!(entries.len(), 4);
+        // First entry sould be version
path: crates/era/src/e2s_file.rs
user: login: Copilot
body: Typo in comment: 'sould' should be corrected to 'should'.
```suggestion
        // First entry should be version
```
created_at: 2025-04-04T17:58:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,212 @@
+//! Era1 types
+//!
+//! See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+
+use crate::{
+    e2s_types::{E2sError, Entry, BLOCK_INDEX},
+    execution_types::{Accumulator, BlockTuple},
+};
+use alloy_primitives::BlockNumber;
+
+/// File content in an Era1 file
+///
+/// Format: `block-tuple* | other-entries* | Accumulator | BlockIndex`
+#[derive(Debug)]
+pub struct Era1Group {
+    /// Blocks in this era1 group
+    pub blocks: Vec<BlockTuple>,
+
+    /// Other entries that don't fit into the standard categories
+    pub other_entries: Vec<Entry>,
+
+    /// Accumulator is hash tree root of block headers and difficulties
+    pub accumulator: Accumulator,
+
+    /// Block index, optional, omitted for genesis era
+    pub block_index: BlockIndex,
+}
+
+impl Era1Group {
+    /// Create a new [`Era1Group`]
+    pub fn new(blocks: Vec<BlockTuple>, accumulator: Accumulator, block_index: BlockIndex) -> Self {
+        Self { blocks, accumulator, block_index, other_entries: Vec::new() }
+    }
+    /// Add another entry to this group
+    pub fn add_entry(&mut self, entry: Entry) {
+        self.other_entries.push(entry);
+    }
+}
+
+/// [`BlockIndex`] records store offsets to data at specific block numbers
+/// from the beginning of the index record to the beginning of the corresponding data.
+///
+/// Format:
+/// `starting-number | index | index | index ... | count`
+#[derive(Debug, Clone)]
+pub struct BlockIndex {
+    /// Starting block number
+    pub starting_number: BlockNumber,
+
+    /// Offsets to data at each block number
+    pub offsets: Vec<i64>,
+}
+
+impl BlockIndex {
+    /// Create a new [`BlockIndex`]
+    pub fn new(starting_number: BlockNumber, offsets: Vec<i64>) -> Self {
+        Self { starting_number, offsets }
+    }
+
+    /// Get the offset for a specific slot
+    pub fn offset_for_block(&self, block_number: BlockNumber) -> Option<i64> {
+        if block_number < self.starting_number {
+            return None;
+        }
+
+        let index = (block_number - self.starting_number) as usize;
+        self.offsets.get(index).copied()
+    }
+
+    /// Convert to an [`Entry`] for storage in an e2store file
+    pub fn to_entry(&self) -> Entry {
+        // Format: starting-slot | index | index | index ... | count
+        let mut data = Vec::with_capacity(8 + self.offsets.len() * 8 + 8);
+
+        // Add starting block number
+        data.extend_from_slice(&self.starting_number.to_le_bytes());
+
+        // Add all offsets
+        for offset in &self.offsets {
+            data.extend_from_slice(&offset.to_le_bytes());
+        }
+
+        // Add count
+        data.extend_from_slice(&(self.offsets.len() as i64).to_le_bytes());
+
+        Entry::new(BLOCK_INDEX, data)
+    }
+
+    /// Create from an [`Entry`]
+    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
+        if entry.entry_type != BLOCK_INDEX {
+            return Err(E2sError::Ssz(format!(
+                "Invalid entry type for BlockIndex: expected {:02x}{:02x}, got {:02x}{:02x}",
+                BLOCK_INDEX[0], BLOCK_INDEX[1], entry.entry_type[0], entry.entry_type[1]
+            )));
+        }
+
+        if entry.data.len() < 16 {
+            return Err(E2sError::Ssz(String::from(
+                "BlockIndex entry too short to contain starting slot and count",
+            )));
+        }
+
+        // Extract starting slot = first 8 bytes
+        let mut starting_number_bytes = [0u8; 8];
+        starting_number_bytes.copy_from_slice(&entry.data[0..8]);
+        let starting_number = u64::from_le_bytes(starting_number_bytes);
+
+        // Extract count = last 8 bytes
+        let mut count_bytes = [0u8; 8];
+        count_bytes.copy_from_slice(&entry.data[entry.data.len() - 8..]);
+        let count = u64::from_le_bytes(count_bytes) as usize;
+
+        // Verify that the entry has the correct size
+        let expected_size = 8 + count * 8 + 8;
+        if entry.data.len() != expected_size {
+            return Err(E2sError::Ssz(format!(
+                "BlockIndex entry has incorrect size: expected {}, got {}",
+                expected_size,
+                entry.data.len()
+            )));
+        }
+
+        // Extract all offsets
+        let mut offsets = Vec::with_capacity(count);
+        for i in 0..count {
+            let start = 8 + i * 8;
+            let end = start + 8;
+            let mut offset_bytes = [0u8; 8];
+            offset_bytes.copy_from_slice(&entry.data[start..end]);
+            offsets.push(i64::from_le_bytes(offset_bytes));
+        }
+
+        Ok(Self { starting_number, offsets })
+    }
+}
+
+/// Era1 file identifier
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Era1Id {
+    /// Network configuration name
+    pub network_name: String,
+
+    /// First block number in file
+    pub start_block: BlockNumber,
+
+    /// Number of blocks in the file
+    pub block_count: u32,
+}
+
+impl Era1Id {
+    /// Create a new [`Era1Id`]
+    pub fn new(
+        network_name: impl Into<String>,
+        start_block: BlockNumber,
+        block_count: u32,
+    ) -> Self {
+        Self { network_name: network_name.into(), start_block, block_count }
+    }
+
+    /// Convert to file name following the era1 file naming:
+    /// `<network-name>-<start-block>-<block-count>.era1`
+    /// inspired from era file naming convention in
+    /// <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era.md#file-name>
+    /// See also <https://github.com/eth-clients/e2store-format-specs/blob/main/formats/era1.md>
+    pub fn to_file_name(&self) -> String {
+        format!("{}-{:10}-{:02}.era1", self.network_name, self.start_block, self.block_count,)
path: crates/era/src/era1_types.rs
user: login: Copilot
body: The use of fixed-width formatting (e.g. "{:10}") for the block number may introduce unwanted spaces in the generated file name. Consider using a more standard numeric formatter to avoid potential naming issues.
```suggestion
        format!("{}-{}-{}.era1", self.network_name, self.start_block, self.block_count)
```
created_at: 2025-04-04T17:59:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+//! `E2Store` file reader
+//!
+//! See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use std::io::{BufReader, Read, Seek, SeekFrom};
+
+/// A reader for `E2Store` files that wraps a [`BufReader`]
+#[derive(Debug)]
+pub struct E2StoreReader<R: Read> {
+    reader: BufReader<R>,
+}
+
+impl<R: Read + Seek> E2StoreReader<R> {
+    /// Create a new [`E2StoreReader`]
+    pub fn new(reader: R) -> Self {
+        Self { reader: BufReader::new(reader) }
+    }
+
+    /// Read and validate the version record
+    pub fn read_version(&mut self) -> Result<Option<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        match Entry::read(&mut self.reader)? {
+            Some(entry) if entry.is_version() => Ok(Some(entry)),
+            Some(_) => Err(E2sError::Ssz("First entry must be a Version entry".to_string())),
+            None => Ok(None),
+        }
+    }
+
+    /// Iterate through all entries, including the version entry
+    pub fn entries(&mut self) -> Result<Vec<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        let mut entries = Vec::new();
+
+        while let Some(entry) = Entry::read(&mut self.reader)? {
+            entries.push(entry);
+        }
path: crates/era/src/e2s_file.rs
user: login: mattsse
body: I think it would be beneficial to this as a `fn next` so we can lazily pop entries, but we can change this later
created_at: 2025-04-04T18:26:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,167 @@
+//! Types to build e2store files
+//! ie. with .e2s extension
+//!
+//! e2store contains header and entry
+//!
+//! The [`Header`] is an 8-byte structure at the beginning of each record in the file
+//!
+//! An [`Entry`] is a complete record in the file, consisting of both a [`Header`] and its
+//! associated data
+
+use ssz_derive::{Decode, Encode};
+use std::io::{self, Read, Write};
+use thiserror::Error;
+
+/// [`Version`] record: ['e', '2']
+pub const VERSION: [u8; 2] = [0x65, 0x32];
+
+/// Empty record
+pub const EMPTY: [u8; 2] = [0x00, 0x00];
+
+/// `BlockIndex` record: ['i', '2']
+pub const BLOCK_INDEX: [u8; 2] = [0x69, 0x32];
+
+/// Error types for e2s file operations
+#[derive(Error, Debug)]
+pub enum E2sError {
+    /// IO error during file operations
+    #[error("IO error: {0}")]
+    Io(#[from] io::Error),
+
+    /// Error during SSZ encoding/decoding
+    #[error("SSZ error")]
path: crates/era/src/e2s_types.rs
user: login: mattsse
body: ```suggestion
    #[error("SSZ error: {0}")]
```
created_at: 2025-04-04T18:26:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,167 @@
+//! Types to build e2store files
+//! ie. with .e2s extension
+//!
+//! e2store contains header and entry
+//!
+//! The [`Header`] is an 8-byte structure at the beginning of each record in the file
+//!
+//! An [`Entry`] is a complete record in the file, consisting of both a [`Header`] and its
+//! associated data
+
+use ssz_derive::{Decode, Encode};
+use std::io::{self, Read, Write};
+use thiserror::Error;
+
+/// [`Version`] record: ['e', '2']
+pub const VERSION: [u8; 2] = [0x65, 0x32];
+
+/// Empty record
+pub const EMPTY: [u8; 2] = [0x00, 0x00];
+
+/// `BlockIndex` record: ['i', '2']
+pub const BLOCK_INDEX: [u8; 2] = [0x69, 0x32];
+
+/// Error types for e2s file operations
+#[derive(Error, Debug)]
+pub enum E2sError {
+    /// IO error during file operations
+    #[error("IO error: {0}")]
+    Io(#[from] io::Error),
+
+    /// Error during SSZ encoding/decoding
+    #[error("SSZ error")]
+    Ssz(String),
+
+    /// Reserved field in header not zero
+    #[error("Reserved field in header not zero")]
+    ReservedNotZero,
+
+    /// Error during snappy compression
+    #[error("Snappy compression error: {0}")]
+    SnappyCompression(String),
+
+    /// Error during snappy decompression
+    #[error("Snappy decompression error: {0}")]
+    SnappyDecompression(String),
+
+    /// Error during RLP encoding/decoding
+    #[error("RLP error: {0}")]
+    Rlp(String),
+}
+
+/// Header for TLV records in e2store files
+#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
+pub struct Header {
+    /// Record type identifier
+    pub header_type: [u8; 2],
+
+    /// Length of data following the header
+    pub length: u32,
+
+    /// Reserved field, must be zero
+    pub reserved: u16,
+}
+
+impl Header {
+    /// Create a new header with the specified type and length
+    pub fn new(header_type: [u8; 2], length: u32) -> Self {
+        Self { header_type, length, reserved: 0 }
+    }
+
+    /// Read header from a reader
+    pub fn read<R: Read>(reader: &mut R) -> Result<Option<Self>, E2sError> {
+        let mut header_bytes = [0u8; 8];
+        match reader.read_exact(&mut header_bytes) {
+            Ok(_) => {}
+            Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
+            Err(e) => return Err(e.into()),
+        }
+
+        let header: Self = match ssz::Decode::from_ssz_bytes(&header_bytes) {
+            Ok(h) => h,
+            Err(_) => return Err(E2sError::Ssz(String::from("Failed to decode SSZ header"))),
+        };
+
+        if header.reserved != 0 {
+            return Err(E2sError::ReservedNotZero);
+        }
+
+        Ok(Some(header))
+    }
+
+    /// Writes the header to the given writer.
+    pub fn write<W: Write>(&self, writer: &mut W) -> io::Result<()> {
+        let encoded = ssz::Encode::as_ssz_bytes(self);
+        writer.write_all(&encoded)
+    }
+}
+
+/// The [`Version`] record must be the first record in an e2store file
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct Version;
+
+impl Version {
+    /// Encode this record to the given writer
+    pub fn encode<W: Write>(&self, writer: &mut W) -> io::Result<()> {
+        let header = Header::new(VERSION, 0);
+        header.write(writer)
+    }
+}
+
+/// Complete record in an e2store file, consisting of a type, length, and associated data
+#[derive(Debug, Clone)]
+pub struct Entry {
+    /// Record type identifier
+    pub entry_type: [u8; 2],
+
+    /// Data contained in the entry
+    pub data: Vec<u8>,
+}
+
+impl Entry {
+    /// Create a new entry
+    pub fn new(entry_type: [u8; 2], data: Vec<u8>) -> Self {
+        Self { entry_type, data }
+    }
+
+    /// Read an entry from a reader
+    pub fn read<R: Read>(reader: &mut R) -> Result<Option<Self>, E2sError> {
+        // Read the header first
+        let header = match Header::read(reader)? {
+            Some(h) => h,
+            None => return Ok(None),
+        };
+
+        // Read the data
+        let mut data = vec![0u8; header.length as usize];
path: crates/era/src/e2s_types.rs
user: login: mattsse
body: this is untrusted input, but is fine here because we already have that data so we can alloc like this
created_at: 2025-04-04T18:27:54Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,95 @@
+//! `E2Store` file reader
+//!
+//! See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md>
+
+use crate::e2s_types::{E2sError, Entry};
+use std::io::{BufReader, Read, Seek, SeekFrom};
+
+/// A reader for `E2Store` files that wraps a [`BufReader`]
+#[derive(Debug)]
+pub struct E2StoreReader<R: Read> {
+    reader: BufReader<R>,
+}
+
+impl<R: Read + Seek> E2StoreReader<R> {
+    /// Create a new [`E2StoreReader`]
+    pub fn new(reader: R) -> Self {
+        Self { reader: BufReader::new(reader) }
+    }
+
+    /// Read and validate the version record
+    pub fn read_version(&mut self) -> Result<Option<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        match Entry::read(&mut self.reader)? {
+            Some(entry) if entry.is_version() => Ok(Some(entry)),
+            Some(_) => Err(E2sError::Ssz("First entry must be a Version entry".to_string())),
+            None => Ok(None),
+        }
+    }
+
+    /// Iterate through all entries, including the version entry
+    pub fn entries(&mut self) -> Result<Vec<Entry>, E2sError> {
+        // Reset reader to beginning
+        self.reader.seek(SeekFrom::Start(0))?;
+
+        let mut entries = Vec::new();
+
+        while let Some(entry) = Entry::read(&mut self.reader)? {
+            entries.push(entry);
+        }
path: crates/era/src/e2s_file.rs
user: login: lean-apple
body: https://github.com/paradigmxyz/reth/pull/15556
created_at: 2025-04-05T17:59:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: lean-apple
created_at: 2025-04-01T05:19:38Z
body: @mattsse  Before going further and implement `Era1Group` and finally `Era1` files reader and writer (maybe in another PR?), 
I'm not sure rn if I complete these implementations with conversion for example from `CompressedHeader` to `alloy_consensus::Header`. 
author_association: CONTRIBUTOR
user: login: mattsse
created_at: 2025-04-01T13:16:10Z
body: > I'm not sure rn if I complete these implementations with conversion for example from CompressedHeader to alloy_consensus::Header.

I think we can add helpers for `fn decode<T:rlp::Decodable>` which would be more flexible, then we can introduce something like `struct Decoder<N>` generic over the type we want to decode?
author_association: COLLABORATOR
user: login: lean-apple
created_at: 2025-04-02T08:51:48Z
body: > > I'm not sure rn if I complete these implementations with conversion for example from CompressedHeader to alloy_consensus::Header.
> 
> I think we can add helpers for `fn decode<T:rlp::Decodable>` which would be more flexible, then we can introduce something like `struct Decoder<N>` generic over the type we want to decode?

Will complete tonight, expect if you need before, feel free to merge and open more issues to complete the helpers + final helper/writer.

Also I kept `EraGroup` as it was introduced in  `era` file but not sure it is still relevant for `era1`.
author_association: CONTRIBUTOR
user: login: lean-apple
created_at: 2025-04-04T18:55:03Z
body: > cool, tysm!
> 
> I think we should include this now and do smaller more incremental changes with this because this is a very solid foundation

@mattsse Thanks for your support for this loooooooooooooooooooong PR, happy to continue on the comments you made above but also these elements : 
- improve `.e2s`/`.era1` files testing with `InMemoryE2Store` / `InMemoryEra1`
- add e2s / era1 writer
author_association: CONTRIBUTOR