Skip to main content

reth_e2e_test_utils/
setup_import.rs

1//! Setup utilities for importing RLP chain data before starting nodes.
2
3use crate::{
4    eth_payload_attributes,
5    setup_builder::{launch_test_node, open_test_database, test_node_config, LaunchArgs},
6    wallet::Wallet,
7    NodeHelperType,
8};
9use eyre::WrapErr;
10use reth_chainspec::ChainSpec;
11use reth_cli_commands::import_core::{import_blocks_from_file, ImportConfig, ImportResult};
12use reth_config::Config;
13use reth_db::DatabaseEnv;
14use reth_node_api::{NodeTypesWithDBAdapter, TreeConfig};
15use reth_node_builder::sync::PipelineBackfill;
16use reth_node_core::args::StorageArgs;
17use reth_node_ethereum::EthereumNode;
18use reth_provider::{
19    providers::{RocksDBProvider, StaticFileProvider},
20    DatabaseProviderFactory, ProviderFactory, StageCheckpointReader, StorageSettings,
21};
22use reth_stages_types::StageId;
23use reth_tasks::Runtime;
24use std::{path::Path, sync::Arc};
25use tempfile::TempDir;
26use tracing::{debug, info, span, Instrument, Level};
27
28/// Setup result containing nodes and temporary directories that must be kept alive
29pub struct ChainImportResult {
30    /// The nodes that were created
31    pub nodes: Vec<NodeHelperType<EthereumNode>>,
32    /// The wallet for testing
33    pub wallet: Wallet,
34    /// Temporary directories that must be kept alive for the duration of the test
35    pub _temp_dirs: Vec<TempDir>,
36}
37
38impl std::fmt::Debug for ChainImportResult {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("ChainImportResult")
41            .field("nodes", &self.nodes.len())
42            .field("wallet", &self.wallet)
43            .field("temp_dirs", &self._temp_dirs.len())
44            .finish()
45    }
46}
47
48/// Creates a test setup with Ethereum nodes that have pre-imported chain data from RLP files.
49///
50/// This function:
51/// 1. Creates a temporary datadir for each node
52/// 2. Imports the specified RLP chain data into the datadir
53/// 3. Starts the nodes with the pre-populated database
54/// 4. Returns the running nodes ready for testing
55///
56/// The import and all nodes share a single [`Runtime::test`] runtime, and the nodes build payloads
57/// with [`eth_payload_attributes`] for the given chain spec. `storage_v2` selects the storage
58/// layout (`--storage.v2`) for both the imported database and the launched nodes.
59///
60/// Note: This function is currently specific to `EthereumNode` because the import process
61/// uses Ethereum-specific consensus and block format. It can be made generic in the future
62/// by abstracting the import process.
63/// It uses `NoopConsensus` during import to bypass validation checks like gas limit constraints,
64/// which allows importing test chains that may not strictly conform to mainnet consensus rules. The
65/// nodes themselves still run with proper consensus when started.
66pub async fn setup_engine_with_chain_import(
67    num_nodes: usize,
68    chain_spec: Arc<ChainSpec>,
69    is_dev: bool,
70    storage_v2: bool,
71    tree_config: TreeConfig,
72    rlp_path: &Path,
73) -> eyre::Result<ChainImportResult> {
74    let runtime = Runtime::test();
75    let attributes_generator = {
76        let chain_spec = chain_spec.clone();
77        Arc::new(move |timestamp| eth_payload_attributes(&chain_spec, timestamp))
78    };
79
80    // Create nodes with imported data.
81    let mut nodes = Vec::with_capacity(num_nodes);
82    // Keep the temp dirs alive for the lifetime of the nodes.
83    let mut temp_dirs = Vec::with_capacity(num_nodes);
84
85    for idx in 0..num_nodes {
86        // Create a temporary datadir for this node.
87        let temp_dir = TempDir::new()?;
88        let datadir = temp_dir.path().to_path_buf();
89        debug!(target: "e2e::import", "Node {idx} datadir: {datadir:?}");
90
91        let span = span!(Level::INFO, "node", idx);
92        let node_config = test_node_config(chain_spec.clone())
93            .set_dev(is_dev)
94            .with_storage(StorageArgs { v2: storage_v2 });
95
96        // First, import the chain data into this datadir.
97        import_chain(
98            &datadir,
99            chain_spec.clone(),
100            node_config.storage_settings(),
101            rlp_path,
102            runtime.clone(),
103        )
104        .instrument(span.clone())
105        .await
106        .wrap_err_with(|| format!("chain import failed for node {idx}"))?;
107
108        // Now launch the node with the pre-populated datadir.
109        debug!(target: "e2e::import", "Launching node with datadir: {:?}", datadir);
110
111        let database = open_test_database(&datadir)?;
112        let node = launch_test_node(
113            LaunchArgs {
114                idx,
115                node_factory: Arc::new(|_| EthereumNode::default()),
116                node_builder_modifiers: Vec::new(),
117                node_config,
118                runtime: Some(runtime.clone()),
119                tree_config: tree_config.clone(),
120                datadir,
121                attributes_generator: attributes_generator.clone(),
122                dev_payload_attributes: None,
123            },
124            database,
125            PipelineBackfill,
126        )
127        .instrument(span)
128        .await?;
129
130        nodes.push(node);
131        temp_dirs.push(temp_dir);
132    }
133
134    Ok(ChainImportResult {
135        nodes,
136        wallet: Wallet::default().with_chain_id(chain_spec.chain.id()),
137        _temp_dirs: temp_dirs,
138    })
139}
140
141/// Helper to load forkchoice state from a JSON file
142pub fn load_forkchoice_state(path: &Path) -> eyre::Result<alloy_rpc_types_engine::ForkchoiceState> {
143    let json_str = std::fs::read_to_string(path)?;
144    let fcu_data: serde_json::Value = serde_json::from_str(&json_str)?;
145
146    // The headfcu.json file contains a JSON-RPC request with the forkchoice state in params[0]
147    let state = &fcu_data["params"][0];
148    Ok(alloy_rpc_types_engine::ForkchoiceState {
149        head_block_hash: state["headBlockHash"]
150            .as_str()
151            .ok_or_else(|| eyre::eyre!("missing headBlockHash"))?
152            .parse()?,
153        safe_block_hash: state["safeBlockHash"]
154            .as_str()
155            .ok_or_else(|| eyre::eyre!("missing safeBlockHash"))?
156            .parse()?,
157        finalized_block_hash: state["finalizedBlockHash"]
158            .as_str()
159            .ok_or_else(|| eyre::eyre!("missing finalizedBlockHash"))?
160            .parse()?,
161    })
162}
163
164/// Initializes the database in `datadir` with the given storage settings and imports the RLP
165/// encoded chain at `rlp_path`.
166///
167/// All database handles are released on return so the datadir can be reopened, e.g. by a node,
168/// which reads the storage settings back from the database.
169async fn import_chain(
170    datadir: &Path,
171    chain_spec: Arc<ChainSpec>,
172    storage_settings: StorageSettings,
173    rlp_path: &Path,
174    runtime: Runtime,
175) -> eyre::Result<ImportResult> {
176    info!(target: "test", "Importing chain data from {:?} into {:?}", rlp_path, datadir);
177
178    // Initialize the database using init_db, same as the CLI import command.
179    let db_args = reth_node_core::args::DatabaseArgs::default().database_args();
180    let db = reth_db::init_db(datadir.join("db"), db_args)?;
181
182    // Create a provider factory with the initialized database, not a `TempDatabase`.
183    let provider_factory =
184        ProviderFactory::<NodeTypesWithDBAdapter<EthereumNode, DatabaseEnv>>::new(
185            db.clone(),
186            chain_spec.clone(),
187            StaticFileProvider::read_write(datadir.join("static_files"))?,
188            RocksDBProvider::builder(datadir.join("rocksdb")).with_default_tables().build()?,
189            runtime.clone(),
190        )?;
191
192    // Initialize genesis with the storage settings of the node that later opens the database.
193    reth_db_common::init::init_genesis_with_settings(&provider_factory, storage_settings)?;
194
195    // Use NoopConsensus to skip gas limit validation for test imports.
196    let result = import_blocks_from_file(
197        rlp_path,
198        ImportConfig::default(),
199        provider_factory.clone(),
200        &Config::default(),
201        reth_node_ethereum::EthEvmConfig::new(chain_spec),
202        reth_consensus::noop::NoopConsensus::arc(),
203        runtime,
204    )
205    .await?;
206
207    info!(
208        target: "test",
209        "Imported {} blocks and {} transactions",
210        result.total_imported_blocks,
211        result.total_imported_txns,
212    );
213
214    debug!(target: "e2e::import",
215        "Import result: decoded {} blocks, imported {} blocks, complete: {}",
216        result.total_decoded_blocks,
217        result.total_imported_blocks,
218        result.is_complete()
219    );
220
221    eyre::ensure!(
222        result.total_decoded_blocks == result.total_imported_blocks,
223        "block count mismatch: decoded {} != imported {}",
224        result.total_decoded_blocks,
225        result.total_imported_blocks
226    );
227    eyre::ensure!(
228        result.total_decoded_txns == result.total_imported_txns,
229        "transaction count mismatch: decoded {} != imported {}",
230        result.total_decoded_txns,
231        result.total_imported_txns
232    );
233
234    // Verify the database was properly initialized by checking stage checkpoints.
235    let headers_checkpoint =
236        provider_factory.database_provider_ro()?.get_stage_checkpoint(StageId::Headers)?;
237    eyre::ensure!(headers_checkpoint.is_some(), "Headers stage checkpoint is missing after import");
238    debug!(target: "e2e::import", "Headers stage checkpoint after import: {headers_checkpoint:?}");
239
240    // Close all database handles to release locks before launching the node.
241    drop(provider_factory);
242    drop(db);
243
244    // The header and body downloader tasks spawned on the runtime hold provider factory clones
245    // and only exit once they are polled after the pipeline was dropped, so yield long enough for
246    // them to release the database.
247    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
248
249    Ok(result)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::{
256        test_chain_spec,
257        test_rlp_utils::{create_fcu_json, generate_test_blocks, write_blocks_to_rlp},
258    };
259    use reth_chainspec::EthereumHardfork;
260    use reth_db::mdbx::DatabaseArguments;
261    use reth_ethereum_primitives::Block;
262    use reth_primitives_traits::SealedBlock;
263    use reth_provider::{BlockHashReader, BlockNumReader, BlockReaderIdExt, MetadataProvider};
264    use std::path::PathBuf;
265
266    /// Helper to setup test blocks and write to RLP.
267    fn setup_test_blocks_and_rlp(
268        chain_spec: &ChainSpec,
269        block_count: u64,
270        temp_dir: &Path,
271    ) -> (Vec<SealedBlock<Block>>, PathBuf) {
272        let test_blocks = generate_test_blocks(chain_spec, block_count);
273        assert_eq!(
274            test_blocks.len(),
275            block_count as usize,
276            "Should have generated expected blocks"
277        );
278
279        let rlp_path = temp_dir.join("test_chain.rlp");
280        write_blocks_to_rlp(&test_blocks, &rlp_path).expect("Failed to write RLP data");
281
282        let rlp_size = std::fs::metadata(&rlp_path).expect("RLP file should exist").len();
283        debug!(target: "e2e::import", "Wrote RLP file with size: {rlp_size} bytes");
284
285        (test_blocks, rlp_path)
286    }
287
288    /// Reopens the database in `datadir` that was written by [`import_chain`].
289    fn reopen_provider_factory(
290        datadir: &Path,
291        chain_spec: Arc<ChainSpec>,
292        runtime: Runtime,
293    ) -> ProviderFactory<NodeTypesWithDBAdapter<EthereumNode, DatabaseEnv>> {
294        let db = reth_db::init_db(datadir.join("db"), DatabaseArguments::default()).unwrap();
295        ProviderFactory::new(
296            db,
297            chain_spec,
298            StaticFileProvider::read_only(datadir.join("static_files")).unwrap(),
299            RocksDBProvider::builder(datadir.join("rocksdb"))
300                .with_default_tables()
301                .build()
302                .unwrap(),
303            runtime,
304        )
305        .expect("failed to create provider factory")
306    }
307
308    #[tokio::test]
309    async fn test_import_blocks_and_reopen() {
310        // Tests the block import without a node, and that the imported chain and stage
311        // checkpoints persist when reopening the database.
312        reth_tracing::init_test_tracing();
313
314        let runtime = Runtime::test();
315        let chain_spec = test_chain_spec(EthereumHardfork::Shanghai);
316        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
317        let (test_blocks, rlp_path) = setup_test_blocks_and_rlp(&chain_spec, 10, temp_dir.path());
318
319        let datadir = temp_dir.path().join("datadir");
320        std::fs::create_dir_all(&datadir).unwrap();
321        let storage_settings = StorageSettings { storage_v2: StorageArgs::default().v2 };
322        let result = import_chain(
323            &datadir,
324            chain_spec.clone(),
325            storage_settings,
326            &rlp_path,
327            runtime.clone(),
328        )
329        .await
330        .unwrap();
331        assert_eq!(result.total_decoded_blocks, 10);
332        assert_eq!(result.total_imported_blocks, 10);
333        assert_eq!(result.total_decoded_txns, 0);
334        assert_eq!(result.total_imported_txns, 0);
335
336        let provider_factory = reopen_provider_factory(&datadir, chain_spec, runtime);
337        let provider = provider_factory.database_provider_ro().unwrap();
338        assert_eq!(provider.last_block_number().unwrap(), 10);
339        assert_eq!(provider.block_hash(10).unwrap(), Some(test_blocks[9].hash()));
340        let headers_checkpoint = provider.get_stage_checkpoint(StageId::Headers).unwrap();
341        assert_eq!(headers_checkpoint.map(|checkpoint| checkpoint.block_number), Some(10));
342    }
343
344    #[tokio::test]
345    async fn test_import_with_node_integration() {
346        // Tests the full integration with node setup, forkchoice updates, and syncing.
347        reth_tracing::init_test_tracing();
348
349        let chain_spec = test_chain_spec(EthereumHardfork::Shanghai);
350        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
351        let (test_blocks, rlp_path) = setup_test_blocks_and_rlp(&chain_spec, 10, temp_dir.path());
352
353        // Create FCU data for the tip.
354        let tip = test_blocks.last().expect("Should have generated blocks");
355        let fcu_path = temp_dir.path().join("test_fcu.json");
356        std::fs::write(&fcu_path, create_fcu_json(tip).to_string())
357            .expect("Failed to write FCU data");
358
359        for storage_v2 in [false, true] {
360            // Setup nodes with imported chain.
361            let result = setup_engine_with_chain_import(
362                1,
363                chain_spec.clone(),
364                false,
365                storage_v2,
366                TreeConfig::default(),
367                &rlp_path,
368            )
369            .await
370            .expect("Failed to setup nodes with chain import");
371
372            // Load and apply forkchoice state.
373            let fcu_state =
374                load_forkchoice_state(&fcu_path).expect("Failed to load forkchoice state");
375
376            let node = &result.nodes[0];
377
378            // The imported database and the node use the requested storage layout.
379            let settings = node
380                .inner
381                .provider
382                .database_provider_ro()
383                .expect("Failed to open database provider")
384                .storage_settings()
385                .expect("Failed to read storage settings");
386            assert_eq!(settings, Some(StorageSettings { storage_v2 }));
387
388            // Send forkchoice update to make the imported chain canonical.
389            node.update_forkchoice(fcu_state.finalized_block_hash, fcu_state.head_block_hash)
390                .await
391                .expect("Failed to update forkchoice");
392
393            // Wait for the node to sync to the head.
394            node.sync_to(fcu_state.head_block_hash).await.expect("Failed to sync to head");
395
396            // Verify the chain tip.
397            let latest = node
398                .inner
399                .provider
400                .sealed_header_by_id(alloy_eips::BlockId::latest())
401                .expect("Failed to get latest header")
402                .expect("No latest header found");
403
404            assert_eq!(
405                latest.hash(),
406                fcu_state.head_block_hash,
407                "Chain tip does not match expected head"
408            );
409        }
410    }
411}