From a0845bab18caa520a311383929cd7cccd299e186 Mon Sep 17 00:00:00 2001 From: tonis Date: Tue, 20 Jan 2026 21:19:31 +0700 Subject: [PATCH] feat: Check CL/Reth capability compatibility (#20348) Co-authored-by: Matthias Seitz Co-authored-by: Amp --- crates/rpc/rpc-engine-api/src/capabilities.rs | 152 +++++++++++++++--- crates/rpc/rpc-engine-api/src/engine_api.rs | 9 +- 2 files changed, 139 insertions(+), 22 deletions(-) diff --git a/crates/rpc/rpc-engine-api/src/capabilities.rs b/crates/rpc/rpc-engine-api/src/capabilities.rs index 1e95d7ed1c..75583c821e 100644 --- a/crates/rpc/rpc-engine-api/src/capabilities.rs +++ b/crates/rpc/rpc-engine-api/src/capabilities.rs @@ -1,6 +1,11 @@ -use std::collections::HashSet; +//! Engine API capabilities. -/// The list of all supported Engine capabilities available over the engine endpoint. +use std::collections::HashSet; +use tracing::warn; + +/// All Engine API capabilities supported by Reth (Ethereum mainnet). +/// +/// See for updates. pub const CAPABILITIES: &[&str] = &[ "engine_forkchoiceUpdatedV1", "engine_forkchoiceUpdatedV2", @@ -22,43 +27,150 @@ pub const CAPABILITIES: &[&str] = &[ "engine_getBlobsV3", ]; -// The list of all supported Engine capabilities available over the engine endpoint. -/// -/// Latest spec: Prague +/// Engine API capabilities set. #[derive(Debug, Clone)] pub struct EngineCapabilities { inner: HashSet, } impl EngineCapabilities { - /// Creates a new `EngineCapabilities` instance with the given capabilities. - pub fn new(capabilities: impl IntoIterator>) -> Self { + /// Creates from an iterator of capability strings. + pub fn new(capabilities: impl IntoIterator>) -> Self { Self { inner: capabilities.into_iter().map(Into::into).collect() } } - /// Returns the list of all supported Engine capabilities for Prague spec. - fn prague() -> Self { - Self { inner: CAPABILITIES.iter().copied().map(str::to_owned).collect() } - } - - /// Returns the list of all supported Engine capabilities. + /// Returns the capabilities as a list of strings. pub fn list(&self) -> Vec { self.inner.iter().cloned().collect() } - /// Inserts a new capability. - pub fn add_capability(&mut self, capability: impl Into) { - self.inner.insert(capability.into()); + /// Returns a reference to the inner set. + pub const fn as_set(&self) -> &HashSet { + &self.inner } - /// Removes a capability. - pub fn remove_capability(&mut self, capability: &str) -> Option { - self.inner.take(capability) + /// Compares CL capabilities with this EL's capabilities and returns any mismatches. + /// + /// Called during `engine_exchangeCapabilities` to detect version mismatches + /// between the consensus layer and execution layer. + pub fn get_capability_mismatches(&self, cl_capabilities: &[String]) -> CapabilityMismatches { + let cl_set: HashSet<&str> = cl_capabilities.iter().map(String::as_str).collect(); + + // CL has methods EL doesn't support + let mut missing_in_el: Vec<_> = cl_capabilities + .iter() + .filter(|cap| !self.inner.contains(cap.as_str())) + .cloned() + .collect(); + missing_in_el.sort(); + + // EL has methods CL doesn't support + let mut missing_in_cl: Vec<_> = + self.inner.iter().filter(|cap| !cl_set.contains(cap.as_str())).cloned().collect(); + missing_in_cl.sort(); + + CapabilityMismatches { missing_in_el, missing_in_cl } + } + + /// Logs warnings if CL and EL capabilities don't match. + /// + /// Called during `engine_exchangeCapabilities` to warn operators about + /// version mismatches between the consensus layer and execution layer. + pub fn log_capability_mismatches(&self, cl_capabilities: &[String]) { + let mismatches = self.get_capability_mismatches(cl_capabilities); + + if !mismatches.missing_in_el.is_empty() { + warn!( + target: "rpc::engine", + missing = ?mismatches.missing_in_el, + "CL supports Engine API methods that Reth doesn't. Consider upgrading Reth." + ); + } + + if !mismatches.missing_in_cl.is_empty() { + warn!( + target: "rpc::engine", + missing = ?mismatches.missing_in_cl, + "Reth supports Engine API methods that CL doesn't. Consider upgrading your consensus client." + ); + } } } impl Default for EngineCapabilities { fn default() -> Self { - Self::prague() + Self::new(CAPABILITIES.iter().copied()) + } +} + +/// Result of comparing CL and EL capabilities. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct CapabilityMismatches { + /// Methods supported by CL but not by EL (Reth). + /// Operators should consider upgrading Reth. + pub missing_in_el: Vec, + /// Methods supported by EL (Reth) but not by CL. + /// Operators should consider upgrading their consensus client. + pub missing_in_cl: Vec, +} + +impl CapabilityMismatches { + /// Returns `true` if there are no mismatches. + pub const fn is_empty(&self) -> bool { + self.missing_in_el.is_empty() && self.missing_in_cl.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_no_mismatches() { + let el = EngineCapabilities::new(["method_a", "method_b"]); + let cl = vec!["method_a".to_string(), "method_b".to_string()]; + + let result = el.get_capability_mismatches(&cl); + assert!(result.is_empty()); + } + + #[test] + fn test_cl_has_extra_methods() { + let el = EngineCapabilities::new(["method_a"]); + let cl = vec!["method_a".to_string(), "method_b".to_string()]; + + let result = el.get_capability_mismatches(&cl); + assert_eq!(result.missing_in_el, vec!["method_b"]); + assert!(result.missing_in_cl.is_empty()); + } + + #[test] + fn test_el_has_extra_methods() { + let el = EngineCapabilities::new(["method_a", "method_b"]); + let cl = vec!["method_a".to_string()]; + + let result = el.get_capability_mismatches(&cl); + assert!(result.missing_in_el.is_empty()); + assert_eq!(result.missing_in_cl, vec!["method_b"]); + } + + #[test] + fn test_both_have_extra_methods() { + let el = EngineCapabilities::new(["method_a", "method_c"]); + let cl = vec!["method_a".to_string(), "method_b".to_string()]; + + let result = el.get_capability_mismatches(&cl); + assert_eq!(result.missing_in_el, vec!["method_b"]); + assert_eq!(result.missing_in_cl, vec!["method_c"]); + } + + #[test] + fn test_results_are_sorted() { + let el = EngineCapabilities::new(["z_method", "a_method"]); + let cl = vec!["z_other".to_string(), "a_other".to_string()]; + + let result = el.get_capability_mismatches(&cl); + assert_eq!(result.missing_in_el, vec!["a_other", "z_other"]); + assert_eq!(result.missing_in_cl, vec!["a_method", "z_method"]); } } diff --git a/crates/rpc/rpc-engine-api/src/engine_api.rs b/crates/rpc/rpc-engine-api/src/engine_api.rs index b1e9986c41..5a7b69dd9e 100644 --- a/crates/rpc/rpc-engine-api/src/engine_api.rs +++ b/crates/rpc/rpc-engine-api/src/engine_api.rs @@ -1134,8 +1134,13 @@ where /// Handler for `engine_exchangeCapabilitiesV1` /// See also - async fn exchange_capabilities(&self, _capabilities: Vec) -> RpcResult> { - Ok(self.capabilities().list()) + async fn exchange_capabilities(&self, capabilities: Vec) -> RpcResult> { + trace!(target: "rpc::engine", "Serving engine_exchangeCapabilities"); + + let el_caps = self.capabilities(); + el_caps.log_capability_mismatches(&capabilities); + + Ok(el_caps.list()) } async fn get_blobs_v1(