Skip to main content

reth_rpc_e2e_tests/
rpc_compat.rs

1//! RPC compatibility test actions for testing RPC methods against execution-apis test data.
2
3use alloy_rpc_types_engine::ForkchoiceUpdated;
4use eyre::{eyre, Result};
5use futures_util::future::BoxFuture;
6use reth_e2e_test_utils::testsuite::{
7    actions::{expect_fcu_valid, Action},
8    BlockInfo, Environment,
9};
10use reth_node_api::EngineTypes;
11use serde_json::Value;
12use std::path::Path;
13use tracing::{debug, info};
14
15/// Test case from execution-apis .io file format
16#[derive(Debug, Clone)]
17pub struct RpcTestCase {
18    /// The test name (filename without .io extension)
19    pub name: String,
20    /// Request to send (as JSON value)
21    pub request: Value,
22    /// Expected response (as JSON value)
23    pub expected_response: Value,
24    /// Whether this test is spec-only
25    pub spec_only: bool,
26}
27
28/// Action that runs RPC compatibility tests from execution-apis test data
29#[derive(Debug)]
30pub struct RunRpcCompatTests {
31    /// RPC methods to test (e.g. `eth_getLogs`)
32    pub methods: Vec<String>,
33    /// Path to the execution-apis tests directory
34    pub test_data_path: String,
35    /// Whether to stop on first failure
36    pub fail_fast: bool,
37}
38
39impl RunRpcCompatTests {
40    /// Create a new RPC compatibility test runner
41    pub fn new(methods: Vec<String>, test_data_path: impl Into<String>) -> Self {
42        Self { methods, test_data_path: test_data_path.into(), fail_fast: false }
43    }
44
45    /// Set whether to stop on first failure
46    pub const fn with_fail_fast(mut self, fail_fast: bool) -> Self {
47        self.fail_fast = fail_fast;
48        self
49    }
50
51    /// Parse a .io test file
52    fn parse_io_file(content: &str) -> Result<RpcTestCase> {
53        let mut lines = content.lines();
54        let mut spec_only = false;
55        let mut request_line = None;
56        let mut response_line = None;
57
58        // Skip comments and look for spec_only marker
59        for line in lines.by_ref() {
60            let line = line.trim();
61            if line.starts_with("//") {
62                if line.contains("speconly:") {
63                    spec_only = true;
64                }
65            } else if let Some(stripped) = line.strip_prefix(">>") {
66                request_line = Some(stripped.trim());
67                break;
68            }
69        }
70
71        // Look for response
72        for line in lines {
73            let line = line.trim();
74            if let Some(stripped) = line.strip_prefix("<<") {
75                response_line = Some(stripped.trim());
76                break;
77            }
78        }
79
80        let request_str =
81            request_line.ok_or_else(|| eyre!("No request found in test file (>> marker)"))?;
82        let response_str =
83            response_line.ok_or_else(|| eyre!("No response found in test file (<< marker)"))?;
84
85        // Parse request
86        let request: Value = serde_json::from_str(request_str)
87            .map_err(|e| eyre!("Failed to parse request: {}", e))?;
88
89        // Parse response
90        let expected_response: Value = serde_json::from_str(response_str)
91            .map_err(|e| eyre!("Failed to parse response: {}", e))?;
92
93        Ok(RpcTestCase { name: String::new(), request, expected_response, spec_only })
94    }
95
96    /// Compare JSON values with special handling for numbers and errors
97    /// Uses iterative approach to avoid stack overflow with deeply nested structures
98    fn compare_json_values(actual: &Value, expected: &Value, path: &str) -> Result<()> {
99        // Stack to hold work items: (actual, expected, path)
100        let mut work_stack = vec![(actual, expected, path.to_string())];
101
102        while let Some((actual, expected, current_path)) = work_stack.pop() {
103            match (actual, expected) {
104                // Number comparison: handle different representations
105                (Value::Number(a), Value::Number(b)) => {
106                    let a_f64 = a.as_f64().ok_or_else(|| eyre!("Invalid number"))?;
107                    let b_f64 = b.as_f64().ok_or_else(|| eyre!("Invalid number"))?;
108                    // Use a reasonable epsilon for floating point comparison
109                    const EPSILON: f64 = 1e-10;
110                    if (a_f64 - b_f64).abs() > EPSILON {
111                        return Err(eyre!("Number mismatch at {}: {} != {}", current_path, a, b));
112                    }
113                }
114                // Array comparison
115                (Value::Array(a), Value::Array(b)) => {
116                    if a.len() != b.len() {
117                        return Err(eyre!(
118                            "Array length mismatch at {}: {} != {}",
119                            current_path,
120                            a.len(),
121                            b.len()
122                        ));
123                    }
124                    // Add array elements to work stack in reverse order
125                    // so they are processed in correct order
126                    for (i, (av, bv)) in a.iter().zip(b.iter()).enumerate().rev() {
127                        work_stack.push((av, bv, format!("{current_path}[{i}]")));
128                    }
129                }
130                // Object comparison
131                (Value::Object(a), Value::Object(b)) => {
132                    // Check all keys in expected are present in actual
133                    for (key, expected_val) in b {
134                        if let Some(actual_val) = a.get(key) {
135                            work_stack.push((
136                                actual_val,
137                                expected_val,
138                                format!("{current_path}.{key}"),
139                            ));
140                        } else {
141                            return Err(eyre!("Missing key at {}.{}", current_path, key));
142                        }
143                    }
144                }
145                // Direct value comparison
146                (a, b) => {
147                    if a != b {
148                        return Err(eyre!("Value mismatch at {}: {:?} != {:?}", current_path, a, b));
149                    }
150                }
151            }
152        }
153        Ok(())
154    }
155
156    /// Execute a single test case
157    async fn execute_test_case<Engine: EngineTypes>(
158        &self,
159        test_case: &RpcTestCase,
160        env: &Environment<Engine>,
161    ) -> Result<()> {
162        let node_client = &env.node_clients[env.active_node_idx];
163
164        // Extract method and params from request
165        let method = test_case
166            .request
167            .get("method")
168            .and_then(|v| v.as_str())
169            .ok_or_else(|| eyre!("Request missing method field"))?;
170
171        let params = test_case.request.get("params").cloned().unwrap_or(Value::Array(vec![]));
172
173        // Non-array params are wrapped in an array, and RPC errors become error responses below.
174        let params = match params {
175            Value::Array(_) => params,
176            _ => Value::Array(vec![params]),
177        };
178        let response_result = node_client.rpc.request::<_, Value>(method.to_owned(), params).await;
179
180        // Build actual response object to match execution-apis format
181        let actual_response = match response_result {
182            Ok(response) => {
183                serde_json::json!({
184                    "jsonrpc": "2.0",
185                    "id": test_case.request.get("id").cloned().unwrap_or(Value::Null),
186                    "result": response
187                })
188            }
189            Err(err) => {
190                // RPC error - build error response
191                serde_json::json!({
192                    "jsonrpc": "2.0",
193                    "id": test_case.request.get("id").cloned().unwrap_or(Value::Null),
194                    "error": {
195                        "code": -32000, // Generic error code
196                        "message": err.to_string()
197                    }
198                })
199            }
200        };
201
202        // Compare responses
203        let expected_result = test_case.expected_response.get("result");
204        let expected_error = test_case.expected_response.get("error");
205        let actual_result = actual_response.get("result");
206        let actual_error = actual_response.get("error");
207
208        match (expected_result, expected_error) {
209            (Some(expected), None) => {
210                // Expected success response
211                if let Some(actual) = actual_result {
212                    Self::compare_json_values(actual, expected, "result")?;
213                } else if let Some(error) = actual_error {
214                    return Err(eyre!("Expected success response but got error: {}", error));
215                } else {
216                    return Err(eyre!("Expected success response but got neither result nor error"));
217                }
218            }
219            (None, Some(_)) => {
220                // Expected error response - just check that we got an error
221                if actual_error.is_none() {
222                    return Err(eyre!("Expected error response but got success"));
223                }
224                debug!("Both responses are errors (expected behavior)");
225            }
226            _ => {
227                return Err(eyre!("Invalid expected response format"));
228            }
229        }
230
231        Ok(())
232    }
233}
234
235impl<Engine> Action<Engine> for RunRpcCompatTests
236where
237    Engine: EngineTypes,
238{
239    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
240        Box::pin(async move {
241            let mut total_tests = 0;
242            let mut passed_tests = 0;
243
244            for method in &self.methods {
245                info!("Running RPC compatibility tests for {}", method);
246
247                let method_dir = Path::new(&self.test_data_path).join(method);
248                if !method_dir.exists() {
249                    return Err(eyre!("Test directory does not exist: {}", method_dir.display()));
250                }
251
252                // Read all .io files in the method directory
253                let entries = std::fs::read_dir(&method_dir)
254                    .map_err(|e| eyre!("Failed to read directory: {}", e))?;
255
256                for entry in entries {
257                    let entry = entry?;
258                    let path = entry.path();
259
260                    if path.extension().and_then(|s| s.to_str()) == Some("io") {
261                        let test_name = path
262                            .file_stem()
263                            .and_then(|s| s.to_str())
264                            .unwrap_or("unknown")
265                            .to_string();
266
267                        let content = std::fs::read_to_string(&path)
268                            .map_err(|e| eyre!("Failed to read test file: {}", e))?;
269
270                        match Self::parse_io_file(&content) {
271                            Ok(mut test_case) => {
272                                test_case.name = test_name.clone();
273                                total_tests += 1;
274
275                                match self.execute_test_case(&test_case, env).await {
276                                    Ok(_) => {
277                                        info!("✓ {}/{}: PASS", method, test_name);
278                                        passed_tests += 1;
279                                    }
280                                    Err(e) => {
281                                        info!("✗ {}/{}: FAIL - {}", method, test_name, e);
282
283                                        if self.fail_fast {
284                                            return Err(eyre!("Test failed (fail-fast enabled)"));
285                                        }
286                                    }
287                                }
288                            }
289                            Err(e) => {
290                                info!("✗ {}/{}: PARSE ERROR - {}", method, test_name, e);
291                                if self.fail_fast {
292                                    return Err(e);
293                                }
294                            }
295                        }
296                    }
297                }
298            }
299
300            info!("RPC compatibility test results: {}/{} passed", passed_tests, total_tests);
301
302            if passed_tests < total_tests {
303                return Err(eyre!("Some tests failed: {}/{} passed", passed_tests, total_tests));
304            }
305
306            Ok(())
307        })
308    }
309}
310
311/// Action to initialize the chain from execution-apis test data
312#[derive(Debug)]
313pub struct InitializeFromExecutionApis {
314    /// Path to the base.rlp file (if different from default)
315    pub chain_rlp_path: Option<String>,
316    /// Path to the headfcu.json file (if different from default)
317    pub fcu_json_path: Option<String>,
318}
319
320impl Default for InitializeFromExecutionApis {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl InitializeFromExecutionApis {
327    /// Create with default paths (assumes execution-apis/tests structure)
328    pub const fn new() -> Self {
329        Self { chain_rlp_path: None, fcu_json_path: None }
330    }
331
332    /// Set custom chain RLP path
333    pub fn with_chain_rlp(mut self, path: impl Into<String>) -> Self {
334        self.chain_rlp_path = Some(path.into());
335        self
336    }
337
338    /// Set custom FCU JSON path
339    pub fn with_fcu_json(mut self, path: impl Into<String>) -> Self {
340        self.fcu_json_path = Some(path.into());
341        self
342    }
343}
344
345impl<Engine> Action<Engine> for InitializeFromExecutionApis
346where
347    Engine: EngineTypes,
348{
349    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
350        Box::pin(async move {
351            // Load forkchoice state
352            let fcu_path = self
353                .fcu_json_path
354                .as_ref()
355                .map(Path::new)
356                .ok_or_else(|| eyre!("FCU JSON path is required"))?;
357
358            let fcu_state = reth_e2e_test_utils::setup_import::load_forkchoice_state(fcu_path)?;
359
360            info!(
361                "Applying forkchoice state - head: {}, safe: {}, finalized: {}",
362                fcu_state.head_block_hash,
363                fcu_state.safe_block_hash,
364                fcu_state.finalized_block_hash
365            );
366
367            // Apply forkchoice update to each node. The import setup only returns once the nodes
368            // accepted the imported head, so the update does not need to wait for syncing.
369            for (idx, client) in env.node_clients.iter().enumerate() {
370                debug!("Applying forkchoice update to node {}", idx);
371
372                let response: ForkchoiceUpdated = client
373                    .engine
374                    .http_client()
375                    .request("engine_forkchoiceUpdatedV3", (fcu_state, None::<()>))
376                    .await
377                    .map_err(|e| eyre!("Failed to update forkchoice on node {}: {}", idx, e))?;
378                expect_fcu_valid(&response, &format!("Forkchoice update on node {idx}"))?;
379            }
380
381            // Update environment state
382            env.active_node_state_mut()?.current_block_info = Some(BlockInfo {
383                hash: fcu_state.head_block_hash,
384                number: 0, // Will be updated when we fetch the actual block
385                timestamp: 0,
386            });
387
388            info!("Successfully initialized chain from execution-apis test data");
389            Ok(())
390        })
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use serde_json::json;
398
399    #[test]
400    fn test_compare_json_values_deeply_nested() {
401        // Test that the iterative comparison handles deeply nested structures
402        // without stack overflow
403        let mut nested = json!({"value": 0});
404        let mut expected = json!({"value": 0});
405
406        // Create a deeply nested structure
407        for i in 1..1000 {
408            nested = json!({"level": i, "nested": nested});
409            expected = json!({"level": i, "nested": expected});
410        }
411
412        // Should not panic with stack overflow
413        RunRpcCompatTests::compare_json_values(&nested, &expected, "root").unwrap();
414    }
415
416    #[test]
417    fn test_compare_json_values_arrays() {
418        // Test array comparison
419        let actual = json!([1, 2, 3, 4, 5]);
420        let expected = json!([1, 2, 3, 4, 5]);
421
422        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
423
424        // Test array length mismatch
425        let actual = json!([1, 2, 3]);
426        let expected = json!([1, 2, 3, 4, 5]);
427
428        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
429        assert!(result.is_err());
430        assert!(result.unwrap_err().to_string().contains("Array length mismatch"));
431    }
432
433    #[test]
434    fn test_compare_json_values_objects() {
435        // Test object comparison
436        let actual = json!({"a": 1, "b": 2, "c": 3});
437        let expected = json!({"a": 1, "b": 2, "c": 3});
438
439        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
440
441        // Test missing key
442        let actual = json!({"a": 1, "b": 2});
443        let expected = json!({"a": 1, "b": 2, "c": 3});
444
445        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
446        assert!(result.is_err());
447        assert!(result.unwrap_err().to_string().contains("Missing key"));
448    }
449
450    #[test]
451    fn test_compare_json_values_numbers() {
452        // Test number comparison with floating point
453        let actual = json!({"value": 1.00000000001});
454        let expected = json!({"value": 1.0});
455
456        // Should be equal within epsilon (1e-10)
457        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
458
459        // Test significant difference
460        let actual = json!({"value": 1.1});
461        let expected = json!({"value": 1.0});
462
463        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
464        assert!(result.is_err());
465        assert!(result.unwrap_err().to_string().contains("Number mismatch"));
466    }
467}