diff --git a/Cargo.lock b/Cargo.lock index 854d4c52..62d8b36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,9 +98,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3842d8c52fcd3378039f4703dba392dca8b546b1c8ed6183048f8dab95b2be78" +checksum = "ef3a72a2247c34a8545ee99e562b1b9b69168e5000567257ae51e91b4e6b1193" dependencies = [ "alloy-primitives", "num_enum", @@ -1645,6 +1645,7 @@ dependencies = [ "k256", "prost 0.14.3", "prost-types 0.14.3", + "regex", "reqwest 0.13.1", "serde", "serde_json", @@ -5645,9 +5646,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 8a162276..4fbe8ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,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" @@ -59,7 +60,6 @@ base64 = "0.22" sha3 = "0.10" k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] } criterion = "0.8.0" -regex = "1.12" reqwest = "0.13" http = "1.4" tempfile = "3.24" diff --git a/crates/charon/Cargo.toml b/crates/charon/Cargo.toml index e2e7af52..15202089 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/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..f954ef18 --- /dev/null +++ b/crates/charon/src/eth2wrap/version.rs @@ -0,0 +1,155 @@ +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, + }, +} + +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()), + ("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") +}); + +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_VERSIONS + .get(&name.to_lowercase().as_str()) + .ok_or(BeaconNodeVersionError::UnknownClient)? + .clone(); + + 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 */ } + } +} + +#[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::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::parse("v5.3.2").unwrap(), + minimum: version::SemVer::parse("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, "input = {input}"); + } + } +} diff --git a/crates/charon/src/lib.rs b/crates/charon/src/lib.rs index a4033417..7839e743 100644 --- a/crates/charon/src/lib.rs +++ b/crates/charon/src/lib.rs @@ -16,7 +16,7 @@ pub mod retry; /// Deadline pub mod deadline; -/// Ethereum EL RPC client management +/// Ethereum EL RPC client management. pub mod eth1wrap; /// Featureset defines a set of global features and their rollout status. @@ -24,3 +24,6 @@ pub mod featureset; /// Obol API client for interacting with the Obol network API. pub mod obolapi; + +/// Ethereum CL RPC client management. +pub mod eth2wrap; 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"; };