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 eyre::{eyre, Result};
4use futures_util::future::BoxFuture;
5use jsonrpsee::core::client::ClientT;
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        // Make the RPC request using jsonrpsee
174        // We need to handle the case where the RPC might return an error
175        use jsonrpsee::core::params::ArrayParams;
176
177        let response_result: Result<Value, jsonrpsee::core::client::Error> = match params {
178            Value::Array(ref arr) => {
179                // Use ArrayParams for array parameters
180                let mut array_params = ArrayParams::new();
181                for param in arr {
182                    array_params
183                        .insert(param.clone())
184                        .map_err(|e| eyre!("Failed to insert param: {}", e))?;
185                }
186                node_client.rpc.request(method, array_params).await
187            }
188            _ => {
189                // For non-array params, wrap in an array
190                let mut array_params = ArrayParams::new();
191                array_params.insert(params).map_err(|e| eyre!("Failed to insert param: {}", e))?;
192                node_client.rpc.request(method, array_params).await
193            }
194        };
195
196        // Build actual response object to match execution-apis format
197        let actual_response = match response_result {
198            Ok(response) => {
199                serde_json::json!({
200                    "jsonrpc": "2.0",
201                    "id": test_case.request.get("id").cloned().unwrap_or(Value::Null),
202                    "result": response
203                })
204            }
205            Err(err) => {
206                // RPC error - build error response
207                serde_json::json!({
208                    "jsonrpc": "2.0",
209                    "id": test_case.request.get("id").cloned().unwrap_or(Value::Null),
210                    "error": {
211                        "code": -32000, // Generic error code
212                        "message": err.to_string()
213                    }
214                })
215            }
216        };
217
218        // Compare responses
219        let expected_result = test_case.expected_response.get("result");
220        let expected_error = test_case.expected_response.get("error");
221        let actual_result = actual_response.get("result");
222        let actual_error = actual_response.get("error");
223
224        match (expected_result, expected_error) {
225            (Some(expected), None) => {
226                // Expected success response
227                if let Some(actual) = actual_result {
228                    Self::compare_json_values(actual, expected, "result")?;
229                } else if let Some(error) = actual_error {
230                    return Err(eyre!("Expected success response but got error: {}", error));
231                } else {
232                    return Err(eyre!("Expected success response but got neither result nor error"));
233                }
234            }
235            (None, Some(_)) => {
236                // Expected error response - just check that we got an error
237                if actual_error.is_none() {
238                    return Err(eyre!("Expected error response but got success"));
239                }
240                debug!("Both responses are errors (expected behavior)");
241            }
242            _ => {
243                return Err(eyre!("Invalid expected response format"));
244            }
245        }
246
247        Ok(())
248    }
249}
250
251impl<Engine> Action<Engine> for RunRpcCompatTests
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            let mut total_tests = 0;
258            let mut passed_tests = 0;
259
260            for method in &self.methods {
261                info!("Running RPC compatibility tests for {}", method);
262
263                let method_dir = Path::new(&self.test_data_path).join(method);
264                if !method_dir.exists() {
265                    return Err(eyre!("Test directory does not exist: {}", method_dir.display()));
266                }
267
268                // Read all .io files in the method directory
269                let entries = std::fs::read_dir(&method_dir)
270                    .map_err(|e| eyre!("Failed to read directory: {}", e))?;
271
272                for entry in entries {
273                    let entry = entry?;
274                    let path = entry.path();
275
276                    if path.extension().and_then(|s| s.to_str()) == Some("io") {
277                        let test_name = path
278                            .file_stem()
279                            .and_then(|s| s.to_str())
280                            .unwrap_or("unknown")
281                            .to_string();
282
283                        let content = std::fs::read_to_string(&path)
284                            .map_err(|e| eyre!("Failed to read test file: {}", e))?;
285
286                        match Self::parse_io_file(&content) {
287                            Ok(mut test_case) => {
288                                test_case.name = test_name.clone();
289                                total_tests += 1;
290
291                                match self.execute_test_case(&test_case, env).await {
292                                    Ok(_) => {
293                                        info!("✓ {}/{}: PASS", method, test_name);
294                                        passed_tests += 1;
295                                    }
296                                    Err(e) => {
297                                        info!("✗ {}/{}: FAIL - {}", method, test_name, e);
298
299                                        if self.fail_fast {
300                                            return Err(eyre!("Test failed (fail-fast enabled)"));
301                                        }
302                                    }
303                                }
304                            }
305                            Err(e) => {
306                                info!("✗ {}/{}: PARSE ERROR - {}", method, test_name, e);
307                                if self.fail_fast {
308                                    return Err(e);
309                                }
310                            }
311                        }
312                    }
313                }
314            }
315
316            info!("RPC compatibility test results: {}/{} passed", passed_tests, total_tests);
317
318            if passed_tests < total_tests {
319                return Err(eyre!("Some tests failed: {}/{} passed", passed_tests, total_tests));
320            }
321
322            Ok(())
323        })
324    }
325}
326
327/// Action to initialize the chain from execution-apis test data
328#[derive(Debug)]
329pub struct InitializeFromExecutionApis {
330    /// Path to the base.rlp file (if different from default)
331    pub chain_rlp_path: Option<String>,
332    /// Path to the headfcu.json file (if different from default)
333    pub fcu_json_path: Option<String>,
334}
335
336impl Default for InitializeFromExecutionApis {
337    fn default() -> Self {
338        Self::new()
339    }
340}
341
342impl InitializeFromExecutionApis {
343    /// Create with default paths (assumes execution-apis/tests structure)
344    pub const fn new() -> Self {
345        Self { chain_rlp_path: None, fcu_json_path: None }
346    }
347
348    /// Set custom chain RLP path
349    pub fn with_chain_rlp(mut self, path: impl Into<String>) -> Self {
350        self.chain_rlp_path = Some(path.into());
351        self
352    }
353
354    /// Set custom FCU JSON path
355    pub fn with_fcu_json(mut self, path: impl Into<String>) -> Self {
356        self.fcu_json_path = Some(path.into());
357        self
358    }
359}
360
361impl<Engine> Action<Engine> for InitializeFromExecutionApis
362where
363    Engine: EngineTypes,
364{
365    fn execute<'a>(&'a mut self, env: &'a mut Environment<Engine>) -> BoxFuture<'a, Result<()>> {
366        Box::pin(async move {
367            // Load forkchoice state
368            let fcu_path = self
369                .fcu_json_path
370                .as_ref()
371                .map(Path::new)
372                .ok_or_else(|| eyre!("FCU JSON path is required"))?;
373
374            let fcu_state = reth_e2e_test_utils::setup_import::load_forkchoice_state(fcu_path)?;
375
376            info!(
377                "Applying forkchoice state - head: {}, safe: {}, finalized: {}",
378                fcu_state.head_block_hash,
379                fcu_state.safe_block_hash,
380                fcu_state.finalized_block_hash
381            );
382
383            // Apply forkchoice update to each node. The import setup only returns once the nodes
384            // accepted the imported head, so the update does not need to wait for syncing.
385            for (idx, client) in env.node_clients.iter().enumerate() {
386                debug!("Applying forkchoice update to node {}", idx);
387
388                let response =
389                    reth_rpc_api::clients::EngineApiClient::<Engine>::fork_choice_updated_v3(
390                        &client.engine.http_client(),
391                        fcu_state,
392                        None,
393                    )
394                    .await
395                    .map_err(|e| eyre!("Failed to update forkchoice on node {}: {}", idx, e))?;
396                expect_fcu_valid(&response, &format!("Forkchoice update on node {idx}"))?;
397            }
398
399            // Update environment state
400            env.active_node_state_mut()?.current_block_info = Some(BlockInfo {
401                hash: fcu_state.head_block_hash,
402                number: 0, // Will be updated when we fetch the actual block
403                timestamp: 0,
404            });
405
406            info!("Successfully initialized chain from execution-apis test data");
407            Ok(())
408        })
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use serde_json::json;
416
417    #[test]
418    fn test_compare_json_values_deeply_nested() {
419        // Test that the iterative comparison handles deeply nested structures
420        // without stack overflow
421        let mut nested = json!({"value": 0});
422        let mut expected = json!({"value": 0});
423
424        // Create a deeply nested structure
425        for i in 1..1000 {
426            nested = json!({"level": i, "nested": nested});
427            expected = json!({"level": i, "nested": expected});
428        }
429
430        // Should not panic with stack overflow
431        RunRpcCompatTests::compare_json_values(&nested, &expected, "root").unwrap();
432    }
433
434    #[test]
435    fn test_compare_json_values_arrays() {
436        // Test array comparison
437        let actual = json!([1, 2, 3, 4, 5]);
438        let expected = json!([1, 2, 3, 4, 5]);
439
440        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
441
442        // Test array length mismatch
443        let actual = json!([1, 2, 3]);
444        let expected = json!([1, 2, 3, 4, 5]);
445
446        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
447        assert!(result.is_err());
448        assert!(result.unwrap_err().to_string().contains("Array length mismatch"));
449    }
450
451    #[test]
452    fn test_compare_json_values_objects() {
453        // Test object comparison
454        let actual = json!({"a": 1, "b": 2, "c": 3});
455        let expected = json!({"a": 1, "b": 2, "c": 3});
456
457        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
458
459        // Test missing key
460        let actual = json!({"a": 1, "b": 2});
461        let expected = json!({"a": 1, "b": 2, "c": 3});
462
463        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
464        assert!(result.is_err());
465        assert!(result.unwrap_err().to_string().contains("Missing key"));
466    }
467
468    #[test]
469    fn test_compare_json_values_numbers() {
470        // Test number comparison with floating point
471        let actual = json!({"value": 1.00000000001});
472        let expected = json!({"value": 1.0});
473
474        // Should be equal within epsilon (1e-10)
475        RunRpcCompatTests::compare_json_values(&actual, &expected, "root").unwrap();
476
477        // Test significant difference
478        let actual = json!({"value": 1.1});
479        let expected = json!({"value": 1.0});
480
481        let result = RunRpcCompatTests::compare_json_values(&actual, &expected, "root");
482        assert!(result.is_err());
483        assert!(result.unwrap_err().to_string().contains("Number mismatch"));
484    }
485}