From 93090fdc2ccdc1143f58a8e1ad753517f7174c79 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 8 Jan 2026 15:57:18 -0300 Subject: [PATCH 1/7] Initial `eth2wrap` module --- crates/charon/src/eth2wrap/mod.rs | 2 ++ crates/charon/src/eth2wrap/version.rs | 1 + crates/charon/src/lib.rs | 5 ++++- 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 crates/charon/src/eth2wrap/mod.rs create mode 100644 crates/charon/src/eth2wrap/version.rs diff --git a/crates/charon/src/eth2wrap/mod.rs b/crates/charon/src/eth2wrap/mod.rs new file mode 100644 index 00000000..527f2ae7 --- /dev/null +++ b/crates/charon/src/eth2wrap/mod.rs @@ -0,0 +1,2 @@ +/// Validate Beacon Node versions +pub mod version; diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/charon/src/eth2wrap/version.rs @@ -0,0 +1 @@ + diff --git a/crates/charon/src/lib.rs b/crates/charon/src/lib.rs index 8f1e4af2..d9f3cca5 100644 --- a/crates/charon/src/lib.rs +++ b/crates/charon/src/lib.rs @@ -19,5 +19,8 @@ pub mod retry; /// Deadline pub mod deadline; -/// Ethereum EL RPC client management +/// Ethereum EL RPC client management. pub mod eth1wrap; + +/// Ethereum CL RPC client management. +pub mod eth2wrap; From dfb913e78d097641d6f0e34ccb5bd44afd5a7c2b Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 8 Jan 2026 15:58:52 -0300 Subject: [PATCH 2/7] Add initial `version` --- Cargo.lock | 1 + Cargo.toml | 1 + crates/charon/Cargo.toml | 1 + crates/charon/src/eth2wrap/version.rs | 85 +++++++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 09b85dcd..46d8e64e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1450,6 +1450,7 @@ dependencies = [ "chrono", "prost 0.14.1", "prost-types 0.14.1", + "regex", "thiserror 2.0.17", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index d41515d6..1bcfa59d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ prost-build = "0.14" prost-types = "0.14" rand = { version = "0.8", features = ["std_rng"] } rand_core = "0.6" +regex = "1.12" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0" } thiserror = "2.0" diff --git a/crates/charon/Cargo.toml b/crates/charon/Cargo.toml index 850b26fd..36a8d3a7 100644 --- a/crates/charon/Cargo.toml +++ b/crates/charon/Cargo.toml @@ -15,6 +15,7 @@ tokio.workspace = true tokio-util.workspace = true prost.workspace = true prost-types.workspace = true +regex.workspace = true thiserror.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs index 8b137891..f3d761d1 100644 --- a/crates/charon/src/eth2wrap/version.rs +++ b/crates/charon/src/eth2wrap/version.rs @@ -1 +1,86 @@ +use charon_core::version::{self}; +use std::sync::LazyLock; +use tracing::warn; +type Result = std::result::Result; + +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +enum BeaconNodeVersionError { + #[error("Version string has an unexpected format")] + InvalidFormat, + + #[error("Unknown beacon node client")] + UnknownClient, + + #[error("Beacon node client version is too old")] + TooOld { + client: version::SemVer, + minimum: version::SemVer, + }, +} + +fn minimum_beacon_node_version(name: &str) -> Option { + let name = name.to_lowercase(); + match name.as_str() { + "lighthouse" => Some(version::SemVer::try_from("v8.0.0-rc.0").unwrap()), + "teku" => Some(version::SemVer::try_from("v25.9.3").unwrap()), + "lodestar" => Some(version::SemVer::try_from("v1.35.0-rc.1").unwrap()), + "nimbus" => Some(version::SemVer::try_from("v25.9.2").unwrap()), + "prysm" => Some(version::SemVer::try_from("v6.1.0").unwrap()), + "grandine" => Some(version::SemVer::try_from("v2.0.0-rc0").unwrap()), + _ => None, + } +} + +static VERSION_EXTRACT_REGEX: LazyLock = LazyLock::new(|| { + regex::Regex::new(r"^([^/]+)/v?([0-9]+\.[0-9]+\.[0-9]+)").expect("invalid regex") +}); + +fn check_beacon_node_version_status(bn_version: &str) -> Result<()> { + let matches = VERSION_EXTRACT_REGEX + .captures(bn_version) + .ok_or(BeaconNodeVersionError::InvalidFormat)?; + + if matches.len() != 3 { + return Err(BeaconNodeVersionError::InvalidFormat); + } + + let client = version::SemVer::parse(&format!("v{}", &matches[2])) + .map_err(|_| BeaconNodeVersionError::InvalidFormat)?; + + let name = &matches[1]; + let minimum = minimum_beacon_node_version(name).ok_or(BeaconNodeVersionError::UnknownClient)?; + + if client < minimum { + return Err(BeaconNodeVersionError::TooOld { client, minimum }); + } + + Ok(()) +} + +/// Checks the version of the beacon node client and logs a warning if the +/// version is below the minimum or if the client is not recognized. +pub fn check_beacon_node_version(bn_version: &str) { + match check_beacon_node_version_status(bn_version) { + Err(BeaconNodeVersionError::InvalidFormat) => { + warn!( + input = bn_version, + "Failed to parse beacon node version string due to unexpected format" + ); + } + Err(BeaconNodeVersionError::UnknownClient) => { + warn!( + client = bn_version, + "Unknown beacon node client not in supported client list" + ); + } + Err(BeaconNodeVersionError::TooOld { client, minimum }) => { + warn!( + client_version = %client, + minimum_required = %minimum, + "Beacon node client version is below the minimum supported version. Please upgrade your beacon node." + ); + } + Ok(()) => { /* Do nothing */ } + } +} From 97c021a1ca1baf324a68a47e4d453edd355d9b0a Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Thu, 8 Jan 2026 15:59:37 -0300 Subject: [PATCH 3/7] Add tests --- crates/charon/src/eth2wrap/version.rs | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs index f3d761d1..e1024d02 100644 --- a/crates/charon/src/eth2wrap/version.rs +++ b/crates/charon/src/eth2wrap/version.rs @@ -84,3 +84,69 @@ pub fn check_beacon_node_version(bn_version: &str) { Ok(()) => { /* Do nothing */ } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_beacon_node_version_status() { + let tc = vec![ + // Teku + ( + "teku/v25.9.3/linux-x86_64/-eclipseadoptium-openjdk64bitservervm-java-21", + Ok(()), + ), + ( + "teku/vUNKNOWN+g40561a9/linux-x86_64/-eclipseadoptium-openjdk64bitservervm-java-21", + Err(BeaconNodeVersionError::InvalidFormat), + ), + // Lighthouse + ("Lighthouse/v8.0.1-e42406d/x86_64-linux", Ok(())), + ("Lighthouse/v8.0.0-54f7bc5/aarch64-linux", Ok(())), + // Lodestar + ("Lodestar/v1.35.0/8335180", Ok(())), + ("Lodestar/v1.36.0/1a34f98", Ok(())), + // Nimbus + ("Nimbus/v26.4.1-77cfa7-stateofus", Ok(())), + ("Nimbus/v26.5.0-d2f233-stateofus", Ok(())), + ( + "Nimbus/v25.9.0-c7e5ca-stateofus", + Err(BeaconNodeVersionError::TooOld { + client: version::SemVer::try_from("v25.9.0").unwrap(), + minimum: version::SemVer::try_from("v25.9.2").unwrap(), + }), + ), + // Prysm + ( + "Prysm/v5.3.2 (linux amd64)", + Err(BeaconNodeVersionError::TooOld { + client: version::SemVer::try_from("v5.3.2").unwrap(), + minimum: version::SemVer::try_from("v6.1.0").unwrap(), + }), + ), + ("Prysm/v6.1.2 (linux amd64)", Ok(())), + ("Prysm/v6.2.0 (linux amd64)", Ok(())), + // Grandine + ("Grandine/2.1.0-29cb5c1/x86_64-linux2025-05-19", Ok(())), + // Additional error cases + ("", Err(BeaconNodeVersionError::InvalidFormat)), + ("justastring", Err(BeaconNodeVersionError::InvalidFormat)), + ("/v7.0.0", Err(BeaconNodeVersionError::InvalidFormat)), + ( + "UnknownClient/v7.0.0", + Err(BeaconNodeVersionError::UnknownClient), + ), + ("Lighthouse/", Err(BeaconNodeVersionError::InvalidFormat)), + ( + "Lighthouse/vBAD", + Err(BeaconNodeVersionError::InvalidFormat), + ), + ]; + + for (input, expected) in tc { + let result = super::check_beacon_node_version_status(input); + assert_eq!(result, expected); + } + } +} From 0ee963606efca19bad58754c89c52f7bed4c82c1 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 14 Jan 2026 11:56:33 -0300 Subject: [PATCH 4/7] Use `SemVer::parse` instead of `SemVer::try_from` for version parsing --- crates/charon/src/eth2wrap/version.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs index e1024d02..abf773c7 100644 --- a/crates/charon/src/eth2wrap/version.rs +++ b/crates/charon/src/eth2wrap/version.rs @@ -22,12 +22,12 @@ enum BeaconNodeVersionError { fn minimum_beacon_node_version(name: &str) -> Option { let name = name.to_lowercase(); match name.as_str() { - "lighthouse" => Some(version::SemVer::try_from("v8.0.0-rc.0").unwrap()), - "teku" => Some(version::SemVer::try_from("v25.9.3").unwrap()), - "lodestar" => Some(version::SemVer::try_from("v1.35.0-rc.1").unwrap()), - "nimbus" => Some(version::SemVer::try_from("v25.9.2").unwrap()), - "prysm" => Some(version::SemVer::try_from("v6.1.0").unwrap()), - "grandine" => Some(version::SemVer::try_from("v2.0.0-rc0").unwrap()), + "lighthouse" => Some(version::SemVer::parse("v8.0.0-rc.0").unwrap()), + "teku" => Some(version::SemVer::parse("v25.9.3").unwrap()), + "lodestar" => Some(version::SemVer::parse("v1.35.0-rc.1").unwrap()), + "nimbus" => Some(version::SemVer::parse("v25.9.2").unwrap()), + "prysm" => Some(version::SemVer::parse("v6.1.0").unwrap()), + "grandine" => Some(version::SemVer::parse("v2.0.0-rc0").unwrap()), _ => None, } } @@ -113,16 +113,16 @@ mod tests { ( "Nimbus/v25.9.0-c7e5ca-stateofus", Err(BeaconNodeVersionError::TooOld { - client: version::SemVer::try_from("v25.9.0").unwrap(), - minimum: version::SemVer::try_from("v25.9.2").unwrap(), + client: version::SemVer::parse("v25.9.0").unwrap(), + minimum: version::SemVer::parse("v25.9.2").unwrap(), }), ), // Prysm ( "Prysm/v5.3.2 (linux amd64)", Err(BeaconNodeVersionError::TooOld { - client: version::SemVer::try_from("v5.3.2").unwrap(), - minimum: version::SemVer::try_from("v6.1.0").unwrap(), + client: version::SemVer::parse("v5.3.2").unwrap(), + minimum: version::SemVer::parse("v6.1.0").unwrap(), }), ), ("Prysm/v6.1.2 (linux amd64)", Ok(())), From af0465903c7c6bf8e8b73e50a074d6a416d48f6a Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 14 Jan 2026 12:14:11 -0300 Subject: [PATCH 5/7] Replace function with static HashMap - Avoids parsing on each request --- crates/charon/src/eth2wrap/version.rs | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs index abf773c7..384a14dd 100644 --- a/crates/charon/src/eth2wrap/version.rs +++ b/crates/charon/src/eth2wrap/version.rs @@ -19,18 +19,17 @@ enum BeaconNodeVersionError { }, } -fn minimum_beacon_node_version(name: &str) -> Option { - let name = name.to_lowercase(); - match name.as_str() { - "lighthouse" => Some(version::SemVer::parse("v8.0.0-rc.0").unwrap()), - "teku" => Some(version::SemVer::parse("v25.9.3").unwrap()), - "lodestar" => Some(version::SemVer::parse("v1.35.0-rc.1").unwrap()), - "nimbus" => Some(version::SemVer::parse("v25.9.2").unwrap()), - "prysm" => Some(version::SemVer::parse("v6.1.0").unwrap()), - "grandine" => Some(version::SemVer::parse("v2.0.0-rc0").unwrap()), - _ => None, - } -} +static MINIMUM_BEACON_NODE_VERSIONS: LazyLock> = + LazyLock::new(|| { + std::collections::HashMap::from([ + ("lighthouse", version::SemVer::parse("v8.0.0-rc.0").unwrap()), + ("teku", version::SemVer::parse("v25.9.3").unwrap()), + ("lodestar", version::SemVer::parse("v1.35.0-rc.1").unwrap()), + ("nimbus", version::SemVer::parse("v25.9.2").unwrap()), + ("prysm", version::SemVer::parse("v6.1.0").unwrap()), + ("grandine", version::SemVer::parse("v2.0.0-rc0").unwrap()), + ]) + }); static VERSION_EXTRACT_REGEX: LazyLock = LazyLock::new(|| { regex::Regex::new(r"^([^/]+)/v?([0-9]+\.[0-9]+\.[0-9]+)").expect("invalid regex") @@ -49,7 +48,10 @@ fn check_beacon_node_version_status(bn_version: &str) -> Result<()> { .map_err(|_| BeaconNodeVersionError::InvalidFormat)?; let name = &matches[1]; - let minimum = minimum_beacon_node_version(name).ok_or(BeaconNodeVersionError::UnknownClient)?; + let minimum = MINIMUM_BEACON_NODE_VERSIONS + .get(&name.to_lowercase().as_str()) + .ok_or(BeaconNodeVersionError::UnknownClient)? + .clone(); if client < minimum { return Err(BeaconNodeVersionError::TooOld { client, minimum }); @@ -146,7 +148,7 @@ mod tests { for (input, expected) in tc { let result = super::check_beacon_node_version_status(input); - assert_eq!(result, expected); + assert_eq!(result, expected, "input = {input}"); } } } From ccc41f19d614fa0c095a76a03643180665126643 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 14 Jan 2026 12:19:08 -0300 Subject: [PATCH 6/7] Fix clippy lint errors --- crates/charon/src/eth2wrap/version.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/charon/src/eth2wrap/version.rs b/crates/charon/src/eth2wrap/version.rs index 384a14dd..f954ef18 100644 --- a/crates/charon/src/eth2wrap/version.rs +++ b/crates/charon/src/eth2wrap/version.rs @@ -21,6 +21,7 @@ enum BeaconNodeVersionError { static MINIMUM_BEACON_NODE_VERSIONS: LazyLock> = LazyLock::new(|| { + #[allow(clippy::unwrap_used, reason = "literals should be valid semver")] std::collections::HashMap::from([ ("lighthouse", version::SemVer::parse("v8.0.0-rc.0").unwrap()), ("teku", version::SemVer::parse("v25.9.3").unwrap()), @@ -44,7 +45,7 @@ fn check_beacon_node_version_status(bn_version: &str) -> Result<()> { return Err(BeaconNodeVersionError::InvalidFormat); } - let client = version::SemVer::parse(&format!("v{}", &matches[2])) + let client = version::SemVer::parse(format!("v{}", &matches[2])) .map_err(|_| BeaconNodeVersionError::InvalidFormat)?; let name = &matches[1]; From 894c9baa5f941d04a773cdb2989b0d6232169244 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 14 Jan 2026 12:19:33 -0300 Subject: [PATCH 7/7] Add `RUSTC_BOOTSTRAP` - Prevents VSCode from rebuilding everything during tests --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index f0ed5d83..50981c99 100644 --- a/flake.nix +++ b/flake.nix @@ -35,6 +35,7 @@ chmod +x .githooks/* && git config --local core.hooksPath .githooks/ ''; + RUSTC_BOOTSTRAP = "1"; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.openssl ]; PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; };