Skip to main content

reth_era_utils/
history.rs

1use alloy_consensus::BlockHeader;
2use alloy_primitives::{BlockHash, BlockNumber, U256};
3use futures_util::{Stream, StreamExt};
4use reth_db_api::{
5    cursor::{DbCursorRO, DbCursorRW},
6    table::Value,
7    tables,
8    transaction::{DbTx, DbTxMut},
9    RawKey, RawTable, RawValue,
10};
11use reth_era::{
12    common::{decode::DecodeCompressedRlp, file_ops::StreamReader},
13    e2s::error::E2sError,
14    era::{file::EraReader, types::consensus::CompressedSignedBeaconBlock},
15    era1::{file::Era1Reader, types::execution::BlockTuple},
16    ere::{file::EreReader, types::execution::BlockTuple as EreBlockTuple},
17};
18use reth_era_downloader::EraMeta;
19use reth_etl::Collector;
20use reth_fs_util as fs;
21use reth_primitives_traits::{Block, BlockBody, FullBlockBody, FullBlockHeader, NodePrimitives};
22use reth_provider::{
23    providers::StaticFileProviderRWRefMut, BlockReader, BlockWriter, StaticFileProviderFactory,
24    StaticFileSegment, StaticFileWriter,
25};
26use reth_stages_types::{
27    CheckpointBlockRange, EntitiesCheckpoint, HeadersCheckpoint, StageCheckpoint, StageId,
28};
29use reth_storage_api::{
30    errors::ProviderResult, DBProvider, DatabaseProviderFactory, NodePrimitivesProvider,
31    StageCheckpointWriter,
32};
33use std::{collections::Bound, error::Error, ops::RangeBounds, sync::mpsc};
34use tracing::info;
35
36/// Reads execution `(header, body)` pairs out of an ERA file.
37///
38/// Per-format seam of the import pipeline.
39pub trait EraBlockReader<BH, BB> {
40    /// Opens the ERA file at `meta` and iterates its execution blocks.
41    fn blocks<M: EraMeta + ?Sized>(
42        meta: &M,
43    ) -> eyre::Result<impl Iterator<Item = eyre::Result<(BH, BB)>>>;
44}
45
46/// [`EraBlockReader`] for `.era1` files.
47#[derive(Debug)]
48pub struct Era1;
49
50impl<BH, BB> EraBlockReader<BH, BB> for Era1
51where
52    BH: FullBlockHeader + Value,
53    BB: FullBlockBody<OmmerHeader = BH>,
54{
55    fn blocks<M: EraMeta + ?Sized>(
56        meta: &M,
57    ) -> eyre::Result<impl Iterator<Item = eyre::Result<(BH, BB)>>> {
58        let reader: Era1Reader<std::fs::File> = open(meta)?;
59        Ok(reader.iter().map(decode::<BH, BB, E2sError>))
60    }
61}
62
63impl<BH, BB> EraBlockReader<BH, BB> for Ere
64where
65    BH: FullBlockHeader + Value,
66    BB: FullBlockBody<OmmerHeader = BH>,
67{
68    fn blocks<M: EraMeta + ?Sized>(
69        meta: &M,
70    ) -> eyre::Result<impl Iterator<Item = eyre::Result<(BH, BB)>>> {
71        let reader: EreReader<std::fs::File> = open(meta)?;
72        Ok(reader.iter().map(Self::decode))
73    }
74}
75
76/// [`EraBlockReader`] for `.ere`/`.erae` files.
77#[derive(Debug)]
78pub struct Ere;
79
80impl Ere {
81    /// Extracts a pair of [`FullBlockHeader`] and [`FullBlockBody`] from an ERE block tuple, whose
82    /// header and body are RLP-compressed.
83    pub fn decode<BH, BB, E>(block: Result<EreBlockTuple, E>) -> eyre::Result<(BH, BB)>
84    where
85        BH: FullBlockHeader + Value,
86        BB: FullBlockBody<OmmerHeader = BH>,
87        E: From<E2sError> + Error + Send + Sync + 'static,
88    {
89        let block = block?;
90        let header: BH = block.header.decode()?;
91        let body: BB = block.body.decode()?;
92        Ok((header, body))
93    }
94}
95
96/// [`EraBlockReader`] for consensus-layer `.era` files.
97///
98/// `.era` files store consensus `SignedBeaconBlock`s. Post-merge
99/// blocks embed an execution payload; this source SSZ-decodes each beacon block, extracts that
100/// payload, and converts it into an execution `(header, body)` pair. Pre-merge slots carry no
101/// payload and are skipped.
102#[derive(Debug)]
103pub struct Era;
104
105impl<BH, BB> EraBlockReader<BH, BB> for Era
106where
107    BH: FullBlockHeader,
108    BB: FullBlockBody,
109{
110    fn blocks<M: EraMeta + ?Sized>(
111        meta: &M,
112    ) -> eyre::Result<impl Iterator<Item = eyre::Result<(BH, BB)>>> {
113        let reader: EraReader<std::fs::File> = open(meta)?;
114        let mut buf = Vec::new();
115        Ok(reader.iter().filter_map(move |block| Self::decode(block, &mut buf).transpose()))
116    }
117}
118
119impl Era {
120    /// Decodes the execution `(header, body)` embedded in a consensus `SignedBeaconBlock`.
121    ///
122    /// Returns `Ok(None)` for pre-merge slots, which carry no execution payload.
123    pub fn decode<BH, BB>(
124        block: Result<CompressedSignedBeaconBlock, E2sError>,
125        buf: &mut Vec<u8>,
126    ) -> eyre::Result<Option<(BH, BB)>>
127    where
128        BH: FullBlockHeader,
129        BB: FullBlockBody,
130    {
131        let Some(alloy_consensus::Block { header, body }) =
132            block?.decode_execution_block::<<BB as BlockBody>::Transaction>()?
133        else {
134            return Ok(None);
135        };
136        // The beacon payload decodes into alloy execution types; re-encode and decode into the
137        // node's own primitives through the same RLP representation the `.era1`/`.ere` paths use.
138        Ok(Some((reencode_rlp(&header, buf)?, reencode_rlp(&body, buf)?)))
139    }
140}
141
142/// Re-encodes an alloy execution type as RLP and decodes it back into the node's primitive type.
143///
144/// `.era` files yield alloy execution headers/bodies, while the import pipeline writes the node's
145/// own primitives. Both share the canonical execution RLP encoding, so a round-trip bridges them
146/// without requiring a direct `From` conversion.
147fn reencode_rlp<T, U>(value: &T, buf: &mut Vec<u8>) -> eyre::Result<U>
148where
149    T: alloy_rlp::Encodable,
150    U: alloy_rlp::Decodable,
151{
152    buf.clear();
153    alloy_rlp::Encodable::encode(value, buf);
154    Ok(<U as alloy_rlp::Decodable>::decode(&mut buf.as_slice())?)
155}
156
157/// Opens the ERA file at `meta` with the format's [`StreamReader`].
158pub fn open<Reader>(meta: &(impl EraMeta + ?Sized)) -> eyre::Result<Reader>
159where
160    Reader: StreamReader<std::fs::File>,
161{
162    Ok(Reader::new(fs::open(meta.path())?))
163}
164
165/// Imports blocks from `downloader`, decoding each file with the [`EraBlockReader`] `S`.
166///
167/// When `to_block` is set, the import stops after reaching that block height; otherwise it
168/// continues until the source has no more files.
169///
170/// Returns current block height.
171pub fn import<S, Downloader, Era, PF, B, BB, BH>(
172    mut downloader: Downloader,
173    provider_factory: &PF,
174    hash_collector: &mut Collector<BlockHash, BlockNumber>,
175    to_block: Option<BlockNumber>,
176) -> eyre::Result<BlockNumber>
177where
178    S: EraBlockReader<BH, BB>,
179    B: Block<Header = BH, Body = BB>,
180    BH: FullBlockHeader + Value,
181    BB: FullBlockBody<
182        Transaction = <<<PF as DatabaseProviderFactory>::ProviderRW as NodePrimitivesProvider>::Primitives as NodePrimitives>::SignedTx,
183        OmmerHeader = BH,
184    >,
185    Downloader: Stream<Item = eyre::Result<Era>> + Send + 'static + Unpin,
186    Era: EraMeta + Send + 'static,
187    PF: DatabaseProviderFactory<
188        ProviderRW: BlockWriter<Block = B>
189            + DBProvider
190            + StaticFileProviderFactory<Primitives: NodePrimitives<Block = B, BlockHeader = BH, BlockBody = BB>>
191            + StageCheckpointWriter,
192    > + StaticFileProviderFactory<Primitives = <<PF as DatabaseProviderFactory>::ProviderRW as NodePrimitivesProvider>::Primitives>,
193{
194    let (tx, rx) = mpsc::channel();
195
196    // Handle IO-bound async download in a background tokio task
197    tokio::spawn(async move {
198        while let Some(file) = downloader.next().await {
199            tx.send(Some(file))?;
200        }
201        tx.send(None)
202    });
203
204    let static_file_provider = provider_factory.static_file_provider();
205
206    // Consistency check of expected headers in static files vs DB is done on provider::sync_gap
207    // when poll_execute_ready is polled.
208    let mut height = static_file_provider
209        .get_highest_static_file_block(StaticFileSegment::Headers)
210        .unwrap_or_default();
211
212    let end = to_block.map_or(Bound::Unbounded, Bound::Included);
213
214    while let Some(meta) = rx.recv()? {
215        let meta = meta?;
216        let from = height;
217        let provider = provider_factory.database_provider_rw()?;
218
219        height = process::<S, _, _, _, _>(
220            &meta,
221            &mut static_file_provider.latest_writer(StaticFileSegment::Headers)?,
222            &provider,
223            hash_collector,
224            (Bound::Included(height), end),
225        )?;
226
227        save_stage_checkpoints(&provider, from, height, height, height)?;
228
229        provider.commit()?;
230
231        info!(target: "era::history::import", first = from, last = height, file = %meta.path().display(), "Imported ERA file");
232
233        if to_block.is_some_and(|to| height >= to) {
234            break;
235        }
236    }
237
238    let provider = provider_factory.database_provider_rw()?;
239
240    build_index(&provider, hash_collector)?;
241
242    provider.commit()?;
243
244    Ok(height)
245}
246
247/// Saves progress of ERA import into stages sync.
248///
249/// Since the ERA import does the same work as `HeaderStage` and `BodyStage`, it needs to inform
250/// these stages that this work has already been done. Otherwise, there might be some conflict with
251/// database integrity.
252pub fn save_stage_checkpoints<P>(
253    provider: P,
254    from: BlockNumber,
255    to: BlockNumber,
256    processed: u64,
257    total: u64,
258) -> ProviderResult<()>
259where
260    P: StageCheckpointWriter,
261{
262    provider.save_stage_checkpoint(
263        StageId::Headers,
264        StageCheckpoint::new(to).with_headers_stage_checkpoint(HeadersCheckpoint {
265            block_range: CheckpointBlockRange { from, to },
266            progress: EntitiesCheckpoint { processed, total },
267        }),
268    )?;
269    provider.save_stage_checkpoint(
270        StageId::Bodies,
271        StageCheckpoint::new(to)
272            .with_entities_stage_checkpoint(EntitiesCheckpoint { processed, total }),
273    )?;
274    Ok(())
275}
276
277/// Reads `meta` with the [`EraBlockReader`] `S`, appends its blocks within `block_numbers`, and
278/// marks `meta` processed if the file was fully consumed. Returns last block height.
279pub fn process<S, P, B, BB, BH>(
280    meta: &(impl EraMeta + ?Sized),
281    writer: &mut StaticFileProviderRWRefMut<'_, <P as NodePrimitivesProvider>::Primitives>,
282    provider: &P,
283    hash_collector: &mut Collector<BlockHash, BlockNumber>,
284    block_numbers: impl RangeBounds<BlockNumber>,
285) -> eyre::Result<BlockNumber>
286where
287    S: EraBlockReader<BH, BB>,
288    B: Block<Header = BH, Body = BB>,
289    BH: FullBlockHeader + Value,
290    BB: FullBlockBody<
291        Transaction = <<P as NodePrimitivesProvider>::Primitives as NodePrimitives>::SignedTx,
292        OmmerHeader = BH,
293    >,
294    P: DBProvider<Tx: DbTxMut> + NodePrimitivesProvider + BlockWriter<Block = B>,
295    <P as NodePrimitivesProvider>::Primitives: NodePrimitives<BlockHeader = BH, BlockBody = BB>,
296{
297    let iter = S::blocks(meta)?
298        .map(Some)
299        .chain(std::iter::once_with(|| match meta.mark_as_processed() {
300            Ok(()) => None,
301            Err(error) => Some(Err(error)),
302        }))
303        .flatten();
304
305    process_iter(iter, writer, provider, hash_collector, block_numbers)
306}
307
308/// Extracts a pair of [`FullBlockHeader`] and [`FullBlockBody`] from [`BlockTuple`].
309pub fn decode<BH, BB, E>(block: Result<BlockTuple, E>) -> eyre::Result<(BH, BB)>
310where
311    BH: FullBlockHeader + Value,
312    BB: FullBlockBody<OmmerHeader = BH>,
313    E: From<E2sError> + Error + Send + Sync + 'static,
314{
315    let block = block?;
316    let header: BH = block.header.decode()?;
317    let body: BB = block.body.decode()?;
318
319    Ok((header, body))
320}
321
322/// Extracts block headers and bodies from `iter` and appends them using `writer` and `provider`.
323///
324/// Collects hash to height using `hash_collector`.
325///
326/// Skips all blocks below the [`start_bound`] of `block_numbers` and stops when reaching past the
327/// [`end_bound`] or the end of the file.
328///
329/// Returns last block height.
330///
331/// [`start_bound`]: RangeBounds::start_bound
332/// [`end_bound`]: RangeBounds::end_bound
333pub fn process_iter<P, B, BB, BH>(
334    mut iter: impl Iterator<Item = eyre::Result<(BH, BB)>>,
335    writer: &mut StaticFileProviderRWRefMut<'_, <P as NodePrimitivesProvider>::Primitives>,
336    provider: &P,
337    hash_collector: &mut Collector<BlockHash, BlockNumber>,
338    block_numbers: impl RangeBounds<BlockNumber>,
339) -> eyre::Result<BlockNumber>
340where
341    B: Block<Header = BH, Body = BB>,
342    BH: FullBlockHeader + Value,
343    BB: FullBlockBody<
344        Transaction = <<P as NodePrimitivesProvider>::Primitives as NodePrimitives>::SignedTx,
345        OmmerHeader = BH,
346    >,
347    P: DBProvider<Tx: DbTxMut> + NodePrimitivesProvider + BlockWriter<Block = B>,
348    <P as NodePrimitivesProvider>::Primitives: NodePrimitives<BlockHeader = BH, BlockBody = BB>,
349{
350    let mut last_header_number = match block_numbers.start_bound() {
351        Bound::Included(&number) => number,
352        Bound::Excluded(&number) => number.saturating_add(1),
353        Bound::Unbounded => 0,
354    };
355    let target = match block_numbers.end_bound() {
356        Bound::Included(&number) => Some(number),
357        Bound::Excluded(&number) => Some(number.saturating_sub(1)),
358        Bound::Unbounded => None,
359    };
360
361    for block in &mut iter {
362        let (header, body) = block?;
363        let number = header.number();
364
365        if number <= last_header_number {
366            continue;
367        }
368        if let Some(target) = target &&
369            number > target
370        {
371            break;
372        }
373
374        // Reject gaps: the import marks the Headers/Bodies stages complete up to `height`, so a
375        // non-contiguous append would leave earlier blocks missing while the stages report done.
376        if number != last_header_number + 1 {
377            eyre::bail!(
378                "non-contiguous ERA import: expected block {}, got {number}; the execution \
379                 database must be synced up to block {} before importing this file",
380                last_header_number + 1,
381                number - 1,
382            );
383        }
384
385        let hash = header.hash_slow();
386        last_header_number = number;
387
388        // Append to Headers segment
389        writer.append_header(&header, &hash)?;
390
391        // Write bodies to database.
392        provider.append_block_bodies(vec![(header.number(), Some(&body))])?;
393
394        hash_collector.insert(hash, number)?;
395    }
396
397    Ok(last_header_number)
398}
399
400/// Dumps the contents of `hash_collector` into [`tables::HeaderNumbers`].
401pub fn build_index<P>(
402    provider: &P,
403    hash_collector: &mut Collector<BlockHash, BlockNumber>,
404) -> eyre::Result<()>
405where
406    P: DBProvider<Tx: DbTxMut>,
407{
408    let total_headers = hash_collector.len();
409    info!(target: "era::history::import", total = total_headers, "Writing headers hash index");
410
411    // Database cursor for hash to number index
412    let mut cursor_header_numbers =
413        provider.tx_ref().cursor_write::<RawTable<tables::HeaderNumbers>>()?;
414    // If we only have the genesis block hash, then we are at first sync, and we can remove it,
415    // add it to the collector and use tx.append on all hashes.
416    let first_sync = if provider.tx_ref().entries::<RawTable<tables::HeaderNumbers>>()? == 1 &&
417        let Some((hash, block_number)) = cursor_header_numbers.last()? &&
418        block_number.value()? == 0
419    {
420        hash_collector.insert(hash.key()?, 0)?;
421        cursor_header_numbers.delete_current()?;
422        true
423    } else {
424        false
425    };
426
427    let interval = (total_headers / 10).max(8192);
428
429    // Build block hash to block number index
430    for (index, hash_to_number) in hash_collector.iter()?.enumerate() {
431        let (hash, number) = hash_to_number?;
432
433        if index != 0 && index.is_multiple_of(interval) {
434            info!(target: "era::history::import", progress = %format!("{:.2}%", (index as f64 / total_headers as f64) * 100.0), "Writing headers hash index");
435        }
436
437        let hash = RawKey::<BlockHash>::from_vec(hash);
438        let number = RawValue::<BlockNumber>::from_vec(number);
439
440        if first_sync {
441            cursor_header_numbers.append(hash, &number)?;
442        } else {
443            cursor_header_numbers.upsert(hash, &number)?;
444        }
445    }
446
447    Ok(())
448}
449
450/// Calculates the total difficulty for a given block number by summing the difficulty
451/// of all blocks from genesis to the given block.
452///
453/// Very expensive - iterates through all blocks in batches of 1000.
454///
455/// Returns an error if any block is missing.
456pub fn calculate_td_by_number<P>(provider: &P, num: BlockNumber) -> eyre::Result<U256>
457where
458    P: BlockReader,
459{
460    let mut total_difficulty = U256::ZERO;
461    let mut start = 0;
462
463    while start <= num {
464        let end = (start + 1000 - 1).min(num);
465
466        total_difficulty +=
467            provider.headers_range(start..=end)?.iter().map(|h| h.difficulty()).sum::<U256>();
468
469        start = end + 1;
470    }
471
472    Ok(total_difficulty)
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use alloy_consensus::Header;
479    use reth_db_common::init::init_genesis;
480    use reth_ethereum_primitives::{Block, BlockBody};
481    use reth_provider::{
482        test_utils::create_test_provider_factory, DatabaseProviderFactory,
483        StaticFileProviderFactory, StaticFileSegment, StaticFileWriter,
484    };
485    use std::{cell::Cell, path::Path};
486    use tempfile::tempdir;
487
488    struct TestEra;
489
490    impl EraBlockReader<Header, BlockBody> for TestEra {
491        fn blocks<M: EraMeta + ?Sized>(
492            _meta: &M,
493        ) -> eyre::Result<impl Iterator<Item = eyre::Result<(Header, BlockBody)>>> {
494            Ok([1, 2]
495                .into_iter()
496                .map(|number| Ok((Header { number, ..Default::default() }, BlockBody::default()))))
497        }
498    }
499
500    #[derive(Debug)]
501    struct TestMeta {
502        marked: Cell<bool>,
503    }
504
505    impl EraMeta for TestMeta {
506        fn mark_as_processed(&self) -> eyre::Result<()> {
507            self.marked.set(true);
508            Ok(())
509        }
510
511        fn path(&self) -> &Path {
512            Path::new("test.era1")
513        }
514    }
515
516    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
517    async fn import_stops_at_to_block() {
518        let pf = create_test_provider_factory();
519        init_genesis(&pf).unwrap();
520
521        let folder = tempdir().unwrap();
522        let mut hash_collector = Collector::new(4096, Some(folder.path().to_owned()));
523
524        // Each file yields blocks 1 and 2; without `to_block` the import would reach 2.
525        let stream = futures_util::stream::iter(vec![
526            Ok(TestMeta { marked: Cell::new(false) }),
527            Ok(TestMeta { marked: Cell::new(false) }),
528        ]);
529
530        let height =
531            import::<TestEra, _, _, _, Block, _, _>(stream, &pf, &mut hash_collector, Some(1))
532                .unwrap();
533
534        assert_eq!(height, 1);
535    }
536
537    #[test]
538    fn process_does_not_mark_partially_consumed_file_processed() {
539        let pf = create_test_provider_factory();
540        init_genesis(&pf).unwrap();
541
542        let static_file_provider = pf.static_file_provider();
543        let mut writer = static_file_provider.latest_writer(StaticFileSegment::Headers).unwrap();
544        let provider = pf.database_provider_rw().unwrap();
545        let folder = tempdir().unwrap();
546        let mut hash_collector = Collector::new(4096, Some(folder.path().to_owned()));
547        let meta = TestMeta { marked: Cell::new(false) };
548
549        let height = process::<TestEra, _, Block, _, _>(
550            &meta,
551            &mut writer,
552            &provider,
553            &mut hash_collector,
554            0..=1,
555        )
556        .unwrap();
557
558        assert_eq!(height, 1);
559        assert!(!meta.marked.get());
560    }
561
562    #[test]
563    fn process_iter_rejects_non_contiguous_blocks() {
564        let pf = create_test_provider_factory();
565        init_genesis(&pf).unwrap();
566
567        let static_file_provider = pf.static_file_provider();
568        let mut writer = static_file_provider.latest_writer(StaticFileSegment::Headers).unwrap();
569        let provider = pf.database_provider_rw().unwrap();
570        let folder = tempdir().unwrap();
571        let mut hash_collector = Collector::new(4096, Some(folder.path().to_owned()));
572
573        // Genesis DB sits at height 0, but the first block is 5: a gap that must be rejected
574        // rather than appended (as a pre-merge `.era` import would otherwise produce).
575        let blocks = [5u64, 6]
576            .into_iter()
577            .map(|number| Ok((Header { number, ..Default::default() }, BlockBody::default())));
578
579        let result = process_iter::<_, Block, _, _>(
580            blocks,
581            &mut writer,
582            &provider,
583            &mut hash_collector,
584            0..,
585        );
586
587        assert!(result.is_err());
588    }
589}