Skip to main content

reth_era/era/types/
consensus.rs

1//! Consensus types for Era post-merge history files
2//!
3//! # Decoding
4//!
5//! This crate handles compression/decompression and, for post-merge blocks, extraction of the
6//! embedded execution block via [`CompressedSignedBeaconBlock::decode_execution_block`]. The
7//! decompressed bytes are SSZ-encoded consensus types decoded with [`alloy_rpc_types_beacon`]'s
8//! fork-specific beacon block types — no external consensus client is required.
9//!
10//! [`alloy_rpc_types_beacon`]: https://docs.rs/alloy-rpc-types-beacon
11//!
12//! # Example
13//!
14//! ## Decoding the execution block from a [`CompressedSignedBeaconBlock`]
15//!
16//! ```no_run
17//! use reth_era::era::types::consensus::CompressedSignedBeaconBlock;
18//! use reth_ethereum_primitives::TransactionSigned;
19//!
20//! fn decode_block(
21//!     compressed: &CompressedSignedBeaconBlock,
22//! ) -> Result<(), Box<dyn std::error::Error>> {
23//!     // Post-merge blocks carry an execution payload; pre-merge slots yield `None`.
24//!     if let Some(block) = compressed.decode_execution_block::<TransactionSigned>()? {
25//!         println!("Execution block number: {}", block.header.number);
26//!     }
27//!     Ok(())
28//! }
29//! ```
30use crate::e2s::{error::E2sError, types::Entry};
31use alloy_consensus::Block;
32use alloy_eips::eip2718::Decodable2718;
33use alloy_rpc_types_beacon::block::{
34    SignedBeaconBlockAltair, SignedBeaconBlockBellatrix, SignedBeaconBlockCapella,
35    SignedBeaconBlockDeneb, SignedBeaconBlockElectra, SignedBeaconBlockPhase0,
36};
37use alloy_rpc_types_engine::{
38    CancunPayloadFields, ExecutionPayload, ExecutionPayloadSidecar, ExecutionPayloadV1,
39    ExecutionPayloadV2, ExecutionPayloadV3, PraguePayloadFields,
40};
41use snap::{read::FrameDecoder, write::FrameEncoder};
42use ssz::Decode;
43use std::io::{Read, Write};
44
45/// Maximum allowed decompressed size for a signed beacon block SSZ payload.
46const MAX_DECOMPRESSED_SIGNED_BEACON_BLOCK_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
47
48/// Maximum allowed decompressed size for a beacon state SSZ payload.
49const MAX_DECOMPRESSED_BEACON_STATE_BYTES: usize = 2 * 1024 * 1024 * 1024; // 2 GiB
50
51fn decompress_snappy_bounded(
52    compressed: &[u8],
53    max_decompressed_bytes: usize,
54    what: &str,
55) -> Result<Vec<u8>, E2sError> {
56    let mut decoder = FrameDecoder::new(compressed).take(max_decompressed_bytes as u64);
57    let mut decompressed = Vec::new();
58
59    Read::read_to_end(&mut decoder, &mut decompressed)
60        .map_err(|e| E2sError::SnappyDecompression(format!("Failed to decompress {what}: {e}")))?;
61
62    if decompressed.len() >= max_decompressed_bytes {
63        return Err(E2sError::SnappyDecompression(format!(
64            "Failed to decompress {what}: decompressed data exceeded limit of {max_decompressed_bytes} bytes"
65        )));
66    }
67
68    Ok(decompressed)
69}
70
71/// `CompressedSignedBeaconBlock` record type: [0x01, 0x00]
72pub const COMPRESSED_SIGNED_BEACON_BLOCK: [u8; 2] = [0x01, 0x00];
73
74/// `CompressedBeaconState` record type: [0x02, 0x00]
75pub const COMPRESSED_BEACON_STATE: [u8; 2] = [0x02, 0x00];
76
77/// Compressed signed beacon block
78///
79/// See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md#compressedsignedbeaconblock>.
80#[derive(Debug, Clone)]
81pub struct CompressedSignedBeaconBlock {
82    /// Snappy-compressed ssz-encoded `SignedBeaconBlock`
83    pub data: Vec<u8>,
84}
85
86impl CompressedSignedBeaconBlock {
87    /// Create a new [`CompressedSignedBeaconBlock`] from compressed data
88    pub const fn new(data: Vec<u8>) -> Self {
89        Self { data }
90    }
91
92    /// Create from ssz-encoded block by compressing it with snappy
93    pub fn from_ssz(ssz_data: &[u8]) -> Result<Self, E2sError> {
94        let mut compressed = Vec::new();
95        {
96            let mut encoder = FrameEncoder::new(&mut compressed);
97
98            Write::write_all(&mut encoder, ssz_data).map_err(|e| {
99                E2sError::SnappyCompression(format!("Failed to compress signed beacon block: {e}"))
100            })?;
101
102            encoder.flush().map_err(|e| {
103                E2sError::SnappyCompression(format!("Failed to flush encoder: {e}"))
104            })?;
105        }
106        Ok(Self { data: compressed })
107    }
108
109    /// Decompress to get the original ssz-encoded signed beacon block
110    pub fn decompress(&self) -> Result<Vec<u8>, E2sError> {
111        decompress_snappy_bounded(
112            self.data.as_slice(),
113            MAX_DECOMPRESSED_SIGNED_BEACON_BLOCK_BYTES,
114            "signed beacon block",
115        )
116    }
117
118    /// Decodes the execution block embedded in this beacon block, if it has one.
119    ///
120    /// Only post-merge (Bellatrix and later) blocks carry a payload; the fork is found by
121    /// trial-decoding newest to oldest. Returns `Ok(None)` for genuine pre-merge slots, confirmed
122    /// to decode as a pre-merge block. Bytes matching no known fork are an error, so malformed data
123    /// is never silently dropped.
124    pub fn decode_execution_block<T: Decodable2718>(&self) -> Result<Option<Block<T>>, E2sError> {
125        let ssz = self.decompress()?;
126
127        if let Ok(beacon) = SignedBeaconBlockElectra::<ExecutionPayloadV3>::from_ssz_bytes(&ssz) {
128            let sidecar = ExecutionPayloadSidecar::v4(
129                CancunPayloadFields {
130                    parent_beacon_block_root: beacon.message.parent_root,
131                    versioned_hashes: Vec::new(),
132                },
133                PraguePayloadFields::new(beacon.message.body.execution_requests.to_requests()),
134            );
135            let payload = ExecutionPayload::V3(beacon.message.body.execution_payload);
136            return Ok(Some(payload.try_into_block_with_sidecar(&sidecar)?));
137        }
138
139        if let Ok(beacon) = SignedBeaconBlockDeneb::<ExecutionPayloadV3>::from_ssz_bytes(&ssz) {
140            let sidecar = ExecutionPayloadSidecar::v3(CancunPayloadFields {
141                parent_beacon_block_root: beacon.message.parent_root,
142                versioned_hashes: Vec::new(),
143            });
144            let payload = ExecutionPayload::V3(beacon.message.body.execution_payload);
145            return Ok(Some(payload.try_into_block_with_sidecar(&sidecar)?));
146        }
147
148        if let Ok(beacon) = SignedBeaconBlockCapella::<ExecutionPayloadV2>::from_ssz_bytes(&ssz) {
149            let payload = ExecutionPayload::V2(beacon.message.body.execution_payload);
150            return Ok(Some(payload.try_into_block()?));
151        }
152
153        if let Ok(beacon) = SignedBeaconBlockBellatrix::<ExecutionPayloadV1>::from_ssz_bytes(&ssz) {
154            let payload = ExecutionPayload::V1(beacon.message.body.execution_payload);
155            return Ok(Some(payload.try_into_block()?));
156        }
157
158        // Pre-merge blocks carry no execution payload. Only skip a slot once it's confirmed to be a
159        // valid pre-merge block; anything else is malformed data and must error rather than skip.
160        if SignedBeaconBlockPhase0::from_ssz_bytes(&ssz).is_ok() ||
161            SignedBeaconBlockAltair::from_ssz_bytes(&ssz).is_ok()
162        {
163            return Ok(None);
164        }
165
166        Err(E2sError::Ssz(format!(
167            "consensus block ({} bytes) is not a valid SignedBeaconBlock of any known fork",
168            ssz.len()
169        )))
170    }
171
172    /// Convert to an [`Entry`]
173    pub fn to_entry(&self) -> Entry {
174        Entry::new(COMPRESSED_SIGNED_BEACON_BLOCK, self.data.clone())
175    }
176
177    /// Create from an [`Entry`]
178    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
179        if entry.entry_type != COMPRESSED_SIGNED_BEACON_BLOCK {
180            return Err(E2sError::Ssz(format!(
181                "Invalid entry type for CompressedSignedBeaconBlock: expected {:02x}{:02x}, got {:02x}{:02x}",
182                COMPRESSED_SIGNED_BEACON_BLOCK[0],
183                COMPRESSED_SIGNED_BEACON_BLOCK[1],
184                entry.entry_type[0],
185                entry.entry_type[1]
186            )));
187        }
188
189        Ok(Self { data: entry.data.clone() })
190    }
191}
192
193/// Compressed beacon state
194///
195/// See also <https://github.com/status-im/nimbus-eth2/blob/stable/docs/e2store.md#compressedbeaconstate>.
196#[derive(Debug, Clone)]
197pub struct CompressedBeaconState {
198    /// Snappy-compressed ssz-encoded `BeaconState`
199    pub data: Vec<u8>,
200}
201
202impl CompressedBeaconState {
203    /// Create a new [`CompressedBeaconState`] from compressed data
204    pub const fn new(data: Vec<u8>) -> Self {
205        Self { data }
206    }
207
208    /// Compress with snappy from ssz-encoded state
209    pub fn from_ssz(ssz_data: &[u8]) -> Result<Self, E2sError> {
210        let mut compressed = Vec::new();
211        {
212            let mut encoder = FrameEncoder::new(&mut compressed);
213
214            Write::write_all(&mut encoder, ssz_data).map_err(|e| {
215                E2sError::SnappyCompression(format!("Failed to compress beacon state: {e}"))
216            })?;
217
218            encoder.flush().map_err(|e| {
219                E2sError::SnappyCompression(format!("Failed to flush encoder: {e}"))
220            })?;
221        }
222        Ok(Self { data: compressed })
223    }
224
225    /// Decompress to get the original ssz-encoded beacon state
226    pub fn decompress(&self) -> Result<Vec<u8>, E2sError> {
227        decompress_snappy_bounded(
228            self.data.as_slice(),
229            MAX_DECOMPRESSED_BEACON_STATE_BYTES,
230            "beacon state",
231        )
232    }
233
234    /// Convert to an [`Entry`]
235    pub fn to_entry(&self) -> Entry {
236        Entry::new(COMPRESSED_BEACON_STATE, self.data.clone())
237    }
238
239    /// Create from an [`Entry`]
240    pub fn from_entry(entry: &Entry) -> Result<Self, E2sError> {
241        if entry.entry_type != COMPRESSED_BEACON_STATE {
242            return Err(E2sError::Ssz(format!(
243                "Invalid entry type for CompressedBeaconState: expected {:02x}{:02x}, got {:02x}{:02x}",
244                COMPRESSED_BEACON_STATE[0],
245                COMPRESSED_BEACON_STATE[1],
246                entry.entry_type[0],
247                entry.entry_type[1]
248            )));
249        }
250
251        Ok(Self { data: entry.data.clone() })
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use alloy_primitives::B256;
259    use alloy_rpc_types_beacon::{
260        block::{BeaconBlock, BeaconBlockBodyPhase0, Eth1Data, SignedBeaconBlock},
261        BlsSignature,
262    };
263    use reth_ethereum_primitives::TransactionSigned;
264    use ssz::Encode;
265
266    #[test]
267    fn test_signed_beacon_block_compression_roundtrip() {
268        let ssz_data = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
269
270        let compressed_block = CompressedSignedBeaconBlock::from_ssz(&ssz_data).unwrap();
271        let decompressed = compressed_block.decompress().unwrap();
272
273        assert_eq!(decompressed, ssz_data);
274    }
275
276    #[test]
277    fn test_beacon_state_compression_roundtrip() {
278        let ssz_data = vec![10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
279
280        let compressed_state = CompressedBeaconState::from_ssz(&ssz_data).unwrap();
281        let decompressed = compressed_state.decompress().unwrap();
282
283        assert_eq!(decompressed, ssz_data);
284    }
285
286    #[test]
287    fn test_entry_conversion_signed_beacon_block() {
288        let ssz_data = vec![1, 2, 3, 4, 5];
289        let compressed_block = CompressedSignedBeaconBlock::from_ssz(&ssz_data).unwrap();
290
291        let entry = compressed_block.to_entry();
292        assert_eq!(entry.entry_type, COMPRESSED_SIGNED_BEACON_BLOCK);
293
294        let recovered = CompressedSignedBeaconBlock::from_entry(&entry).unwrap();
295        let recovered_ssz = recovered.decompress().unwrap();
296
297        assert_eq!(recovered_ssz, ssz_data);
298    }
299
300    #[test]
301    fn test_entry_conversion_beacon_state() {
302        let ssz_data = vec![5, 4, 3, 2, 1];
303        let compressed_state = CompressedBeaconState::from_ssz(&ssz_data).unwrap();
304
305        let entry = compressed_state.to_entry();
306        assert_eq!(entry.entry_type, COMPRESSED_BEACON_STATE);
307
308        let recovered = CompressedBeaconState::from_entry(&entry).unwrap();
309        let recovered_ssz = recovered.decompress().unwrap();
310
311        assert_eq!(recovered_ssz, ssz_data);
312    }
313
314    #[test]
315    fn test_invalid_entry_type() {
316        let invalid_entry = Entry::new([0xFF, 0xFF], vec![1, 2, 3]);
317
318        let result = CompressedSignedBeaconBlock::from_entry(&invalid_entry);
319        assert!(result.is_err());
320
321        let result = CompressedBeaconState::from_entry(&invalid_entry);
322        assert!(result.is_err());
323    }
324
325    #[test]
326    fn test_bounded_decompression_rejects_oversized_output() {
327        let ssz_data = vec![42u8; 1024];
328        let compressed = CompressedBeaconState::from_ssz(&ssz_data).unwrap();
329
330        let err =
331            decompress_snappy_bounded(compressed.data.as_slice(), 100, "beacon state").unwrap_err();
332
333        assert!(format!("{err:?}").contains("exceeded limit"));
334    }
335
336    #[test]
337    fn decode_execution_block_skips_genuine_pre_merge() {
338        // A genuine pre-merge (phase0) beacon block carries no execution payload and yields `None`.
339        let block = SignedBeaconBlock {
340            message: BeaconBlock {
341                slot: 0,
342                proposer_index: 0,
343                parent_root: B256::ZERO,
344                state_root: B256::ZERO,
345                body: BeaconBlockBodyPhase0 {
346                    randao_reveal: BlsSignature::ZERO,
347                    eth1_data: Eth1Data {
348                        deposit_root: B256::ZERO,
349                        deposit_count: 0,
350                        block_hash: B256::ZERO,
351                    },
352                    graffiti: B256::ZERO,
353                    proposer_slashings: vec![],
354                    attester_slashings: vec![],
355                    attestations: vec![],
356                    deposits: vec![],
357                    voluntary_exits: vec![],
358                },
359            },
360            signature: BlsSignature::ZERO,
361        };
362        let compressed = CompressedSignedBeaconBlock::from_ssz(&block.as_ssz_bytes()).unwrap();
363        assert!(compressed.decode_execution_block::<TransactionSigned>().unwrap().is_none());
364    }
365
366    #[test]
367    fn decode_execution_block_errors_on_malformed() {
368        // Bytes that decode as no known fork are malformed, not pre-merge slots, and must error
369        // instead of being silently dropped.
370        for bytes in [vec![], vec![0u8; 8]] {
371            let compressed = CompressedSignedBeaconBlock::from_ssz(&bytes).unwrap();
372            assert!(compressed.decode_execution_block::<TransactionSigned>().is_err());
373        }
374    }
375}