reth_e2e_test_utils/testsuite/actions/
node_ops.rs

1//! Node-specific operations for multi-node testing.
2
3use crate::testsuite::{Action, Environment};
4use alloy_rpc_types_eth::{Block, Header, Receipt, Transaction, TransactionRequest};
5use eyre::Result;
6use futures_util::future::BoxFuture;
7use reth_ethereum_primitives::TransactionSigned;
8use reth_node_api::EngineTypes;
9use reth_rpc_api::clients::EthApiClient;
10use std::time::Duration;
11use tokio::time::{sleep, timeout};
12use tracing::debug;
13
14/// Action to select which node should be active for subsequent single-node operations.
15#[derive(Debug)]
16pub struct SelectActiveNode {
17    /// Node index to set as active
18    pub node_idx: usize,
19}
20
21impl SelectActiveNode {
22    /// Create a new `SelectActiveNode` action
23    pub const fn new(node_idx: usize) -> Self {
24        Self { node_idx }
25    }
26}
27
28impl<Engine> Action<Engine> for SelectActiveNode
29where
30    Engine: EngineTypes,
31{
32    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
33        Box::pin(async move {
34            env.set_active_node(self.node_idx)?;
35            debug!("Set active node to {}", self.node_idx);
36            Ok(())
37        })
38    }
39}
40
41/// Action to compare chain tips between two nodes.
42#[derive(Debug)]
43pub struct CompareNodeChainTips {
44    /// First node index
45    pub node_a: usize,
46    /// Second node index
47    pub node_b: usize,
48    /// Whether tips should be the same or different
49    pub should_be_equal: bool,
50}
51
52impl CompareNodeChainTips {
53    /// Create a new action expecting nodes to have the same chain tip
54    pub const fn expect_same(node_a: usize, node_b: usize) -> Self {
55        Self { node_a, node_b, should_be_equal: true }
56    }
57
58    /// Create a new action expecting nodes to have different chain tips
59    pub const fn expect_different(node_a: usize, node_b: usize) -> Self {
60        Self { node_a, node_b, should_be_equal: false }
61    }
62}
63
64impl<Engine> Action<Engine> for CompareNodeChainTips
65where
66    Engine: EngineTypes,
67{
68    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
69        Box::pin(async move {
70            if self.node_a >= env.node_count() || self.node_b >= env.node_count() {
71                return Err(eyre::eyre!("Node index out of bounds"));
72            }
73
74            let node_a_client = &env.node_clients[self.node_a];
75            let node_b_client = &env.node_clients[self.node_b];
76
77            // Get latest block from each node
78            let block_a = EthApiClient::<
79                TransactionRequest,
80                Transaction,
81                Block,
82                Receipt,
83                Header,
84                TransactionSigned,
85            >::block_by_number(
86                &node_a_client.rpc, alloy_eips::BlockNumberOrTag::Latest, false
87            )
88            .await?
89            .ok_or_else(|| eyre::eyre!("Failed to get latest block from node {}", self.node_a))?;
90
91            let block_b = EthApiClient::<
92                TransactionRequest,
93                Transaction,
94                Block,
95                Receipt,
96                Header,
97                TransactionSigned,
98            >::block_by_number(
99                &node_b_client.rpc, alloy_eips::BlockNumberOrTag::Latest, false
100            )
101            .await?
102            .ok_or_else(|| eyre::eyre!("Failed to get latest block from node {}", self.node_b))?;
103
104            let tips_equal = block_a.header.hash == block_b.header.hash;
105
106            debug!(
107                "Node {} chain tip: {} (block {}), Node {} chain tip: {} (block {})",
108                self.node_a,
109                block_a.header.hash,
110                block_a.header.number,
111                self.node_b,
112                block_b.header.hash,
113                block_b.header.number
114            );
115
116            if self.should_be_equal && !tips_equal {
117                return Err(eyre::eyre!(
118                    "Expected nodes {} and {} to have the same chain tip, but node {} has {} and node {} has {}",
119                    self.node_a, self.node_b, self.node_a, block_a.header.hash, self.node_b, block_b.header.hash
120                ));
121            }
122
123            if !self.should_be_equal && tips_equal {
124                return Err(eyre::eyre!(
125                    "Expected nodes {} and {} to have different chain tips, but both have {}",
126                    self.node_a,
127                    self.node_b,
128                    block_a.header.hash
129                ));
130            }
131
132            Ok(())
133        })
134    }
135}
136
137/// Action to capture a block with a tag, associating it with a specific node.
138#[derive(Debug)]
139pub struct CaptureBlockOnNode {
140    /// Tag name to associate with the block
141    pub tag: String,
142    /// Node index to capture the block from
143    pub node_idx: usize,
144}
145
146impl CaptureBlockOnNode {
147    /// Create a new `CaptureBlockOnNode` action
148    pub fn new(tag: impl Into<String>, node_idx: usize) -> Self {
149        Self { tag: tag.into(), node_idx }
150    }
151}
152
153impl<Engine> Action<Engine> for CaptureBlockOnNode
154where
155    Engine: EngineTypes,
156{
157    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
158        Box::pin(async move {
159            let node_state = env.node_state(self.node_idx)?;
160            let current_block = node_state.current_block_info.ok_or_else(|| {
161                eyre::eyre!("No current block information available for node {}", self.node_idx)
162            })?;
163
164            env.block_registry.insert(self.tag.clone(), (current_block, self.node_idx));
165
166            debug!(
167                "Captured block {} (hash: {}) from node {} with tag '{}'",
168                current_block.number, current_block.hash, self.node_idx, self.tag
169            );
170
171            Ok(())
172        })
173    }
174}
175
176/// Action to get a block by tag and verify which node it came from.
177#[derive(Debug)]
178pub struct ValidateBlockTag {
179    /// Tag to look up
180    pub tag: String,
181    /// Expected node index (optional)
182    pub expected_node_idx: Option<usize>,
183}
184
185impl ValidateBlockTag {
186    /// Create a new action to validate a block tag exists
187    pub fn exists(tag: impl Into<String>) -> Self {
188        Self { tag: tag.into(), expected_node_idx: None }
189    }
190
191    /// Create a new action to validate a block tag came from a specific node
192    pub fn from_node(tag: impl Into<String>, node_idx: usize) -> Self {
193        Self { tag: tag.into(), expected_node_idx: Some(node_idx) }
194    }
195}
196
197impl<Engine> Action<Engine> for ValidateBlockTag
198where
199    Engine: EngineTypes,
200{
201    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
202        Box::pin(async move {
203            let (block_info, node_idx) = env
204                .block_registry
205                .get(&self.tag)
206                .copied()
207                .ok_or_else(|| eyre::eyre!("Block tag '{}' not found in registry", self.tag))?;
208
209            if let Some(expected_node) = self.expected_node_idx &&
210                node_idx != expected_node
211            {
212                return Err(eyre::eyre!(
213                    "Block tag '{}' came from node {} but expected node {}",
214                    self.tag,
215                    node_idx,
216                    expected_node
217                ));
218            }
219
220            debug!(
221                "Validated block tag '{}': block {} (hash: {}) from node {}",
222                self.tag, block_info.number, block_info.hash, node_idx
223            );
224
225            Ok(())
226        })
227    }
228}
229
230/// Action that waits for two nodes to sync and have the same chain tip.
231#[derive(Debug)]
232pub struct WaitForSync {
233    /// First node index
234    pub node_a: usize,
235    /// Second node index
236    pub node_b: usize,
237    /// Maximum time to wait for sync (default: 30 seconds)
238    pub timeout_secs: u64,
239    /// Polling interval (default: 1 second)
240    pub poll_interval_secs: u64,
241}
242
243impl WaitForSync {
244    /// Create a new `WaitForSync` action with default timeouts
245    pub const fn new(node_a: usize, node_b: usize) -> Self {
246        Self { node_a, node_b, timeout_secs: 30, poll_interval_secs: 1 }
247    }
248
249    /// Set custom timeout
250    pub const fn with_timeout(mut self, timeout_secs: u64) -> Self {
251        self.timeout_secs = timeout_secs;
252        self
253    }
254
255    /// Set custom poll interval
256    pub const fn with_poll_interval(mut self, poll_interval_secs: u64) -> Self {
257        self.poll_interval_secs = poll_interval_secs;
258        self
259    }
260}
261
262impl<Engine> Action<Engine> for WaitForSync
263where
264    Engine: EngineTypes,
265{
266    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
267        Box::pin(async move {
268            if self.node_a >= env.node_count() || self.node_b >= env.node_count() {
269                return Err(eyre::eyre!("Node index out of bounds"));
270            }
271
272            let timeout_duration = Duration::from_secs(self.timeout_secs);
273            let poll_interval = Duration::from_secs(self.poll_interval_secs);
274
275            debug!(
276                "Waiting for nodes {} and {} to sync (timeout: {}s, poll interval: {}s)",
277                self.node_a, self.node_b, self.timeout_secs, self.poll_interval_secs
278            );
279
280            let sync_check = async {
281                loop {
282                    let node_a_client = &env.node_clients[self.node_a];
283                    let node_b_client = &env.node_clients[self.node_b];
284
285                    // Get latest block from each node
286                    let block_a = EthApiClient::<
287                        TransactionRequest,
288                        Transaction,
289                        Block,
290                        Receipt,
291                        Header,
292                        TransactionSigned,
293                    >::block_by_number(
294                        &node_a_client.rpc,
295                        alloy_eips::BlockNumberOrTag::Latest,
296                        false,
297                    )
298                    .await?
299                    .ok_or_else(|| {
300                        eyre::eyre!("Failed to get latest block from node {}", self.node_a)
301                    })?;
302
303                    let block_b = EthApiClient::<
304                        TransactionRequest,
305                        Transaction,
306                        Block,
307                        Receipt,
308                        Header,
309                        TransactionSigned,
310                    >::block_by_number(
311                        &node_b_client.rpc,
312                        alloy_eips::BlockNumberOrTag::Latest,
313                        false,
314                    )
315                    .await?
316                    .ok_or_else(|| {
317                        eyre::eyre!("Failed to get latest block from node {}", self.node_b)
318                    })?;
319
320                    debug!(
321                        "Sync check: Node {} tip: {} (block {}), Node {} tip: {} (block {})",
322                        self.node_a,
323                        block_a.header.hash,
324                        block_a.header.number,
325                        self.node_b,
326                        block_b.header.hash,
327                        block_b.header.number
328                    );
329
330                    if block_a.header.hash == block_b.header.hash {
331                        debug!(
332                            "Nodes {} and {} successfully synced to block {} (hash: {})",
333                            self.node_a, self.node_b, block_a.header.number, block_a.header.hash
334                        );
335                        return Ok(());
336                    }
337
338                    sleep(poll_interval).await;
339                }
340            };
341
342            match timeout(timeout_duration, sync_check).await {
343                Ok(result) => result,
344                Err(_) => Err(eyre::eyre!(
345                    "Timeout waiting for nodes {} and {} to sync after {}s",
346                    self.node_a,
347                    self.node_b,
348                    self.timeout_secs
349                )),
350            }
351        })
352    }
353}
354
355/// Action to assert the current chain tip is at a specific block number.
356#[derive(Debug)]
357pub struct AssertChainTip {
358    /// Expected block number
359    pub expected_block_number: u64,
360}
361
362impl AssertChainTip {
363    /// Create a new `AssertChainTip` action
364    pub const fn new(expected_block_number: u64) -> Self {
365        Self { expected_block_number }
366    }
367}
368
369impl<Engine> Action<Engine> for AssertChainTip
370where
371    Engine: EngineTypes,
372{
373    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
374        Box::pin(async move {
375            let current_block = env
376                .current_block_info()
377                .ok_or_else(|| eyre::eyre!("No current block information available"))?;
378
379            if current_block.number != self.expected_block_number {
380                return Err(eyre::eyre!(
381                    "Expected chain tip to be at block {}, but found block {}",
382                    self.expected_block_number,
383                    current_block.number
384                ));
385            }
386
387            debug!(
388                "Chain tip verified at block {} (hash: {})",
389                current_block.number, current_block.hash
390            );
391
392            Ok(())
393        })
394    }
395}