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