Skip to main content

reth_e2e_test_utils/testsuite/
setup.rs

1//! Test setup utilities for configuring the initial state.
2
3use crate::{
4    testsuite::{actions::expect_fcu_valid, Environment},
5    wait::{poll_until, poll_until_with, PollOpts},
6    E2ETestSetupExt, NodeBuilderHelper,
7};
8use alloy_eips::BlockNumberOrTag;
9use alloy_rpc_types_engine::{ForkchoiceState, ForkchoiceUpdated, PayloadAttributes};
10use eyre::{eyre, Result};
11use reth_chainspec::ChainSpec;
12use reth_node_api::{EngineTypes, PayloadAttrTy, PayloadTypes, TreeConfig};
13use reth_node_core::args::StorageArgs;
14#[cfg(feature = "chain-import")]
15use std::path::Path;
16use std::{marker::PhantomData, sync::Arc, time::Duration};
17use tokio::sync::mpsc;
18use tracing::debug;
19
20/// Configuration for setting up test environment
21#[derive(Debug)]
22pub struct Setup<I> {
23    /// Chain specification to use
24    pub chain_spec: Option<Arc<ChainSpec>>,
25    /// Network configuration
26    pub network: NetworkSetup,
27    /// Engine tree configuration
28    pub tree_config: TreeConfig,
29    /// Shutdown channel to stop nodes when setup is dropped
30    shutdown_tx: Option<mpsc::Sender<()>>,
31    /// Is this setup in dev mode
32    pub is_dev: bool,
33    /// Whether to use v2 storage mode (hashed keys, static file changesets, rocksdb history).
34    ///
35    /// Defaults to the node's `--storage.v2` default.
36    pub storage_v2: bool,
37    /// Tracks instance generic.
38    _phantom: PhantomData<I>,
39    /// Holds the import result to keep nodes alive when using imported chain
40    /// This is stored as an option to avoid lifetime issues with `tokio::spawn`
41    #[cfg(feature = "chain-import")]
42    import_result_holder: Option<crate::setup_import::ChainImportResult>,
43    /// Path to RLP file to import during setup
44    pub import_rlp_path: Option<std::path::PathBuf>,
45}
46
47impl<I> Default for Setup<I> {
48    fn default() -> Self {
49        Self {
50            chain_spec: None,
51            network: NetworkSetup::default(),
52            tree_config: TreeConfig::default(),
53            shutdown_tx: None,
54            is_dev: true,
55            storage_v2: StorageArgs::default().v2,
56            _phantom: Default::default(),
57            #[cfg(feature = "chain-import")]
58            import_result_holder: None,
59            import_rlp_path: None,
60        }
61    }
62}
63
64impl<I> Drop for Setup<I> {
65    fn drop(&mut self) {
66        // Send shutdown signal if the channel exists
67        if let Some(tx) = self.shutdown_tx.take() {
68            let _ = tx.try_send(());
69        }
70    }
71}
72
73impl<I> Setup<I>
74where
75    I: EngineTypes,
76{
77    /// Set the chain specification
78    pub fn with_chain_spec(mut self, chain_spec: Arc<ChainSpec>) -> Self {
79        self.chain_spec = Some(chain_spec);
80        self
81    }
82
83    /// Set the network configuration
84    pub const fn with_network(mut self, network: NetworkSetup) -> Self {
85        self.network = network;
86        self
87    }
88
89    /// Set dev mode
90    pub const fn with_dev_mode(mut self, is_dev: bool) -> Self {
91        self.is_dev = is_dev;
92        self
93    }
94
95    /// Set the engine tree configuration
96    pub const fn with_tree_config(mut self, tree_config: TreeConfig) -> Self {
97        self.tree_config = tree_config;
98        self
99    }
100
101    /// Set whether to use v2 storage mode (hashed keys, static file changesets, rocksdb history)
102    pub const fn with_storage_v2(mut self, storage_v2: bool) -> Self {
103        self.storage_v2 = storage_v2;
104        self
105    }
106
107    /// Apply setup using pre-imported chain data from RLP file
108    ///
109    /// Returns once the engine of every node accepted the latest imported block as head and safe
110    /// block, with genesis as finalized block, which is the forkchoice state recorded in `env`.
111    /// Returns an error if a node does not accept it within [`WAIT_TIMEOUT`].
112    ///
113    /// [`WAIT_TIMEOUT`]: crate::wait::WAIT_TIMEOUT
114    #[cfg(feature = "chain-import")]
115    pub async fn apply_with_import<N>(
116        &mut self,
117        env: &mut Environment<I>,
118        rlp_path: &Path,
119    ) -> Result<()>
120    where
121        N: NodeBuilderHelper<Payload = I>,
122    {
123        // Note: this future is quite large so we box it
124        Box::pin(self.apply_with_import_(env, rlp_path)).await
125    }
126
127    /// Apply setup using pre-imported chain data from RLP file
128    #[cfg(feature = "chain-import")]
129    async fn apply_with_import_(
130        &mut self,
131        env: &mut Environment<I>,
132        rlp_path: &Path,
133    ) -> Result<()> {
134        // Create nodes with imported chain data
135        let import_result = self.create_nodes_with_import(rlp_path).await?;
136
137        // Extract node clients
138        let mut node_clients = Vec::new();
139        let nodes = &import_result.nodes;
140        for node in nodes {
141            let rpc = node
142                .rpc_client()
143                .ok_or_else(|| eyre!("Failed to create HTTP RPC client for node"))?;
144            let auth = node.auth_server_handle();
145            let url = node.rpc_url();
146            // TODO: Pass beacon_engine_handle once import system supports generic types
147            node_clients.push(crate::testsuite::NodeClient::new(rpc, auth, url));
148        }
149
150        // Store the import result to keep nodes alive
151        // They will be dropped when the Setup is dropped
152        self.import_result_holder = Some(import_result);
153
154        // Finalize setup - this will wait for nodes and initialize states
155        self.finalize_setup(env, node_clients, true).await
156    }
157
158    /// Apply the setup to the environment
159    pub async fn apply<N>(&mut self, env: &mut Environment<I>) -> Result<()>
160    where
161        N: NodeBuilderHelper<Payload = I, ChainSpec: From<ChainSpec>>,
162        PayloadAttrTy<N>: From<PayloadAttributes>,
163    {
164        // Note: this future is quite large so we box it
165        Box::pin(self.apply_::<N>(env)).await
166    }
167
168    /// Apply the setup to the environment
169    async fn apply_<N>(&mut self, env: &mut Environment<I>) -> Result<()>
170    where
171        N: NodeBuilderHelper<Payload = I, ChainSpec: From<ChainSpec>>,
172        PayloadAttrTy<N>: From<PayloadAttributes>,
173    {
174        // If import_rlp_path is set, use apply_with_import instead
175        if let Some(rlp_path) = self.import_rlp_path.take() {
176            #[cfg(feature = "chain-import")]
177            return self.apply_with_import::<N>(env, &rlp_path).await;
178            #[cfg(not(feature = "chain-import"))]
179            return Err(eyre!(
180                "importing {} requires the `chain-import` feature",
181                rlp_path.display()
182            ));
183        }
184        let chain_spec =
185            self.chain_spec.clone().ok_or_else(|| eyre!("Chain specification is required"))?;
186
187        let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
188        self.shutdown_tx = Some(shutdown_tx);
189
190        let tree_config = self.tree_config.clone();
191
192        let result = N::test_setup(
193            self.network.node_count,
194            Arc::<N::ChainSpec>::new((*chain_spec).clone().into()),
195        )
196        .with_tree_config_modifier(move |base| {
197            tree_config.clone().with_cross_block_cache_size(base.cross_block_cache_size())
198        })
199        .with_dev_mode(self.is_dev)
200        .with_storage_v2(self.storage_v2)
201        .with_connect_nodes(self.network.connect_nodes)
202        .build()
203        .await;
204
205        let mut node_clients = Vec::new();
206        match result {
207            Ok((nodes, _wallet)) => {
208                // create HTTP clients for each node's RPC and Engine API endpoints
209                for node in &nodes {
210                    node_clients.push(node.to_node_client()?);
211                }
212
213                // spawn a separate task just to handle the shutdown
214                tokio::spawn(async move {
215                    // keep nodes in scope to ensure they're not dropped
216                    let _nodes = nodes;
217                    // Wait for shutdown signal
218                    let _ = shutdown_rx.recv().await;
219                    // nodes will be dropped here when the test completes
220                });
221            }
222            Err(e) => {
223                return Err(eyre!("Failed to setup nodes: {}", e));
224            }
225        }
226
227        // Finalize setup
228        self.finalize_setup(env, node_clients, false).await
229    }
230
231    /// Create nodes with imported chain data
232    ///
233    /// Note: Currently this only supports `EthereumNode` due to the import process
234    /// being Ethereum-specific. The generic parameter N is kept for consistency
235    /// with other methods but is not used.
236    #[cfg(feature = "chain-import")]
237    async fn create_nodes_with_import(
238        &self,
239        rlp_path: &Path,
240    ) -> Result<crate::setup_import::ChainImportResult> {
241        let chain_spec =
242            self.chain_spec.clone().ok_or_else(|| eyre!("Chain specification is required"))?;
243
244        crate::setup_import::setup_engine_with_chain_import(
245            self.network.node_count,
246            chain_spec,
247            self.is_dev,
248            self.storage_v2,
249            self.tree_config.clone(),
250            rlp_path,
251        )
252        .await
253    }
254
255    /// Common finalization logic for both apply methods
256    async fn finalize_setup(
257        &self,
258        env: &mut Environment<I>,
259        node_clients: Vec<crate::testsuite::NodeClient<I>>,
260        use_latest_block: bool,
261    ) -> Result<()> {
262        if node_clients.is_empty() {
263            return Err(eyre!("No nodes were created"));
264        }
265
266        // Wait for all nodes to be ready
267        self.wait_for_nodes_ready(&node_clients).await?;
268
269        env.node_clients = node_clients;
270        env.initialize_node_states(self.network.node_count);
271
272        // Get initial block info (genesis or latest depending on use_latest_block)
273        let (initial_block_info, genesis_block_info) = if use_latest_block {
274            // For imported chain, get both latest and genesis
275            let latest =
276                self.get_block_info(&env.node_clients[0], BlockNumberOrTag::Latest).await?;
277            let genesis =
278                self.get_block_info(&env.node_clients[0], BlockNumberOrTag::Number(0)).await?;
279            (latest, genesis)
280        } else {
281            // For fresh chain, both are genesis
282            let genesis =
283                self.get_block_info(&env.node_clients[0], BlockNumberOrTag::Number(0)).await?;
284            (genesis, genesis)
285        };
286
287        // Initialize all node states
288        let fork_choice_state = ForkchoiceState {
289            head_block_hash: initial_block_info.hash,
290            safe_block_hash: initial_block_info.hash,
291            finalized_block_hash: genesis_block_info.hash,
292        };
293        for (node_idx, node_state) in env.node_states.iter_mut().enumerate() {
294            node_state.current_block_info = Some(initial_block_info);
295            node_state.latest_header_time = initial_block_info.timestamp;
296            node_state.latest_fork_choice_state = fork_choice_state;
297
298            debug!(
299                "Node {} initialized with block {} (hash: {})",
300                node_idx, initial_block_info.number, initial_block_info.hash
301            );
302        }
303
304        // Fresh nodes are launched with genesis as their forkchoice state, nodes on an imported
305        // chain are not, so make the imported head canonical before actions build on it.
306        if use_latest_block {
307            self.wait_for_forkchoice_valid(&env.node_clients, fork_choice_state).await?;
308        }
309
310        debug!(
311            "Environment initialized with {} nodes, starting from block {} (hash: {})",
312            self.network.node_count, initial_block_info.number, initial_block_info.hash
313        );
314
315        Ok(())
316    }
317
318    /// Wait for all nodes to be ready to accept RPC requests
319    async fn wait_for_nodes_ready<P>(
320        &self,
321        node_clients: &[crate::testsuite::NodeClient<P>],
322    ) -> Result<()>
323    where
324        P: PayloadTypes,
325    {
326        for (idx, client) in node_clients.iter().enumerate() {
327            poll_until(format!("node {idx} RPC endpoint to accept requests"), || async {
328                Ok(client.is_ready().await.then_some(()))
329            })
330            .await?;
331            debug!("Node {idx} RPC endpoint is ready");
332        }
333        Ok(())
334    }
335
336    /// Waits until the engine of every node accepts `state` as its forkchoice state.
337    ///
338    /// The chain import leaves some stage checkpoints, e.g. of the prune stages, behind the
339    /// imported head, so a node launched on an imported chain starts with a backfill run to the
340    /// head and answers forkchoice updates with SYNCING until it finished. This resends the update
341    /// until the node answers with another status, and returns an error unless that status is
342    /// VALID. Every attempt is a forkchoice update, so attempts are spaced further apart than the
343    /// default poll interval.
344    async fn wait_for_forkchoice_valid(
345        &self,
346        node_clients: &[crate::testsuite::NodeClient<I>],
347        state: ForkchoiceState,
348    ) -> Result<()> {
349        for (idx, client) in node_clients.iter().enumerate() {
350            let engine = client.engine.http_client();
351            let response = poll_until_with(
352                PollOpts { interval: Duration::from_millis(100), ..Default::default() },
353                format!("node {idx} to stop syncing to block {}", state.head_block_hash),
354                || async {
355                    let response = engine
356                        .request::<_, ForkchoiceUpdated>(
357                            "engine_forkchoiceUpdatedV3",
358                            (state, None::<PayloadAttributes>),
359                        )
360                        .await?;
361                    Ok((!response.is_syncing()).then_some(response))
362                },
363            )
364            .await?;
365            expect_fcu_valid(
366                &response,
367                &format!("Node {idx} forkchoice update to block {}", state.head_block_hash),
368            )?;
369        }
370        Ok(())
371    }
372
373    /// Get block info for a given block number or tag
374    async fn get_block_info<P>(
375        &self,
376        client: &crate::testsuite::NodeClient<P>,
377        block: BlockNumberOrTag,
378    ) -> Result<crate::testsuite::BlockInfo>
379    where
380        P: PayloadTypes,
381    {
382        let block = client
383            .get_block_by_number(block)
384            .await?
385            .ok_or_else(|| eyre!("Block {:?} not found", block))?;
386
387        Ok(crate::testsuite::BlockInfo {
388            hash: block.header.hash,
389            number: block.header.number,
390            timestamp: block.header.timestamp,
391        })
392    }
393}
394
395/// Network configuration for setup
396#[derive(Debug, Default)]
397pub struct NetworkSetup {
398    /// Number of nodes to create
399    pub node_count: usize,
400    /// Whether nodes should be connected to each other
401    pub connect_nodes: bool,
402}
403
404impl NetworkSetup {
405    /// Create a new network setup with a single node
406    pub const fn single_node() -> Self {
407        Self { node_count: 1, connect_nodes: true }
408    }
409
410    /// Create a new network setup with multiple nodes (connected)
411    pub const fn multi_node(count: usize) -> Self {
412        Self { node_count: count, connect_nodes: true }
413    }
414
415    /// Create a new network setup with multiple nodes (disconnected)
416    pub const fn multi_node_unconnected(count: usize) -> Self {
417        Self { node_count: count, connect_nodes: false }
418    }
419}