Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions crates/app/src/node/config.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<FeatureSet>,
/// 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
Expand Down
106 changes: 104 additions & 2 deletions crates/app/src/node/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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, &eth1).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.
Expand Down Expand Up @@ -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) ----
Expand Down Expand Up @@ -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<pluto_featureset::FeatureSet, AppError> {
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(
Expand Down Expand Up @@ -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<u8> {
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));
}
}
4 changes: 2 additions & 2 deletions crates/app/tests/simnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 30 additions & 28 deletions crates/cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -815,7 +814,7 @@ impl TryFrom<RunArgs> 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,
Expand Down Expand Up @@ -999,7 +998,7 @@ fn build_app_config(config: RunConfig) -> Result<pluto_app::node::AppConfig> {
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)?;
Expand All @@ -1017,7 +1016,7 @@ fn build_app_config(config: RunConfig) -> Result<pluto_app::node::AppConfig> {
let RunConfig {
p2p,
log: _,
feature: _,
feature_set: _,
lock_file,
manifest_file: _,
no_verify,
Expand Down Expand Up @@ -1077,7 +1076,7 @@ fn build_app_config(config: RunConfig) -> Result<pluto_app::node::AppConfig> {
// 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,
Expand Down Expand Up @@ -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<Arc<FeatureSet>> {
// `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<pluto_featureset::Config> {
let min_status = Status::try_from(feature.min_status.as_str()).map_err(|_| {
FeaturesetError::UnknownMinStatus {
min_status: feature.min_status.clone(),
Expand All @@ -1177,13 +1170,11 @@ fn build_feature_set(feature: &FeatureConfig) -> Result<Arc<FeatureSet>> {
.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
Expand Down Expand Up @@ -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.
Expand All @@ -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(&[
Expand Down Expand Up @@ -1814,8 +1812,9 @@ mod tests {
"127.0.0.1:3600".parse::<SocketAddr>().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]
Expand Down Expand Up @@ -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");
Expand Down
Loading