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