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