diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3214fce6..1cd17e9e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -18,6 +18,7 @@ pluto-consensus.workspace = true pluto-eth1wrap.workspace = true pluto-featureset.workspace = true pluto-eth2api.workspace = true +pluto-eth2util.workspace = true pluto-p2p.workspace = true pluto-parsigex.workspace = true pluto-peerinfo.workspace = true @@ -50,7 +51,6 @@ pluto-ssz.workspace = true # `--simnet-validator-mock`), mirroring Charon which always compiles its mocks # into the binary. pluto-testutil.workspace = true -pluto-eth2util.workspace = true [build-dependencies] pluto-build-proto.workspace = true diff --git a/crates/app/src/node/config.rs b/crates/app/src/node/config.rs index bae915cb..5167045d 100644 --- a/crates/app/src/node/config.rs +++ b/crates/app/src/node/config.rs @@ -1,8 +1,7 @@ //! Configuration for the distributed-validator node. -use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{net::SocketAddr, path::PathBuf, time::Duration}; -use pluto_featureset::FeatureSet; use pluto_p2p::config::P2PConfig; /// Application configuration for running a distributed-validator node. @@ -69,10 +68,10 @@ pub struct AppConfig { /// Disable appending the client version/codex to graffiti. pub graffiti_disable_client_append: bool, - /// Feature set controlling optional/alpha behaviors (e.g. - /// `FetchOnlyCommIdx0`, `ChainSplitHalt`). Resolved from the CLI - /// feature flags (out of scope here). - pub feature_set: Arc, + /// Feature-set configuration for optional/alpha capabilities (e.g. + /// `FetchOnlyCommIdx0`, `ChainSplitHalt`) and chain-specific behavior + /// (e.g. `GnosisBlockHotfix`), resolved into a `FeatureSet`. + pub feature_set: pluto_featureset::Config, /// Enable the in-process simnet mock beacon node. When set, the beacon /// clients target an internal `BeaconMock` seeded with the cluster's diff --git a/crates/app/src/node/mod.rs b/crates/app/src/node/mod.rs index 3bdb29ce..d7a766ec 100644 --- a/crates/app/src/node/mod.rs +++ b/crates/app/src/node/mod.rs @@ -193,6 +193,10 @@ pub enum AppError { /// Simnet (beacon/validator mock) setup failed. #[error("simnet: {0}")] Simnet(String), + + /// Feature set resolution failed (invalid minimum status). + #[error("feature set: {0}")] + FeatureSet(#[from] pluto_featureset::FeaturesetError), } /// A wired, runnable distributed-validator node. @@ -233,6 +237,13 @@ async fn run(config: AppConfig, ct: CancellationToken) -> Result<(), AppError> { pluto_cluster::load::load_cluster_lock(&config.lock_file, config.no_verify, ð1).await?; let threshold = lock.threshold; + // Resolve the injectable feature set now that the lock's fork version is + // known. + let feature_set = Arc::new(resolve_feature_set( + &config.feature_set, + &lock.fork_version, + )?); + let key = pluto_k1util::load(&config.priv_key_file)?; // Guards against a second node running against the same key. @@ -369,8 +380,6 @@ async fn run(config: AppConfig, ct: CancellationToken) -> Result<(), AppError> { .unwrap_or(0) .saturating_mul(slots_per_epoch); - // Feature set drives optional/alpha behaviors. - let feature_set = Arc::clone(&config.feature_set); let fetch_only_comm_idx0 = feature_set.enabled(pluto_featureset::Feature::FetchOnlyCommIdx0); // ---- Consensus (built directly; shared with p2p behaviour + core stitch) ---- @@ -952,6 +961,27 @@ fn network_name_or_hex(fork_version: &[u8]) -> String { .unwrap_or_else(|_| format!("0x{}", hex::encode(fork_version))) } +/// Resolves the injectable feature set from its config and the cluster's fork +/// version. +/// +/// Performs the Rust analogs of Charon's `featureset.Init` and, for +/// gnosis/chiado, `EnableGnosisBlockHotfixIfNotDisabled`. +fn resolve_feature_set( + config: &pluto_featureset::Config, + fork_version: &[u8], +) -> Result { + let mut feature_set = pluto_featureset::FeatureSet::from_config(config.clone())?; + + if let Ok(network) = pluto_eth2util::network::fork_version_to_network(fork_version) + && (network == pluto_eth2util::network::GNOSIS.name + || network == pluto_eth2util::network::CHIADO.name) + { + feature_set.enable_gnosis_block_hotfix_if_not_disabled(config); + } + + Ok(feature_set) +} + /// Builds an [`EthBeaconNodeApiClient`](pluto_eth2api::EthBeaconNodeApiClient) /// for `base_url` with the given request timeout. fn build_api_client( @@ -1332,4 +1362,76 @@ mod tests { other => panic!("expected ForkScheduleMismatch, got {other:?}"), } } + + /// Decodes a predefined network's genesis fork version into raw bytes. + fn fork_version_bytes(hex_str: &str) -> Vec { + hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)).expect("valid fork version hex") + } + + #[test] + fn resolve_feature_set_auto_enables_gnosis_hotfix_for_gnosis_and_chiado() { + use pluto_eth2util::network::{CHIADO, GNOSIS}; + use pluto_featureset::{Config, Feature}; + + // Default config leaves GnosisBlockHotfix at alpha (off under the stable + // minimum), so the auto-enable is what flips it on. + for network in [GNOSIS, CHIADO] { + let feature_set = resolve_feature_set( + &Config::default(), + &fork_version_bytes(network.genesis_fork_version_hex), + ) + .expect("resolve should succeed"); + assert!( + feature_set.enabled(Feature::GnosisBlockHotfix), + "GnosisBlockHotfix should be auto-enabled for {}", + network.name + ); + } + } + + #[test] + fn resolve_feature_set_leaves_other_networks_untouched() { + use pluto_eth2util::network::MAINNET; + use pluto_featureset::{Config, Feature}; + + let feature_set = resolve_feature_set( + &Config::default(), + &fork_version_bytes(MAINNET.genesis_fork_version_hex), + ) + .expect("resolve should succeed"); + + assert!(!feature_set.enabled(Feature::GnosisBlockHotfix)); + } + + #[test] + fn resolve_feature_set_respects_explicit_gnosis_hotfix_disable() { + use pluto_eth2util::network::GNOSIS; + use pluto_featureset::{Config, Feature, Status}; + + // Explicitly disabling the feature must win even on gnosis, matching + // Charon's `EnableGnosisBlockHotfixIfNotDisabled`. + let config = Config { + min_status: Status::Stable, + enabled: vec![], + disabled: vec![Feature::GnosisBlockHotfix], + }; + let feature_set = resolve_feature_set( + &config, + &fork_version_bytes(GNOSIS.genesis_fork_version_hex), + ) + .expect("resolve should succeed"); + + assert!(!feature_set.enabled(Feature::GnosisBlockHotfix)); + } + + #[test] + fn resolve_feature_set_skips_unrecognized_fork_version() { + use pluto_featureset::{Config, Feature}; + + // An unknown fork version must not panic and must not auto-enable. + let feature_set = resolve_feature_set(&Config::default(), &[0x01, 0x02, 0x03, 0x04]) + .expect("resolve should succeed"); + + assert!(!feature_set.enabled(Feature::GnosisBlockHotfix)); + } } diff --git a/crates/app/tests/simnet.rs b/crates/app/tests/simnet.rs index ee8219b8..6ac087f7 100644 --- a/crates/app/tests/simnet.rs +++ b/crates/app/tests/simnet.rs @@ -11,7 +11,7 @@ //! duty-submission path is covered by //! `multinode_parsig_exchange_reaches_submission` in `tests/wiring.rs`. -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::{net::SocketAddr, time::Duration}; use pluto_app::node::{App, AppConfig}; use tokio_util::sync::CancellationToken; @@ -76,7 +76,7 @@ async fn simnet_single_node_boots_and_serves_validator_api() { eth1_endpoint: None, graffiti: None, graffiti_disable_client_append: false, - feature_set: Arc::new(pluto_featureset::FeatureSet::default()), + feature_set: pluto_featureset::Config::default(), simnet_beacon_mock: true, simnet_validator_mock: true, simnet_beacon_mock_fuzz: false, diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 1e700262..8db86143 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -29,13 +29,12 @@ use std::{ collections::HashMap, net::{SocketAddr, ToSocketAddrs}, path::{Path, PathBuf}, - sync::Arc, time::Duration as StdDuration, }; use libp2p::multiaddr::Protocol; use pluto_eth2util::helpers::validate_http_headers; -use pluto_featureset::{Feature, FeatureSet, FeaturesetError, Status}; +use pluto_featureset::{Feature, FeaturesetError, Status}; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -606,7 +605,7 @@ impl From<&TestnetConfig> for pluto_eth2util::network::Network { /// Feature set configuration. #[derive(Debug, Clone, Default)] -pub struct FeatureConfig { +pub struct FeatureSetConfig { /// Minimum feature status to enable by default (alpha/beta/stable). pub min_status: String, /// Features to enable on top of the minimum set. @@ -626,7 +625,7 @@ pub struct RunConfig { /// Tracing configuration built from [`RunLogArgs`]/[`RunLokiArgs`]. pub log: pluto_tracing::TracingConfig, /// Feature set configuration. - pub feature: FeatureConfig, + pub feature_set: FeatureSetConfig, /// Path to the cluster lock file. pub lock_file: String, /// Path to the cluster manifest file. @@ -815,7 +814,7 @@ impl TryFrom for RunConfig { Ok(Self { p2p: p2p_config, log: log_config, - feature: FeatureConfig { + feature_set: FeatureSetConfig { min_status: feature.feature_set, enabled: feature.feature_set_enable, disabled: feature.feature_set_disable, @@ -999,7 +998,7 @@ fn build_app_config(config: RunConfig) -> Result { check_unsupported_flags(&config)?; warn_ignored_flags(&config); - let feature_set = build_feature_set(&config.feature)?; + let feature_config = parse_featureset_config(&config.feature_set)?; let validator_api_addr = parse_socket_addr("validator-api-address", &config.validator_api_addr)?; let monitoring_addr = parse_socket_addr("monitoring-address", &config.monitoring_addr)?; @@ -1017,7 +1016,7 @@ fn build_app_config(config: RunConfig) -> Result { let RunConfig { p2p, log: _, - feature: _, + feature_set: _, lock_file, manifest_file: _, no_verify, @@ -1077,7 +1076,7 @@ fn build_app_config(config: RunConfig) -> Result { // validator" (`NewGraffitiBuilder`), matching `AppConfig`'s `None`. graffiti: (!graffiti.is_empty()).then_some(graffiti), graffiti_disable_client_append, - feature_set, + feature_set: feature_config, simnet_beacon_mock, simnet_validator_mock, simnet_beacon_mock_fuzz, @@ -1152,14 +1151,8 @@ fn warn_ignored_flags(config: &RunConfig) { } } -/// Resolves the feature-set flags into an injectable [`FeatureSet`]. -/// -/// Matches Charon: an unknown min status is a hard error, while unknown -/// enabled/disabled feature names are warned about and ignored. -fn build_feature_set(feature: &FeatureConfig) -> Result> { - // `Status::try_from` also parses statuses like "enable" that are not valid - // *min* statuses; `FeatureSet::from_config` rejects those below, so both - // paths surface Charon's "unknown min status" error. +/// Parses the feature-set flags into a [`pluto_featureset::Config`]. +fn parse_featureset_config(feature: &FeatureSetConfig) -> Result { let min_status = Status::try_from(feature.min_status.as_str()).map_err(|_| { FeaturesetError::UnknownMinStatus { min_status: feature.min_status.clone(), @@ -1177,13 +1170,11 @@ fn build_feature_set(feature: &FeatureConfig) -> Result> { .collect() }; - let feature_set = FeatureSet::from_config(pluto_featureset::Config { + Ok(pluto_featureset::Config { min_status, enabled: parse_features(&feature.enabled, "enabled"), disabled: parse_features(&feature.disabled, "disabled"), - })?; - - Ok(Arc::new(feature_set)) + }) } /// Parses an HTTP listen address flag (`flag` names it for errors). Unlike a @@ -1732,9 +1723,9 @@ mod tests { assert_eq!(config.debug_addr, "127.0.0.1:9630"); assert_eq!(config.p2p.relays.len(), 2); assert_eq!(config.p2p.tcp_addrs, vec!["0.0.0.0:9000".to_string()]); - assert_eq!(config.feature.min_status, "alpha"); + assert_eq!(config.feature_set.min_status, "alpha"); assert_eq!( - config.feature.enabled, + config.feature_set.enabled, vec!["feat_a".to_string(), "feat_b".to_string()] ); // p2p_fuzz is never set on the safe `run` path. @@ -1757,6 +1748,13 @@ mod tests { .to_string() } + /// Resolves the forwarded feature config into a `FeatureSet`, as `App::run` + /// does (the gnosis/chiado fork-version step is covered in the app crate). + fn resolved_feature_set(config: &pluto_app::node::AppConfig) -> pluto_featureset::FeatureSet { + pluto_featureset::FeatureSet::from_config(config.feature_set.clone()) + .expect("feature config should resolve") + } + #[test] fn build_app_config_maps_fields() { let config = app_config(&[ @@ -1814,8 +1812,9 @@ mod tests { "127.0.0.1:3600".parse::().expect("socket addr") ); // The default feature set enables stable features only. - assert!(config.feature_set.enabled(Feature::EagerDoubleLinear)); - assert!(!config.feature_set.enabled(Feature::MockAlpha)); + let feature_set = resolved_feature_set(&config); + assert!(feature_set.enabled(Feature::EagerDoubleLinear)); + assert!(!feature_set.enabled(Feature::MockAlpha)); } #[test] @@ -2042,18 +2041,21 @@ mod tests { } #[test] - fn build_app_config_resolves_feature_set() { + fn build_app_config_forwards_feature_config() { + // The bridge forwards a `featureset::Config`; `App::run` resolves it. + // Assert on the resolved set to keep the semantics observable here. + // Min status is parsed case-insensitively, like Charon. let config = app_config(&["--feature-set=ALPHA"]).expect("alpha min status"); - assert!(config.feature_set.enabled(Feature::MockAlpha)); + assert!(resolved_feature_set(&config).enabled(Feature::MockAlpha)); let config = app_config(&["--feature-set-enable=chain_split_halt"]).expect("explicit enable"); - assert!(config.feature_set.enabled(Feature::ChainSplitHalt)); + assert!(resolved_feature_set(&config).enabled(Feature::ChainSplitHalt)); let config = app_config(&["--feature-set-disable=eager_double_linear"]).expect("explicit disable"); - assert!(!config.feature_set.enabled(Feature::EagerDoubleLinear)); + assert!(!resolved_feature_set(&config).enabled(Feature::EagerDoubleLinear)); // Unknown feature names are warned about and ignored, like Charon. app_config(&["--feature-set-enable=not_a_feature"]).expect("unknown feature ignored");