From 0f0ff03bee6ebb5ac73c4745b0b52bd52b8a78f2 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 20 Mar 2026 16:17:36 +0100 Subject: [PATCH 01/41] Command test beacon ported from Charon. Added url parsing and basic auth for request_rrt function. --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/commands/test/beacon.rs | 2124 +++++++++++++++++++++++- crates/cli/src/commands/test/mod.rs | 50 +- crates/cli/src/duration.rs | 23 + crates/cli/src/main.rs | 6 + 6 files changed, 2182 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2275275a..b473e582 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5473,6 +5473,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d646107e..425e4222 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -21,6 +21,7 @@ pluto-app.workspace = true pluto-cluster.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true +tracing-subscriber.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true pluto-eth2util.workspace = true diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 2669b731..b90c8305 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -1,9 +1,34 @@ //! Beacon node API tests. +//! +//! Port of charon/cmd/testbeacon.go — runs connectivity, load, and simulation +//! tests against one or more beacon node endpoints. -use super::{TestCategoryResult, TestConfigArgs}; -use crate::error::Result; +use super::{ + CategoryScore, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, TestCaseName, TestCategory, + TestCategoryResult, TestConfigArgs, TestResult, TestVerdict, apply_basic_auth, calculate_score, + evaluate_highest_rtt, evaluate_rtt, filter_tests, must_output_to_file_on_quiet, + parse_endpoint_url, publish_result_to_obol_api, request_rtt, sort_tests, write_result_to_file, + write_result_to_writer, +}; +use crate::{duration::Duration, error::Result as CliResult}; use clap::Args; -use std::io::Write; +use rand::Rng; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, io::Write, path::PathBuf, time::Duration as StdDuration}; +use tokio::sync::mpsc; + +const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40); +const THRESHOLD_BEACON_MEASURE_POOR: StdDuration = StdDuration::from_millis(100); +const THRESHOLD_BEACON_LOAD_AVG: StdDuration = StdDuration::from_millis(40); +const THRESHOLD_BEACON_LOAD_POOR: StdDuration = StdDuration::from_millis(100); +const THRESHOLD_BEACON_PEERS_AVG: u64 = 50; +const THRESHOLD_BEACON_PEERS_POOR: u64 = 20; +const THRESHOLD_BEACON_SIMULATION_AVG: StdDuration = StdDuration::from_millis(200); +const THRESHOLD_BEACON_SIMULATION_POOR: StdDuration = StdDuration::from_millis(400); + +const COMMITTEE_SIZE_PER_SLOT: u64 = 64; +const SUB_COMMITTEE_SIZE: u64 = 4; /// Arguments for the beacon test command. #[derive(Args, Clone, Debug)] @@ -15,20 +40,2093 @@ pub struct TestBeaconArgs { #[arg( long = "endpoints", value_delimiter = ',', + required = true, help = "Comma separated list of one or more beacon node endpoint URLs." )] pub endpoints: Vec, - // TODO: Add remaining flags from Go implementation + + /// Enable load test, not advisable when testing towards external beacon nodes. + #[arg(long = "load-test", help = "Enable load test.")] + pub load_test: bool, + + /// Time to keep running the load tests. + #[arg( + long = "load-test-duration", + default_value = "5s", + value_parser = humantime::parse_duration, + help = "Time to keep running the load tests. For each second a new continuous ping instance is spawned." + )] + pub load_test_duration: StdDuration, + + /// Simulation duration in slots. + #[arg( + long = "simulation-duration-in-slots", + default_value_t = SLOTS_IN_EPOCH, + help = "Time to keep running the simulation in slots." + )] + pub simulation_duration: u64, + + /// Directory to write simulation result files. + #[arg( + long = "simulation-file-dir", + default_value = "./", + help = "Directory to write simulation result JSON files." + )] + pub simulation_file_dir: PathBuf, + + /// Show results for each request and each validator. + #[arg( + long = "simulation-verbose", + help = "Show results for each request and each validator." + )] + pub simulation_verbose: bool, + + /// Run custom simulation with the specified amount of validators. + #[arg( + long = "simulation-custom", + default_value_t = 0, + help = "Run custom simulation with the specified amount of validators." + )] + pub simulation_custom: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationValues { + #[serde(skip_serializing_if = "String::is_empty", default)] + pub endpoint: String, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub all: Vec, + pub min: Duration, + pub max: Duration, + pub median: Duration, + pub avg: Duration, +} + +#[derive(Debug, Clone, Copy)] +struct RequestsIntensity { + attestation_duty: StdDuration, + aggregator_duty: StdDuration, + proposal_duty: StdDuration, + sync_committee_submit: StdDuration, + sync_committee_contribution: StdDuration, + sync_committee_subscribe: StdDuration, +} + +#[derive(Debug, Clone, Copy)] +struct DutiesPerformed { + attestation: bool, + aggregation: bool, + proposal: bool, + sync_committee: bool, +} + +#[derive(Debug, Clone, Copy)] +struct SimParams { + total_validators_count: u64, + attestation_validators_count: u64, + proposal_validators_count: u64, + sync_committee_validators_count: u64, + request_intensity: RequestsIntensity, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Simulation { + pub general_cluster_requests: SimulationCluster, + pub validators_requests: SimulationValidatorsResult, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationValidatorsResult { + pub averaged: SimulationSingleValidator, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub all_validators: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationSingleValidator { + #[serde(flatten)] + pub values: SimulationValues, + pub attestation_duty: SimulationAttestation, + pub aggregation_duty: SimulationAggregation, + pub proposal_duty: SimulationProposal, + pub sync_committee_duties: SimulationSyncCommittee, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationAttestation { + #[serde(flatten)] + pub values: SimulationValues, + pub get_attestation_data_request: SimulationValues, + pub post_attestations_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationAggregation { + #[serde(flatten)] + pub values: SimulationValues, + pub get_aggregate_attestation_request: SimulationValues, + pub post_aggregate_and_proofs_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationProposal { + #[serde(flatten)] + pub values: SimulationValues, + pub produce_block_request: SimulationValues, + pub publish_blinded_block_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationSyncCommittee { + #[serde(flatten)] + pub values: SimulationValues, + pub message_duty: SyncCommitteeMessageDuty, + pub contribution_duty: SyncCommitteeContributionDuty, + pub subscribe_sync_committee_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SyncCommitteeContributionDuty { + #[serde(flatten)] + pub values: SimulationValues, + pub produce_sync_committee_contribution_request: SimulationValues, + pub submit_sync_committee_contribution_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SyncCommitteeMessageDuty { + pub submit_sync_committee_message_request: SimulationValues, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SimulationCluster { + pub attestations_for_block_request: SimulationValues, + pub proposal_duties_for_epoch_request: SimulationValues, + pub syncing_request: SimulationValues, + pub peer_count_request: SimulationValues, + pub beacon_committee_subscription_request: SimulationValues, + pub duties_attester_for_epoch_request: SimulationValues, + pub duties_sync_committee_for_epoch_request: SimulationValues, + pub beacon_head_validators_request: SimulationValues, + pub beacon_genesis_request: SimulationValues, + pub prep_beacon_proposer_request: SimulationValues, + pub config_spec_request: SimulationValues, + pub node_version_request: SimulationValues, +} + +fn supported_beacon_test_cases() -> Vec { + vec![ + TestCaseName::new("Ping", 1), + TestCaseName::new("PingMeasure", 2), + TestCaseName::new("Version", 3), + TestCaseName::new("Synced", 4), + TestCaseName::new("PeerCount", 5), + TestCaseName::new("PingLoad", 6), + TestCaseName::new("Simulate1", 7), + TestCaseName::new("Simulate10", 8), + TestCaseName::new("Simulate100", 9), + TestCaseName::new("Simulate500", 10), + TestCaseName::new("Simulate1000", 11), + TestCaseName::new("SimulateCustom", 12), + ] +} + +async fn run_test_case( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, + name: &str, +) -> TestResult { + match name { + "Ping" => beacon_ping_test(cancel, cfg, target).await, + "PingMeasure" => beacon_ping_measure_test(cancel, cfg, target).await, + "Version" => beacon_version_test(cancel, cfg, target).await, + "Synced" => beacon_is_synced_test(cancel, cfg, target).await, + "PeerCount" => beacon_peer_count_test(cancel, cfg, target).await, + "PingLoad" => beacon_ping_load_test(cancel, cfg, target).await, + "Simulate1" => beacon_simulation_1_test(cancel, cfg, target).await, + "Simulate10" => beacon_simulation_10_test(cancel, cfg, target).await, + "Simulate100" => beacon_simulation_100_test(cancel, cfg, target).await, + "Simulate500" => beacon_simulation_500_test(cancel, cfg, target).await, + "Simulate1000" => beacon_simulation_1000_test(cancel, cfg, target).await, + "SimulateCustom" => beacon_simulation_custom_test(cancel, cfg, target).await, + _ => { + let mut res = TestResult::new(name); + res.verdict = TestVerdict::Fail; + res.error = super::TestResultError::from_string(format!("unknown test case: {name}")); + res + } + } +} + +pub fn test_case_names() -> Vec { + let mut cases = supported_beacon_test_cases(); + sort_tests(&mut cases); + cases.iter().map(|n| n.name.clone()).collect() } /// Runs the beacon node tests. -pub async fn run(_args: TestBeaconArgs, _writer: &mut dyn Write) -> Result { - // TODO: Implement beacon tests - // - Ping - // - PingMeasure - // - Synced - // - Version - // - Pubkeys - // - etc. - unimplemented!("beacon test not yet implemented") +pub async fn run(args: TestBeaconArgs, writer: &mut dyn Write) -> CliResult { + must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; + + tracing::info!("Starting beacon node test"); + + let all_cases = supported_beacon_test_cases(); + let mut queued = filter_tests(&all_cases, args.test_config.test_cases.as_deref()); + + if queued.is_empty() { + return Err(crate::error::CliError::Other( + "test case not supported".into(), + )); + } + sort_tests(&mut queued); + + let cancel = tokio_util::sync::CancellationToken::new(); + let timeout_cancel = cancel.clone(); + let timeout = args.test_config.timeout; + tokio::spawn(async move { + tokio::time::sleep(timeout).await; + timeout_cancel.cancel(); + }); + + let start = std::time::Instant::now(); + + let (tx, mut rx) = mpsc::channel::<(String, Vec)>(args.endpoints.len()); + + for endpoint in &args.endpoints { + let tx = tx.clone(); + let queued = queued.clone(); + let cfg = args.clone(); + let target = endpoint.clone(); + let cancel = cancel.clone(); + + tokio::spawn(async move { + let results = test_single_beacon(cancel, &queued, cfg, &target).await; + let _ = tx.send((target, results)).await; + }); + } + drop(tx); + + let mut test_results: HashMap> = HashMap::new(); + while let Some((target, results)) = rx.recv().await { + test_results.insert(target, results); + } + + let exec_time = Duration::new(start.elapsed()); + + let score = test_results + .values() + .map(|t| calculate_score(t)) + .min() + .unwrap_or(CategoryScore::A); + + let res = TestCategoryResult { + category_name: Some(TestCategory::Beacon), + targets: test_results, + execution_time: Some(exec_time), + score: Some(score), + }; + + if !args.test_config.quiet { + write_result_to_writer(&res, writer)?; + } + + if !args.test_config.output_json.is_empty() { + write_result_to_file( + &res, + &std::path::PathBuf::from(&args.test_config.output_json), + ) + .await?; + } + + if args.test_config.publish { + let all = super::AllCategoriesResult { + beacon: Some(res.clone()), + ..Default::default() + }; + publish_result_to_obol_api( + all, + &args.test_config.publish_addr, + &args.test_config.publish_private_key_file, + ) + .await?; + } + + Ok(res) +} + +async fn test_single_beacon( + cancel: tokio_util::sync::CancellationToken, + queued: &[TestCaseName], + cfg: TestBeaconArgs, + target: &str, +) -> Vec { + let mut results = Vec::new(); + + for tc in queued { + if cancel.is_cancelled() { + results.push(TestResult { + name: tc.name.clone(), + verdict: TestVerdict::Fail, + error: super::TestResultError::from_string("timeout/interrupted"), + ..TestResult::new(&tc.name) + }); + break; + } + + let result = run_test_case(cancel.clone(), cfg.clone(), target.to_string(), &tc.name).await; + results.push(result); + } + + results +} + +async fn beacon_ping_test( + _cancel: tokio_util::sync::CancellationToken, + _cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let mut res = TestResult::new("Ping"); + let url = format!("{target}/eth/v1/node/health"); + + match request_rtt(&url, Method::GET, None, reqwest::StatusCode::OK).await { + Ok(_) => { + res.verdict = TestVerdict::Ok; + res + } + Err(e) => res.fail(e), + } +} + +async fn beacon_ping_measure_test( + _cancel: tokio_util::sync::CancellationToken, + _cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("PingMeasure"); + + match beacon_ping_once(&target).await { + Ok(rtt) => evaluate_rtt( + rtt, + res, + THRESHOLD_BEACON_MEASURE_AVG, + THRESHOLD_BEACON_MEASURE_POOR, + ), + Err(e) => res.fail(e), + } +} + +async fn beacon_version_test( + _cancel: tokio_util::sync::CancellationToken, + _cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let mut res = TestResult::new("Version"); + let url = format!("{target}/eth/v1/node/version"); + + let (clean_url, credentials) = match parse_endpoint_url(&url) { + Ok(v) => v, + Err(e) => return res.fail(e), + }; + let client = reqwest::Client::new(); + let resp = match apply_basic_auth(client.get(&clean_url), &credentials) + .send() + .await + { + Ok(r) => r, + Err(e) => return res.fail(e), + }; + + if !resp.status().is_success() { + return res.fail(super::TestResultError::from_string(format!( + "http status {}", + resp.status().as_u16() + ))); + } + + #[derive(Deserialize)] + struct VersionData { + version: String, + } + #[derive(Deserialize)] + struct VersionResponse { + data: VersionData, + } + + let body = match resp.json::().await { + Ok(b) => b, + Err(e) => return res.fail(e), + }; + + // Keep only provider, version and platform + let parts: Vec<&str> = body.data.version.split('/').collect(); + let version = if parts.len() > 3 { + parts[..3].join("/") + } else { + body.data.version.clone() + }; + + res.measurement = version; + res.verdict = TestVerdict::Ok; + res +} + +async fn beacon_is_synced_test( + _cancel: tokio_util::sync::CancellationToken, + _cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let mut res = TestResult::new("Synced"); + let url = format!("{target}/eth/v1/node/syncing"); + + let (clean_url, credentials) = match parse_endpoint_url(&url) { + Ok(v) => v, + Err(e) => return res.fail(e), + }; + let client = reqwest::Client::new(); + let resp = match apply_basic_auth(client.get(&clean_url), &credentials) + .send() + .await + { + Ok(r) => r, + Err(e) => return res.fail(e), + }; + + if !resp.status().is_success() { + return res.fail(super::TestResultError::from_string(format!( + "http status {}", + resp.status().as_u16() + ))); + } + + #[derive(Deserialize)] + struct SyncData { + is_syncing: bool, + } + #[derive(Deserialize)] + struct SyncResponse { + data: SyncData, + } + + let body = match resp.json::().await { + Ok(b) => b, + Err(e) => return res.fail(e), + }; + + if body.data.is_syncing { + res.verdict = TestVerdict::Fail; + } else { + res.verdict = TestVerdict::Ok; + } + res +} + +async fn beacon_peer_count_test( + _cancel: tokio_util::sync::CancellationToken, + _cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let mut res = TestResult::new("PeerCount"); + let url = format!("{target}/eth/v1/node/peers?state=connected"); + + let (clean_url, credentials) = match parse_endpoint_url(&url) { + Ok(v) => v, + Err(e) => return res.fail(e), + }; + let client = reqwest::Client::new(); + let resp = match apply_basic_auth(client.get(&clean_url), &credentials) + .send() + .await + { + Ok(r) => r, + Err(e) => return res.fail(e), + }; + + if !resp.status().is_success() { + return res.fail(super::TestResultError::from_string(format!( + "http status {}", + resp.status().as_u16() + ))); + } + + #[derive(Deserialize)] + struct Meta { + count: u64, + } + #[derive(Deserialize)] + struct PeerCountResponse { + meta: Meta, + } + + let body = match resp.json::().await { + Ok(b) => b, + Err(e) => return res.fail(e), + }; + + res.measurement = body.meta.count.to_string(); + + if body.meta.count < THRESHOLD_BEACON_PEERS_POOR { + res.verdict = TestVerdict::Poor; + } else if body.meta.count < THRESHOLD_BEACON_PEERS_AVG { + res.verdict = TestVerdict::Avg; + } else { + res.verdict = TestVerdict::Good; + } + res +} + +async fn beacon_ping_once(target: &str) -> CliResult { + let url = format!("{target}/eth/v1/node/health"); + request_rtt(&url, Method::GET, None, reqwest::StatusCode::OK).await +} + +async fn ping_beacon_continuously( + cancel: tokio_util::sync::CancellationToken, + target: String, + tx: mpsc::Sender, +) { + loop { + let rtt = match beacon_ping_once(&target).await { + Ok(rtt) => rtt, + Err(_) => return, + }; + + tokio::select! { + _ = cancel.cancelled() => return, + r = tx.send(rtt) => { + if r.is_err() { + return; + } + let jitter = rand::thread_rng().gen_range(0..100u64); + tokio::time::sleep(StdDuration::from_millis(jitter)).await; + } + } + } +} + +async fn beacon_ping_load_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let mut res = TestResult::new("PingLoad"); + if !cfg.load_test { + res.verdict = TestVerdict::Skip; + return res; + } + + tracing::info!( + duration = ?cfg.load_test_duration, + target = %target, + "Running ping load tests..." + ); + + let (tx, mut rx) = mpsc::channel::(65536); + + let load_cancel = cancel.child_token(); + let timeout_cancel = load_cancel.clone(); + let duration = cfg.load_test_duration; + tokio::spawn(async move { + tokio::time::sleep(duration).await; + timeout_cancel.cancel(); + }); + + let mut handles = Vec::new(); + let mut interval = tokio::time::interval(StdDuration::from_secs(1)); + + loop { + tokio::select! { + _ = load_cancel.cancelled() => break, + _ = interval.tick() => { + let c = load_cancel.clone(); + let t = target.clone(); + let tx = tx.clone(); + handles.push(tokio::spawn(async move { + ping_beacon_continuously(c, t, tx).await; + })); + } + } + } + + drop(tx); + for h in handles { + let _ = h.await; + } + + let mut rtts = Vec::new(); + while let Some(rtt) = rx.recv().await { + rtts.push(rtt); + } + + tracing::info!(target = %target, "Ping load tests finished"); + + evaluate_highest_rtt( + rtts, + res, + THRESHOLD_BEACON_LOAD_AVG, + THRESHOLD_BEACON_LOAD_POOR, + ) +} + +fn default_intensity() -> RequestsIntensity { + RequestsIntensity { + attestation_duty: SLOT_TIME, + aggregator_duty: SLOT_TIME * 2, + proposal_duty: SLOT_TIME * 4, + sync_committee_submit: SLOT_TIME, + sync_committee_contribution: SLOT_TIME * 4, + sync_committee_subscribe: EPOCH_TIME, + } +} + +async fn beacon_simulation_1_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("Simulate1"); + if !cfg.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..res + }; + } + let params = SimParams { + total_validators_count: 1, + attestation_validators_count: 0, + proposal_validators_count: 0, + sync_committee_validators_count: 1, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_10_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("Simulate10"); + if !cfg.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..res + }; + } + let params = SimParams { + total_validators_count: 10, + attestation_validators_count: 6, + proposal_validators_count: 3, + sync_committee_validators_count: 1, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_100_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("Simulate100"); + if !cfg.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..res + }; + } + let params = SimParams { + total_validators_count: 100, + attestation_validators_count: 80, + proposal_validators_count: 18, + sync_committee_validators_count: 2, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_500_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("Simulate500"); + if !cfg.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..res + }; + } + let params = SimParams { + total_validators_count: 500, + attestation_validators_count: 450, + proposal_validators_count: 45, + sync_committee_validators_count: 5, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_1000_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + let res = TestResult::new("Simulate1000"); + if !cfg.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..res + }; + } + let params = SimParams { + total_validators_count: 1000, + attestation_validators_count: 930, + proposal_validators_count: 65, + sync_committee_validators_count: 5, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_custom_test( + cancel: tokio_util::sync::CancellationToken, + cfg: TestBeaconArgs, + target: String, +) -> TestResult { + if cfg.simulation_custom < 1 { + return TestResult { + verdict: TestVerdict::Skip, + ..TestResult::new("SimulateCustom") + }; + } + + let total = cfg.simulation_custom; + let mut sync_committees = total / 100; + if sync_committees == 0 { + sync_committees = 1; + } + let mut proposals = total / 15; + if proposals == 0 && (total.saturating_sub(sync_committees) != 0) { + proposals = 1; + } + let attestations = total + .saturating_sub(sync_committees) + .saturating_sub(proposals); + + let res = TestResult::new(format!("Simulate{total}")); + let params = SimParams { + total_validators_count: total, + attestation_validators_count: attestations, + proposal_validators_count: proposals, + sync_committee_validators_count: sync_committees, + request_intensity: default_intensity(), + }; + beacon_simulation_test(cancel, &cfg, &target, res, params).await +} + +async fn beacon_simulation_test( + cancel: tokio_util::sync::CancellationToken, + cfg: &TestBeaconArgs, + target: &str, + mut test_res: TestResult, + params: SimParams, +) -> TestResult { + let sim_duration = StdDuration::from_secs(cfg.simulation_duration * SLOT_TIME.as_secs()) + + StdDuration::from_secs(1); + + tracing::info!( + validators_count = params.total_validators_count, + target = %target, + duration_in_slots = cfg.simulation_duration, + slot_duration = ?SLOT_TIME, + "Running beacon node simulation..." + ); + + let sim_cancel = cancel.child_token(); + let timeout_cancel = sim_cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(sim_duration).await; + timeout_cancel.cancel(); + }); + + // General cluster requests + let cluster_cancel = sim_cancel.clone(); + let cluster_target = target.to_string(); + let cluster_handle = tokio::spawn(async move { + single_cluster_simulation(cluster_cancel, sim_duration, &cluster_target).await + }); + + // Validator simulations + let mut validator_handles = Vec::new(); + + let sync_duties = DutiesPerformed { + attestation: true, + aggregation: true, + proposal: true, + sync_committee: true, + }; + tracing::info!( + validators = params.sync_committee_validators_count, + "Starting validators performing duties attestation, aggregation, proposal, sync committee..." + ); + for _ in 0..params.sync_committee_validators_count { + let c = sim_cancel.clone(); + let t = target.to_string(); + let intensity = params.request_intensity; + validator_handles.push(tokio::spawn(async move { + single_validator_simulation(c, sim_duration, &t, intensity, sync_duties).await + })); + } + + let proposal_duties = DutiesPerformed { + attestation: true, + aggregation: true, + proposal: true, + sync_committee: false, + }; + tracing::info!( + validators = params.proposal_validators_count, + "Starting validators performing duties attestation, aggregation, proposal..." + ); + for _ in 0..params.proposal_validators_count { + let c = sim_cancel.clone(); + let t = target.to_string(); + let intensity = params.request_intensity; + validator_handles.push(tokio::spawn(async move { + single_validator_simulation(c, sim_duration, &t, intensity, proposal_duties).await + })); + } + + let attester_duties = DutiesPerformed { + attestation: true, + aggregation: true, + proposal: false, + sync_committee: false, + }; + tracing::info!( + validators = params.attestation_validators_count, + "Starting validators performing duties attestation, aggregation..." + ); + for _ in 0..params.attestation_validators_count { + let c = sim_cancel.clone(); + let t = target.to_string(); + let intensity = params.request_intensity; + validator_handles.push(tokio::spawn(async move { + single_validator_simulation(c, sim_duration, &t, intensity, attester_duties).await + })); + } + + tracing::info!("Waiting for simulation to complete..."); + + let cluster_result = cluster_handle.await.unwrap_or_default(); + let mut all_validators = Vec::new(); + for h in validator_handles { + if let Ok(v) = h.await { + all_validators.push(v); + } + } + + tracing::info!("Simulation finished, evaluating results..."); + + let averaged = average_validators_result(&all_validators); + + let mut final_simulation = Simulation { + general_cluster_requests: cluster_result, + validators_requests: SimulationValidatorsResult { + averaged, + all_validators: all_validators.clone(), + }, + }; + + if !cfg.simulation_verbose { + strip_verbose(&mut final_simulation); + } + + if let Ok(json) = serde_json::to_vec(&final_simulation) { + let path = cfg + .simulation_file_dir + .join(format!("{}-validators.json", params.total_validators_count)); + if let Err(e) = std::fs::write(&path, json) { + tracing::error!(?e, "Failed to write simulation file"); + } + } + + let highest_rtt = all_validators + .iter() + .map(|v| v.values.max) + .max_by_key(|d| d.as_nanos()) + .unwrap_or_default(); + + test_res = evaluate_rtt( + highest_rtt.into(), + test_res, + THRESHOLD_BEACON_SIMULATION_AVG, + THRESHOLD_BEACON_SIMULATION_POOR, + ); + + tracing::info!( + validators_count = params.total_validators_count, + target = %target, + "Validators simulation finished" + ); + + test_res +} + +async fn single_cluster_simulation( + cancel: tokio_util::sync::CancellationToken, + sim_duration: StdDuration, + target: &str, +) -> SimulationCluster { + let mut attestations_for_block = Vec::new(); + let mut proposal_duties_for_epoch = Vec::new(); + let mut syncing = Vec::new(); + let mut peer_count = Vec::new(); + let mut beacon_committee_sub = Vec::new(); + let mut duties_attester = Vec::new(); + let mut duties_sync_committee = Vec::new(); + let mut beacon_head_validators = Vec::new(); + let mut beacon_genesis_all = Vec::new(); + let mut prep_beacon_proposer = Vec::new(); + let mut config_spec_all = Vec::new(); + let mut node_version_all = Vec::new(); + + let mut slot = get_current_slot(target).await.unwrap_or(1); + + let mut slot_interval = tokio::time::interval(SLOT_TIME); + let mut interval_12_slots = tokio::time::interval(SLOT_TIME * 12); + let mut interval_10_sec = tokio::time::interval(StdDuration::from_secs(10)); + let mut interval_minute = tokio::time::interval(StdDuration::from_secs(60)); + + let deadline = tokio::time::Instant::now() + sim_duration; + + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = slot_interval.tick() => { + slot += 1; + let epoch = slot / SLOTS_IN_EPOCH; + + if let Ok(rtt) = req_get_attestations_for_block(target, slot.saturating_sub(6)).await { + attestations_for_block.push(rtt); + } + if let Ok(rtt) = req_get_proposal_duties_for_epoch(target, epoch).await { + proposal_duties_for_epoch.push(rtt); + } + + // First slot of epoch + if slot % SLOTS_IN_EPOCH == 0 { + if let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } + if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch).await { duties_sync_committee.push(rtt); } + if let Ok(rtt) = req_beacon_head_validators(target).await { beacon_head_validators.push(rtt); } + if let Ok(rtt) = req_beacon_genesis(target).await { beacon_genesis_all.push(rtt); } + if let Ok(rtt) = req_prep_beacon_proposer(target).await { prep_beacon_proposer.push(rtt); } + if let Ok(rtt) = req_config_spec(target).await { config_spec_all.push(rtt); } + if let Ok(rtt) = req_node_version(target).await { node_version_all.push(rtt); } + } + + // Last-but-one slot of epoch + if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 2 { + if let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } + } + + // Last slot of epoch + if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 1 { + if let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } + if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch).await { duties_sync_committee.push(rtt); } + if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch + 256).await { duties_sync_committee.push(rtt); } + } + } + _ = interval_12_slots.tick() => { + if let Ok(rtt) = req_beacon_committee_sub(target).await { beacon_committee_sub.push(rtt); } + } + _ = interval_10_sec.tick() => { + if let Ok(rtt) = req_get_syncing(target).await { syncing.push(rtt); } + } + _ = interval_minute.tick() => { + if let Ok(rtt) = req_get_peer_count(target).await { peer_count.push(rtt); } + } + } + } + + SimulationCluster { + attestations_for_block_request: generate_simulation_values( + &attestations_for_block, + "GET /eth/v1/beacon/blocks/{BLOCK}/attestations", + ), + proposal_duties_for_epoch_request: generate_simulation_values( + &proposal_duties_for_epoch, + "GET /eth/v1/validator/duties/proposer/{EPOCH}", + ), + syncing_request: generate_simulation_values(&syncing, "GET /eth/v1/node/syncing"), + peer_count_request: generate_simulation_values(&peer_count, "GET /eth/v1/node/peer_count"), + beacon_committee_subscription_request: generate_simulation_values( + &beacon_committee_sub, + "POST /eth/v1/validator/beacon_committee_subscriptions", + ), + duties_attester_for_epoch_request: generate_simulation_values( + &duties_attester, + "POST /eth/v1/validator/duties/attester/{EPOCH}", + ), + duties_sync_committee_for_epoch_request: generate_simulation_values( + &duties_sync_committee, + "POST /eth/v1/validator/duties/sync/{EPOCH}", + ), + beacon_head_validators_request: generate_simulation_values( + &beacon_head_validators, + "POST /eth/v1/beacon/states/head/validators", + ), + beacon_genesis_request: generate_simulation_values( + &beacon_genesis_all, + "GET /eth/v1/beacon/genesis", + ), + prep_beacon_proposer_request: generate_simulation_values( + &prep_beacon_proposer, + "POST /eth/v1/validator/prepare_beacon_proposer", + ), + config_spec_request: generate_simulation_values( + &config_spec_all, + "GET /eth/v1/config/spec", + ), + node_version_request: generate_simulation_values( + &node_version_all, + "GET /eth/v1/node/version", + ), + } +} + +async fn single_validator_simulation( + cancel: tokio_util::sync::CancellationToken, + sim_duration: StdDuration, + target: &str, + intensity: RequestsIntensity, + duties: DutiesPerformed, +) -> SimulationSingleValidator { + let mut get_attestation_data_all = Vec::new(); + let mut submit_attestation_object_all = Vec::new(); + let mut get_aggregate_attestations_all = Vec::new(); + let mut submit_aggregate_and_proofs_all = Vec::new(); + let mut produce_block_all = Vec::new(); + let mut publish_blinded_block_all = Vec::new(); + let mut sync_committee_subscription_all = Vec::new(); + let mut submit_sync_committee_message_all = Vec::new(); + let mut produce_sync_committee_contribution_all = Vec::new(); + let mut submit_sync_committee_contribution_all = Vec::new(); + + // Attestation duty + let (att_get_tx, mut att_get_rx) = mpsc::channel(256); + let (att_sub_tx, mut att_sub_rx) = mpsc::channel(256); + if duties.attestation { + let c = cancel.clone(); + let t = target.to_string(); + tokio::spawn(async move { + attestation_duty( + c, + &t, + sim_duration, + intensity.attestation_duty, + att_get_tx, + att_sub_tx, + ) + .await; + }); + } else { + drop(att_get_tx); + drop(att_sub_tx); + } + + // Aggregation duty + let (agg_get_tx, mut agg_get_rx) = mpsc::channel(256); + let (agg_sub_tx, mut agg_sub_rx) = mpsc::channel(256); + if duties.aggregation { + let c = cancel.clone(); + let t = target.to_string(); + tokio::spawn(async move { + aggregation_duty( + c, + &t, + sim_duration, + intensity.aggregator_duty, + agg_get_tx, + agg_sub_tx, + ) + .await; + }); + } else { + drop(agg_get_tx); + drop(agg_sub_tx); + } + + // Proposal duty + let (prop_produce_tx, mut prop_produce_rx) = mpsc::channel(256); + let (prop_publish_tx, mut prop_publish_rx) = mpsc::channel(256); + if duties.proposal { + let c = cancel.clone(); + let t = target.to_string(); + tokio::spawn(async move { + proposal_duty( + c, + &t, + sim_duration, + intensity.proposal_duty, + prop_produce_tx, + prop_publish_tx, + ) + .await; + }); + } else { + drop(prop_produce_tx); + drop(prop_publish_tx); + } + + // Sync committee duties + let (sc_sub_tx, mut sc_sub_rx) = mpsc::channel(256); + let (sc_msg_tx, mut sc_msg_rx) = mpsc::channel(256); + let (sc_produce_tx, mut sc_produce_rx) = mpsc::channel(256); + let (sc_contrib_tx, mut sc_contrib_rx) = mpsc::channel(256); + if duties.sync_committee { + let c = cancel.clone(); + let t = target.to_string(); + tokio::spawn(async move { + sync_committee_duties( + c, + &t, + sim_duration, + intensity.sync_committee_submit, + intensity.sync_committee_subscribe, + intensity.sync_committee_contribution, + sc_msg_tx, + sc_produce_tx, + sc_sub_tx, + sc_contrib_tx, + ) + .await; + }); + } else { + drop(sc_sub_tx); + drop(sc_msg_tx); + drop(sc_produce_tx); + drop(sc_contrib_tx); + } + + // Collect results from all channels + loop { + tokio::select! { + biased; + Some(v) = att_get_rx.recv() => get_attestation_data_all.push(v), + Some(v) = att_sub_rx.recv() => submit_attestation_object_all.push(v), + Some(v) = agg_get_rx.recv() => get_aggregate_attestations_all.push(v), + Some(v) = agg_sub_rx.recv() => submit_aggregate_and_proofs_all.push(v), + Some(v) = prop_produce_rx.recv() => produce_block_all.push(v), + Some(v) = prop_publish_rx.recv() => publish_blinded_block_all.push(v), + Some(v) = sc_sub_rx.recv() => sync_committee_subscription_all.push(v), + Some(v) = sc_msg_rx.recv() => submit_sync_committee_message_all.push(v), + Some(v) = sc_produce_rx.recv() => produce_sync_committee_contribution_all.push(v), + Some(v) = sc_contrib_rx.recv() => submit_sync_committee_contribution_all.push(v), + else => break, + } + } + + let mut all_requests = Vec::new(); + + // Attestation results + let attestation_result = if duties.attestation { + let get_vals = generate_simulation_values( + &get_attestation_data_all, + "GET /eth/v1/validator/attestation_data", + ); + let post_vals = generate_simulation_values( + &submit_attestation_object_all, + "POST /eth/v1/beacon/pool/attestations", + ); + let cumulative: Vec<_> = get_attestation_data_all + .iter() + .zip(&submit_attestation_object_all) + .map(|(a, b)| *a + *b) + .collect(); + all_requests.extend_from_slice(&cumulative); + SimulationAttestation { + values: generate_simulation_values(&cumulative, ""), + get_attestation_data_request: get_vals, + post_attestations_request: post_vals, + } + } else { + SimulationAttestation::default() + }; + + // Aggregation results + let aggregation_result = if duties.aggregation { + let get_vals = generate_simulation_values( + &get_aggregate_attestations_all, + "GET /eth/v1/validator/aggregate_attestation", + ); + let post_vals = generate_simulation_values( + &submit_aggregate_and_proofs_all, + "POST /eth/v1/validator/aggregate_and_proofs", + ); + let cumulative: Vec<_> = get_aggregate_attestations_all + .iter() + .zip(&submit_aggregate_and_proofs_all) + .map(|(a, b)| *a + *b) + .collect(); + all_requests.extend_from_slice(&cumulative); + SimulationAggregation { + values: generate_simulation_values(&cumulative, ""), + get_aggregate_attestation_request: get_vals, + post_aggregate_and_proofs_request: post_vals, + } + } else { + SimulationAggregation::default() + }; + + // Proposal results + let proposal_result = if duties.proposal { + let produce_vals = + generate_simulation_values(&produce_block_all, "GET /eth/v3/validator/blocks/{SLOT}"); + let publish_vals = + generate_simulation_values(&publish_blinded_block_all, "POST /eth/v2/beacon/blinded"); + let cumulative: Vec<_> = produce_block_all + .iter() + .zip(&publish_blinded_block_all) + .map(|(a, b)| *a + *b) + .collect(); + all_requests.extend_from_slice(&cumulative); + SimulationProposal { + values: generate_simulation_values(&cumulative, ""), + produce_block_request: produce_vals, + publish_blinded_block_request: publish_vals, + } + } else { + SimulationProposal::default() + }; + + // Sync committee results + let sync_committee_result = if duties.sync_committee { + let sub_vals = generate_simulation_values( + &sync_committee_subscription_all, + "POST /eth/v1/validator/sync_committee_subscriptions", + ); + let msg_vals = generate_simulation_values( + &submit_sync_committee_message_all, + "POST /eth/v1/beacon/pool/sync_committees", + ); + let produce_vals = generate_simulation_values( + &produce_sync_committee_contribution_all, + "GET /eth/v1/validator/sync_committee_contribution", + ); + let contrib_vals = generate_simulation_values( + &submit_sync_committee_contribution_all, + "POST /eth/v1/validator/contribution_and_proofs", + ); + + let contribution_cumulative: Vec<_> = produce_sync_committee_contribution_all + .iter() + .zip(&submit_sync_committee_contribution_all) + .map(|(a, b)| *a + *b) + .collect(); + + let mut sc_all = Vec::new(); + sc_all.extend_from_slice(&sync_committee_subscription_all); + sc_all.extend_from_slice(&submit_sync_committee_message_all); + sc_all.extend_from_slice(&contribution_cumulative); + all_requests.extend_from_slice(&sc_all); + + SimulationSyncCommittee { + values: generate_simulation_values(&sc_all, ""), + message_duty: SyncCommitteeMessageDuty { + submit_sync_committee_message_request: msg_vals, + }, + contribution_duty: SyncCommitteeContributionDuty { + values: generate_simulation_values(&contribution_cumulative, ""), + produce_sync_committee_contribution_request: produce_vals, + submit_sync_committee_contribution_request: contrib_vals, + }, + subscribe_sync_committee_request: sub_vals, + } + } else { + SimulationSyncCommittee::default() + }; + + SimulationSingleValidator { + values: generate_simulation_values(&all_requests, ""), + attestation_duty: attestation_result, + aggregation_duty: aggregation_result, + proposal_duty: proposal_result, + sync_committee_duties: sync_committee_result, + } +} + +async fn attestation_duty( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time: StdDuration, + get_tx: mpsc::Sender, + submit_tx: mpsc::Sender, +) { + let deadline = tokio::time::Instant::now() + sim_duration; + tokio::time::sleep(randomize_start(tick_time)).await; + + let mut interval = tokio::time::interval(tick_time); + let mut slot = get_current_slot(target).await.unwrap_or(1); + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + let committee_index = rand::thread_rng().gen_range(0..COMMITTEE_SIZE_PER_SLOT); + if let Ok(rtt) = req_get_attestation_data(target, slot, committee_index).await { + let _ = get_tx.send(rtt).await; + } + if let Ok(rtt) = req_submit_attestation_object(target).await { + let _ = submit_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => { + slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + } + } + } +} + +async fn aggregation_duty( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time: StdDuration, + get_tx: mpsc::Sender, + submit_tx: mpsc::Sender, +) { + let deadline = tokio::time::Instant::now() + sim_duration; + let mut slot = get_current_slot(target).await.unwrap_or(1); + tokio::time::sleep(randomize_start(tick_time)).await; + + let mut interval = tokio::time::interval(tick_time); + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + if let Ok(rtt) = req_get_aggregate_attestations( + target, + slot, + "0x87db5c50a4586fa37662cf332382d56a0eeea688a7d7311a42735683dfdcbfa4", + ) + .await + { + let _ = get_tx.send(rtt).await; + } + if let Ok(rtt) = req_post_aggregate_and_proofs(target).await { + let _ = submit_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => { + slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + } + } + } +} + +async fn proposal_duty( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time: StdDuration, + produce_tx: mpsc::Sender, + publish_tx: mpsc::Sender, +) { + let deadline = tokio::time::Instant::now() + sim_duration; + tokio::time::sleep(randomize_start(tick_time)).await; + + let mut interval = tokio::time::interval(tick_time); + let mut slot = get_current_slot(target).await.unwrap_or(1); + let randao = "0x1fe79e4193450abda94aec753895cfb2aac2c2a930b6bab00fbb27ef6f4a69f4400ad67b5255b91837982b4c511ae1d94eae1cf169e20c11bd417c1fffdb1f99f4e13e2de68f3b5e73f1de677d73cd43e44bf9b133a79caf8e5fad06738e1b0c"; + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + if let Ok(rtt) = req_produce_block(target, slot, randao).await { + let _ = produce_tx.send(rtt).await; + } + if let Ok(rtt) = req_publish_blinded_block(target).await { + let _ = publish_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => { + slot += tick_time.as_secs() / SLOT_TIME.as_secs() + 1; + } + } + } +} + +async fn sync_committee_duties( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time_submit: StdDuration, + tick_time_subscribe: StdDuration, + tick_time_contribution: StdDuration, + msg_tx: mpsc::Sender, + produce_tx: mpsc::Sender, + sub_tx: mpsc::Sender, + contrib_tx: mpsc::Sender, +) { + let c1 = cancel.clone(); + let t1 = target.to_string(); + tokio::spawn(async move { + sync_committee_contribution_duty( + c1, + &t1, + sim_duration, + tick_time_contribution, + produce_tx, + contrib_tx, + ) + .await; + }); + + let c2 = cancel.clone(); + let t2 = target.to_string(); + tokio::spawn(async move { + sync_committee_message_duty(c2, &t2, sim_duration, tick_time_submit, msg_tx).await; + }); + + // Subscribe loop + let deadline = tokio::time::Instant::now() + sim_duration; + tokio::time::sleep(randomize_start(tick_time_subscribe)).await; + let mut interval = tokio::time::interval(tick_time_subscribe); + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + if let Ok(rtt) = req_sync_committee_subscription(target).await { + let _ = sub_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => {} + } + } +} + +async fn sync_committee_contribution_duty( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time: StdDuration, + produce_tx: mpsc::Sender, + contrib_tx: mpsc::Sender, +) { + let deadline = tokio::time::Instant::now() + sim_duration; + tokio::time::sleep(randomize_start(tick_time)).await; + let mut interval = tokio::time::interval(tick_time); + let mut slot = get_current_slot(target).await.unwrap_or(1); + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + let sub_idx = rand::thread_rng().gen_range(0..SUB_COMMITTEE_SIZE); + let beacon_block_root = + "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"; + if let Ok(rtt) = + req_produce_sync_committee_contribution(target, slot, sub_idx, beacon_block_root).await + { + let _ = produce_tx.send(rtt).await; + } + if let Ok(rtt) = req_submit_sync_committee_contribution(target).await { + let _ = contrib_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => { + slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + } + } + } +} + +async fn sync_committee_message_duty( + cancel: tokio_util::sync::CancellationToken, + target: &str, + sim_duration: StdDuration, + tick_time: StdDuration, + msg_tx: mpsc::Sender, +) { + let deadline = tokio::time::Instant::now() + sim_duration; + tokio::time::sleep(randomize_start(tick_time)).await; + let mut interval = tokio::time::interval(tick_time); + + loop { + if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + break; + } + + if let Ok(rtt) = req_submit_sync_committee(target).await { + let _ = msg_tx.send(rtt).await; + } + + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep_until(deadline) => break, + _ = interval.tick() => {} + } + } +} + +async fn get_current_slot(target: &str) -> CliResult { + let url = format!("{target}/eth/v1/node/syncing"); + let (clean_url, credentials) = parse_endpoint_url(&url)?; + let client = reqwest::Client::new(); + let resp = apply_basic_auth(client.get(&clean_url), &credentials) + .send() + .await?; + + if !resp.status().is_success() { + return Err(crate::error::CliError::Other(format!( + "syncing request failed: {}", + resp.status() + ))); + } + + #[derive(Deserialize)] + struct Data { + head_slot: String, + } + #[derive(Deserialize)] + struct Response { + data: Data, + } + + let body: Response = resp.json().await?; + body.data + .head_slot + .parse() + .map_err(|e| crate::error::CliError::Other(format!("parse head_slot: {e}"))) +} + +fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> SimulationValues { + if durations.is_empty() { + return SimulationValues { + endpoint: endpoint.to_string(), + ..Default::default() + }; + } + + let mut sorted: Vec = durations.to_vec(); + sorted.sort(); + + let min = sorted[0]; + let max = sorted[sorted.len() - 1]; + let median = sorted[sorted.len() / 2]; + let sum: StdDuration = durations.iter().sum(); + let avg = sum / durations.len() as u32; + + let all: Vec = durations.iter().map(|d| Duration::new(*d)).collect(); + + SimulationValues { + endpoint: endpoint.to_string(), + all, + min: Duration::new(min), + max: Duration::new(max), + median: Duration::new(median), + avg: Duration::new(avg), + } +} + +fn average_validators_result( + validators: &[SimulationSingleValidator], +) -> SimulationSingleValidator { + if validators.is_empty() { + return SimulationSingleValidator::default(); + } + + let collect_durations = + |f: &dyn Fn(&SimulationSingleValidator) -> &SimulationValues| -> Vec { + validators + .iter() + .flat_map(|v| f(v).all.iter().map(|d| (*d).into())) + .collect() + }; + + let att_get = collect_durations(&|v| &v.attestation_duty.get_attestation_data_request); + let att_post = collect_durations(&|v| &v.attestation_duty.post_attestations_request); + let att_all = collect_durations(&|v| &v.attestation_duty.values); + + let agg_get = collect_durations(&|v| &v.aggregation_duty.get_aggregate_attestation_request); + let agg_post = collect_durations(&|v| &v.aggregation_duty.post_aggregate_and_proofs_request); + let agg_all = collect_durations(&|v| &v.aggregation_duty.values); + + let prop_produce = collect_durations(&|v| &v.proposal_duty.produce_block_request); + let prop_publish = collect_durations(&|v| &v.proposal_duty.publish_blinded_block_request); + let prop_all = collect_durations(&|v| &v.proposal_duty.values); + + let sc_msg = collect_durations(&|v| { + &v.sync_committee_duties + .message_duty + .submit_sync_committee_message_request + }); + let sc_produce = collect_durations(&|v| { + &v.sync_committee_duties + .contribution_duty + .produce_sync_committee_contribution_request + }); + let sc_contrib = collect_durations(&|v| { + &v.sync_committee_duties + .contribution_duty + .submit_sync_committee_contribution_request + }); + let sc_contrib_all = collect_durations(&|v| &v.sync_committee_duties.contribution_duty.values); + let sc_sub = collect_durations(&|v| &v.sync_committee_duties.subscribe_sync_committee_request); + let sc_all = collect_durations(&|v| &v.sync_committee_duties.values); + + let all = collect_durations(&|v| &v.values); + + SimulationSingleValidator { + values: generate_simulation_values(&all, ""), + attestation_duty: SimulationAttestation { + values: generate_simulation_values(&att_all, ""), + get_attestation_data_request: generate_simulation_values( + &att_get, + "GET /eth/v1/validator/attestation_data", + ), + post_attestations_request: generate_simulation_values( + &att_post, + "POST /eth/v1/beacon/pool/attestations", + ), + }, + aggregation_duty: SimulationAggregation { + values: generate_simulation_values(&agg_all, ""), + get_aggregate_attestation_request: generate_simulation_values( + &agg_get, + "GET /eth/v1/validator/aggregate_attestation", + ), + post_aggregate_and_proofs_request: generate_simulation_values( + &agg_post, + "POST /eth/v1/validator/aggregate_and_proofs", + ), + }, + proposal_duty: SimulationProposal { + values: generate_simulation_values(&prop_all, ""), + produce_block_request: generate_simulation_values( + &prop_produce, + "GET /eth/v3/validator/blocks/{SLOT}", + ), + publish_blinded_block_request: generate_simulation_values( + &prop_publish, + "POST /eth/v2/beacon/blinded", + ), + }, + sync_committee_duties: SimulationSyncCommittee { + values: generate_simulation_values(&sc_all, ""), + message_duty: SyncCommitteeMessageDuty { + submit_sync_committee_message_request: generate_simulation_values( + &sc_msg, + "POST /eth/v1/beacon/pool/sync_committees", + ), + }, + contribution_duty: SyncCommitteeContributionDuty { + values: generate_simulation_values(&sc_contrib_all, ""), + produce_sync_committee_contribution_request: generate_simulation_values( + &sc_produce, + "GET /eth/v1/validator/sync_committee_contribution", + ), + submit_sync_committee_contribution_request: generate_simulation_values( + &sc_contrib, + "POST /eth/v1/validator/contribution_and_proofs", + ), + }, + subscribe_sync_committee_request: generate_simulation_values( + &sc_sub, + "POST /eth/v1/validator/sync_committee_subscriptions", + ), + }, + } +} + +fn randomize_start(tick_time: StdDuration) -> StdDuration { + let slots = (tick_time.as_secs() / SLOT_TIME.as_secs()).max(1); + let random_slots = rand::thread_rng().gen_range(0..slots); + SLOT_TIME * random_slots as u32 +} + +fn strip_verbose(sim: &mut Simulation) { + sim.validators_requests.all_validators.clear(); + + strip_vals(&mut sim.validators_requests.averaged.values); + strip_vals(&mut sim.validators_requests.averaged.attestation_duty.values); + strip_vals( + &mut sim + .validators_requests + .averaged + .attestation_duty + .get_attestation_data_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .attestation_duty + .post_attestations_request, + ); + strip_vals(&mut sim.validators_requests.averaged.aggregation_duty.values); + strip_vals( + &mut sim + .validators_requests + .averaged + .aggregation_duty + .get_aggregate_attestation_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .aggregation_duty + .post_aggregate_and_proofs_request, + ); + strip_vals(&mut sim.validators_requests.averaged.proposal_duty.values); + strip_vals( + &mut sim + .validators_requests + .averaged + .proposal_duty + .produce_block_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .proposal_duty + .publish_blinded_block_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .values, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .contribution_duty + .values, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .contribution_duty + .produce_sync_committee_contribution_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .contribution_duty + .submit_sync_committee_contribution_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .message_duty + .submit_sync_committee_message_request, + ); + strip_vals( + &mut sim + .validators_requests + .averaged + .sync_committee_duties + .subscribe_sync_committee_request, + ); + + strip_vals(&mut sim.general_cluster_requests.attestations_for_block_request); + strip_vals( + &mut sim + .general_cluster_requests + .proposal_duties_for_epoch_request, + ); + strip_vals(&mut sim.general_cluster_requests.syncing_request); + strip_vals(&mut sim.general_cluster_requests.peer_count_request); + strip_vals( + &mut sim + .general_cluster_requests + .beacon_committee_subscription_request, + ); + strip_vals( + &mut sim + .general_cluster_requests + .duties_attester_for_epoch_request, + ); + strip_vals( + &mut sim + .general_cluster_requests + .duties_sync_committee_for_epoch_request, + ); + strip_vals(&mut sim.general_cluster_requests.beacon_head_validators_request); + strip_vals(&mut sim.general_cluster_requests.beacon_genesis_request); + strip_vals(&mut sim.general_cluster_requests.prep_beacon_proposer_request); + strip_vals(&mut sim.general_cluster_requests.config_spec_request); + strip_vals(&mut sim.general_cluster_requests.node_version_request); +} + +fn strip_vals(v: &mut SimulationValues) { + v.all.clear(); +} + +async fn req_get_attestations_for_block(target: &str, block: u64) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/beacon/blocks/{block}/attestations"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_proposal_duties_for_epoch(target: &str, epoch: u64) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/validator/duties/proposer/{epoch}"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_syncing(target: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/node/syncing"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_peer_count(target: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/node/peer_count"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_beacon_committee_sub(target: &str) -> CliResult { + let body = r#"[{"validator_index":"1","committee_index":"1","committees_at_slot":"1","slot":"1","is_aggregator":true}]"#; + request_rtt( + &format!("{target}/eth/v1/validator/beacon_committee_subscriptions"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_attester_duties_for_epoch(target: &str, epoch: u64) -> CliResult { + let body = r#"["1"]"#; + request_rtt( + &format!("{target}/eth/v1/validator/duties/attester/{epoch}"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_sync_committee_duties_for_epoch( + target: &str, + epoch: u64, +) -> CliResult { + let body = r#"["1"]"#; + request_rtt( + &format!("{target}/eth/v1/validator/duties/sync/{epoch}"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_beacon_head_validators(target: &str) -> CliResult { + let body = r#"{"ids":["0xb6066945aa87a1e0e4b55e347d3a8a0ef7f0d9f7ef2c46abebadb25d7de176b83c88547e5f8644b659598063c845719a"]}"#; + request_rtt( + &format!("{target}/eth/v1/beacon/states/head/validators"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_beacon_genesis(target: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/beacon/genesis"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_prep_beacon_proposer(target: &str) -> CliResult { + let body = r#"[{"validator_index":"1725802","fee_recipient":"0x74b1C2f5788510c9ecA5f56D367B0a3D8a15a430"}]"#; + request_rtt( + &format!("{target}/eth/v1/validator/prepare_beacon_proposer"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_config_spec(target: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/config/spec"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_node_version(target: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v1/node/version"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_get_attestation_data( + target: &str, + slot: u64, + committee_index: u64, +) -> CliResult { + request_rtt(&format!("{target}/eth/v1/validator/attestation_data?slot={slot}&committee_index={committee_index}"), Method::GET, None, reqwest::StatusCode::OK).await +} + +async fn req_submit_attestation_object(target: &str) -> CliResult { + let body = r#"{{"aggregation_bits":"0x01","signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505","data":{"slot":"1","index":"1","beacon_block_root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2","source":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"},"target":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}}}}"#; + request_rtt( + &format!("{target}/eth/v1/beacon/pool/attestations"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::BAD_REQUEST, + ) + .await +} + +async fn req_get_aggregate_attestations( + target: &str, + slot: u64, + attestation_data_root: &str, +) -> CliResult { + request_rtt(&format!("{target}/eth/v1/validator/aggregate_attestation?slot={slot}&attestation_data_root={attestation_data_root}"), Method::GET, None, reqwest::StatusCode::NOT_FOUND).await +} + +async fn req_post_aggregate_and_proofs(target: &str) -> CliResult { + let body = r#"[{"message":{"aggregator_index":"1","aggregate":{"aggregation_bits":"0x01","signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505","data":{"slot":"1","index":"1","beacon_block_root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2","source":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"},"target":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}}},"selection_proof":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"},"signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}]"#; + request_rtt( + &format!("{target}/eth/v1/validator/aggregate_and_proofs"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::BAD_REQUEST, + ) + .await +} + +async fn req_produce_block(target: &str, slot: u64, randao_reveal: &str) -> CliResult { + request_rtt( + &format!("{target}/eth/v3/validator/blocks/{slot}?randao_reveal={randao_reveal}"), + Method::GET, + None, + reqwest::StatusCode::OK, + ) + .await +} + +async fn req_publish_blinded_block(target: &str) -> CliResult { + let body = r#"{"message":{"slot":"2872079","proposer_index":"1725813","parent_root":"0x05bea9b8e9cc28c4efa5586b4efac20b7a42c3112dbe144fb552b37ded249abd","state_root":"0x0138e6e8e956218aa534597a450a93c2c98f07da207077b4be05742279688da2","body":{"randao_reveal":"0x9880dad5a0e900906a1355da0697821af687b4c2cd861cd219f2d779c50a47d3c0335c08d840c86c167986ae0aaf50070b708fe93a83f66c99a4f931f9a520aebb0f5b11ca202c3d76343e30e49f43c0479e850af0e410333f7c59c4d37fa95a","eth1_data":{"deposit_root":"0x7dbea1a0af14d774da92d94a88d3bb1ae7abad16374da4db2c71dd086c84029e","deposit_count":"452100","block_hash":"0xc4bf450c9e362dcb2b50e76b45938c78d455acd1e1aec4e1ce4338ec023cd32a"},"graffiti":"0x636861726f6e2f76312e312e302d613139336638340000000000000000000000","proposer_slashings":[],"attester_slashings":[],"attestations":[{"aggregation_bits":"0xdbedbfa74eccaf3d7ef570bfdbbf84b4dffc5beede1c1f8b59feb8b3f2fbabdbdef3ceeb7b3dfdeeef8efcbdcd7bebbeff7adfff5ae3bf66bc5613feffef3deb987f7e7fff87ed6f8bbd1fffa57f1677efff646f0d3bd79fffdc5dfd78df6cf79fb7febff5dfdefb8e03","data":{"slot":"2872060","index":"12","beacon_block_root":"0x310506169f7f92dcd2bf00e8b4c2daac999566929395120fbbf4edd222e003eb","source":{"epoch":"89750","root":"0xcdb449d69e3e2d22378bfc2299ee1e9aeb1b2d15066022e854759dda73d1e219"},"target":{"epoch":"89751","root":"0x4ad0882f7adbb735c56b0b3f09d8e45dbd79db9528110f7117ec067f3a19eb0e"}},"signature":"0xa9d91d6cbc669ffcc8ba2435c633e0ec0eebecaa3acdcaa1454282ece1f816e8b853f00ba67ec1244703221efae4c834012819ca7b199354669f24ba8ab1c769f072c9f46b803082eac32e3611cd323eeb5b17fcd6201b41f3063834ff26ef53"}],"deposits":[],"voluntary_exits":[],"sync_aggregate":{"sync_committee_bits":"0xf9ff3ff7ffffb7dbfefddff5fffffefdbffffffffffedfefffffff7fbe9fdffffdb5feffffffbfdbefff3ffdf7f3fc6ff7fffbffff9df6fbbaf3beffefffffff","sync_committee_signature":"0xa9cf7d9f23a62e84f11851e2e4b3b929b1d03719a780b59ecba5daf57e21a0ceccaf13db4e1392a42e3603abeb839a2d16373dcdd5e696f11c5a809972c1e368d794f1c61d4d10b220df52616032f09b33912febf8c7a64f3ce067ab771c7ddf"},"execution_payload_header":{"parent_hash":"0x71c564f4a0c1dea921e8063fc620ccfa39c1b073e4ac0845ce7e9e6f909752de","fee_recipient":"0x148914866080716b10D686F5570631Fbb2207002","state_root":"0x89e74be562cd4a10eb20cdf674f65b1b0e53b33a7c3f2df848eb4f7e226742e0","receipts_root":"0x55b494ee1bb919e7abffaab1d5be05a109612c59a77406d929d77c0ce714f21d","logs_bloom":"0x20500886140245d001002010680c10411a2540420182810440a108800fc008440801180020011008004045005a2007826802e102000005c0c04030590004044810d0d20745c0904a4d583008a01758018001082024e40046000410020042400100012260220299a8084415e20002891224c132220010003a00006010020ed0c108920a13c0e200a1a00251100888c01408008132414068c88b028920440248209a280581a0e10800c14ea63082c1781308208b130508d4000400802d1224521094260912473404012810001503417b4050141100c1103004000c8900644560080472688450710084088800c4c80000c02008931188204c008009011784488060","prev_randao":"0xf4e9a4a7b88a3d349d779e13118b6d099f7773ec5323921343ac212df19c620f","block_number":"2643688","gas_limit":"30000000","gas_used":"24445884","timestamp":"1730367348","extra_data":"0x546974616e2028746974616e6275696c6465722e78797a29","base_fee_per_gas":"122747440","block_hash":"0x7524d779d328159e4d9ee8a4b04c4b251261da9a6da1d1461243125faa447227","transactions_root":"0x7e8a3391a77eaea563bf4e0ca4cf3190425b591ed8572818924c38f7e423c257","withdrawals_root":"0x61a5653b614ec3db0745ae5568e6de683520d84bc3db2dedf6a5158049cee807","blob_gas_used":"0","excess_blob_gas":"0"},"bls_to_execution_changes":[],"blob_kzg_commitments":[]}},"signature":"0x94320e6aecd65da3ef3e55e45208978844b262fe21cacbb0a8448b2caf21e8619b205c830116d8aad0a2c55d879fb571123a3fcf31b515f9508eb346ecd3de2db07cea6700379c00831cfb439f4aeb3bfa164395367c8d8befb92aa6682eae51"}"#; + request_rtt( + &format!("{target}/eth/v2/beacon/blinded"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::NOT_FOUND, + ) + .await +} + +async fn req_submit_sync_committee(target: &str) -> CliResult { + let body = r#"{{"aggregation_bits":"0x01","signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505","data":{"slot":"1","index":"1","beacon_block_root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2","source":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"},"target":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}}}}"#; + request_rtt( + &format!("{target}/eth/v1/beacon/pool/sync_committees"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::BAD_REQUEST, + ) + .await +} + +async fn req_produce_sync_committee_contribution( + target: &str, + slot: u64, + subcommittee_index: u64, + beacon_block_root: &str, +) -> CliResult { + request_rtt(&format!("{target}/eth/v1/validator/sync_committee_contribution?slot={slot}&subcommittee_index={subcommittee_index}&beacon_block_root={beacon_block_root}"), Method::GET, None, reqwest::StatusCode::NOT_FOUND).await +} + +async fn req_sync_committee_subscription(target: &str) -> CliResult { + let body = r#"[{"message":{"aggregator_index":"1","aggregate":{"aggregation_bits":"0x01","signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505","data":{"slot":"1","index":"1","beacon_block_root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2","source":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"},"target":{"epoch":"1","root":"0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"}}},"selection_proof":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"},"signature":"0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505"}]"#; + request_rtt( + &format!("{target}/eth/v1/validator/sync_committee_subscriptions"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::BAD_REQUEST, + ) + .await +} + +async fn req_submit_sync_committee_contribution(target: &str) -> CliResult { + let body = r#"[{"message":{"aggregator_index":"1","contribution":{"slot":"1","beacon_block_root":"0xace2cad95a1b113457ccc680372880694a3ef820584d04a165aa2bda0f261950","subcommittee_index":"3","aggregation_bits":"0xfffffbfff7ddffffbef3bfffebffff7f","signature":"0xaa4cf0db0677555025fe12223572e67b509b0b24a2b07dc162aed38522febb2a64ad293e6dbfa1b81481eec250a2cdb61619456291f8d0e3f86097a42a71985d6dabd256107af8b4dfc2982a7d67ac63e2d6b7d59d24a9e87546c71b9c68ca1f"},"selection_proof":"0xb177453ba19233da0625b354d6a43e8621b676243ec4aa5dbb269ac750079cc23fced007ea6cdc1bfb6cc0e2fc796fbb154abed04d9aac7c1171810085beff2b9e5cff961975dbdce4199f39d97b4c46339e26eb7946762394905dbdb9818afe"},"signature":"0x8f73f3185164454f6807549bcbf9d1b0b5516279f35ead1a97812da5db43088de344fdc46aaafd20650bd6685515fb4e18f9f053e9e3691065f8a87f6160456ef8aa550f969ef8260368aae3e450e8763c6317f40b09863ad9b265a0e618e472"}]"#; + request_rtt( + &format!("{target}/eth/v1/validator/contribution_and_proofs"), + Method::POST, + Some(body.into()), + reqwest::StatusCode::OK, + ) + .await } diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index 57dc3a8c..ab4e0c60 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -132,10 +132,7 @@ fn list_test_cases(category: TestCategory) -> Vec { "PingLoad".to_string(), ] } - TestCategory::Beacon => { - // TODO: Extract from beacon::supported_beacon_test_cases() - vec![] - } + TestCategory::Beacon => beacon::test_case_names(), TestCategory::Mev => { vec![ "Ping".to_string(), @@ -221,6 +218,10 @@ impl TestResultError { Self(String::new()) } + pub(crate) fn from_string(s: impl Into) -> Self { + Self(s.into()) + } + pub(crate) fn is_empty(&self) -> bool { self.0.is_empty() } @@ -639,11 +640,11 @@ pub(crate) fn calculate_score(results: &[TestResult]) -> CategoryScore { } /// Filters tests based on configuration. -pub(crate) fn filter_tests( - supported_test_cases: &HashMap, +pub(crate) fn filter_tests( + supported_test_cases: &[TestCaseName], test_cases: Option<&[String]>, ) -> Vec { - let mut filtered: Vec = supported_test_cases.keys().cloned().collect(); + let mut filtered: Vec = supported_test_cases.to_vec(); if let Some(cases) = test_cases { filtered.retain(|supported_case| cases.contains(&supported_case.name)); } @@ -682,6 +683,33 @@ fn hash_ssz(data: &[u8]) -> CliResult<[u8; 32]> { Ok(hasher.hash_root()?) } +pub(crate) fn parse_endpoint_url(endpoint: &str) -> CliResult<(String, Option<(String, String)>)> { + let mut parsed = reqwest::Url::parse(endpoint) + .map_err(|e| CliError::Other(format!("parse endpoint URL: {e}")))?; + + if parsed.username().is_empty() { + return Ok((endpoint.to_string(), None)); + } + + let username = parsed.username().to_string(); + let password = parsed.password().unwrap_or_default().to_string(); + parsed.set_username("").ok(); + parsed.set_password(None).ok(); + + Ok((parsed.to_string(), Some((username, password)))) +} + +pub(crate) fn apply_basic_auth( + builder: reqwest::RequestBuilder, + credentials: &Option<(String, String)>, +) -> reqwest::RequestBuilder { + if let Some((username, password)) = credentials { + builder.basic_auth(username, Some(password)) + } else { + builder + } +} + /// Measures the round-trip time (RTT) for an HTTP request and logs a warning if /// the response status code doesn't match the expected status. pub(crate) async fn request_rtt( @@ -690,9 +718,11 @@ pub(crate) async fn request_rtt( body: Option>, expected_status: StatusCode, ) -> CliResult { + let (clean_url, credentials) = parse_endpoint_url(url.as_ref())?; let client = reqwest::Client::new(); - let mut request_builder = client.request(method, url.as_ref()); + let mut request_builder = client.request(method, &clean_url); + request_builder = apply_basic_auth(request_builder, &credentials); if let Some(body_bytes) = body { request_builder = request_builder @@ -710,14 +740,14 @@ pub(crate) async fn request_rtt( Ok(body) if !body.is_empty() => tracing::warn!( status_code = status.as_u16(), expected_status_code = expected_status.as_u16(), - endpoint = url.as_ref(), + endpoint = clean_url, body = body, "Unexpected status code" ), _ => tracing::warn!( status_code = status.as_u16(), expected_status_code = expected_status.as_u16(), - endpoint = url.as_ref(), + endpoint = clean_url, "Unexpected status code" ), } diff --git a/crates/cli/src/duration.rs b/crates/cli/src/duration.rs index adffe1b8..82a704d4 100644 --- a/crates/cli/src/duration.rs +++ b/crates/cli/src/duration.rs @@ -49,6 +49,11 @@ impl Duration { Self::new(rounded) } + + /// Returns the total number of nanoseconds. + pub fn as_nanos(&self) -> u128 { + self.inner.as_nanos() + } } impl From for Duration { @@ -57,6 +62,24 @@ impl From for Duration { } } +impl From for StdDuration { + fn from(d: Duration) -> Self { + d.inner + } +} + +impl PartialOrd for Duration { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Duration { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.inner.cmp(&other.inner) + } +} + impl std::str::FromStr for Duration { type Err = String; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cd3b9e7d..d594ff73 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -45,6 +45,12 @@ async fn main() -> ExitResult { Commands::Relay(args) => commands::relay::run(*args, ct.child_token()).await, Commands::Alpha(args) => match args.command { AlphaCommands::Test(args) => { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); let mut stdout = std::io::stdout(); match args.command { TestCommands::Peers(args) => commands::test::peers::run(args, &mut stdout) From f7c275e4d5c7fee36699bbd2df180b2c589c35d5 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 20 Mar 2026 18:41:40 +0100 Subject: [PATCH 02/41] clippy and some functions extracted --- crates/cli/src/commands/test/beacon.rs | 147 +++++++++++-------------- crates/cli/src/duration.rs | 2 +- 2 files changed, 67 insertions(+), 82 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index b90c8305..41fd6b49 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -3,6 +3,8 @@ //! Port of charon/cmd/testbeacon.go — runs connectivity, load, and simulation //! tests against one or more beacon node endpoints. +#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] + use super::{ CategoryScore, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, TestCaseName, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, TestVerdict, apply_basic_auth, calculate_score, @@ -282,12 +284,7 @@ pub async fn run(args: TestBeaconArgs, writer: &mut dyn Write) -> CliResult(65536); let load_cancel = cancel.child_token(); - let timeout_cancel = load_cancel.clone(); - let duration = cfg.load_test_duration; - tokio::spawn(async move { - tokio::time::sleep(duration).await; - timeout_cancel.cancel(); - }); + cancel_after(&load_cancel, cfg.load_test_duration); let mut handles = Vec::new(); let mut interval = tokio::time::interval(StdDuration::from_secs(1)); @@ -844,11 +836,7 @@ async fn beacon_simulation_test( ); let sim_cancel = cancel.child_token(); - let timeout_cancel = sim_cancel.clone(); - tokio::spawn(async move { - tokio::time::sleep(sim_duration).await; - timeout_cancel.cancel(); - }); + cancel_after(&sim_cancel, sim_duration); // General cluster requests let cluster_cancel = sim_cancel.clone(); @@ -1028,8 +1016,10 @@ async fn single_cluster_simulation( } // Last-but-one slot of epoch - if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 2 { - if let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } + if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 2 + && let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await + { + duties_attester.push(rtt); } // Last slot of epoch @@ -1232,74 +1222,45 @@ async fn single_validator_simulation( let mut all_requests = Vec::new(); // Attestation results - let attestation_result = if duties.attestation { - let get_vals = generate_simulation_values( - &get_attestation_data_all, - "GET /eth/v1/validator/attestation_data", - ); - let post_vals = generate_simulation_values( - &submit_attestation_object_all, - "POST /eth/v1/beacon/pool/attestations", - ); - let cumulative: Vec<_> = get_attestation_data_all - .iter() - .zip(&submit_attestation_object_all) - .map(|(a, b)| *a + *b) - .collect(); - all_requests.extend_from_slice(&cumulative); - SimulationAttestation { - values: generate_simulation_values(&cumulative, ""), - get_attestation_data_request: get_vals, - post_attestations_request: post_vals, - } - } else { - SimulationAttestation::default() + let (values, get_vals, post_vals) = compute_two_phase_results( + &get_attestation_data_all, + "GET /eth/v1/validator/attestation_data", + &submit_attestation_object_all, + "POST /eth/v1/beacon/pool/attestations", + &mut all_requests, + ); + let attestation_result = SimulationAttestation { + values, + get_attestation_data_request: get_vals, + post_attestations_request: post_vals, }; // Aggregation results - let aggregation_result = if duties.aggregation { - let get_vals = generate_simulation_values( - &get_aggregate_attestations_all, - "GET /eth/v1/validator/aggregate_attestation", - ); - let post_vals = generate_simulation_values( - &submit_aggregate_and_proofs_all, - "POST /eth/v1/validator/aggregate_and_proofs", - ); - let cumulative: Vec<_> = get_aggregate_attestations_all - .iter() - .zip(&submit_aggregate_and_proofs_all) - .map(|(a, b)| *a + *b) - .collect(); - all_requests.extend_from_slice(&cumulative); - SimulationAggregation { - values: generate_simulation_values(&cumulative, ""), - get_aggregate_attestation_request: get_vals, - post_aggregate_and_proofs_request: post_vals, - } - } else { - SimulationAggregation::default() + let (values, get_vals, post_vals) = compute_two_phase_results( + &get_aggregate_attestations_all, + "GET /eth/v1/validator/aggregate_attestation", + &submit_aggregate_and_proofs_all, + "POST /eth/v1/validator/aggregate_and_proofs", + &mut all_requests, + ); + let aggregation_result = SimulationAggregation { + values, + get_aggregate_attestation_request: get_vals, + post_aggregate_and_proofs_request: post_vals, }; // Proposal results - let proposal_result = if duties.proposal { - let produce_vals = - generate_simulation_values(&produce_block_all, "GET /eth/v3/validator/blocks/{SLOT}"); - let publish_vals = - generate_simulation_values(&publish_blinded_block_all, "POST /eth/v2/beacon/blinded"); - let cumulative: Vec<_> = produce_block_all - .iter() - .zip(&publish_blinded_block_all) - .map(|(a, b)| *a + *b) - .collect(); - all_requests.extend_from_slice(&cumulative); - SimulationProposal { - values: generate_simulation_values(&cumulative, ""), - produce_block_request: produce_vals, - publish_blinded_block_request: publish_vals, - } - } else { - SimulationProposal::default() + let (values, produce_vals, publish_vals) = compute_two_phase_results( + &produce_block_all, + "GET /eth/v3/validator/blocks/{SLOT}", + &publish_blinded_block_all, + "POST /eth/v2/beacon/blinded", + &mut all_requests, + ); + let proposal_result = SimulationProposal { + values, + produce_block_request: produce_vals, + publish_blinded_block_request: publish_vals, }; // Sync committee results @@ -1474,6 +1435,7 @@ async fn proposal_duty( } } +#[allow(clippy::too_many_arguments)] async fn sync_committee_duties( cancel: tokio_util::sync::CancellationToken, target: &str, @@ -1627,6 +1589,21 @@ async fn get_current_slot(target: &str) -> CliResult { .map_err(|e| crate::error::CliError::Other(format!("parse head_slot: {e}"))) } +fn compute_two_phase_results( + first: &[StdDuration], + first_endpoint: &str, + second: &[StdDuration], + second_endpoint: &str, + all_requests: &mut Vec, +) -> (SimulationValues, SimulationValues, SimulationValues) { + let first_vals = generate_simulation_values(first, first_endpoint); + let second_vals = generate_simulation_values(second, second_endpoint); + let cumulative: Vec<_> = first.iter().zip(second).map(|(a, b)| *a + *b).collect(); + all_requests.extend_from_slice(&cumulative); + let cumulative_vals = generate_simulation_values(&cumulative, ""); + (cumulative_vals, first_vals, second_vals) +} + fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> SimulationValues { if durations.is_empty() { return SimulationValues { @@ -1766,6 +1743,14 @@ fn average_validators_result( } } +fn cancel_after(token: &tokio_util::sync::CancellationToken, duration: StdDuration) { + let token = token.clone(); + tokio::spawn(async move { + tokio::time::sleep(duration).await; + token.cancel(); + }); +} + fn randomize_start(tick_time: StdDuration) -> StdDuration { let slots = (tick_time.as_secs() / SLOT_TIME.as_secs()).max(1); let random_slots = rand::thread_rng().gen_range(0..slots); diff --git a/crates/cli/src/duration.rs b/crates/cli/src/duration.rs index 82a704d4..f5c43cfa 100644 --- a/crates/cli/src/duration.rs +++ b/crates/cli/src/duration.rs @@ -49,7 +49,7 @@ impl Duration { Self::new(rounded) } - + /// Returns the total number of nanoseconds. pub fn as_nanos(&self) -> u128 { self.inner.as_nanos() From 468508a907637e4de12b469bbf572f67d3accdfd Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 20 Mar 2026 18:43:14 +0100 Subject: [PATCH 03/41] fmt --- crates/cli/src/commands/test/beacon.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 41fd6b49..45c86704 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -47,7 +47,8 @@ pub struct TestBeaconArgs { )] pub endpoints: Vec, - /// Enable load test, not advisable when testing towards external beacon nodes. + /// Enable load test, not advisable when testing towards external beacon + /// nodes. #[arg(long = "load-test", help = "Enable load test.")] pub load_test: bool, From 24a875abbe0d3891cd98cd73a7129dd9e0775db2 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 20 Mar 2026 18:57:55 +0100 Subject: [PATCH 04/41] skip result extracted --- crates/cli/src/commands/test/beacon.rs | 55 +++++++++++--------------- 1 file changed, 23 insertions(+), 32 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 45c86704..9b41b1c9 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -511,11 +511,11 @@ async fn beacon_is_synced_test( Err(e) => return res.fail(e), }; - if body.data.is_syncing { - res.verdict = TestVerdict::Fail; + res.verdict = if body.data.is_syncing { + TestVerdict::Fail } else { - res.verdict = TestVerdict::Ok; - } + TestVerdict::Ok + }; res } @@ -607,11 +607,10 @@ async fn beacon_ping_load_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let mut res = TestResult::new("PingLoad"); if !cfg.load_test { - res.verdict = TestVerdict::Skip; - return res; + return skip_result("PingLoad"); } + let res = TestResult::new("PingLoad"); tracing::info!( duration = ?cfg.load_test_duration, @@ -677,13 +676,10 @@ async fn beacon_simulation_1_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let res = TestResult::new("Simulate1"); if !cfg.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..res - }; + return skip_result("Simulate1"); } + let res = TestResult::new("Simulate1"); let params = SimParams { total_validators_count: 1, attestation_validators_count: 0, @@ -699,13 +695,10 @@ async fn beacon_simulation_10_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let res = TestResult::new("Simulate10"); if !cfg.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..res - }; + return skip_result("Simulate10"); } + let res = TestResult::new("Simulate10"); let params = SimParams { total_validators_count: 10, attestation_validators_count: 6, @@ -721,13 +714,10 @@ async fn beacon_simulation_100_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let res = TestResult::new("Simulate100"); if !cfg.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..res - }; + return skip_result("Simulate100"); } + let res = TestResult::new("Simulate100"); let params = SimParams { total_validators_count: 100, attestation_validators_count: 80, @@ -743,13 +733,10 @@ async fn beacon_simulation_500_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let res = TestResult::new("Simulate500"); if !cfg.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..res - }; + return skip_result("Simulate500"); } + let res = TestResult::new("Simulate500"); let params = SimParams { total_validators_count: 500, attestation_validators_count: 450, @@ -765,13 +752,10 @@ async fn beacon_simulation_1000_test( cfg: TestBeaconArgs, target: String, ) -> TestResult { - let res = TestResult::new("Simulate1000"); if !cfg.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..res - }; + return skip_result("Simulate1000"); } + let res = TestResult::new("Simulate1000"); let params = SimParams { total_validators_count: 1000, attestation_validators_count: 930, @@ -1744,6 +1728,13 @@ fn average_validators_result( } } +fn skip_result(name: &str) -> TestResult { + TestResult { + verdict: TestVerdict::Skip, + ..TestResult::new(name) + } +} + fn cancel_after(token: &tokio_util::sync::CancellationToken, duration: StdDuration) { let token = token.clone(); tokio::spawn(async move { From aebf02a18ee99f18b4b78063f273c0c6b2078a57 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 20 Mar 2026 19:03:13 +0100 Subject: [PATCH 05/41] pass string by reference to tests --- crates/cli/src/commands/test/beacon.rs | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 9b41b1c9..1efb35a9 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -237,7 +237,7 @@ fn supported_beacon_test_cases() -> Vec { async fn run_test_case( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, name: &str, ) -> TestResult { match name { @@ -372,7 +372,7 @@ async fn test_single_beacon( break; } - let result = run_test_case(cancel.clone(), cfg.clone(), target.to_string(), &tc.name).await; + let result = run_test_case(cancel.clone(), cfg.clone(), target, &tc.name).await; results.push(result); } @@ -382,7 +382,7 @@ async fn test_single_beacon( async fn beacon_ping_test( _cancel: tokio_util::sync::CancellationToken, _cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { let mut res = TestResult::new("Ping"); let url = format!("{target}/eth/v1/node/health"); @@ -399,11 +399,11 @@ async fn beacon_ping_test( async fn beacon_ping_measure_test( _cancel: tokio_util::sync::CancellationToken, _cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { let res = TestResult::new("PingMeasure"); - match beacon_ping_once(&target).await { + match beacon_ping_once(target).await { Ok(rtt) => evaluate_rtt( rtt, res, @@ -417,7 +417,7 @@ async fn beacon_ping_measure_test( async fn beacon_version_test( _cancel: tokio_util::sync::CancellationToken, _cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { let mut res = TestResult::new("Version"); let url = format!("{target}/eth/v1/node/version"); @@ -472,7 +472,7 @@ async fn beacon_version_test( async fn beacon_is_synced_test( _cancel: tokio_util::sync::CancellationToken, _cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { let mut res = TestResult::new("Synced"); let url = format!("{target}/eth/v1/node/syncing"); @@ -522,7 +522,7 @@ async fn beacon_is_synced_test( async fn beacon_peer_count_test( _cancel: tokio_util::sync::CancellationToken, _cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { let mut res = TestResult::new("PeerCount"); let url = format!("{target}/eth/v1/node/peers?state=connected"); @@ -605,7 +605,7 @@ async fn ping_beacon_continuously( async fn beacon_ping_load_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("PingLoad"); @@ -631,7 +631,7 @@ async fn beacon_ping_load_test( _ = load_cancel.cancelled() => break, _ = interval.tick() => { let c = load_cancel.clone(); - let t = target.clone(); + let t = target.to_string(); let tx = tx.clone(); handles.push(tokio::spawn(async move { ping_beacon_continuously(c, t, tx).await; @@ -674,7 +674,7 @@ fn default_intensity() -> RequestsIntensity { async fn beacon_simulation_1_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("Simulate1"); @@ -687,13 +687,13 @@ async fn beacon_simulation_1_test( sync_committee_validators_count: 1, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_10_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("Simulate10"); @@ -706,13 +706,13 @@ async fn beacon_simulation_10_test( sync_committee_validators_count: 1, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_100_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("Simulate100"); @@ -725,13 +725,13 @@ async fn beacon_simulation_100_test( sync_committee_validators_count: 2, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_500_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("Simulate500"); @@ -744,13 +744,13 @@ async fn beacon_simulation_500_test( sync_committee_validators_count: 5, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_1000_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if !cfg.load_test { return skip_result("Simulate1000"); @@ -763,13 +763,13 @@ async fn beacon_simulation_1000_test( sync_committee_validators_count: 5, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_custom_test( cancel: tokio_util::sync::CancellationToken, cfg: TestBeaconArgs, - target: String, + target: &str, ) -> TestResult { if cfg.simulation_custom < 1 { return TestResult { @@ -799,7 +799,7 @@ async fn beacon_simulation_custom_test( sync_committee_validators_count: sync_committees, request_intensity: default_intensity(), }; - beacon_simulation_test(cancel, &cfg, &target, res, params).await + beacon_simulation_test(cancel, &cfg, target, res, params).await } async fn beacon_simulation_test( From 52567aa0fb47a0f8ac903cb726b9d02bad33991b Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 23 Mar 2026 13:06:50 +0100 Subject: [PATCH 06/41] cli test commands constants module --- crates/cli/src/commands/test/beacon.rs | 13 +++++-------- crates/cli/src/commands/test/constants.rs | 7 +++++++ crates/cli/src/commands/test/mod.rs | 8 ++------ 3 files changed, 14 insertions(+), 14 deletions(-) create mode 100644 crates/cli/src/commands/test/constants.rs diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 1efb35a9..0f87d7a8 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -6,11 +6,11 @@ #![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] use super::{ - CategoryScore, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, TestCaseName, TestCategory, - TestCategoryResult, TestConfigArgs, TestResult, TestVerdict, apply_basic_auth, calculate_score, - evaluate_highest_rtt, evaluate_rtt, filter_tests, must_output_to_file_on_quiet, - parse_endpoint_url, publish_result_to_obol_api, request_rtt, sort_tests, write_result_to_file, - write_result_to_writer, + COMMITTEE_SIZE_PER_SLOT, CategoryScore, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, + SUB_COMMITTEE_SIZE, TestCaseName, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, + TestVerdict, apply_basic_auth, calculate_score, evaluate_highest_rtt, evaluate_rtt, + filter_tests, must_output_to_file_on_quiet, parse_endpoint_url, publish_result_to_obol_api, + request_rtt, sort_tests, write_result_to_file, write_result_to_writer, }; use crate::{duration::Duration, error::Result as CliResult}; use clap::Args; @@ -29,9 +29,6 @@ const THRESHOLD_BEACON_PEERS_POOR: u64 = 20; const THRESHOLD_BEACON_SIMULATION_AVG: StdDuration = StdDuration::from_millis(200); const THRESHOLD_BEACON_SIMULATION_POOR: StdDuration = StdDuration::from_millis(400); -const COMMITTEE_SIZE_PER_SLOT: u64 = 64; -const SUB_COMMITTEE_SIZE: u64 = 4; - /// Arguments for the beacon test command. #[derive(Args, Clone, Debug)] pub struct TestBeaconArgs { diff --git a/crates/cli/src/commands/test/constants.rs b/crates/cli/src/commands/test/constants.rs new file mode 100644 index 00000000..39b10caa --- /dev/null +++ b/crates/cli/src/commands/test/constants.rs @@ -0,0 +1,7 @@ +use std::time::Duration as StdDuration; + +pub(crate) const COMMITTEE_SIZE_PER_SLOT: u64 = 64; +pub(crate) const SUB_COMMITTEE_SIZE: u64 = 4; +pub(crate) const SLOT_TIME: StdDuration = StdDuration::from_secs(12); +pub(crate) const SLOTS_IN_EPOCH: u64 = 32; +pub(crate) const EPOCH_TIME: StdDuration = StdDuration::from_secs(SLOTS_IN_EPOCH * 12); diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index ab4e0c60..f30249e8 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -9,6 +9,7 @@ pub mod all; pub mod beacon; +pub mod constants; pub mod infra; pub mod mev; pub mod peers; @@ -65,12 +66,7 @@ impl fmt::Display for TestCategory { } } -/// Ethereum beacon chain constants. -pub(crate) const COMMITTEE_SIZE_PER_SLOT: u64 = 64; -pub(crate) const SUB_COMMITTEE_SIZE: u64 = 4; -pub(crate) const SLOT_TIME: StdDuration = StdDuration::from_secs(12); -pub(crate) const SLOTS_IN_EPOCH: u64 = 32; -pub(crate) const EPOCH_TIME: StdDuration = StdDuration::from_secs(SLOTS_IN_EPOCH * 12); +pub(crate) use constants::*; /// Base test configuration shared by all test commands. #[derive(Args, Clone, Debug)] From 5f23fd0d94ea6b773d7a3f2b1ef6d1efc8f9a412 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 23 Mar 2026 17:17:26 +0100 Subject: [PATCH 07/41] Added beacon.rs unit tests, moved common test functions to helpers module. --- Cargo.lock | 2 + Cargo.toml | 1 + crates/cli/Cargo.toml | 2 + crates/cli/src/commands/test/beacon.rs | 309 ++++++- crates/cli/src/commands/test/helpers.rs | 1028 +++++++++++++++++++++ crates/cli/src/commands/test/infra.rs | 2 +- crates/cli/src/commands/test/mev.rs | 2 +- crates/cli/src/commands/test/mod.rs | 944 +------------------ crates/cli/src/commands/test/peers.rs | 2 +- crates/cli/src/commands/test/validator.rs | 2 +- 10 files changed, 1345 insertions(+), 949 deletions(-) create mode 100644 crates/cli/src/commands/test/helpers.rs diff --git a/Cargo.lock b/Cargo.lock index b473e582..8ff7129f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5454,6 +5454,7 @@ dependencies = [ "humantime", "k256", "libp2p", + "percent-encoding", "pluto-app", "pluto-cluster", "pluto-core", @@ -5474,6 +5475,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "wiremock", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 93eddca0..0399cf65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ tokio = { version = "1", features = ["full"] } tokio-util = "0.7.11" libp2p = { version = "0.56", features = ["full", "secp256k1"] } url = "2.5" +percent-encoding = "2.3" aes = "0.8.4" ctr = "0.9.2" cipher = "0.4.4" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 425e4222..f39b6af8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -35,11 +35,13 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true +percent-encoding.workspace = true [dev-dependencies] tempfile.workspace = true test-case.workspace = true backon.workspace = true +wiremock.workspace = true [lints] workspace = true diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 0f87d7a8..461ca573 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -6,11 +6,16 @@ #![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] use super::{ - COMMITTEE_SIZE_PER_SLOT, CategoryScore, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, - SUB_COMMITTEE_SIZE, TestCaseName, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, - TestVerdict, apply_basic_auth, calculate_score, evaluate_highest_rtt, evaluate_rtt, - filter_tests, must_output_to_file_on_quiet, parse_endpoint_url, publish_result_to_obol_api, - request_rtt, sort_tests, write_result_to_file, write_result_to_writer, + TestConfigArgs, + constants::{ + COMMITTEE_SIZE_PER_SLOT, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, SUB_COMMITTEE_SIZE, + }, + helpers::{ + CategoryScore, TestCaseName, TestCategory, TestCategoryResult, TestResult, TestVerdict, + apply_basic_auth, calculate_score, evaluate_highest_rtt, evaluate_rtt, filter_tests, + must_output_to_file_on_quiet, parse_endpoint_url, publish_result_to_obol_api, request_rtt, + sort_tests, write_result_to_file, write_result_to_writer, + }, }; use crate::{duration::Duration, error::Result as CliResult}; use clap::Args; @@ -2104,3 +2109,297 @@ async fn req_submit_sync_committee_contribution(target: &str) -> CliResult TestConfigArgs { + TestConfigArgs { + output_json: String::new(), + quiet: false, + test_cases: None, + timeout: StdDuration::from_secs(60), + publish: false, + publish_addr: String::new(), + publish_private_key_file: std::path::PathBuf::new(), + } + } + + fn default_beacon_args(endpoints: Vec) -> TestBeaconArgs { + TestBeaconArgs { + test_config: default_test_config(), + endpoints, + load_test: false, + load_test_duration: StdDuration::from_secs(5), + simulation_duration: SLOTS_IN_EPOCH, + simulation_file_dir: std::path::PathBuf::from("./"), + simulation_verbose: false, + simulation_custom: 0, + } + } + + async fn start_healthy_mocked_beacon_node() -> MockServer { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/syncing")) + .respond_with( + ResponseTemplate::new(200).set_body_string( + r#"{"data":{"head_slot":"0","sync_distance":"0","is_optimistic":false,"is_syncing":false}}"#, + ), + ) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/peers")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"meta":{"count":500}}"#)) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/version")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"data":{"version":"BeaconNodeProvider/v1.0.0/linux_x86_64"}}"#, + )) + .mount(&server) + .await; + + server + } + + fn expected_results_for_healthy_node() -> Vec<(&'static str, TestVerdict)> { + vec![ + ("Ping", TestVerdict::Ok), + ("PingMeasure", TestVerdict::Good), + ("Version", TestVerdict::Ok), + ("Synced", TestVerdict::Ok), + ("PeerCount", TestVerdict::Good), + ("PingLoad", TestVerdict::Skip), + ("Simulate1", TestVerdict::Skip), + ("Simulate10", TestVerdict::Skip), + ("Simulate100", TestVerdict::Skip), + ("Simulate500", TestVerdict::Skip), + ("Simulate1000", TestVerdict::Skip), + ("SimulateCustom", TestVerdict::Skip), + ] + } + + fn assert_results( + results: &std::collections::HashMap>, + target: &str, + expected: &[(&str, TestVerdict)], + ) { + let target_results = results.get(target).expect("missing target in results"); + assert_eq!( + target_results.len(), + expected.len(), + "result count mismatch for {target}" + ); + for (result, (name, verdict)) in target_results.iter().zip(expected) { + assert_eq!(result.name, *name, "name mismatch"); + assert_eq!(result.verdict, *verdict, "verdict mismatch for {name}"); + } + } + + #[tokio::test] + async fn test_beacon_default_scenario() { + let server = start_healthy_mocked_beacon_node().await; + let url = server.uri(); + let args = default_beacon_args(vec![url.clone()]); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + let expected = expected_results_for_healthy_node(); + assert_results(&res.targets, &url, &expected); + } + + #[tokio::test] + async fn test_beacon_connection_refused() { + let port1 = 19876; + let port2 = 19877; + let endpoint1 = format!("http://localhost:{port1}"); + let endpoint2 = format!("http://localhost:{port2}"); + let args = default_beacon_args(vec![endpoint1.clone(), endpoint2.clone()]); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + for endpoint in [&endpoint1, &endpoint2] { + let target_results = res.targets.get(endpoint).expect("missing target"); + for r in target_results { + match r.name.as_str() { + "PingLoad" | "Simulate1" | "Simulate10" | "Simulate100" | "Simulate500" + | "Simulate1000" | "SimulateCustom" => { + assert_eq!(r.verdict, TestVerdict::Skip, "expected skip for {}", r.name); + } + _ => { + assert_eq!(r.verdict, TestVerdict::Fail, "expected fail for {}", r.name); + assert!( + r.error.message().is_some(), + "expected error message for {}", + r.name + ); + } + } + } + } + } + + #[tokio::test] + async fn test_beacon_timeout() { + let endpoint1 = "http://localhost:19878".to_string(); + let endpoint2 = "http://localhost:19879".to_string(); + let mut args = default_beacon_args(vec![endpoint1.clone(), endpoint2.clone()]); + args.test_config.timeout = StdDuration::from_nanos(100); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + for endpoint in [&endpoint1, &endpoint2] { + let target_results = res.targets.get(endpoint).expect("missing target"); + let first = &target_results[0]; + assert_eq!(first.name, "Ping"); + assert_eq!(first.verdict, TestVerdict::Fail); + } + } + + #[tokio::test] + async fn test_beacon_quiet() { + let dir = tempfile::tempdir().unwrap(); + let json_path = dir.path().join("output.json"); + + let endpoint1 = "http://localhost:19880".to_string(); + let endpoint2 = "http://localhost:19881".to_string(); + let mut args = default_beacon_args(vec![endpoint1, endpoint2]); + args.test_config.quiet = true; + args.test_config.output_json = json_path.to_str().unwrap().to_string(); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + assert!(buf.is_empty(), "expected no output on quiet mode"); + assert!(!res.targets.is_empty()); + } + + #[tokio::test] + async fn test_beacon_unsupported_test() { + let args = TestBeaconArgs { + test_config: TestConfigArgs { + test_cases: Some(vec!["notSupportedTest".to_string()]), + ..default_test_config() + }, + ..default_beacon_args(vec!["http://localhost:19882".to_string()]) + }; + + let mut buf = Vec::new(); + let err = run(args, &mut buf).await.unwrap_err(); + assert!( + err.to_string().contains("test case not supported"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_beacon_custom_test_cases() { + let endpoint1 = "http://localhost:19883".to_string(); + let endpoint2 = "http://localhost:19884".to_string(); + let mut args = default_beacon_args(vec![endpoint1.clone(), endpoint2.clone()]); + args.test_config.test_cases = Some(vec!["Ping".to_string()]); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + for endpoint in [&endpoint1, &endpoint2] { + let target_results = res.targets.get(endpoint).expect("missing target"); + assert_eq!(target_results.len(), 1); + assert_eq!(target_results[0].name, "Ping"); + assert_eq!(target_results[0].verdict, TestVerdict::Fail); + } + } + + #[tokio::test] + async fn test_beacon_write_to_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("beacon-test-output.json"); + + let endpoint1 = "http://localhost:19885".to_string(); + let endpoint2 = "http://localhost:19886".to_string(); + let mut args = default_beacon_args(vec![endpoint1, endpoint2]); + args.test_config.output_json = file_path.to_str().unwrap().to_string(); + + let mut buf = Vec::new(); + let res = run(args, &mut buf).await.unwrap(); + + assert!(file_path.exists(), "output file should exist"); + + let content = std::fs::read_to_string(&file_path).unwrap(); + let written: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!( + written.get("beacon_node").is_some(), + "expected beacon_node key in output JSON" + ); + + assert_eq!(res.category_name, Some(TestCategory::Beacon)); + assert!(res.score.is_some()); + } + + #[tokio::test] + async fn test_beacon_basic_auth_with_credentials() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/health")) + .and(wiremock::matchers::header_exists("Authorization")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let addr = server.address(); + let url_with_auth = format!("http://testuser:testpass123@{addr}"); + + let cancel = tokio_util::sync::CancellationToken::new(); + let cfg = default_beacon_args(vec![]); + let result = beacon_ping_test(cancel, cfg, &url_with_auth).await; + + assert_eq!(result.verdict, TestVerdict::Ok); + } + + #[tokio::test] + async fn test_beacon_basic_auth_without_credentials() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/eth/v1/node/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let url_without_auth = server.uri(); + + let cancel = tokio_util::sync::CancellationToken::new(); + let cfg = default_beacon_args(vec![]); + let result = beacon_ping_test(cancel, cfg, &url_without_auth).await; + + // Without credentials the request still succeeds (no auth enforcement by request_rtt), + // but no Authorization header is sent. + assert_eq!(result.verdict, TestVerdict::Ok); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert!( + requests[0].headers.get("Authorization").is_none(), + "Authorization header should not be present without credentials" + ); + } +} diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs new file mode 100644 index 00000000..e496fe09 --- /dev/null +++ b/crates/cli/src/commands/test/helpers.rs @@ -0,0 +1,1028 @@ +//! Shared types and helper functions for all test categories. + +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fmt, io::Write, path::Path, time::Duration as StdDuration}; + +use crate::{ + ascii::{append_score, get_category_ascii, get_score_ascii}, + duration::Duration, + error::{CliError, Result as CliResult}, +}; + +use k256::SecretKey; +use pluto_app::obolapi::{Client, ClientOptions}; +use pluto_cluster::ssz_hasher::{HashWalker, Hasher}; +use pluto_eth2util::enr::Record; +use pluto_k1util::{load, sign}; +use reqwest::{Method, StatusCode, header::CONTENT_TYPE}; +use serde_with::{base64::Base64, serde_as}; +use std::os::unix::fs::PermissionsExt as _; +use tokio::io::AsyncReadExt; + +/// Test category identifiers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum TestCategory { + Peers, + Beacon, + Validator, + Mev, + Infra, + All, +} + +impl fmt::Display for TestCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + TestCategory::Peers => "peers", + TestCategory::Beacon => "beacon", + TestCategory::Validator => "validator", + TestCategory::Mev => "mev", + TestCategory::Infra => "infra", + TestCategory::All => "all", + }) + } +} + +/// Test verdict indicating the outcome of a test. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum TestVerdict { + #[serde(rename = "OK")] + Ok, + Good, + Avg, + Poor, + Fail, + Skip, +} + +impl fmt::Display for TestVerdict { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TestVerdict::Ok => write!(f, "OK"), + TestVerdict::Good => write!(f, "Good"), + TestVerdict::Avg => write!(f, "Avg"), + TestVerdict::Poor => write!(f, "Poor"), + TestVerdict::Fail => write!(f, "Fail"), + TestVerdict::Skip => write!(f, "Skip"), + } + } +} + +/// Category-level score. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub(crate) enum CategoryScore { + A, + B, + C, +} + +impl fmt::Display for CategoryScore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CategoryScore::A => write!(f, "A"), + CategoryScore::B => write!(f, "B"), + CategoryScore::C => write!(f, "C"), + } + } +} + +/// Wrapper for test error with custom serialization. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub(crate) struct TestResultError(String); + +impl TestResultError { + pub(crate) fn empty() -> Self { + Self(String::new()) + } + + pub(crate) fn from_string(s: impl Into) -> Self { + Self(s.into()) + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(crate) fn message(&self) -> Option<&str> { + if self.0.is_empty() { + None + } else { + Some(&self.0) + } + } +} + +impl fmt::Display for TestResultError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for TestResultError { + fn from(err: E) -> Self { + Self(err.to_string()) + } +} + +/// Result of a single test. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct TestResult { + #[serde(rename = "name")] + pub name: String, + + #[serde(rename = "verdict")] + pub verdict: TestVerdict, + + #[serde( + rename = "measurement", + skip_serializing_if = "String::is_empty", + default + )] + pub measurement: String, + + #[serde( + rename = "suggestion", + skip_serializing_if = "String::is_empty", + default + )] + pub suggestion: String, + + #[serde( + rename = "error", + skip_serializing_if = "TestResultError::is_empty", + default + )] + pub error: TestResultError, + + #[serde(skip)] + pub is_acceptable: bool, +} + +impl TestResult { + /// Creates a new test result with the given name. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + verdict: TestVerdict::Fail, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + } + } + + /// Marks the test as failed with the given error. + pub fn fail(mut self, error: impl Into) -> Self { + self.verdict = TestVerdict::Fail; + self.error = error.into(); + self + } + + /// Marks the test as passed (OK verdict). + pub fn ok(mut self) -> Self { + self.verdict = TestVerdict::Ok; + self + } +} + +/// Test case name with execution order. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct TestCaseName { + pub name: String, + pub order: u32, +} + +impl TestCaseName { + /// Creates a new test case name. + pub fn new(name: &str, order: u32) -> Self { + Self { + name: name.into(), + order, + } + } +} + +/// Result of a test category. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct TestCategoryResult { + #[serde( + rename = "category_name", + skip_serializing_if = "Option::is_none", + default + )] + pub category_name: Option, + + #[serde(rename = "targets", skip_serializing_if = "HashMap::is_empty", default)] + pub targets: HashMap>, + + // NOTE: Duration wraps Go's time.Duration and mimics the same formatting for compatibility. + // This works correctly but isn't ideal design - duration formatting typically varies between + // languages. + #[serde(rename = "execution_time", skip_serializing_if = "Option::is_none")] + pub execution_time: Option, + + #[serde(rename = "score", skip_serializing_if = "Option::is_none")] + pub score: Option, +} + +impl TestCategoryResult { + /// Creates a new test category result with the given name. + pub fn new(category_name: TestCategory) -> Self { + Self { + category_name: Some(category_name), + targets: HashMap::new(), + execution_time: None, + score: None, + } + } +} + +/// All test categories result for JSON output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub(crate) struct AllCategoriesResult { + #[serde(rename = "charon_peers", skip_serializing_if = "Option::is_none")] + pub peers: Option, + + #[serde(rename = "beacon_node", skip_serializing_if = "Option::is_none")] + pub beacon: Option, + + #[serde(rename = "validator_client", skip_serializing_if = "Option::is_none")] + pub validator: Option, + + #[serde(rename = "mev", skip_serializing_if = "Option::is_none")] + pub mev: Option, + + #[serde(rename = "infra", skip_serializing_if = "Option::is_none")] + pub infra: Option, +} + +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ObolApiResult { + #[serde(rename = "enr")] + enr: String, + + /// Base64-encoded signature (65 bytes) + /// TODO: double check with obol - API docs show "0x..." but Go []byte + /// marshals to base64 + #[serde_as(as = "Base64")] + #[serde(rename = "sig")] + sig: Vec, + + #[serde(rename = "data")] + data: AllCategoriesResult, +} + +/// Publishes test results to the Obol API. +pub(crate) async fn publish_result_to_obol_api( + data: AllCategoriesResult, + api_url: impl AsRef, + private_key_file: impl AsRef, +) -> CliResult<()> { + let private_key = load_or_generate_key(private_key_file.as_ref()).await?; + let enr = Record::new(&private_key, vec![])?; + let sign_data_bytes = serde_json::to_vec(&data)?; + let hash = hash_ssz(&sign_data_bytes)?; + let sig = sign(&private_key, &hash)?; + + let result = ObolApiResult { + enr: enr.to_string(), + sig: sig.to_vec(), + data, + }; + + let obol_api_json = serde_json::to_vec(&result)?; + let client = Client::new(api_url.as_ref(), ClientOptions::default())?; + client.post_test_result(obol_api_json).await?; + + Ok(()) +} + +/// Writes test results to a JSON file. +pub(crate) async fn write_result_to_file( + result: &TestCategoryResult, + path: &Path, +) -> CliResult<()> { + let mut existing_file: tokio::fs::File = tokio::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .mode(0o644) + .open(path) + .await?; + + let stat = existing_file.metadata().await?; + + let mut all_results: AllCategoriesResult = if stat.len() == 0 { + AllCategoriesResult::default() + } else { + let mut buf = Vec::new(); + existing_file.read_to_end(&mut buf).await?; + serde_json::from_slice(&buf)? + }; + + let category = result + .category_name + .ok_or_else(|| CliError::Other("unknown category: (missing)".to_string()))?; + + match category { + TestCategory::Peers => all_results.peers = Some(result.clone()), + TestCategory::Beacon => all_results.beacon = Some(result.clone()), + TestCategory::Validator => all_results.validator = Some(result.clone()), + TestCategory::Mev => all_results.mev = Some(result.clone()), + TestCategory::Infra => all_results.infra = Some(result.clone()), + TestCategory::All => { + return Err(CliError::Other("unknown category: all".to_string())); + } + } + + let dir = path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + let base = path + .file_name() + .ok_or_else(|| CliError::Other(format!("no filename in path: {}", path.display())))? + .to_string_lossy() + .to_string(); + let path_buf = path.to_path_buf(); + + let file_content_json = serde_json::to_vec(&all_results)?; + + // tempfile is a synchronous crate, but keep existing_file open during operation + tokio::task::spawn_blocking(move || -> CliResult<()> { + use std::io::Write as _; + + let mut tmp_file = tempfile::Builder::new() + .prefix(&format!("{base}-tmp-")) + .suffix(".json") + .tempfile_in(&dir)?; + + tmp_file + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o644))?; + + tmp_file.as_file_mut().write_all(&file_content_json)?; + + tmp_file + .persist(&path_buf) + .map_err(|e| CliError::Io(e.error))?; + + Ok(()) + }) + .await + .map_err(|e| CliError::Other(format!("spawn_blocking: {}", e)))? +} + +/// Writes test results to a writer (stdout or file). +pub(crate) fn write_result_to_writer( + result: &TestCategoryResult, + writer: &mut W, +) -> CliResult<()> { + let mut lines = Vec::new(); + + // Add category ASCII art + lines.extend(get_category_ascii(&result.category_name)); + + if let Some(score) = result.score { + let score_ascii = get_score_ascii(score); + lines = append_score(lines, score_ascii); + } + + // Add test results + lines.push(String::new()); + lines.push(format!("{:<64}{}", "TEST NAME", "RESULT")); + + let mut suggestions = Vec::new(); + + // Sort targets by name for consistent output + let mut targets: Vec<_> = result.targets.iter().collect(); + targets.sort_by_key(|(name, _)| *name); + + for (target, test_results) in targets { + if !target.is_empty() && !test_results.is_empty() { + lines.push(String::new()); + lines.push(target.clone()); + } + + for test_result in test_results { + let mut test_output = format!("{:<64}", test_result.name); + + if !test_result.measurement.is_empty() { + let trim_count = test_result.measurement.chars().count().saturating_add(1); + let spaces_to_trim = " ".repeat(trim_count); + + if test_output.ends_with(&spaces_to_trim) { + let new_len = test_output.len().saturating_sub(trim_count); + test_output.truncate(new_len); + } + + test_output.push_str(&test_result.measurement); + test_output.push(' '); + } + + // Add verdict + test_output.push_str(&test_result.verdict.to_string()); + + // Add suggestion if present + if !test_result.suggestion.is_empty() { + suggestions.push(test_result.suggestion.clone()); + } + + // Add error if present + if let Some(err_msg) = test_result.error.message() { + test_output.push_str(&format!(" - {}", err_msg)); + } + + lines.push(test_output); + } + } + + // Add suggestions section + if !suggestions.is_empty() { + lines.push(String::new()); + lines.push("SUGGESTED IMPROVEMENTS".to_string()); + lines.extend(suggestions); + } + + // Add execution time + lines.push(String::new()); + lines.push(result.execution_time.unwrap_or_default().to_string()); + + // Write all lines + lines.push(String::new()); + for line in lines { + writeln!(writer, "{}", line)?; + } + + Ok(()) +} + +/// Evaluates highest RTT from a channel and assigns a verdict. +pub(crate) fn evaluate_highest_rtt( + rtts: Vec, + result: TestResult, + avg_threshold: StdDuration, + poor_threshold: StdDuration, +) -> TestResult { + let highest_rtt = rtts.into_iter().max().unwrap_or_default(); + evaluate_rtt(highest_rtt, result, avg_threshold, poor_threshold) +} + +/// Evaluates RTT (Round Trip Time) and assigns a verdict based on thresholds. +pub(crate) fn evaluate_rtt( + rtt: StdDuration, + mut result: TestResult, + avg_threshold: StdDuration, + poor_threshold: StdDuration, +) -> TestResult { + if rtt.is_zero() || rtt > poor_threshold { + result.verdict = TestVerdict::Poor; + } else if rtt > avg_threshold { + result.verdict = TestVerdict::Avg; + } else { + result.verdict = TestVerdict::Good; + } + + result.measurement = Duration::new(rtt).round().to_string(); + result +} + +/// Calculates the overall score for a list of test results. +pub(crate) fn calculate_score(results: &[TestResult]) -> CategoryScore { + // TODO: calculate score more elaborately (potentially use weights) + let mut avg: i32 = 0; + + for test in results { + match test.verdict { + TestVerdict::Poor => return CategoryScore::C, + TestVerdict::Good => avg = avg.saturating_add(1), + TestVerdict::Avg => avg = avg.saturating_sub(1), + TestVerdict::Fail => { + if !test.is_acceptable { + return CategoryScore::C; + } + continue; + } + TestVerdict::Ok | TestVerdict::Skip => continue, + } + } + + if avg < 0 { + CategoryScore::B + } else { + CategoryScore::A + } +} + +/// Filters tests based on configuration. +pub(crate) fn filter_tests( + supported_test_cases: &[TestCaseName], + test_cases: Option<&[String]>, +) -> Vec { + let mut filtered: Vec = supported_test_cases.to_vec(); + if let Some(cases) = test_cases { + filtered.retain(|supported_case| cases.contains(&supported_case.name)); + } + filtered +} + +/// Sorts tests by their order field. +pub(crate) fn sort_tests(tests: &mut [TestCaseName]) { + tests.sort_by_key(|t| t.order); +} + +pub(crate) fn must_output_to_file_on_quiet(quiet: bool, output_json: &str) -> CliResult<()> { + if quiet && output_json.is_empty() { + Err(CliError::Other( + "on --quiet, an --output-json is required".to_string(), + )) + } else { + Ok(()) + } +} + +async fn load_or_generate_key(path: &Path) -> CliResult { + if tokio::fs::try_exists(path).await? { + Ok(load(path)?) + } else { + tracing::warn!( + private_key_file = %path.display(), + "Private key file does not exist, will generate a temporary key" + ); + use k256::elliptic_curve::rand_core::OsRng; + Ok(SecretKey::random(&mut OsRng)) + } +} + +pub(crate) fn hash_ssz(data: &[u8]) -> CliResult<[u8; 32]> { + if data.is_empty() { + return Ok([0u8; 32]); + } + + let mut hasher: Hasher = Hasher::default(); + let index = hasher.index(); + + hasher.put_bytes(data)?; + hasher.merkleize(index)?; + + Ok(hasher.hash_root()?) +} + +fn percent_decode(s: &str) -> String { + percent_encoding::percent_decode_str(s) + .decode_utf8_lossy() + .into_owned() +} + +pub(crate) fn parse_endpoint_url(endpoint: &str) -> CliResult<(String, Option<(String, String)>)> { + let mut parsed = reqwest::Url::parse(endpoint) + .map_err(|e| CliError::Other(format!("parse endpoint URL: {e}")))?; + + if parsed.username().is_empty() { + return Ok((endpoint.to_string(), None)); + } + + let username = percent_decode(parsed.username()); + let password = percent_decode(parsed.password().unwrap_or_default()); + parsed.set_username("").ok(); + parsed.set_password(None).ok(); + + Ok((parsed.to_string(), Some((username, password)))) +} + +pub(crate) fn apply_basic_auth( + builder: reqwest::RequestBuilder, + credentials: &Option<(String, String)>, +) -> reqwest::RequestBuilder { + if let Some((username, password)) = credentials { + builder.basic_auth(username, Some(password)) + } else { + builder + } +} + +/// Measures the round-trip time (RTT) for an HTTP request and logs a warning if +/// the response status code doesn't match the expected status. +pub(crate) async fn request_rtt( + url: impl AsRef, + method: Method, + body: Option>, + expected_status: StatusCode, +) -> CliResult { + let (clean_url, credentials) = parse_endpoint_url(url.as_ref())?; + let client = reqwest::Client::new(); + + let mut request_builder = client.request(method, &clean_url); + request_builder = apply_basic_auth(request_builder, &credentials); + + if let Some(body_bytes) = body { + request_builder = request_builder + .header(CONTENT_TYPE, "application/json") + .body(body_bytes); + } + + let start = std::time::Instant::now(); + let response = request_builder.send().await?; + let rtt = start.elapsed(); + + let status = response.status(); + if status.as_u16() > 399 { + return Err(CliError::Other(format!( + "HTTP status code {}", + status.as_u16() + ))); + } + + if status != expected_status { + match response.text().await { + Ok(body) if !body.is_empty() => tracing::warn!( + status_code = status.as_u16(), + expected_status_code = expected_status.as_u16(), + endpoint = clean_url, + body = body, + "Unexpected status code" + ), + _ => tracing::warn!( + status_code = status.as_u16(), + expected_status_code = expected_status.as_u16(), + endpoint = clean_url, + "Unexpected status code" + ), + } + } + + Ok(rtt) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn calculate_score_output() { + let mut results = vec![ + TestResult { + name: "test1".to_string(), + verdict: TestVerdict::Good, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }, + TestResult { + name: "test2".to_string(), + verdict: TestVerdict::Good, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }, + ]; + + assert_eq!(calculate_score(&results), CategoryScore::A); + + results.push(TestResult { + name: "test3".to_string(), + verdict: TestVerdict::Poor, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }); + + assert_eq!(calculate_score(&results), CategoryScore::C); + } + + #[test] + fn must_output_to_file_on_quiet_output() { + assert!(must_output_to_file_on_quiet(false, "").is_ok()); + assert!(must_output_to_file_on_quiet(true, "out.json").is_ok()); + assert!(must_output_to_file_on_quiet(true, "").is_err()); + } + + // Ground truth from Go fastssz (with Duration as string format matching Rust) + const GO_HASH_EMPTY: &str = "7b7d000000000000000000000000000000000000000000000000000000000000"; + const GO_HASH_ALL_CATEGORIES: &str = + "64469d918903e272849172b3b36e812f602411b664a89b59c04393332b69f63b"; + + fn assert_hash(data: &AllCategoriesResult, expected_go_hash: &str) { + let json_bytes = serde_json::to_vec(data).expect("Failed to serialize to JSON"); + let rust_hash = hash_ssz(&json_bytes).expect("hash_ssz failed"); + assert_eq!(hex::encode(rust_hash), expected_go_hash); + } + + #[test] + fn hash_ssz_empty_all_categories_result() { + assert_hash(&AllCategoriesResult::default(), GO_HASH_EMPTY); + } + + #[test] + fn hash_ssz_multi_category_result() { + use crate::duration::Duration; + + let result = AllCategoriesResult { + peers: Some(TestCategoryResult { + category_name: Some(TestCategory::Peers), + targets: HashMap::from([( + "peer1".to_string(), + vec![TestResult { + name: "Test1".to_string(), + verdict: TestVerdict::Ok, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }], + )]), + execution_time: Some(Duration::new(std::time::Duration::from_nanos(1500000000))), + score: Some(CategoryScore::A), + }), + beacon: Some(TestCategoryResult { + category_name: Some(TestCategory::Beacon), + targets: HashMap::from([( + "beacon1".to_string(), + vec![TestResult { + name: "Test2".to_string(), + verdict: TestVerdict::Good, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }], + )]), + execution_time: Some(Duration::new(std::time::Duration::from_nanos(2500000000))), + score: Some(CategoryScore::A), + }), + validator: Some(TestCategoryResult { + category_name: Some(TestCategory::Validator), + targets: HashMap::from([( + "validator1".to_string(), + vec![TestResult { + name: "Test3".to_string(), + verdict: TestVerdict::Avg, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }], + )]), + execution_time: Some(Duration::new(std::time::Duration::from_nanos(500000000))), + score: Some(CategoryScore::B), + }), + mev: Some(TestCategoryResult { + category_name: Some(TestCategory::Mev), + targets: HashMap::from([( + "mev1".to_string(), + vec![TestResult { + name: "Test4".to_string(), + verdict: TestVerdict::Poor, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }], + )]), + execution_time: Some(Duration::new(std::time::Duration::from_nanos(3000000000))), + score: Some(CategoryScore::C), + }), + infra: Some(TestCategoryResult { + category_name: Some(TestCategory::Infra), + targets: HashMap::from([( + "server1".to_string(), + vec![TestResult { + name: "Test5".to_string(), + verdict: TestVerdict::Skip, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + }], + )]), + execution_time: Some(Duration::new(std::time::Duration::from_nanos(1000000000))), + score: Some(CategoryScore::A), + }), + }; + + assert_hash(&result, GO_HASH_ALL_CATEGORIES); + } + + #[tokio::test] + async fn test_write_result_to_file_creates_new_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.json"); + + let mut result = TestCategoryResult::new(TestCategory::Peers); + result.score = Some(CategoryScore::A); + let mut tests = vec![TestResult::new("Ping")]; + tests[0].verdict = TestVerdict::Ok; + tests[0].measurement = "5ms".to_string(); + result.targets.insert("peer1".to_string(), tests); + + write_result_to_file(&result, &path).await.unwrap(); + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); + + let expected = AllCategoriesResult { + peers: Some(result), + ..Default::default() + }; + assert_eq!(written, expected); + } + + #[tokio::test] + async fn test_write_result_to_file_merges_categories() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.json"); + + let mut peers = TestCategoryResult::new(TestCategory::Peers); + peers.score = Some(CategoryScore::A); + peers + .targets + .insert("peer1".to_string(), vec![TestResult::new("Ping")]); + write_result_to_file(&peers, &path).await.unwrap(); + + let mut beacon = TestCategoryResult::new(TestCategory::Beacon); + beacon.score = Some(CategoryScore::B); + beacon.targets.insert( + "http://beacon:5052".to_string(), + vec![TestResult::new("Version")], + ); + write_result_to_file(&beacon, &path).await.unwrap(); + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); + + let expected = AllCategoriesResult { + peers: Some(peers), + beacon: Some(beacon), + ..Default::default() + }; + assert_eq!(written, expected); + } + + #[tokio::test] + async fn test_write_result_to_file_overwrites_same_category() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.json"); + + let mut first = TestCategoryResult::new(TestCategory::Peers); + first.score = Some(CategoryScore::A); + first + .targets + .insert("peer1".to_string(), vec![TestResult::new("Ping")]); + write_result_to_file(&first, &path).await.unwrap(); + + let mut second = TestCategoryResult::new(TestCategory::Peers); + second.score = Some(CategoryScore::C); + second + .targets + .insert("peer2".to_string(), vec![TestResult::new("PingMeasure")]); + write_result_to_file(&second, &path).await.unwrap(); + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); + + let expected = AllCategoriesResult { + peers: Some(second), + ..Default::default() + }; + assert_eq!(written, expected); + } + + fn sample_test_cases() -> Vec { + vec![ + TestCaseName::new("Ping", 0), + TestCaseName::new("Version", 1), + TestCaseName::new("Health", 2), + ] + } + + #[test] + fn filter_tests_unsupported_case_filtered_out() { + let supported = sample_test_cases(); + let filtered = filter_tests(&supported, Some(&["notSupportedTest".into()])); + assert!(filtered.is_empty()); + } + + #[test] + fn filter_tests_specific_case() { + let supported = sample_test_cases(); + let filtered = filter_tests(&supported, Some(&["Ping".into()])); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "Ping"); + } + + #[test] + fn filter_tests_none_returns_all() { + let supported = sample_test_cases(); + let filtered = filter_tests(&supported, None); + assert_eq!(filtered.len(), supported.len()); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_write_result_to_file_sets_permissions() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.json"); + + let result = TestCategoryResult::new(TestCategory::Infra); + write_result_to_file(&result, &path).await.unwrap(); + + let metadata = tokio::fs::metadata(&path).await.unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o644); + } + + #[tokio::test] + async fn test_write_result_to_file_all_categories() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("output.json"); + + let mut expected = AllCategoriesResult::default(); + let categories = [ + TestCategory::Peers, + TestCategory::Beacon, + TestCategory::Validator, + TestCategory::Mev, + TestCategory::Infra, + ]; + + for category in &categories { + let mut result = TestCategoryResult::new(*category); + result.score = Some(CategoryScore::A); + result.targets.insert( + format!("target-{}", category), + vec![TestResult::new("Ping")], + ); + write_result_to_file(&result, &path).await.unwrap(); + + match category { + TestCategory::Peers => expected.peers = Some(result), + TestCategory::Beacon => expected.beacon = Some(result), + TestCategory::Validator => expected.validator = Some(result), + TestCategory::Mev => expected.mev = Some(result), + TestCategory::Infra => expected.infra = Some(result), + TestCategory::All => unreachable!(), + } + } + + let content = tokio::fs::read_to_string(&path).await.unwrap(); + let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); + + assert_eq!(written, expected); + } + + #[test] + fn test_parse_endpoint_url_without_auth() { + let (clean, creds) = parse_endpoint_url("https://beacon.example.com/path").unwrap(); + assert_eq!(clean, "https://beacon.example.com/path"); + assert!(creds.is_none()); + } + + #[test] + fn test_parse_endpoint_url_with_auth() { + let (clean, creds) = + parse_endpoint_url("https://user:pass@beacon.example.com/path").unwrap(); + assert_eq!(clean, "https://beacon.example.com/path"); + let (user, pass) = creds.unwrap(); + assert_eq!(user, "user"); + assert_eq!(pass, "pass"); + } + + #[test] + fn test_parse_endpoint_url_with_query_params() { + let (clean, creds) = + parse_endpoint_url("https://user:pass@beacon.example.com/path?query=value").unwrap(); + assert_eq!(clean, "https://beacon.example.com/path?query=value"); + let (user, pass) = creds.unwrap(); + assert_eq!(user, "user"); + assert_eq!(pass, "pass"); + } + + #[test] + fn test_parse_endpoint_url_http_with_port() { + let (clean, creds) = parse_endpoint_url("http://admin:secret@localhost:5051").unwrap(); + assert_eq!(clean, "http://localhost:5051/"); + let (user, pass) = creds.unwrap(); + assert_eq!(user, "admin"); + assert_eq!(pass, "secret"); + } + + #[test] + fn test_parse_endpoint_url_with_special_chars_in_password() { + // Go source uses "p@ss!123" as the raw password; in Rust's url crate the '@' must be + // percent-encoded as "p%40ss!123" to be unambiguously parsed. + let (clean, creds) = + parse_endpoint_url("https://user:p%40ss!123@beacon.example.com").unwrap(); + assert_eq!(clean, "https://beacon.example.com/"); + let (user, pass) = creds.unwrap(); + assert_eq!(user, "user"); + assert_eq!(pass, "p@ss!123"); + } +} diff --git a/crates/cli/src/commands/test/infra.rs b/crates/cli/src/commands/test/infra.rs index 2bfa77ed..87a66f8d 100644 --- a/crates/cli/src/commands/test/infra.rs +++ b/crates/cli/src/commands/test/infra.rs @@ -1,6 +1,6 @@ //! Infrastructure and hardware tests. -use super::{TestCategoryResult, TestConfigArgs}; +use super::{TestConfigArgs, helpers::TestCategoryResult}; use crate::error::Result; use clap::Args; use std::io::Write; diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 473bbd9b..dfa930c5 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -1,6 +1,6 @@ //! MEV relay tests. -use super::{TestCategoryResult, TestConfigArgs}; +use super::{TestConfigArgs, helpers::TestCategoryResult}; use crate::error::Result; use clap::Args; use std::io::Write; diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index f30249e8..0bcb1d08 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -10,63 +10,16 @@ pub mod all; pub mod beacon; pub mod constants; +pub mod helpers; pub mod infra; pub mod mev; pub mod peers; pub mod validator; -use clap::Args; -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - fmt, - io::Write, - path::{Path, PathBuf}, - time::Duration as StdDuration, -}; - -use crate::{ - ascii::{append_score, get_category_ascii, get_score_ascii}, - duration::Duration, - error::{CliError, Result as CliResult}, -}; - -use k256::SecretKey; -use pluto_app::obolapi::{Client, ClientOptions}; -use pluto_cluster::ssz_hasher::{HashWalker, Hasher}; -use pluto_eth2util::enr::Record; -use pluto_k1util::{load, sign}; -use reqwest::{Method, StatusCode, header::CONTENT_TYPE}; -use serde_with::{base64::Base64, serde_as}; -use std::os::unix::fs::PermissionsExt as _; -use tokio::io::AsyncReadExt; - -/// Test category identifiers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum TestCategory { - Peers, - Beacon, - Validator, - Mev, - Infra, - All, -} - -impl fmt::Display for TestCategory { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(match self { - TestCategory::Peers => "peers", - TestCategory::Beacon => "beacon", - TestCategory::Validator => "validator", - TestCategory::Mev => "mev", - TestCategory::Infra => "infra", - TestCategory::All => "all", - }) - } -} +pub(crate) use helpers::*; -pub(crate) use constants::*; +use clap::Args; +use std::{path::PathBuf, time::Duration as StdDuration}; /// Base test configuration shared by all test commands. #[derive(Args, Clone, Debug)] @@ -152,606 +105,6 @@ fn list_test_cases(category: TestCategory) -> Vec { } } -pub(crate) fn must_output_to_file_on_quiet(quiet: bool, output_json: &str) -> CliResult<()> { - if quiet && output_json.is_empty() { - Err(CliError::Other( - "on --quiet, an --output-json is required".to_string(), - )) - } else { - Ok(()) - } -} - -/// Test verdict indicating the outcome of a test. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) enum TestVerdict { - #[serde(rename = "OK")] - Ok, - Good, - Avg, - Poor, - Fail, - Skip, -} - -impl fmt::Display for TestVerdict { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - TestVerdict::Ok => write!(f, "OK"), - TestVerdict::Good => write!(f, "Good"), - TestVerdict::Avg => write!(f, "Avg"), - TestVerdict::Poor => write!(f, "Poor"), - TestVerdict::Fail => write!(f, "Fail"), - TestVerdict::Skip => write!(f, "Skip"), - } - } -} - -/// Category-level score. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub(crate) enum CategoryScore { - A, - B, - C, -} - -impl fmt::Display for CategoryScore { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CategoryScore::A => write!(f, "A"), - CategoryScore::B => write!(f, "B"), - CategoryScore::C => write!(f, "C"), - } - } -} - -/// Wrapper for test error with custom serialization. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub(crate) struct TestResultError(String); - -impl TestResultError { - pub(crate) fn empty() -> Self { - Self(String::new()) - } - - pub(crate) fn from_string(s: impl Into) -> Self { - Self(s.into()) - } - - pub(crate) fn is_empty(&self) -> bool { - self.0.is_empty() - } - - pub(crate) fn message(&self) -> Option<&str> { - if self.0.is_empty() { - None - } else { - Some(&self.0) - } - } -} - -impl fmt::Display for TestResultError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for TestResultError { - fn from(err: E) -> Self { - Self(err.to_string()) - } -} - -/// Result of a single test. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct TestResult { - #[serde(rename = "name")] - pub name: String, - - #[serde(rename = "verdict")] - pub verdict: TestVerdict, - - #[serde( - rename = "measurement", - skip_serializing_if = "String::is_empty", - default - )] - pub measurement: String, - - #[serde( - rename = "suggestion", - skip_serializing_if = "String::is_empty", - default - )] - pub suggestion: String, - - #[serde( - rename = "error", - skip_serializing_if = "TestResultError::is_empty", - default - )] - pub error: TestResultError, - - #[serde(skip)] - pub is_acceptable: bool, -} - -impl TestResult { - /// Creates a new test result with the given name. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - verdict: TestVerdict::Fail, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - } - } - - /// Marks the test as failed with the given error. - pub fn fail(mut self, error: impl Into) -> Self { - self.verdict = TestVerdict::Fail; - self.error = error.into(); - self - } - - /// Marks the test as passed (OK verdict). - pub fn ok(mut self) -> Self { - self.verdict = TestVerdict::Ok; - self - } -} - -/// Test case name with execution order. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub(crate) struct TestCaseName { - pub name: String, - pub order: u32, -} - -impl TestCaseName { - /// Creates a new test case name. - pub fn new(name: &str, order: u32) -> Self { - Self { - name: name.into(), - order, - } - } -} - -/// Result of a test category. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub(crate) struct TestCategoryResult { - #[serde( - rename = "category_name", - skip_serializing_if = "Option::is_none", - default - )] - pub category_name: Option, - - #[serde(rename = "targets", skip_serializing_if = "HashMap::is_empty", default)] - pub targets: HashMap>, - - // NOTE: Duration wraps Go's time.Duration and mimics the same formatting for compatibility. - // This works correctly but isn't ideal design - duration formatting typically varies between - // languages. - #[serde(rename = "execution_time", skip_serializing_if = "Option::is_none")] - pub execution_time: Option, - - #[serde(rename = "score", skip_serializing_if = "Option::is_none")] - pub score: Option, -} - -impl TestCategoryResult { - /// Creates a new test category result with the given name. - pub fn new(category_name: TestCategory) -> Self { - Self { - category_name: Some(category_name), - targets: HashMap::new(), - execution_time: None, - score: None, - } - } -} - -/// All test categories result for JSON output. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -pub(crate) struct AllCategoriesResult { - #[serde(rename = "charon_peers", skip_serializing_if = "Option::is_none")] - pub peers: Option, - - #[serde(rename = "beacon_node", skip_serializing_if = "Option::is_none")] - pub beacon: Option, - - #[serde(rename = "validator_client", skip_serializing_if = "Option::is_none")] - pub validator: Option, - - #[serde(rename = "mev", skip_serializing_if = "Option::is_none")] - pub mev: Option, - - #[serde(rename = "infra", skip_serializing_if = "Option::is_none")] - pub infra: Option, -} - -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ObolApiResult { - #[serde(rename = "enr")] - enr: String, - - /// Base64-encoded signature (65 bytes) - /// TODO: double check with obol - API docs show "0x..." but Go []byte - /// marshals to base64 - #[serde_as(as = "Base64")] - #[serde(rename = "sig")] - sig: Vec, - - #[serde(rename = "data")] - data: AllCategoriesResult, -} - -/// Publishes test results to the Obol API. -pub(crate) async fn publish_result_to_obol_api( - data: AllCategoriesResult, - api_url: impl AsRef, - private_key_file: impl AsRef, -) -> CliResult<()> { - let private_key = load_or_generate_key(private_key_file.as_ref()).await?; - let enr = Record::new(&private_key, vec![])?; - let sign_data_bytes = serde_json::to_vec(&data)?; - let hash = hash_ssz(&sign_data_bytes)?; - let sig = sign(&private_key, &hash)?; - - let result = ObolApiResult { - enr: enr.to_string(), - sig: sig.to_vec(), - data, - }; - - let obol_api_json = serde_json::to_vec(&result)?; - let client = Client::new(api_url.as_ref(), ClientOptions::default())?; - client.post_test_result(obol_api_json).await?; - - Ok(()) -} - -/// Writes test results to a JSON file. -pub(crate) async fn write_result_to_file( - result: &TestCategoryResult, - path: &Path, -) -> CliResult<()> { - let mut existing_file: tokio::fs::File = tokio::fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .truncate(false) - .mode(0o644) - .open(path) - .await?; - - let stat = existing_file.metadata().await?; - - let mut all_results: AllCategoriesResult = if stat.len() == 0 { - AllCategoriesResult::default() - } else { - let mut buf = Vec::new(); - existing_file.read_to_end(&mut buf).await?; - serde_json::from_slice(&buf)? - }; - - let category = result - .category_name - .ok_or_else(|| CliError::Other("unknown category: (missing)".to_string()))?; - - match category { - TestCategory::Peers => all_results.peers = Some(result.clone()), - TestCategory::Beacon => all_results.beacon = Some(result.clone()), - TestCategory::Validator => all_results.validator = Some(result.clone()), - TestCategory::Mev => all_results.mev = Some(result.clone()), - TestCategory::Infra => all_results.infra = Some(result.clone()), - TestCategory::All => { - return Err(CliError::Other("unknown category: all".to_string())); - } - } - - let dir = path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_path_buf(); - let base = path - .file_name() - .ok_or_else(|| CliError::Other(format!("no filename in path: {}", path.display())))? - .to_string_lossy() - .to_string(); - let path_buf = path.to_path_buf(); - - let file_content_json = serde_json::to_vec(&all_results)?; - - // tempfile is a synchronous crate, but keep existing_file open during operation - tokio::task::spawn_blocking(move || -> CliResult<()> { - use std::io::Write as _; - - let mut tmp_file = tempfile::Builder::new() - .prefix(&format!("{base}-tmp-")) - .suffix(".json") - .tempfile_in(&dir)?; - - tmp_file - .as_file() - .set_permissions(std::fs::Permissions::from_mode(0o644))?; - - tmp_file.as_file_mut().write_all(&file_content_json)?; - - tmp_file - .persist(&path_buf) - .map_err(|e| CliError::Io(e.error))?; - - Ok(()) - }) - .await - .map_err(|e| CliError::Other(format!("spawn_blocking: {}", e)))? -} - -/// Writes test results to a writer (stdout or file). -pub(crate) fn write_result_to_writer( - result: &TestCategoryResult, - writer: &mut W, -) -> CliResult<()> { - let mut lines = Vec::new(); - - // Add category ASCII art - lines.extend(get_category_ascii(&result.category_name)); - - if let Some(score) = result.score { - let score_ascii = get_score_ascii(score); - lines = append_score(lines, score_ascii); - } - - // Add test results - lines.push(String::new()); - lines.push(format!("{:<64}{}", "TEST NAME", "RESULT")); - - let mut suggestions = Vec::new(); - - // Sort targets by name for consistent output - let mut targets: Vec<_> = result.targets.iter().collect(); - targets.sort_by_key(|(name, _)| *name); - - for (target, test_results) in targets { - if !target.is_empty() && !test_results.is_empty() { - lines.push(String::new()); - lines.push(target.clone()); - } - - for test_result in test_results { - let mut test_output = format!("{:<64}", test_result.name); - - if !test_result.measurement.is_empty() { - let trim_count = test_result.measurement.chars().count().saturating_add(1); - let spaces_to_trim = " ".repeat(trim_count); - - if test_output.ends_with(&spaces_to_trim) { - let new_len = test_output.len().saturating_sub(trim_count); - test_output.truncate(new_len); - } - - test_output.push_str(&test_result.measurement); - test_output.push(' '); - } - - // Add verdict - test_output.push_str(&test_result.verdict.to_string()); - - // Add suggestion if present - if !test_result.suggestion.is_empty() { - suggestions.push(test_result.suggestion.clone()); - } - - // Add error if present - if let Some(err_msg) = test_result.error.message() { - test_output.push_str(&format!(" - {}", err_msg)); - } - - lines.push(test_output); - } - } - - // Add suggestions section - if !suggestions.is_empty() { - lines.push(String::new()); - lines.push("SUGGESTED IMPROVEMENTS".to_string()); - lines.extend(suggestions); - } - - // Add execution time - lines.push(String::new()); - lines.push(result.execution_time.unwrap_or_default().to_string()); - - // Write all lines - lines.push(String::new()); - for line in lines { - writeln!(writer, "{}", line)?; - } - - Ok(()) -} - -/// Evaluates highest RTT from a channel and assigns a verdict. -pub(crate) fn evaluate_highest_rtt( - rtts: Vec, - result: TestResult, - avg_threshold: StdDuration, - poor_threshold: StdDuration, -) -> TestResult { - let highest_rtt = rtts.into_iter().max().unwrap_or_default(); - evaluate_rtt(highest_rtt, result, avg_threshold, poor_threshold) -} - -/// Evaluates RTT (Round Trip Time) and assigns a verdict based on thresholds. -pub(crate) fn evaluate_rtt( - rtt: StdDuration, - mut result: TestResult, - avg_threshold: StdDuration, - poor_threshold: StdDuration, -) -> TestResult { - if rtt.is_zero() || rtt > poor_threshold { - result.verdict = TestVerdict::Poor; - } else if rtt > avg_threshold { - result.verdict = TestVerdict::Avg; - } else { - result.verdict = TestVerdict::Good; - } - - result.measurement = Duration::new(rtt).round().to_string(); - result -} - -/// Calculates the overall score for a list of test results. -pub(crate) fn calculate_score(results: &[TestResult]) -> CategoryScore { - // TODO: calculate score more elaborately (potentially use weights) - let mut avg: i32 = 0; - - for test in results { - match test.verdict { - TestVerdict::Poor => return CategoryScore::C, - TestVerdict::Good => avg = avg.saturating_add(1), - TestVerdict::Avg => avg = avg.saturating_sub(1), - TestVerdict::Fail => { - if !test.is_acceptable { - return CategoryScore::C; - } - continue; - } - TestVerdict::Ok | TestVerdict::Skip => continue, - } - } - - if avg < 0 { - CategoryScore::B - } else { - CategoryScore::A - } -} - -/// Filters tests based on configuration. -pub(crate) fn filter_tests( - supported_test_cases: &[TestCaseName], - test_cases: Option<&[String]>, -) -> Vec { - let mut filtered: Vec = supported_test_cases.to_vec(); - if let Some(cases) = test_cases { - filtered.retain(|supported_case| cases.contains(&supported_case.name)); - } - filtered -} - -/// Sorts tests by their order field. -pub(crate) fn sort_tests(tests: &mut [TestCaseName]) { - tests.sort_by_key(|t| t.order); -} - -async fn load_or_generate_key(path: &Path) -> CliResult { - if tokio::fs::try_exists(path).await? { - Ok(load(path)?) - } else { - tracing::warn!( - private_key_file = %path.display(), - "Private key file does not exist, will generate a temporary key" - ); - use k256::elliptic_curve::rand_core::OsRng; - Ok(SecretKey::random(&mut OsRng)) - } -} - -fn hash_ssz(data: &[u8]) -> CliResult<[u8; 32]> { - if data.is_empty() { - return Ok([0u8; 32]); - } - - let mut hasher: Hasher = Hasher::default(); - let index = hasher.index(); - - hasher.put_bytes(data)?; - hasher.merkleize(index)?; - - Ok(hasher.hash_root()?) -} - -pub(crate) fn parse_endpoint_url(endpoint: &str) -> CliResult<(String, Option<(String, String)>)> { - let mut parsed = reqwest::Url::parse(endpoint) - .map_err(|e| CliError::Other(format!("parse endpoint URL: {e}")))?; - - if parsed.username().is_empty() { - return Ok((endpoint.to_string(), None)); - } - - let username = parsed.username().to_string(); - let password = parsed.password().unwrap_or_default().to_string(); - parsed.set_username("").ok(); - parsed.set_password(None).ok(); - - Ok((parsed.to_string(), Some((username, password)))) -} - -pub(crate) fn apply_basic_auth( - builder: reqwest::RequestBuilder, - credentials: &Option<(String, String)>, -) -> reqwest::RequestBuilder { - if let Some((username, password)) = credentials { - builder.basic_auth(username, Some(password)) - } else { - builder - } -} - -/// Measures the round-trip time (RTT) for an HTTP request and logs a warning if -/// the response status code doesn't match the expected status. -pub(crate) async fn request_rtt( - url: impl AsRef, - method: Method, - body: Option>, - expected_status: StatusCode, -) -> CliResult { - let (clean_url, credentials) = parse_endpoint_url(url.as_ref())?; - let client = reqwest::Client::new(); - - let mut request_builder = client.request(method, &clean_url); - request_builder = apply_basic_auth(request_builder, &credentials); - - if let Some(body_bytes) = body { - request_builder = request_builder - .header(CONTENT_TYPE, "application/json") - .body(body_bytes); - } - - let start = std::time::Instant::now(); - let response = request_builder.send().await?; - let rtt = start.elapsed(); - - let status = response.status(); - if status != expected_status { - match response.text().await { - Ok(body) if !body.is_empty() => tracing::warn!( - status_code = status.as_u16(), - expected_status_code = expected_status.as_u16(), - endpoint = clean_url, - body = body, - "Unexpected status code" - ), - _ => tracing::warn!( - status_code = status.as_u16(), - expected_status_code = expected_status.as_u16(), - endpoint = clean_url, - "Unexpected status code" - ), - } - } - - Ok(rtt) -} - /// Updates the `--test-cases` argument help text to include available tests /// dynamically. pub fn update_test_cases_help(mut cmd: clap::Command) -> clap::Command { @@ -781,292 +134,3 @@ pub fn update_test_cases_help(mut cmd: clap::Command) -> clap::Command { } cmd } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn calculate_score_output() { - let mut results = vec![ - TestResult { - name: "test1".to_string(), - verdict: TestVerdict::Good, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }, - TestResult { - name: "test2".to_string(), - verdict: TestVerdict::Good, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }, - ]; - - assert_eq!(calculate_score(&results), CategoryScore::A); - - results.push(TestResult { - name: "test3".to_string(), - verdict: TestVerdict::Poor, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }); - - assert_eq!(calculate_score(&results), CategoryScore::C); - } - - #[test] - fn must_output_to_file_on_quiet_output() { - assert!(must_output_to_file_on_quiet(false, "").is_ok()); - assert!(must_output_to_file_on_quiet(true, "out.json").is_ok()); - assert!(must_output_to_file_on_quiet(true, "").is_err()); - } - - // Ground truth from Go fastssz (with Duration as string format matching Rust) - const GO_HASH_EMPTY: &str = "7b7d000000000000000000000000000000000000000000000000000000000000"; - const GO_HASH_ALL_CATEGORIES: &str = - "64469d918903e272849172b3b36e812f602411b664a89b59c04393332b69f63b"; - - fn assert_hash(data: &AllCategoriesResult, expected_go_hash: &str) { - let json_bytes = serde_json::to_vec(data).expect("Failed to serialize to JSON"); - let rust_hash = hash_ssz(&json_bytes).expect("hash_ssz failed"); - assert_eq!(hex::encode(rust_hash), expected_go_hash); - } - - #[test] - fn hash_ssz_empty_all_categories_result() { - assert_hash(&AllCategoriesResult::default(), GO_HASH_EMPTY); - } - - #[test] - fn hash_ssz_multi_category_result() { - let result = AllCategoriesResult { - peers: Some(TestCategoryResult { - category_name: Some(TestCategory::Peers), - targets: HashMap::from([( - "peer1".to_string(), - vec![TestResult { - name: "Test1".to_string(), - verdict: TestVerdict::Ok, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }], - )]), - execution_time: Some(Duration::new(std::time::Duration::from_nanos(1500000000))), - score: Some(CategoryScore::A), - }), - beacon: Some(TestCategoryResult { - category_name: Some(TestCategory::Beacon), - targets: HashMap::from([( - "beacon1".to_string(), - vec![TestResult { - name: "Test2".to_string(), - verdict: TestVerdict::Good, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }], - )]), - execution_time: Some(Duration::new(std::time::Duration::from_nanos(2500000000))), - score: Some(CategoryScore::A), - }), - validator: Some(TestCategoryResult { - category_name: Some(TestCategory::Validator), - targets: HashMap::from([( - "validator1".to_string(), - vec![TestResult { - name: "Test3".to_string(), - verdict: TestVerdict::Avg, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }], - )]), - execution_time: Some(Duration::new(std::time::Duration::from_nanos(500000000))), - score: Some(CategoryScore::B), - }), - mev: Some(TestCategoryResult { - category_name: Some(TestCategory::Mev), - targets: HashMap::from([( - "mev1".to_string(), - vec![TestResult { - name: "Test4".to_string(), - verdict: TestVerdict::Poor, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }], - )]), - execution_time: Some(Duration::new(std::time::Duration::from_nanos(3000000000))), - score: Some(CategoryScore::C), - }), - infra: Some(TestCategoryResult { - category_name: Some(TestCategory::Infra), - targets: HashMap::from([( - "server1".to_string(), - vec![TestResult { - name: "Test5".to_string(), - verdict: TestVerdict::Skip, - measurement: String::new(), - suggestion: String::new(), - error: TestResultError::empty(), - is_acceptable: false, - }], - )]), - execution_time: Some(Duration::new(std::time::Duration::from_nanos(1000000000))), - score: Some(CategoryScore::A), - }), - }; - - assert_hash(&result, GO_HASH_ALL_CATEGORIES); - } - - #[tokio::test] - async fn test_write_result_to_file_creates_new_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.json"); - - let mut result = TestCategoryResult::new(TestCategory::Peers); - result.score = Some(CategoryScore::A); - let mut tests = vec![TestResult::new("Ping")]; - tests[0].verdict = TestVerdict::Ok; - tests[0].measurement = "5ms".to_string(); - result.targets.insert("peer1".to_string(), tests); - - write_result_to_file(&result, &path).await.unwrap(); - - let content = tokio::fs::read_to_string(&path).await.unwrap(); - let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); - - let expected = AllCategoriesResult { - peers: Some(result), - ..Default::default() - }; - assert_eq!(written, expected); - } - - #[tokio::test] - async fn test_write_result_to_file_merges_categories() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.json"); - - let mut peers = TestCategoryResult::new(TestCategory::Peers); - peers.score = Some(CategoryScore::A); - peers - .targets - .insert("peer1".to_string(), vec![TestResult::new("Ping")]); - write_result_to_file(&peers, &path).await.unwrap(); - - let mut beacon = TestCategoryResult::new(TestCategory::Beacon); - beacon.score = Some(CategoryScore::B); - beacon.targets.insert( - "http://beacon:5052".to_string(), - vec![TestResult::new("Version")], - ); - write_result_to_file(&beacon, &path).await.unwrap(); - - let content = tokio::fs::read_to_string(&path).await.unwrap(); - let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); - - let expected = AllCategoriesResult { - peers: Some(peers), - beacon: Some(beacon), - ..Default::default() - }; - assert_eq!(written, expected); - } - - #[tokio::test] - async fn test_write_result_to_file_overwrites_same_category() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.json"); - - let mut first = TestCategoryResult::new(TestCategory::Peers); - first.score = Some(CategoryScore::A); - first - .targets - .insert("peer1".to_string(), vec![TestResult::new("Ping")]); - write_result_to_file(&first, &path).await.unwrap(); - - let mut second = TestCategoryResult::new(TestCategory::Peers); - second.score = Some(CategoryScore::C); - second - .targets - .insert("peer2".to_string(), vec![TestResult::new("PingMeasure")]); - write_result_to_file(&second, &path).await.unwrap(); - - let content = tokio::fs::read_to_string(&path).await.unwrap(); - let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); - - let expected = AllCategoriesResult { - peers: Some(second), - ..Default::default() - }; - assert_eq!(written, expected); - } - - #[cfg(unix)] - #[tokio::test] - async fn test_write_result_to_file_sets_permissions() { - use std::os::unix::fs::PermissionsExt as _; - - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.json"); - - let result = TestCategoryResult::new(TestCategory::Infra); - write_result_to_file(&result, &path).await.unwrap(); - - let metadata = tokio::fs::metadata(&path).await.unwrap(); - assert_eq!(metadata.permissions().mode() & 0o777, 0o644); - } - - #[tokio::test] - async fn test_write_result_to_file_all_categories() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.json"); - - let mut expected = AllCategoriesResult::default(); - let categories = [ - TestCategory::Peers, - TestCategory::Beacon, - TestCategory::Validator, - TestCategory::Mev, - TestCategory::Infra, - ]; - - for category in &categories { - let mut result = TestCategoryResult::new(*category); - result.score = Some(CategoryScore::A); - result.targets.insert( - format!("target-{}", category), - vec![TestResult::new("Ping")], - ); - write_result_to_file(&result, &path).await.unwrap(); - - match category { - TestCategory::Peers => expected.peers = Some(result), - TestCategory::Beacon => expected.beacon = Some(result), - TestCategory::Validator => expected.validator = Some(result), - TestCategory::Mev => expected.mev = Some(result), - TestCategory::Infra => expected.infra = Some(result), - TestCategory::All => unreachable!(), - } - } - - let content = tokio::fs::read_to_string(&path).await.unwrap(); - let written: AllCategoriesResult = serde_json::from_str(&content).unwrap(); - - assert_eq!(written, expected); - } -} diff --git a/crates/cli/src/commands/test/peers.rs b/crates/cli/src/commands/test/peers.rs index 069b4e70..a4a83cf5 100644 --- a/crates/cli/src/commands/test/peers.rs +++ b/crates/cli/src/commands/test/peers.rs @@ -1,6 +1,6 @@ //! Peer connectivity tests. -use super::{TestCategoryResult, TestConfigArgs}; +use super::{TestConfigArgs, helpers::TestCategoryResult}; use crate::error::Result; use clap::Args; use std::io::Write; diff --git a/crates/cli/src/commands/test/validator.rs b/crates/cli/src/commands/test/validator.rs index 26cd64d1..29254403 100644 --- a/crates/cli/src/commands/test/validator.rs +++ b/crates/cli/src/commands/test/validator.rs @@ -1,6 +1,6 @@ //! Validator client connectivity tests. -use super::{TestCategoryResult, TestConfigArgs}; +use super::{TestConfigArgs, helpers::TestCategoryResult}; use crate::error::Result; use clap::Args; use std::{io::Write, time::Duration}; From 22b5cbf8112b79f675ea9b5ac04ca37c27ef63c9 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 23 Mar 2026 21:14:20 +0100 Subject: [PATCH 08/41] fmt --- crates/cli/src/commands/test/beacon.rs | 10 ++++++---- crates/cli/src/commands/test/helpers.rs | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 461ca573..8ebc7fd0 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -2113,8 +2113,10 @@ async fn req_submit_sync_committee_contribution(target: &str) -> CliResult TestConfigArgs { TestConfigArgs { @@ -2391,8 +2393,8 @@ mod tests { let cfg = default_beacon_args(vec![]); let result = beacon_ping_test(cancel, cfg, &url_without_auth).await; - // Without credentials the request still succeeds (no auth enforcement by request_rtt), - // but no Authorization header is sent. + // Without credentials the request still succeeds (no auth enforcement by + // request_rtt), but no Authorization header is sent. assert_eq!(result.verdict, TestVerdict::Ok); let requests = server.received_requests().await.unwrap(); diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index e496fe09..1c69d7b7 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -1016,8 +1016,8 @@ mod tests { #[test] fn test_parse_endpoint_url_with_special_chars_in_password() { - // Go source uses "p@ss!123" as the raw password; in Rust's url crate the '@' must be - // percent-encoded as "p%40ss!123" to be unambiguously parsed. + // Go source uses "p@ss!123" as the raw password; in Rust's url crate the '@' + // must be percent-encoded as "p%40ss!123" to be unambiguously parsed. let (clean, creds) = parse_endpoint_url("https://user:p%40ss!123@beacon.example.com").unwrap(); assert_eq!(clean, "https://beacon.example.com/"); From 13b61e0b7e0832e2326cf3693dde868539fe73c7 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 24 Mar 2026 09:34:50 +0100 Subject: [PATCH 09/41] reduced usage of clippy::arithmetic_side_effects to concrete lines, added NonZero for SLOT_TIME_SECS and SLOTS_IN_EPOCH --- crates/cli/src/commands/test/beacon.rs | 71 +++++++++++++++-------- crates/cli/src/commands/test/constants.rs | 9 +-- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 8ebc7fd0..7a1bf81d 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -3,12 +3,11 @@ //! Port of charon/cmd/testbeacon.go — runs connectivity, load, and simulation //! tests against one or more beacon node endpoints. -#![allow(clippy::arithmetic_side_effects, clippy::cast_possible_truncation)] - use super::{ TestConfigArgs, constants::{ - COMMITTEE_SIZE_PER_SLOT, EPOCH_TIME, SLOT_TIME, SLOTS_IN_EPOCH, SUB_COMMITTEE_SIZE, + COMMITTEE_SIZE_PER_SLOT, EPOCH_TIME, SLOT_TIME, SLOT_TIME_SECS, SLOTS_IN_EPOCH, + SUB_COMMITTEE_SIZE, }, helpers::{ CategoryScore, TestCaseName, TestCategory, TestCategoryResult, TestResult, TestVerdict, @@ -66,7 +65,7 @@ pub struct TestBeaconArgs { /// Simulation duration in slots. #[arg( long = "simulation-duration-in-slots", - default_value_t = SLOTS_IN_EPOCH, + default_value_t = SLOTS_IN_EPOCH.get(), help = "Time to keep running the simulation in slots." )] pub simulation_duration: u64, @@ -665,10 +664,10 @@ async fn beacon_ping_load_test( fn default_intensity() -> RequestsIntensity { RequestsIntensity { attestation_duty: SLOT_TIME, - aggregator_duty: SLOT_TIME * 2, - proposal_duty: SLOT_TIME * 4, + aggregator_duty: SLOT_TIME.saturating_mul(2), + proposal_duty: SLOT_TIME.saturating_mul(4), sync_committee_submit: SLOT_TIME, - sync_committee_contribution: SLOT_TIME * 4, + sync_committee_contribution: SLOT_TIME.saturating_mul(4), sync_committee_subscribe: EPOCH_TIME, } } @@ -811,8 +810,11 @@ async fn beacon_simulation_test( mut test_res: TestResult, params: SimParams, ) -> TestResult { - let sim_duration = StdDuration::from_secs(cfg.simulation_duration * SLOT_TIME.as_secs()) - + StdDuration::from_secs(1); + let sim_duration = StdDuration::from_secs( + cfg.simulation_duration + .saturating_mul(SLOT_TIME_SECS.get()) + .saturating_add(1), + ); tracing::info!( validators_count = params.total_validators_count, @@ -970,10 +972,11 @@ async fn single_cluster_simulation( let mut slot = get_current_slot(target).await.unwrap_or(1); let mut slot_interval = tokio::time::interval(SLOT_TIME); - let mut interval_12_slots = tokio::time::interval(SLOT_TIME * 12); + let mut interval_12_slots = tokio::time::interval(SLOT_TIME.saturating_mul(12)); let mut interval_10_sec = tokio::time::interval(StdDuration::from_secs(10)); let mut interval_minute = tokio::time::interval(StdDuration::from_secs(60)); + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; loop { @@ -981,7 +984,7 @@ async fn single_cluster_simulation( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = slot_interval.tick() => { - slot += 1; + slot = slot.saturating_add(1); let epoch = slot / SLOTS_IN_EPOCH; if let Ok(rtt) = req_get_attestations_for_block(target, slot.saturating_sub(6)).await { @@ -1003,17 +1006,17 @@ async fn single_cluster_simulation( } // Last-but-one slot of epoch - if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 2 + if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH.get().saturating_sub(2) && let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } // Last slot of epoch - if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH - 1 { + if slot % SLOTS_IN_EPOCH == SLOTS_IN_EPOCH.get().saturating_sub(1) { if let Ok(rtt) = req_get_attester_duties_for_epoch(target, epoch).await { duties_attester.push(rtt); } if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch).await { duties_sync_committee.push(rtt); } - if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch + 256).await { duties_sync_committee.push(rtt); } + if let Ok(rtt) = req_get_sync_committee_duties_for_epoch(target, epoch.saturating_add(256)).await { duties_sync_committee.push(rtt); } } } _ = interval_12_slots.tick() => { @@ -1272,7 +1275,7 @@ async fn single_validator_simulation( let contribution_cumulative: Vec<_> = produce_sync_committee_contribution_all .iter() .zip(&submit_sync_committee_contribution_all) - .map(|(a, b)| *a + *b) + .map(|(a, b)| a.saturating_add(*b)) .collect(); let mut sc_all = Vec::new(); @@ -1314,6 +1317,7 @@ async fn attestation_duty( get_tx: mpsc::Sender, submit_tx: mpsc::Sender, ) { + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; tokio::time::sleep(randomize_start(tick_time)).await; @@ -1337,7 +1341,7 @@ async fn attestation_duty( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = interval.tick() => { - slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } } } @@ -1351,6 +1355,7 @@ async fn aggregation_duty( get_tx: mpsc::Sender, submit_tx: mpsc::Sender, ) { + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; let mut slot = get_current_slot(target).await.unwrap_or(1); tokio::time::sleep(randomize_start(tick_time)).await; @@ -1379,7 +1384,7 @@ async fn aggregation_duty( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = interval.tick() => { - slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } } } @@ -1393,6 +1398,7 @@ async fn proposal_duty( produce_tx: mpsc::Sender, publish_tx: mpsc::Sender, ) { + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; tokio::time::sleep(randomize_start(tick_time)).await; @@ -1416,7 +1422,7 @@ async fn proposal_duty( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = interval.tick() => { - slot += tick_time.as_secs() / SLOT_TIME.as_secs() + 1; + slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS).saturating_add(1); } } } @@ -1456,6 +1462,7 @@ async fn sync_committee_duties( }); // Subscribe loop + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; tokio::time::sleep(randomize_start(tick_time_subscribe)).await; let mut interval = tokio::time::interval(tick_time_subscribe); @@ -1485,6 +1492,7 @@ async fn sync_committee_contribution_duty( produce_tx: mpsc::Sender, contrib_tx: mpsc::Sender, ) { + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; tokio::time::sleep(randomize_start(tick_time)).await; let mut interval = tokio::time::interval(tick_time); @@ -1511,7 +1519,7 @@ async fn sync_committee_contribution_duty( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = interval.tick() => { - slot += tick_time.as_secs() / SLOT_TIME.as_secs(); + slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } } } @@ -1524,6 +1532,7 @@ async fn sync_committee_message_duty( tick_time: StdDuration, msg_tx: mpsc::Sender, ) { + #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration let deadline = tokio::time::Instant::now() + sim_duration; tokio::time::sleep(randomize_start(tick_time)).await; let mut interval = tokio::time::interval(tick_time); @@ -1585,7 +1594,11 @@ fn compute_two_phase_results( ) -> (SimulationValues, SimulationValues, SimulationValues) { let first_vals = generate_simulation_values(first, first_endpoint); let second_vals = generate_simulation_values(second, second_endpoint); - let cumulative: Vec<_> = first.iter().zip(second).map(|(a, b)| *a + *b).collect(); + let cumulative: Vec<_> = first + .iter() + .zip(second) + .map(|(a, b)| a.saturating_add(*b)) + .collect(); all_requests.extend_from_slice(&cumulative); let cumulative_vals = generate_simulation_values(&cumulative, ""); (cumulative_vals, first_vals, second_vals) @@ -1603,10 +1616,15 @@ fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> Simu sorted.sort(); let min = sorted[0]; - let max = sorted[sorted.len() - 1]; + let max = sorted[sorted.len().saturating_sub(1)]; let median = sorted[sorted.len() / 2]; let sum: StdDuration = durations.iter().sum(); - let avg = sum / durations.len() as u32; + let count = u32::try_from(durations.len()).unwrap_or_else(|_| { + tracing::warn!("Failed to convert duration length to u32"); + u32::MAX + }); + #[allow(clippy::arithmetic_side_effects)] // count is non-zero (early return above) + let avg = sum / count; let all: Vec = durations.iter().map(|d| Duration::new(*d)).collect(); @@ -1746,9 +1764,12 @@ fn cancel_after(token: &tokio_util::sync::CancellationToken, duration: StdDurati } fn randomize_start(tick_time: StdDuration) -> StdDuration { - let slots = (tick_time.as_secs() / SLOT_TIME.as_secs()).max(1); + let slots = (tick_time.as_secs() / SLOT_TIME_SECS).max(1); let random_slots = rand::thread_rng().gen_range(0..slots); - SLOT_TIME * random_slots as u32 + SLOT_TIME.saturating_mul(u32::try_from(random_slots).unwrap_or_else(|_| { + tracing::warn!("Failed to convert random slots to u32"); + u32::MAX + })) } fn strip_verbose(sim: &mut Simulation) { @@ -2136,7 +2157,7 @@ mod tests { endpoints, load_test: false, load_test_duration: StdDuration::from_secs(5), - simulation_duration: SLOTS_IN_EPOCH, + simulation_duration: SLOTS_IN_EPOCH.get(), simulation_file_dir: std::path::PathBuf::from("./"), simulation_verbose: false, simulation_custom: 0, diff --git a/crates/cli/src/commands/test/constants.rs b/crates/cli/src/commands/test/constants.rs index 39b10caa..9f4a9333 100644 --- a/crates/cli/src/commands/test/constants.rs +++ b/crates/cli/src/commands/test/constants.rs @@ -1,7 +1,8 @@ -use std::time::Duration as StdDuration; +use std::{num::NonZero, time::Duration as StdDuration}; pub(crate) const COMMITTEE_SIZE_PER_SLOT: u64 = 64; pub(crate) const SUB_COMMITTEE_SIZE: u64 = 4; -pub(crate) const SLOT_TIME: StdDuration = StdDuration::from_secs(12); -pub(crate) const SLOTS_IN_EPOCH: u64 = 32; -pub(crate) const EPOCH_TIME: StdDuration = StdDuration::from_secs(SLOTS_IN_EPOCH * 12); +pub(crate) const SLOT_TIME_SECS: NonZero = NonZero::::new(12).unwrap(); +pub(crate) const SLOT_TIME: StdDuration = StdDuration::from_secs(SLOT_TIME_SECS.get()); +pub(crate) const SLOTS_IN_EPOCH: NonZero = NonZero::::new(32).unwrap(); +pub(crate) const EPOCH_TIME: StdDuration = StdDuration::from_secs(SLOTS_IN_EPOCH.get() * 12); From be642dc5d48fc3960f7dba89867d9bb1a5bd46d3 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 24 Mar 2026 11:32:27 +0100 Subject: [PATCH 10/41] slot update comment --- crates/cli/src/commands/test/beacon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 7a1bf81d..06528d6d 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -1422,7 +1422,7 @@ async fn proposal_duty( _ = cancel.cancelled() => break, _ = tokio::time::sleep_until(deadline) => break, _ = interval.tick() => { - slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS).saturating_add(1); + slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS).saturating_add(1); // produce block for the next slot, as the current one might have already been proposed } } } From 5bb2f2662ebe89a3729d4c4fee6e6517a68f0b07 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 24 Mar 2026 12:01:43 +0100 Subject: [PATCH 11/41] reveiw corrections --- crates/cli/src/commands/test/helpers.rs | 8 ++++++-- crates/cli/src/main.rs | 13 +++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 1c69d7b7..74c1ffa2 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -587,8 +587,12 @@ pub(crate) fn parse_endpoint_url(endpoint: &str) -> CliResult<(String, Option<(S let username = percent_decode(parsed.username()); let password = percent_decode(parsed.password().unwrap_or_default()); - parsed.set_username("").ok(); - parsed.set_password(None).ok(); + parsed.set_username("").map_err(|e| { + CliError::Other(format!("failed to clear username from endpoint URL: {e:?}")) + })?; + parsed.set_password(None).map_err(|e| { + CliError::Other(format!("failed to clear password from endpoint URL: {e:?}")) + })?; Ok((parsed.to_string(), Some((username, password)))) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d594ff73..38c95ce2 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -19,6 +19,13 @@ use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> ExitResult { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let cmd = commands::test::update_test_cases_help(Cli::command()); let matches = cmd.get_matches(); let cli = match Cli::from_arg_matches(&matches) { @@ -45,12 +52,6 @@ async fn main() -> ExitResult { Commands::Relay(args) => commands::relay::run(*args, ct.child_token()).await, Commands::Alpha(args) => match args.command { AlphaCommands::Test(args) => { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); let mut stdout = std::io::stdout(); match args.command { TestCommands::Peers(args) => commands::test::peers::run(args, &mut stdout) From bde4d4c63c64ce3ebc6e840dbdc1740b224535e1 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 24 Mar 2026 12:13:53 +0100 Subject: [PATCH 12/41] Corrected passing cancelation token to the beacon test --- crates/cli/src/commands/test/beacon.rs | 36 +++++++++++++++++++------- crates/cli/src/main.rs | 8 +++--- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 06528d6d..cb1892d1 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -270,7 +270,11 @@ pub fn test_case_names() -> Vec { } /// Runs the beacon node tests. -pub async fn run(args: TestBeaconArgs, writer: &mut dyn Write) -> CliResult { +pub async fn run( + args: TestBeaconArgs, + writer: &mut dyn Write, + shutdown: tokio_util::sync::CancellationToken, +) -> CliResult { must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; tracing::info!("Starting beacon node test"); @@ -285,7 +289,7 @@ pub async fn run(args: TestBeaconArgs, writer: &mut dyn Write) -> CliResult ExitResult { TestCommands::Peers(args) => commands::test::peers::run(args, &mut stdout) .await .map(|_| ()), - TestCommands::Beacon(args) => commands::test::beacon::run(args, &mut stdout) - .await - .map(|_| ()), + TestCommands::Beacon(args) => { + commands::test::beacon::run(args, &mut stdout, ct.child_token()) + .await + .map(|_| ()) + } TestCommands::Validator(args) => { commands::test::validator::run(args, &mut stdout) .await From 290fa62b823fd6dd8432029efa61307c156c9631 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 24 Mar 2026 12:30:45 +0100 Subject: [PATCH 13/41] Docker compose file for the pluto alpha test beacon --- test-infra/docker-compose.hoodi.yml | 53 +++++++++++++++++++++++++++++ test-infra/jwt.hex | 1 + 2 files changed, 54 insertions(+) create mode 100644 test-infra/docker-compose.hoodi.yml create mode 100644 test-infra/jwt.hex diff --git a/test-infra/docker-compose.hoodi.yml b/test-infra/docker-compose.hoodi.yml new file mode 100644 index 00000000..f19d26a5 --- /dev/null +++ b/test-infra/docker-compose.hoodi.yml @@ -0,0 +1,53 @@ +# Hoodi testnet execution (Nethermind) and consensus (Lighthouse) node pair. +# Exposes the beacon HTTP API on port 5052, suitable for use with `pluto alpha test beacon --endpoints http://localhost:5052`. +# Data is persisted in named volumes so syncing progress survives restarts. + +services: + nethermind: + image: nethermind/nethermind:latest + restart: unless-stopped + ports: + - "8545:8545" # JSON-RPC + - "8551:8551" # Engine API + - "30303:30303" # P2P + - "30303:30303/udp" + volumes: + - nethermind-data:/nethermind/data + - ./jwt.hex:/jwt.hex:ro + command: + - --config=hoodi + - --datadir=/nethermind/data + - --JsonRpc.Enabled=true + - --JsonRpc.Host=0.0.0.0 + - --JsonRpc.Port=8545 + - --JsonRpc.EngineHost=0.0.0.0 + - --JsonRpc.EnginePort=8551 + - --JsonRpc.JwtSecretFile=/jwt.hex + + lighthouse: + image: sigp/lighthouse:latest + restart: unless-stopped + depends_on: + - nethermind + ports: + - "5052:5052" # Beacon HTTP API + - "9000:9000" # P2P + - "9000:9000/udp" + volumes: + - lighthouse-data:/lighthouse/data + - ./jwt.hex:/jwt.hex:ro + command: + - lighthouse + - beacon_node + - --network=hoodi + - --datadir=/lighthouse/data + - --execution-endpoint=http://nethermind:8551 + - --execution-jwt=/jwt.hex + - --http + - --http-address=0.0.0.0 + - --http-port=5052 + - --checkpoint-sync-url=https://checkpoint-sync.hoodi.ethpandaops.io + +volumes: + nethermind-data: + lighthouse-data: diff --git a/test-infra/jwt.hex b/test-infra/jwt.hex new file mode 100644 index 00000000..6aade986 --- /dev/null +++ b/test-infra/jwt.hex @@ -0,0 +1 @@ +0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3 From 7a5f3218356b32aa77d0418f084b77885785dbcf Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 10:50:20 +0100 Subject: [PATCH 14/41] fixed tests --- crates/cli/src/commands/test/constants.rs | 1 + crates/cli/src/commands/test/helpers.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/constants.rs b/crates/cli/src/commands/test/constants.rs index 9f4a9333..83aad4a4 100644 --- a/crates/cli/src/commands/test/constants.rs +++ b/crates/cli/src/commands/test/constants.rs @@ -1,5 +1,6 @@ use std::{num::NonZero, time::Duration as StdDuration}; +/// Ethereum beacon chain constants. pub(crate) const COMMITTEE_SIZE_PER_SLOT: u64 = 64; pub(crate) const SUB_COMMITTEE_SIZE: u64 = 4; pub(crate) const SLOT_TIME_SECS: NonZero = NonZero::::new(12).unwrap(); diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 74c1ffa2..34850ea7 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -11,9 +11,9 @@ use crate::{ use k256::SecretKey; use pluto_app::obolapi::{Client, ClientOptions}; -use pluto_cluster::ssz_hasher::{HashWalker, Hasher}; use pluto_eth2util::enr::Record; use pluto_k1util::{load, sign}; +use pluto_ssz::{HashWalker, Hasher}; use reqwest::{Method, StatusCode, header::CONTENT_TYPE}; use serde_with::{base64::Base64, serde_as}; use std::os::unix::fs::PermissionsExt as _; From 4ed89243f14ff2619e885930b529fe97223017de Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 11:00:14 +0100 Subject: [PATCH 15/41] Made test cases const array --- crates/cli/src/commands/test/beacon.rs | 49 ++++++++++++------------- crates/cli/src/commands/test/helpers.rs | 12 ++---- 2 files changed, 28 insertions(+), 33 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index cb1892d1..2b719152 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -218,21 +218,26 @@ pub struct SimulationCluster { pub node_version_request: SimulationValues, } -fn supported_beacon_test_cases() -> Vec { - vec![ - TestCaseName::new("Ping", 1), - TestCaseName::new("PingMeasure", 2), - TestCaseName::new("Version", 3), - TestCaseName::new("Synced", 4), - TestCaseName::new("PeerCount", 5), - TestCaseName::new("PingLoad", 6), - TestCaseName::new("Simulate1", 7), - TestCaseName::new("Simulate10", 8), - TestCaseName::new("Simulate100", 9), - TestCaseName::new("Simulate500", 10), - TestCaseName::new("Simulate1000", 11), - TestCaseName::new("SimulateCustom", 12), - ] +const SUPPORTED_BEACON_TEST_CASES: [TestCaseName; 12] = [ + TestCaseName::new("Ping", 1), + TestCaseName::new("PingMeasure", 2), + TestCaseName::new("Version", 3), + TestCaseName::new("Synced", 4), + TestCaseName::new("PeerCount", 5), + TestCaseName::new("PingLoad", 6), + TestCaseName::new("Simulate1", 7), + TestCaseName::new("Simulate10", 8), + TestCaseName::new("Simulate100", 9), + TestCaseName::new("Simulate500", 10), + TestCaseName::new("Simulate1000", 11), + TestCaseName::new("SimulateCustom", 12), +]; + +pub fn test_case_names() -> Vec { + SUPPORTED_BEACON_TEST_CASES + .iter() + .map(|n| n.name.to_string()) + .collect() } async fn run_test_case( @@ -263,12 +268,6 @@ async fn run_test_case( } } -pub fn test_case_names() -> Vec { - let mut cases = supported_beacon_test_cases(); - sort_tests(&mut cases); - cases.iter().map(|n| n.name.clone()).collect() -} - /// Runs the beacon node tests. pub async fn run( args: TestBeaconArgs, @@ -279,7 +278,7 @@ pub async fn run( tracing::info!("Starting beacon node test"); - let all_cases = supported_beacon_test_cases(); + let all_cases = SUPPORTED_BEACON_TEST_CASES; let mut queued = filter_tests(&all_cases, args.test_config.test_cases.as_deref()); if queued.is_empty() { @@ -369,15 +368,15 @@ async fn test_single_beacon( for tc in queued { if cancel.is_cancelled() { results.push(TestResult { - name: tc.name.clone(), + name: tc.name.to_string(), verdict: TestVerdict::Fail, error: super::TestResultError::from_string("timeout/interrupted"), - ..TestResult::new(&tc.name) + ..TestResult::new(tc.name) }); break; } - let result = run_test_case(cancel.clone(), cfg.clone(), target, &tc.name).await; + let result = run_test_case(cancel.clone(), cfg.clone(), target, tc.name).await; results.push(result); } diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 34850ea7..ba0cbdb9 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -189,17 +189,13 @@ impl TestResult { /// Test case name with execution order. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct TestCaseName { - pub name: String, + pub name: &'static str, pub order: u32, } impl TestCaseName { - /// Creates a new test case name. - pub fn new(name: &str, order: u32) -> Self { - Self { - name: name.into(), - order, - } + pub const fn new(name: &'static str, order: u32) -> Self { + Self { name, order } } } @@ -524,7 +520,7 @@ pub(crate) fn filter_tests( ) -> Vec { let mut filtered: Vec = supported_test_cases.to_vec(); if let Some(cases) = test_cases { - filtered.retain(|supported_case| cases.contains(&supported_case.name)); + filtered.retain(|supported_case| cases.iter().any(|c| c == supported_case.name)); } filtered } From 5f7c13d622ae4c1addaca2f4a49a9bcc6bd4b839 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 11:29:35 +0100 Subject: [PATCH 16/41] unneeded child_token --- crates/cli/src/commands/test/beacon.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 2b719152..e28f7f84 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -288,8 +288,7 @@ pub async fn run( } sort_tests(&mut queued); - let cancel = shutdown.child_token(); - cancel_after(&cancel, args.test_config.timeout); + cancel_after(&shutdown, args.test_config.timeout); let start = std::time::Instant::now(); @@ -300,7 +299,7 @@ pub async fn run( let queued = queued.clone(); let cfg = args.clone(); let target = endpoint.clone(); - let cancel = cancel.clone(); + let cancel = shutdown.clone(); tokio::spawn(async move { let results = test_single_beacon(cancel, &queued, cfg, &target).await; From ef74443c79ee145910b95457c4e3f31f7f2a2918 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 11:40:35 +0100 Subject: [PATCH 17/41] JoinSet for collecting tests result instead of channels vector --- crates/cli/src/commands/test/beacon.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index e28f7f84..5d24b653 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -22,7 +22,7 @@ use rand::Rng; use reqwest::Method; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io::Write, path::PathBuf, time::Duration as StdDuration}; -use tokio::sync::mpsc; +use tokio::{sync::mpsc, task::JoinSet}; const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40); const THRESHOLD_BEACON_MEASURE_POOR: StdDuration = StdDuration::from_millis(100); @@ -292,24 +292,23 @@ pub async fn run( let start = std::time::Instant::now(); - let (tx, mut rx) = mpsc::channel::<(String, Vec)>(args.endpoints.len()); + let mut set = JoinSet::new(); for endpoint in &args.endpoints { - let tx = tx.clone(); let queued = queued.clone(); let cfg = args.clone(); let target = endpoint.clone(); let cancel = shutdown.clone(); - tokio::spawn(async move { + set.spawn(async move { let results = test_single_beacon(cancel, &queued, cfg, &target).await; - let _ = tx.send((target, results)).await; + (target, results) }); } - drop(tx); let mut test_results: HashMap> = HashMap::new(); - while let Some((target, results)) = rx.recv().await { + while let Some(res) = set.join_next().await { + let (target, results) = res.map_err(|e| crate::error::CliError::Other(e.to_string()))?; test_results.insert(target, results); } From b6240bc6024f8cf15bdf0f1898636c6a660ee799 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 11:48:13 +0100 Subject: [PATCH 18/41] unneeded clone --- crates/cli/src/commands/test/beacon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 5d24b653..d44d2609 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -463,7 +463,7 @@ async fn beacon_version_test( let version = if parts.len() > 3 { parts[..3].join("/") } else { - body.data.version.clone() + body.data.version }; res.measurement = version; From d13bdf25d978b5e020d59833be6daa8c567feb26 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 11:59:43 +0100 Subject: [PATCH 19/41] Comment regarding status code check --- crates/cli/src/commands/test/beacon.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index d44d2609..237c331f 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -437,6 +437,7 @@ async fn beacon_version_test( Err(e) => return res.fail(e), }; + // more strict than Charon check which requires status code to be > 399 if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -492,6 +493,7 @@ async fn beacon_is_synced_test( Err(e) => return res.fail(e), }; + // more strict than Charon check which requires status code to be > 399 if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -542,6 +544,7 @@ async fn beacon_peer_count_test( Err(e) => return res.fail(e), }; + // more strict than Charon check which requires status code to be > 399 if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -1563,6 +1566,7 @@ async fn get_current_slot(target: &str) -> CliResult { .send() .await?; + // more strict than Charon check which requires status code to be > 399 if !resp.status().is_success() { return Err(crate::error::CliError::Other(format!( "syncing request failed: {}", From 7c164e20895751698c3bdb6d76fb9951f0215218 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 12:05:54 +0100 Subject: [PATCH 20/41] review corrections --- crates/cli/src/commands/test/beacon.rs | 88 +++++++++++--------------- 1 file changed, 38 insertions(+), 50 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 237c331f..3fdbfa72 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -23,6 +23,7 @@ use reqwest::Method; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io::Write, path::PathBuf, time::Duration as StdDuration}; use tokio::{sync::mpsc, task::JoinSet}; +use tokio_util::sync::CancellationToken; const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40); const THRESHOLD_BEACON_MEASURE_POOR: StdDuration = StdDuration::from_millis(100); @@ -241,7 +242,7 @@ pub fn test_case_names() -> Vec { } async fn run_test_case( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, name: &str, @@ -272,7 +273,7 @@ async fn run_test_case( pub async fn run( args: TestBeaconArgs, writer: &mut dyn Write, - shutdown: tokio_util::sync::CancellationToken, + shutdown: CancellationToken, ) -> CliResult { must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; @@ -356,7 +357,7 @@ pub async fn run( } async fn test_single_beacon( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, queued: &[TestCaseName], cfg: TestBeaconArgs, target: &str, @@ -382,7 +383,7 @@ async fn test_single_beacon( } async fn beacon_ping_test( - _cancel: tokio_util::sync::CancellationToken, + _cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -399,7 +400,7 @@ async fn beacon_ping_test( } async fn beacon_ping_measure_test( - _cancel: tokio_util::sync::CancellationToken, + _cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -417,7 +418,7 @@ async fn beacon_ping_measure_test( } async fn beacon_version_test( - _cancel: tokio_util::sync::CancellationToken, + _cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -473,7 +474,7 @@ async fn beacon_version_test( } async fn beacon_is_synced_test( - _cancel: tokio_util::sync::CancellationToken, + _cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -524,7 +525,7 @@ async fn beacon_is_synced_test( } async fn beacon_peer_count_test( - _cancel: tokio_util::sync::CancellationToken, + _cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -584,14 +585,13 @@ async fn beacon_ping_once(target: &str) -> CliResult { } async fn ping_beacon_continuously( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: String, tx: mpsc::Sender, ) { loop { - let rtt = match beacon_ping_once(&target).await { - Ok(rtt) => rtt, - Err(_) => return, + let Ok(rtt) = beacon_ping_once(&target).await else { + return; }; tokio::select! { @@ -608,7 +608,7 @@ async fn ping_beacon_continuously( } async fn beacon_ping_load_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -677,7 +677,7 @@ fn default_intensity() -> RequestsIntensity { } async fn beacon_simulation_1_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -696,7 +696,7 @@ async fn beacon_simulation_1_test( } async fn beacon_simulation_10_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -715,7 +715,7 @@ async fn beacon_simulation_10_test( } async fn beacon_simulation_100_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -734,7 +734,7 @@ async fn beacon_simulation_100_test( } async fn beacon_simulation_500_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -753,7 +753,7 @@ async fn beacon_simulation_500_test( } async fn beacon_simulation_1000_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -772,7 +772,7 @@ async fn beacon_simulation_1000_test( } async fn beacon_simulation_custom_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { @@ -808,7 +808,7 @@ async fn beacon_simulation_custom_test( } async fn beacon_simulation_test( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, cfg: &TestBeaconArgs, target: &str, mut test_res: TestResult, @@ -956,7 +956,7 @@ async fn beacon_simulation_test( } async fn single_cluster_simulation( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, sim_duration: StdDuration, target: &str, ) -> SimulationCluster { @@ -1082,7 +1082,7 @@ async fn single_cluster_simulation( } async fn single_validator_simulation( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, sim_duration: StdDuration, target: &str, intensity: RequestsIntensity, @@ -1314,7 +1314,7 @@ async fn single_validator_simulation( } async fn attestation_duty( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time: StdDuration, @@ -1352,7 +1352,7 @@ async fn attestation_duty( } async fn aggregation_duty( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time: StdDuration, @@ -1395,7 +1395,7 @@ async fn aggregation_duty( } async fn proposal_duty( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time: StdDuration, @@ -1434,7 +1434,7 @@ async fn proposal_duty( #[allow(clippy::too_many_arguments)] async fn sync_committee_duties( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time_submit: StdDuration, @@ -1489,7 +1489,7 @@ async fn sync_committee_duties( } async fn sync_committee_contribution_duty( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time: StdDuration, @@ -1530,7 +1530,7 @@ async fn sync_committee_contribution_duty( } async fn sync_committee_message_duty( - cancel: tokio_util::sync::CancellationToken, + cancel: CancellationToken, target: &str, sim_duration: StdDuration, tick_time: StdDuration, @@ -1760,7 +1760,7 @@ fn skip_result(name: &str) -> TestResult { } } -fn cancel_after(token: &tokio_util::sync::CancellationToken, duration: StdDuration) { +fn cancel_after(token: &CancellationToken, duration: StdDuration) { let token = token.clone(); tokio::spawn(async move { tokio::time::sleep(duration).await; @@ -2246,9 +2246,7 @@ mod tests { let args = default_beacon_args(vec![url.clone()]); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); let expected = expected_results_for_healthy_node(); assert_results(&res.targets, &url, &expected); @@ -2263,9 +2261,7 @@ mod tests { let args = default_beacon_args(vec![endpoint1.clone(), endpoint2.clone()]); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); for endpoint in [&endpoint1, &endpoint2] { let target_results = res.targets.get(endpoint).expect("missing target"); @@ -2296,9 +2292,7 @@ mod tests { args.test_config.timeout = StdDuration::from_nanos(100); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); for endpoint in [&endpoint1, &endpoint2] { let target_results = res.targets.get(endpoint).expect("missing target"); @@ -2320,9 +2314,7 @@ mod tests { args.test_config.output_json = json_path.to_str().unwrap().to_string(); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); assert!(buf.is_empty(), "expected no output on quiet mode"); assert!(!res.targets.is_empty()); @@ -2339,7 +2331,7 @@ mod tests { }; let mut buf = Vec::new(); - let err = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) + let err = run(args, &mut buf, CancellationToken::new()) .await .unwrap_err(); assert!( @@ -2356,9 +2348,7 @@ mod tests { args.test_config.test_cases = Some(vec!["Ping".to_string()]); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); for endpoint in [&endpoint1, &endpoint2] { let target_results = res.targets.get(endpoint).expect("missing target"); @@ -2379,9 +2369,7 @@ mod tests { args.test_config.output_json = file_path.to_str().unwrap().to_string(); let mut buf = Vec::new(); - let res = run(args, &mut buf, tokio_util::sync::CancellationToken::new()) - .await - .unwrap(); + let res = run(args, &mut buf, CancellationToken::new()).await.unwrap(); assert!(file_path.exists(), "output file should exist"); @@ -2410,7 +2398,7 @@ mod tests { let addr = server.address(); let url_with_auth = format!("http://testuser:testpass123@{addr}"); - let cancel = tokio_util::sync::CancellationToken::new(); + let cancel = CancellationToken::new(); let cfg = default_beacon_args(vec![]); let result = beacon_ping_test(cancel, cfg, &url_with_auth).await; @@ -2429,7 +2417,7 @@ mod tests { let url_without_auth = server.uri(); - let cancel = tokio_util::sync::CancellationToken::new(); + let cancel = CancellationToken::new(); let cfg = default_beacon_args(vec![]); let result = beacon_ping_test(cancel, cfg, &url_without_auth).await; From 475e7acab8e2f6665c8a530ce9e776ec17973f23 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 13:01:14 +0100 Subject: [PATCH 21/41] review cont. --- crates/cli/src/commands/test/beacon.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 3fdbfa72..3ea1ff94 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -623,19 +623,17 @@ async fn beacon_ping_load_test( "Running ping load tests..." ); - let (tx, mut rx) = mpsc::channel::(65536); - - let load_cancel = cancel.child_token(); - cancel_after(&load_cancel, cfg.load_test_duration); + let (tx, mut rx) = mpsc::channel::(i16::MAX as usize); + cancel_after(&cancel, cfg.load_test_duration); let mut handles = Vec::new(); let mut interval = tokio::time::interval(StdDuration::from_secs(1)); loop { tokio::select! { - _ = load_cancel.cancelled() => break, + _ = cancel.cancelled() => break, _ = interval.tick() => { - let c = load_cancel.clone(); + let c = cancel.clone(); let t = target.to_string(); let tx = tx.clone(); handles.push(tokio::spawn(async move { @@ -828,11 +826,10 @@ async fn beacon_simulation_test( "Running beacon node simulation..." ); - let sim_cancel = cancel.child_token(); - cancel_after(&sim_cancel, sim_duration); + cancel_after(&cancel, sim_duration); // General cluster requests - let cluster_cancel = sim_cancel.clone(); + let cluster_cancel = cancel.clone(); let cluster_target = target.to_string(); let cluster_handle = tokio::spawn(async move { single_cluster_simulation(cluster_cancel, sim_duration, &cluster_target).await @@ -852,7 +849,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal, sync committee..." ); for _ in 0..params.sync_committee_validators_count { - let c = sim_cancel.clone(); + let c = cancel.clone(); let t = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { @@ -871,7 +868,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal..." ); for _ in 0..params.proposal_validators_count { - let c = sim_cancel.clone(); + let c = cancel.clone(); let t = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { @@ -890,7 +887,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation..." ); for _ in 0..params.attestation_validators_count { - let c = sim_cancel.clone(); + let c = cancel.clone(); let t = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { From 2f2a61537ab2ee74fe28b4146b57f17712b7b8ee Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 13:02:50 +0100 Subject: [PATCH 22/41] missing log info --- crates/cli/src/commands/test/beacon.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 3ea1ff94..92368774 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -829,6 +829,7 @@ async fn beacon_simulation_test( cancel_after(&cancel, sim_duration); // General cluster requests + tracing::info!("Starting general cluster requests..."); let cluster_cancel = cancel.clone(); let cluster_target = target.to_string(); let cluster_handle = tokio::spawn(async move { From b94422c0e43484de58be736224b3dd59785900ef Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Fri, 27 Mar 2026 13:20:43 +0100 Subject: [PATCH 23/41] fixed canceling timeout task --- crates/cli/src/commands/test/beacon.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 92368774..59860e69 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -1761,8 +1761,10 @@ fn skip_result(name: &str) -> TestResult { fn cancel_after(token: &CancellationToken, duration: StdDuration) { let token = token.clone(); tokio::spawn(async move { - tokio::time::sleep(duration).await; - token.cancel(); + tokio::select! { + _ = tokio::time::sleep(duration) => token.cancel(), + _ = token.cancelled() => {} + } }); } From dd70a2337cb17281493109be5ca62103328d9dad Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 30 Mar 2026 12:15:27 +0200 Subject: [PATCH 24/41] tokio::fs::write instead of std --- crates/cli/src/commands/test/beacon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 59860e69..7c21d164 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -926,7 +926,7 @@ async fn beacon_simulation_test( let path = cfg .simulation_file_dir .join(format!("{}-validators.json", params.total_validators_count)); - if let Err(e) = std::fs::write(&path, json) { + if let Err(e) = tokio::fs::write(&path, json).await { tracing::error!(?e, "Failed to write simulation file"); } } From a43fe7765f4d814d6fbb84de2a3bbdc8b994e412 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 30 Mar 2026 12:35:07 +0200 Subject: [PATCH 25/41] Update crates/cli/src/commands/test/beacon.rs Co-authored-by: Lautaro Emanuel <31224949+emlautarom1@users.noreply.github.com> --- crates/cli/src/commands/test/beacon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 59860e69..a9a92fd1 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -1626,7 +1626,7 @@ fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> Simu tracing::warn!("Failed to convert duration length to u32"); u32::MAX }); - #[allow(clippy::arithmetic_side_effects)] // count is non-zero (early return above) + #[allow(clippy::arithmetic_side_effects, reason = "count is non-zero (early return above)")] let avg = sum / count; let all: Vec = durations.iter().map(|d| Duration::new(*d)).collect(); From 0d0818e4a7b3365a9bb310f7c137d96f1cdc5e29 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 30 Mar 2026 12:38:15 +0200 Subject: [PATCH 26/41] Apply suggestion from @emlautarom1 Co-authored-by: Lautaro Emanuel <31224949+emlautarom1@users.noreply.github.com> --- crates/cli/src/commands/test/beacon.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index a9a92fd1..8801221f 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -128,9 +128,9 @@ struct DutiesPerformed { #[derive(Debug, Clone, Copy)] struct SimParams { total_validators_count: u64, - attestation_validators_count: u64, - proposal_validators_count: u64, - sync_committee_validators_count: u64, + attestation_validators_count: u64, // attestation + aggregation + proposal_validators_count: u64, // attestation + aggregation + proposals + sync_committee_validators_count: u64, // attestation + aggregation + proposals + sync committee request_intensity: RequestsIntensity, } From 3283c8924693c3b080dd82a5b8e7c88acaf7fee1 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 30 Mar 2026 17:28:41 +0200 Subject: [PATCH 27/41] review, fixed intervals tick for tests --- Cargo.lock | 1 - crates/cli/Cargo.toml | 1 - crates/cli/src/commands/test/beacon.rs | 137 ++++++++++++++----------- crates/cli/src/main.rs | 9 +- 4 files changed, 81 insertions(+), 67 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4ee013b..350d6d3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5473,7 +5473,6 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "tracing-subscriber", "wiremock", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1696ee1d..2f92fd94 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -21,7 +21,6 @@ pluto-app.workspace = true pluto-cluster.workspace = true pluto-relay-server.workspace = true pluto-tracing.workspace = true -tracing-subscriber.workspace = true pluto-core.workspace = true pluto-p2p.workspace = true pluto-eth2util.workspace = true diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 7c21d164..97986ef4 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -1,6 +1,6 @@ //! Beacon node API tests. //! -//! Port of charon/cmd/testbeacon.go — runs connectivity, load, and simulation +//! Connectivity, load, and simulation //! tests against one or more beacon node endpoints. use super::{ @@ -22,7 +22,11 @@ use rand::Rng; use reqwest::Method; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io::Write, path::PathBuf, time::Duration as StdDuration}; -use tokio::{sync::mpsc, task::JoinSet}; +use tokio::{ + sync::mpsc, + task::JoinSet, + time::{Instant, interval, interval_at, sleep, sleep_until}, +}; use tokio_util::sync::CancellationToken; const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40); @@ -137,11 +141,11 @@ struct SimParams { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Simulation { pub general_cluster_requests: SimulationCluster, - pub validators_requests: SimulationValidatorsResult, + pub validators_requests: SimulationValidators, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SimulationValidatorsResult { +pub struct SimulationValidators { pub averaged: SimulationSingleValidator, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub all_validators: Vec, @@ -291,7 +295,7 @@ pub async fn run( cancel_after(&shutdown, args.test_config.timeout); - let start = std::time::Instant::now(); + let start = Instant::now(); let mut set = JoinSet::new(); @@ -438,7 +442,8 @@ async fn beacon_version_test( Err(e) => return res.fail(e), }; - // more strict than Charon check which requires status code to be > 399 + // More strict than the Charon check, which requires the status code to be > + // 399. if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -494,7 +499,8 @@ async fn beacon_is_synced_test( Err(e) => return res.fail(e), }; - // more strict than Charon check which requires status code to be > 399 + // More strict than the Charon check, which requires the status code to be > + // 399. if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -545,7 +551,8 @@ async fn beacon_peer_count_test( Err(e) => return res.fail(e), }; - // more strict than Charon check which requires status code to be > 399 + // More strict than the Charon check, which requires the status code to be > + // 399. if !resp.status().is_success() { return res.fail(super::TestResultError::from_string(format!( "http status {}", @@ -601,7 +608,7 @@ async fn ping_beacon_continuously( return; } let jitter = rand::thread_rng().gen_range(0..100u64); - tokio::time::sleep(StdDuration::from_millis(jitter)).await; + sleep(StdDuration::from_millis(jitter)).await; } } } @@ -627,7 +634,7 @@ async fn beacon_ping_load_test( cancel_after(&cancel, cfg.load_test_duration); let mut handles = Vec::new(); - let mut interval = tokio::time::interval(StdDuration::from_secs(1)); + let mut interval = interval(StdDuration::from_secs(1)); loop { tokio::select! { @@ -912,7 +919,7 @@ async fn beacon_simulation_test( let mut final_simulation = Simulation { general_cluster_requests: cluster_result, - validators_requests: SimulationValidatorsResult { + validators_requests: SimulationValidators { averaged, all_validators: all_validators.clone(), }, @@ -973,18 +980,27 @@ async fn single_cluster_simulation( let mut slot = get_current_slot(target).await.unwrap_or(1); - let mut slot_interval = tokio::time::interval(SLOT_TIME); - let mut interval_12_slots = tokio::time::interval(SLOT_TIME.saturating_mul(12)); - let mut interval_10_sec = tokio::time::interval(StdDuration::from_secs(10)); - let mut interval_minute = tokio::time::interval(StdDuration::from_secs(60)); + let deadline = sim_deadline(sim_duration); - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; + let now = Instant::now(); + #[allow(clippy::arithmetic_side_effects)] + let mut slot_interval = interval_at(now + SLOT_TIME, SLOT_TIME); + #[allow(clippy::arithmetic_side_effects)] + let mut interval_12_slots = interval_at( + now + SLOT_TIME.saturating_mul(12), + SLOT_TIME.saturating_mul(12), + ); + #[allow(clippy::arithmetic_side_effects)] + let mut interval_10_sec = + interval_at(now + StdDuration::from_secs(10), StdDuration::from_secs(10)); + #[allow(clippy::arithmetic_side_effects)] + let mut interval_minute = + interval_at(now + StdDuration::from_secs(60), StdDuration::from_secs(60)); loop { tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = slot_interval.tick() => { slot = slot.saturating_add(1); let epoch = slot / SLOTS_IN_EPOCH; @@ -1319,15 +1335,14 @@ async fn attestation_duty( get_tx: mpsc::Sender, submit_tx: mpsc::Sender, ) { - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; - tokio::time::sleep(randomize_start(tick_time)).await; - - let mut interval = tokio::time::interval(tick_time); + let deadline = sim_deadline(sim_duration); + sleep(randomize_start(tick_time)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time, tick_time); let mut slot = get_current_slot(target).await.unwrap_or(1); loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1341,7 +1356,7 @@ async fn attestation_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1357,15 +1372,14 @@ async fn aggregation_duty( get_tx: mpsc::Sender, submit_tx: mpsc::Sender, ) { - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; + let deadline = sim_deadline(sim_duration); let mut slot = get_current_slot(target).await.unwrap_or(1); - tokio::time::sleep(randomize_start(tick_time)).await; - - let mut interval = tokio::time::interval(tick_time); + sleep(randomize_start(tick_time)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time, tick_time); loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1384,7 +1398,7 @@ async fn aggregation_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1400,16 +1414,15 @@ async fn proposal_duty( produce_tx: mpsc::Sender, publish_tx: mpsc::Sender, ) { - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; - tokio::time::sleep(randomize_start(tick_time)).await; - - let mut interval = tokio::time::interval(tick_time); + let deadline = sim_deadline(sim_duration); + sleep(randomize_start(tick_time)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time, tick_time); let mut slot = get_current_slot(target).await.unwrap_or(1); let randao = "0x1fe79e4193450abda94aec753895cfb2aac2c2a930b6bab00fbb27ef6f4a69f4400ad67b5255b91837982b4c511ae1d94eae1cf169e20c11bd417c1fffdb1f99f4e13e2de68f3b5e73f1de677d73cd43e44bf9b133a79caf8e5fad06738e1b0c"; loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1422,7 +1435,7 @@ async fn proposal_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS).saturating_add(1); // produce block for the next slot, as the current one might have already been proposed } @@ -1464,13 +1477,13 @@ async fn sync_committee_duties( }); // Subscribe loop - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; - tokio::time::sleep(randomize_start(tick_time_subscribe)).await; - let mut interval = tokio::time::interval(tick_time_subscribe); + let deadline = sim_deadline(sim_duration); + sleep(randomize_start(tick_time_subscribe)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time_subscribe, tick_time_subscribe); loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1480,7 +1493,7 @@ async fn sync_committee_duties( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => {} } } @@ -1494,14 +1507,14 @@ async fn sync_committee_contribution_duty( produce_tx: mpsc::Sender, contrib_tx: mpsc::Sender, ) { - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; - tokio::time::sleep(randomize_start(tick_time)).await; - let mut interval = tokio::time::interval(tick_time); + let deadline = sim_deadline(sim_duration); + sleep(randomize_start(tick_time)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time, tick_time); let mut slot = get_current_slot(target).await.unwrap_or(1); loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1519,7 +1532,7 @@ async fn sync_committee_contribution_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1534,13 +1547,13 @@ async fn sync_committee_message_duty( tick_time: StdDuration, msg_tx: mpsc::Sender, ) { - #[allow(clippy::arithmetic_side_effects)] // Instant + Duration; bounded sim_duration - let deadline = tokio::time::Instant::now() + sim_duration; - tokio::time::sleep(randomize_start(tick_time)).await; - let mut interval = tokio::time::interval(tick_time); + let deadline = sim_deadline(sim_duration); + sleep(randomize_start(tick_time)).await; + #[allow(clippy::arithmetic_side_effects)] + let mut interval = interval_at(Instant::now() + tick_time, tick_time); loop { - if cancel.is_cancelled() || tokio::time::Instant::now() >= deadline { + if cancel.is_cancelled() || Instant::now() >= deadline { break; } @@ -1550,7 +1563,7 @@ async fn sync_committee_message_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = tokio::time::sleep_until(deadline) => break, + _ = sleep_until(deadline) => break, _ = interval.tick() => {} } } @@ -1564,7 +1577,8 @@ async fn get_current_slot(target: &str) -> CliResult { .send() .await?; - // more strict than Charon check which requires status code to be > 399 + // More strict than the Charon check, which requires the status code to be > + // 399. if !resp.status().is_success() { return Err(crate::error::CliError::Other(format!( "syncing request failed: {}", @@ -1762,12 +1776,17 @@ fn cancel_after(token: &CancellationToken, duration: StdDuration) { let token = token.clone(); tokio::spawn(async move { tokio::select! { - _ = tokio::time::sleep(duration) => token.cancel(), + _ = sleep(duration) => token.cancel(), _ = token.cancelled() => {} } }); } +#[allow(clippy::arithmetic_side_effects)] +fn sim_deadline(sim_duration: StdDuration) -> Instant { + Instant::now() + sim_duration +} + fn randomize_start(tick_time: StdDuration) -> StdDuration { let slots = (tick_time.as_secs() / SLOT_TIME_SECS).max(1); let random_slots = rand::thread_rng().gen_range(0..slots); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index f8cd9b01..443549c7 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -19,12 +19,9 @@ use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> ExitResult { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); + let mut builder = pluto_tracing::TracingConfig::builder(); + builder = builder.with_default_console(); + builder.build(); let cmd = commands::test::update_test_cases_help(Cli::command()); let matches = cmd.get_matches(); From b19d3f2ba3099f25907c3cc756f45cfe6bdee2b0 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Mon, 30 Mar 2026 17:29:03 +0200 Subject: [PATCH 28/41] fmt --- crates/cli/src/commands/test/beacon.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 65421da6..e0d0704b 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -133,7 +133,7 @@ struct DutiesPerformed { struct SimParams { total_validators_count: u64, attestation_validators_count: u64, // attestation + aggregation - proposal_validators_count: u64, // attestation + aggregation + proposals + proposal_validators_count: u64, // attestation + aggregation + proposals sync_committee_validators_count: u64, // attestation + aggregation + proposals + sync committee request_intensity: RequestsIntensity, } @@ -1640,7 +1640,10 @@ fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> Simu tracing::warn!("Failed to convert duration length to u32"); u32::MAX }); - #[allow(clippy::arithmetic_side_effects, reason = "count is non-zero (early return above)")] + #[allow( + clippy::arithmetic_side_effects, + reason = "count is non-zero (early return above)" + )] let avg = sum / count; let all: Vec = durations.iter().map(|d| Duration::new(*d)).collect(); From be408eca4d679b343663c372fcd2228329677014 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 10:07:23 +0200 Subject: [PATCH 29/41] changed &str to impl AsRef --- crates/cli/src/commands/test/helpers.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index ba0cbdb9..90a61673 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -530,8 +530,11 @@ pub(crate) fn sort_tests(tests: &mut [TestCaseName]) { tests.sort_by_key(|t| t.order); } -pub(crate) fn must_output_to_file_on_quiet(quiet: bool, output_json: &str) -> CliResult<()> { - if quiet && output_json.is_empty() { +pub(crate) fn must_output_to_file_on_quiet( + quiet: bool, + output_json: impl AsRef, +) -> CliResult<()> { + if quiet && output_json.as_ref().is_empty() { Err(CliError::Other( "on --quiet, an --output-json is required".to_string(), )) From f7aea17e19dc7e42dced9b25de7ca90baf2fcd9a Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 10:23:27 +0200 Subject: [PATCH 30/41] removed parse_endpoint_url, percent_decode, apply_basic_auth --- crates/cli/src/commands/test/beacon.rs | 38 ++-------- crates/cli/src/commands/test/helpers.rs | 93 +------------------------ 2 files changed, 10 insertions(+), 121 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index e0d0704b..eb037192 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -11,9 +11,9 @@ use super::{ }, helpers::{ CategoryScore, TestCaseName, TestCategory, TestCategoryResult, TestResult, TestVerdict, - apply_basic_auth, calculate_score, evaluate_highest_rtt, evaluate_rtt, filter_tests, - must_output_to_file_on_quiet, parse_endpoint_url, publish_result_to_obol_api, request_rtt, - sort_tests, write_result_to_file, write_result_to_writer, + calculate_score, evaluate_highest_rtt, evaluate_rtt, filter_tests, + must_output_to_file_on_quiet, publish_result_to_obol_api, request_rtt, sort_tests, + write_result_to_file, write_result_to_writer, }, }; use crate::{duration::Duration, error::Result as CliResult}; @@ -429,15 +429,8 @@ async fn beacon_version_test( let mut res = TestResult::new("Version"); let url = format!("{target}/eth/v1/node/version"); - let (clean_url, credentials) = match parse_endpoint_url(&url) { - Ok(v) => v, - Err(e) => return res.fail(e), - }; let client = reqwest::Client::new(); - let resp = match apply_basic_auth(client.get(&clean_url), &credentials) - .send() - .await - { + let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => return res.fail(e), }; @@ -486,15 +479,8 @@ async fn beacon_is_synced_test( let mut res = TestResult::new("Synced"); let url = format!("{target}/eth/v1/node/syncing"); - let (clean_url, credentials) = match parse_endpoint_url(&url) { - Ok(v) => v, - Err(e) => return res.fail(e), - }; let client = reqwest::Client::new(); - let resp = match apply_basic_auth(client.get(&clean_url), &credentials) - .send() - .await - { + let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => return res.fail(e), }; @@ -538,15 +524,8 @@ async fn beacon_peer_count_test( let mut res = TestResult::new("PeerCount"); let url = format!("{target}/eth/v1/node/peers?state=connected"); - let (clean_url, credentials) = match parse_endpoint_url(&url) { - Ok(v) => v, - Err(e) => return res.fail(e), - }; let client = reqwest::Client::new(); - let resp = match apply_basic_auth(client.get(&clean_url), &credentials) - .send() - .await - { + let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => return res.fail(e), }; @@ -1571,11 +1550,8 @@ async fn sync_committee_message_duty( async fn get_current_slot(target: &str) -> CliResult { let url = format!("{target}/eth/v1/node/syncing"); - let (clean_url, credentials) = parse_endpoint_url(&url)?; let client = reqwest::Client::new(); - let resp = apply_basic_auth(client.get(&clean_url), &credentials) - .send() - .await?; + let resp = client.get(&url).send().await?; // More strict than the Charon check, which requires the status code to be > // 399. diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 90a61673..785a1f6a 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -570,43 +570,6 @@ pub(crate) fn hash_ssz(data: &[u8]) -> CliResult<[u8; 32]> { Ok(hasher.hash_root()?) } -fn percent_decode(s: &str) -> String { - percent_encoding::percent_decode_str(s) - .decode_utf8_lossy() - .into_owned() -} - -pub(crate) fn parse_endpoint_url(endpoint: &str) -> CliResult<(String, Option<(String, String)>)> { - let mut parsed = reqwest::Url::parse(endpoint) - .map_err(|e| CliError::Other(format!("parse endpoint URL: {e}")))?; - - if parsed.username().is_empty() { - return Ok((endpoint.to_string(), None)); - } - - let username = percent_decode(parsed.username()); - let password = percent_decode(parsed.password().unwrap_or_default()); - parsed.set_username("").map_err(|e| { - CliError::Other(format!("failed to clear username from endpoint URL: {e:?}")) - })?; - parsed.set_password(None).map_err(|e| { - CliError::Other(format!("failed to clear password from endpoint URL: {e:?}")) - })?; - - Ok((parsed.to_string(), Some((username, password)))) -} - -pub(crate) fn apply_basic_auth( - builder: reqwest::RequestBuilder, - credentials: &Option<(String, String)>, -) -> reqwest::RequestBuilder { - if let Some((username, password)) = credentials { - builder.basic_auth(username, Some(password)) - } else { - builder - } -} - /// Measures the round-trip time (RTT) for an HTTP request and logs a warning if /// the response status code doesn't match the expected status. pub(crate) async fn request_rtt( @@ -615,11 +578,9 @@ pub(crate) async fn request_rtt( body: Option>, expected_status: StatusCode, ) -> CliResult { - let (clean_url, credentials) = parse_endpoint_url(url.as_ref())?; let client = reqwest::Client::new(); - let mut request_builder = client.request(method, &clean_url); - request_builder = apply_basic_auth(request_builder, &credentials); + let mut request_builder = client.request(method, url.as_ref()); if let Some(body_bytes) = body { request_builder = request_builder @@ -644,14 +605,14 @@ pub(crate) async fn request_rtt( Ok(body) if !body.is_empty() => tracing::warn!( status_code = status.as_u16(), expected_status_code = expected_status.as_u16(), - endpoint = clean_url, + endpoint = url.as_ref(), body = body, "Unexpected status code" ), _ => tracing::warn!( status_code = status.as_u16(), expected_status_code = expected_status.as_u16(), - endpoint = clean_url, + endpoint = url.as_ref(), "Unexpected status code" ), } @@ -980,52 +941,4 @@ mod tests { assert_eq!(written, expected); } - - #[test] - fn test_parse_endpoint_url_without_auth() { - let (clean, creds) = parse_endpoint_url("https://beacon.example.com/path").unwrap(); - assert_eq!(clean, "https://beacon.example.com/path"); - assert!(creds.is_none()); - } - - #[test] - fn test_parse_endpoint_url_with_auth() { - let (clean, creds) = - parse_endpoint_url("https://user:pass@beacon.example.com/path").unwrap(); - assert_eq!(clean, "https://beacon.example.com/path"); - let (user, pass) = creds.unwrap(); - assert_eq!(user, "user"); - assert_eq!(pass, "pass"); - } - - #[test] - fn test_parse_endpoint_url_with_query_params() { - let (clean, creds) = - parse_endpoint_url("https://user:pass@beacon.example.com/path?query=value").unwrap(); - assert_eq!(clean, "https://beacon.example.com/path?query=value"); - let (user, pass) = creds.unwrap(); - assert_eq!(user, "user"); - assert_eq!(pass, "pass"); - } - - #[test] - fn test_parse_endpoint_url_http_with_port() { - let (clean, creds) = parse_endpoint_url("http://admin:secret@localhost:5051").unwrap(); - assert_eq!(clean, "http://localhost:5051/"); - let (user, pass) = creds.unwrap(); - assert_eq!(user, "admin"); - assert_eq!(pass, "secret"); - } - - #[test] - fn test_parse_endpoint_url_with_special_chars_in_password() { - // Go source uses "p@ss!123" as the raw password; in Rust's url crate the '@' - // must be percent-encoded as "p%40ss!123" to be unambiguously parsed. - let (clean, creds) = - parse_endpoint_url("https://user:p%40ss!123@beacon.example.com").unwrap(); - assert_eq!(clean, "https://beacon.example.com/"); - let (user, pass) = creds.unwrap(); - assert_eq!(user, "user"); - assert_eq!(pass, "p@ss!123"); - } } From 9cac53229768e983f7deeb98bab0082d9e27b6d1 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 10:42:47 +0200 Subject: [PATCH 31/41] shadowing variables instead of renaming --- crates/cli/src/commands/test/beacon.rs | 68 +++++++++++++------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index eb037192..0c0e7ef8 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -301,13 +301,13 @@ pub async fn run( for endpoint in &args.endpoints { let queued = queued.clone(); - let cfg = args.clone(); - let target = endpoint.clone(); - let cancel = shutdown.clone(); + let args = args.clone(); + let endpoint = endpoint.clone(); + let shutdown = shutdown.clone(); set.spawn(async move { - let results = test_single_beacon(cancel, &queued, cfg, &target).await; - (target, results) + let results = test_single_beacon(shutdown, &queued, args, &endpoint).await; + (endpoint, results) }); } @@ -619,11 +619,11 @@ async fn beacon_ping_load_test( tokio::select! { _ = cancel.cancelled() => break, _ = interval.tick() => { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); let tx = tx.clone(); handles.push(tokio::spawn(async move { - ping_beacon_continuously(c, t, tx).await; + ping_beacon_continuously(cancel, target, tx).await; })); } } @@ -836,11 +836,11 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal, sync committee..." ); for _ in 0..params.sync_committee_validators_count { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { - single_validator_simulation(c, sim_duration, &t, intensity, sync_duties).await + single_validator_simulation(cancel, sim_duration, &target, intensity, sync_duties).await })); } @@ -855,11 +855,12 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal..." ); for _ in 0..params.proposal_validators_count { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { - single_validator_simulation(c, sim_duration, &t, intensity, proposal_duties).await + single_validator_simulation(cancel, sim_duration, &target, intensity, proposal_duties) + .await })); } @@ -874,11 +875,12 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation..." ); for _ in 0..params.attestation_validators_count { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { - single_validator_simulation(c, sim_duration, &t, intensity, attester_duties).await + single_validator_simulation(cancel, sim_duration, &target, intensity, attester_duties) + .await })); } @@ -1096,12 +1098,12 @@ async fn single_validator_simulation( let (att_get_tx, mut att_get_rx) = mpsc::channel(256); let (att_sub_tx, mut att_sub_rx) = mpsc::channel(256); if duties.attestation { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); tokio::spawn(async move { attestation_duty( - c, - &t, + cancel, + &target, sim_duration, intensity.attestation_duty, att_get_tx, @@ -1118,12 +1120,12 @@ async fn single_validator_simulation( let (agg_get_tx, mut agg_get_rx) = mpsc::channel(256); let (agg_sub_tx, mut agg_sub_rx) = mpsc::channel(256); if duties.aggregation { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); tokio::spawn(async move { aggregation_duty( - c, - &t, + cancel, + &target, sim_duration, intensity.aggregator_duty, agg_get_tx, @@ -1140,12 +1142,12 @@ async fn single_validator_simulation( let (prop_produce_tx, mut prop_produce_rx) = mpsc::channel(256); let (prop_publish_tx, mut prop_publish_rx) = mpsc::channel(256); if duties.proposal { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); tokio::spawn(async move { proposal_duty( - c, - &t, + cancel, + &target, sim_duration, intensity.proposal_duty, prop_produce_tx, @@ -1164,12 +1166,12 @@ async fn single_validator_simulation( let (sc_produce_tx, mut sc_produce_rx) = mpsc::channel(256); let (sc_contrib_tx, mut sc_contrib_rx) = mpsc::channel(256); if duties.sync_committee { - let c = cancel.clone(); - let t = target.to_string(); + let cancel = cancel.clone(); + let target = target.to_string(); tokio::spawn(async move { sync_committee_duties( - c, - &t, + cancel, + &target, sim_duration, intensity.sync_committee_submit, intensity.sync_committee_subscribe, From 66c4099c21ff78ac190bb72671244fded0c56c07 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 11:17:07 +0200 Subject: [PATCH 32/41] impl AsRef instead of `&` --- crates/cli/src/commands/test/beacon.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 0c0e7ef8..cb155b6c 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -248,9 +248,11 @@ pub fn test_case_names() -> Vec { async fn run_test_case( cancel: CancellationToken, cfg: TestBeaconArgs, - target: &str, - name: &str, + target: impl AsRef, + name: impl AsRef, ) -> TestResult { + let target = target.as_ref(); + let name = name.as_ref(); match name { "Ping" => beacon_ping_test(cancel, cfg, target).await, "PingMeasure" => beacon_ping_measure_test(cancel, cfg, target).await, @@ -306,7 +308,7 @@ pub async fn run( let shutdown = shutdown.clone(); set.spawn(async move { - let results = test_single_beacon(shutdown, &queued, args, &endpoint).await; + let results = test_single_beacon(&args, &queued, &endpoint, shutdown).await; (endpoint, results) }); } @@ -361,14 +363,14 @@ pub async fn run( } async fn test_single_beacon( + cfg: &TestBeaconArgs, + queued: impl AsRef<[TestCaseName]>, + target: impl AsRef, cancel: CancellationToken, - queued: &[TestCaseName], - cfg: TestBeaconArgs, - target: &str, ) -> Vec { let mut results = Vec::new(); - for tc in queued { + for tc in queued.as_ref() { if cancel.is_cancelled() { results.push(TestResult { name: tc.name.to_string(), @@ -379,7 +381,7 @@ async fn test_single_beacon( break; } - let result = run_test_case(cancel.clone(), cfg.clone(), target, tc.name).await; + let result = run_test_case(cancel.clone(), cfg.clone(), target.as_ref(), tc.name).await; results.push(result); } From 6f440b6d4017491450fb759eef5d5c622fa4be87 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 11:49:03 +0200 Subject: [PATCH 33/41] join set --- crates/cli/src/commands/test/beacon.rs | 42 ++++++++------------------ 1 file changed, 13 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index cb155b6c..7c951b01 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -572,25 +572,19 @@ async fn beacon_ping_once(target: &str) -> CliResult { request_rtt(&url, Method::GET, None, reqwest::StatusCode::OK).await } -async fn ping_beacon_continuously( - cancel: CancellationToken, - target: String, - tx: mpsc::Sender, -) { +async fn ping_beacon_continuously(cancel: CancellationToken, target: String) -> Vec { + let mut rtts = Vec::new(); loop { let Ok(rtt) = beacon_ping_once(&target).await else { - return; + return rtts; }; + rtts.push(rtt); + + let jitter = rand::thread_rng().gen_range(0..100u64); tokio::select! { - _ = cancel.cancelled() => return, - r = tx.send(rtt) => { - if r.is_err() { - return; - } - let jitter = rand::thread_rng().gen_range(0..100u64); - sleep(StdDuration::from_millis(jitter)).await; - } + _ = cancel.cancelled() => return rtts, + _ = sleep(StdDuration::from_millis(jitter)) => {} } } } @@ -611,10 +605,9 @@ async fn beacon_ping_load_test( "Running ping load tests..." ); - let (tx, mut rx) = mpsc::channel::(i16::MAX as usize); cancel_after(&cancel, cfg.load_test_duration); - let mut handles = Vec::new(); + let mut set = JoinSet::new(); let mut interval = interval(StdDuration::from_secs(1)); loop { @@ -623,23 +616,14 @@ async fn beacon_ping_load_test( _ = interval.tick() => { let cancel = cancel.clone(); let target = target.to_string(); - let tx = tx.clone(); - handles.push(tokio::spawn(async move { - ping_beacon_continuously(cancel, target, tx).await; - })); + set.spawn(async move { + ping_beacon_continuously(cancel, target).await + }); } } } - drop(tx); - for h in handles { - let _ = h.await; - } - - let mut rtts = Vec::new(); - while let Some(rtt) = rx.recv().await { - rtts.push(rtt); - } + let rtts: Vec = set.join_all().await.into_iter().flatten().collect(); tracing::info!(target = %target, "Ping load tests finished"); From 6f73b5439f82998f43f3f900a8f815678a64217f Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 15:33:23 +0200 Subject: [PATCH 34/41] cancelation fixes, more Join Sets usage --- 1-validators.json | 1 + 10-validators.json | 1 + 100-validators.json | 1 + 1000-validators.json | 1 + 5-validators.json | 1 + 500-validators.json | 1 + crates/cli/src/commands/test/beacon.rs | 290 ++++++++++++++---------- crates/cli/src/commands/test/helpers.rs | 34 +-- crates/cli/src/main.rs | 7 +- test-infra/kurtosis-params.yaml | 15 ++ 10 files changed, 219 insertions(+), 133 deletions(-) create mode 100644 1-validators.json create mode 100644 10-validators.json create mode 100644 100-validators.json create mode 100644 1000-validators.json create mode 100644 5-validators.json create mode 100644 500-validators.json create mode 100644 test-infra/kurtosis-params.yaml diff --git a/1-validators.json b/1-validators.json new file mode 100644 index 00000000..8ce6e8d9 --- /dev/null +++ b/1-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.300437ms","max":"1.325384ms","median":"1.325384ms","avg":"1.31291ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.18009ms","max":"1.484803ms","median":"1.484803ms","avg":"1.332446ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.592055ms","max":"1.665664ms","median":"1.665664ms","avg":"1.628859ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"1.505151ms","max":"1.505151ms","median":"1.505151ms","avg":"1.505151ms"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"1.133843ms","max":"3.255583ms","median":"2.932225ms","avg":"2.175416ms","attestation_duty":{"min":"2.932225ms","max":"3.255583ms","median":"3.054496ms","avg":"3.080768ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.51544ms","max":"1.745443ms","median":"1.592516ms","avg":"1.617799ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"1.186782ms","max":"1.740143ms","median":"1.46198ms","avg":"1.462968ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.327897ms","max":"1.552059ms","median":"1.552059ms","avg":"1.439978ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"1.133843ms","max":"1.465496ms","median":"1.210858ms","avg":"1.270065ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"1.133843ms","max":"1.465496ms","median":"1.210858ms","avg":"1.270065ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/10-validators.json b/10-validators.json new file mode 100644 index 00000000..9e92c27e --- /dev/null +++ b/10-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.567537ms","max":"2.25033ms","median":"2.25033ms","avg":"1.908933ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.961307ms","max":"2.051517ms","median":"2.051517ms","avg":"2.006412ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.70756ms","max":"1.73402ms","median":"1.73402ms","avg":"1.72079ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"2.129593ms","max":"77.137333ms","median":"4.4363ms","avg":"12.097771ms","attestation_duty":{"min":"2.546757ms","max":"77.137333ms","median":"4.442362ms","avg":"12.166839ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.099958ms","max":"65.727033ms","median":"3.12753ms","avg":"9.445979ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"875.365µs","max":"26.882427ms","median":"1.540496ms","avg":"2.72086ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.071154ms","max":"4.813499ms","median":"1.548171ms","avg":"1.861265ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"2.129593ms","max":"29.207609ms","median":"2.884082ms","avg":"11.407094ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"2.129593ms","max":"29.207609ms","median":"2.884082ms","avg":"11.407094ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"1.676061ms","max":"1.676061ms","median":"1.676061ms","avg":"1.676061ms"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/100-validators.json b/100-validators.json new file mode 100644 index 00000000..14d1ca9c --- /dev/null +++ b/100-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"109.096048ms","max":"486.517478ms","median":"486.517478ms","avg":"297.806763ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"898.03µs","max":"142.757209ms","median":"142.757209ms","avg":"71.827619ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.021552ms","max":"1.096433ms","median":"1.096433ms","avg":"1.058992ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"965.627µs","max":"965.627µs","median":"965.627µs","avg":"965.627µs"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/1000-validators.json b/1000-validators.json new file mode 100644 index 00000000..6bf4649d --- /dev/null +++ b/1000-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"7.261419933s","max":"7.261419933s","median":"7.261419933s","avg":"7.261419933s"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.046480723s","max":"1.046480723s","median":"1.046480723s","avg":"1.046480723s"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.203265ms","max":"4.856420573s","median":"4.856420573s","avg":"2.428811919s"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"461.490808ms","max":"11.076962925s","median":"6.17034467s","avg":"5.660866134s"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/5-validators.json b/5-validators.json new file mode 100644 index 00000000..56531931 --- /dev/null +++ b/5-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.547149ms","max":"1.929989ms","median":"1.929989ms","avg":"1.738569ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.425029ms","max":"1.477127ms","median":"1.477127ms","avg":"1.451078ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.645874ms","max":"1.796427ms","median":"1.796427ms","avg":"1.72115ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"1.066174ms","max":"3.10653ms","median":"2.926622ms","avg":"2.672526ms","attestation_duty":{"min":"2.363654ms","max":"3.10653ms","median":"2.956618ms","avg":"2.87212ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.212489ms","max":"1.82986ms","median":"1.475063ms","avg":"1.501039ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"1.050595ms","max":"1.602804ms","median":"1.391816ms","avg":"1.37108ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.32953ms","max":"2.768995ms","median":"1.46851ms","avg":"1.604438ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"1.066174ms","max":"2.508065ms","median":"1.449425ms","avg":"1.674554ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"1.066174ms","max":"2.508065ms","median":"1.449425ms","avg":"1.674554ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/500-validators.json b/500-validators.json new file mode 100644 index 00000000..b3591a1b --- /dev/null +++ b/500-validators.json @@ -0,0 +1 @@ +{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"930.984293ms","max":"930.984293ms","median":"930.984293ms","avg":"930.984293ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.450179479s","max":"1.450179479s","median":"1.450179479s","avg":"1.450179479s"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"991.405µs","max":"386.167233ms","median":"386.167233ms","avg":"193.579319ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"653.75µs","max":"3.459796007s","median":"946.913378ms","avg":"1.204859006s"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 7c951b01..0d98bd3a 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -575,7 +575,7 @@ async fn beacon_ping_once(target: &str) -> CliResult { async fn ping_beacon_continuously(cancel: CancellationToken, target: String) -> Vec { let mut rtts = Vec::new(); loop { - let Ok(rtt) = beacon_ping_once(&target).await else { + let Some(Ok(rtt)) = cancel.run_until_cancelled(beacon_ping_once(&target)).await else { return rtts; }; @@ -605,16 +605,17 @@ async fn beacon_ping_load_test( "Running ping load tests..." ); - cancel_after(&cancel, cfg.load_test_duration); + let load_cancel = cancel.child_token(); + cancel_after(&load_cancel, cfg.load_test_duration); let mut set = JoinSet::new(); let mut interval = interval(StdDuration::from_secs(1)); loop { tokio::select! { - _ = cancel.cancelled() => break, + _ = load_cancel.cancelled() => break, _ = interval.tick() => { - let cancel = cancel.clone(); + let cancel = load_cancel.clone(); let target = target.to_string(); set.spawn(async move { ping_beacon_continuously(cancel, target).await @@ -798,11 +799,12 @@ async fn beacon_simulation_test( "Running beacon node simulation..." ); - cancel_after(&cancel, sim_duration); + let sim_cancel = cancel.child_token(); + cancel_after(&sim_cancel, sim_duration); // General cluster requests tracing::info!("Starting general cluster requests..."); - let cluster_cancel = cancel.clone(); + let cluster_cancel = sim_cancel.clone(); let cluster_target = target.to_string(); let cluster_handle = tokio::spawn(async move { single_cluster_simulation(cluster_cancel, sim_duration, &cluster_target).await @@ -822,7 +824,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal, sync committee..." ); for _ in 0..params.sync_committee_validators_count { - let cancel = cancel.clone(); + let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { @@ -841,7 +843,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation, proposal..." ); for _ in 0..params.proposal_validators_count { - let cancel = cancel.clone(); + let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { @@ -861,7 +863,7 @@ async fn beacon_simulation_test( "Starting validators performing duties attestation, aggregation..." ); for _ in 0..params.attestation_validators_count { - let cancel = cancel.clone(); + let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; validator_handles.push(tokio::spawn(async move { @@ -1069,82 +1071,43 @@ async fn single_validator_simulation( intensity: RequestsIntensity, duties: DutiesPerformed, ) -> SimulationSingleValidator { - let mut get_attestation_data_all = Vec::new(); - let mut submit_attestation_object_all = Vec::new(); - let mut get_aggregate_attestations_all = Vec::new(); - let mut submit_aggregate_and_proofs_all = Vec::new(); - let mut produce_block_all = Vec::new(); - let mut publish_blinded_block_all = Vec::new(); let mut sync_committee_subscription_all = Vec::new(); let mut submit_sync_committee_message_all = Vec::new(); let mut produce_sync_committee_contribution_all = Vec::new(); let mut submit_sync_committee_contribution_all = Vec::new(); // Attestation duty - let (att_get_tx, mut att_get_rx) = mpsc::channel(256); - let (att_sub_tx, mut att_sub_rx) = mpsc::channel(256); - if duties.attestation { + let att_handle = if duties.attestation { let cancel = cancel.clone(); let target = target.to_string(); - tokio::spawn(async move { - attestation_duty( - cancel, - &target, - sim_duration, - intensity.attestation_duty, - att_get_tx, - att_sub_tx, - ) - .await; - }); + Some(tokio::spawn(async move { + attestation_duty(cancel, &target, sim_duration, intensity.attestation_duty).await + })) } else { - drop(att_get_tx); - drop(att_sub_tx); - } + None + }; // Aggregation duty - let (agg_get_tx, mut agg_get_rx) = mpsc::channel(256); - let (agg_sub_tx, mut agg_sub_rx) = mpsc::channel(256); - if duties.aggregation { + let agg_handle = if duties.aggregation { let cancel = cancel.clone(); let target = target.to_string(); - tokio::spawn(async move { - aggregation_duty( - cancel, - &target, - sim_duration, - intensity.aggregator_duty, - agg_get_tx, - agg_sub_tx, - ) - .await; - }); + Some(tokio::spawn(async move { + aggregation_duty(cancel, &target, sim_duration, intensity.aggregator_duty).await + })) } else { - drop(agg_get_tx); - drop(agg_sub_tx); - } + None + }; // Proposal duty - let (prop_produce_tx, mut prop_produce_rx) = mpsc::channel(256); - let (prop_publish_tx, mut prop_publish_rx) = mpsc::channel(256); - if duties.proposal { + let prop_handle = if duties.proposal { let cancel = cancel.clone(); let target = target.to_string(); - tokio::spawn(async move { - proposal_duty( - cancel, - &target, - sim_duration, - intensity.proposal_duty, - prop_produce_tx, - prop_publish_tx, - ) - .await; - }); + Some(tokio::spawn(async move { + proposal_duty(cancel, &target, sim_duration, intensity.proposal_duty).await + })) } else { - drop(prop_produce_tx); - drop(prop_publish_tx); - } + None + }; // Sync committee duties let (sc_sub_tx, mut sc_sub_rx) = mpsc::channel(256); @@ -1176,16 +1139,13 @@ async fn single_validator_simulation( drop(sc_contrib_tx); } - // Collect results from all channels + // Collect results from sync committee channels + let sc_deadline = sim_deadline(sim_duration); loop { tokio::select! { biased; - Some(v) = att_get_rx.recv() => get_attestation_data_all.push(v), - Some(v) = att_sub_rx.recv() => submit_attestation_object_all.push(v), - Some(v) = agg_get_rx.recv() => get_aggregate_attestations_all.push(v), - Some(v) = agg_sub_rx.recv() => submit_aggregate_and_proofs_all.push(v), - Some(v) = prop_produce_rx.recv() => produce_block_all.push(v), - Some(v) = prop_publish_rx.recv() => publish_blinded_block_all.push(v), + _ = cancel.cancelled() => break, + _ = sleep_until(sc_deadline) => break, Some(v) = sc_sub_rx.recv() => sync_committee_subscription_all.push(v), Some(v) = sc_msg_rx.recv() => submit_sync_committee_message_all.push(v), Some(v) = sc_produce_rx.recv() => produce_sync_committee_contribution_all.push(v), @@ -1194,6 +1154,19 @@ async fn single_validator_simulation( } } + let (get_attestation_data_all, submit_attestation_object_all) = match att_handle { + Some(h) => h.await.unwrap_or_default(), + None => (Vec::new(), Vec::new()), + }; + let (get_aggregate_attestations_all, submit_aggregate_and_proofs_all) = match agg_handle { + Some(h) => h.await.unwrap_or_default(), + None => (Vec::new(), Vec::new()), + }; + let (produce_block_all, publish_blinded_block_all) = match prop_handle { + Some(h) => h.await.unwrap_or_default(), + None => (Vec::new(), Vec::new()), + }; + let mut all_requests = Vec::new(); // Attestation results @@ -1299,14 +1272,24 @@ async fn attestation_duty( target: &str, sim_duration: StdDuration, tick_time: StdDuration, - get_tx: mpsc::Sender, - submit_tx: mpsc::Sender, -) { +) -> (Vec, Vec) { + let mut get_all = Vec::new(); + let mut submit_all = Vec::new(); let deadline = sim_deadline(sim_duration); - sleep(randomize_start(tick_time)).await; + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time))) + .await + .is_none() + { + return Default::default(); + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time, tick_time); - let mut slot = get_current_slot(target).await.unwrap_or(1); + let mut slot = cancel + .run_until_cancelled(get_current_slot(target)) + .await + .and_then(|r| r.ok()) + .unwrap_or(1); loop { if cancel.is_cancelled() || Instant::now() >= deadline { @@ -1314,11 +1297,17 @@ async fn attestation_duty( } let committee_index = rand::thread_rng().gen_range(0..COMMITTEE_SIZE_PER_SLOT); - if let Ok(rtt) = req_get_attestation_data(target, slot, committee_index).await { - let _ = get_tx.send(rtt).await; + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_get_attestation_data(target, slot, committee_index)) + .await + { + get_all.push(rtt); } - if let Ok(rtt) = req_submit_attestation_object(target).await { - let _ = submit_tx.send(rtt).await; + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_submit_attestation_object(target)) + .await + { + submit_all.push(rtt); } tokio::select! { @@ -1329,6 +1318,8 @@ async fn attestation_duty( } } } + + (get_all, submit_all) } async fn aggregation_duty( @@ -1336,12 +1327,22 @@ async fn aggregation_duty( target: &str, sim_duration: StdDuration, tick_time: StdDuration, - get_tx: mpsc::Sender, - submit_tx: mpsc::Sender, -) { +) -> (Vec, Vec) { + let mut get_all = Vec::new(); + let mut submit_all = Vec::new(); let deadline = sim_deadline(sim_duration); - let mut slot = get_current_slot(target).await.unwrap_or(1); - sleep(randomize_start(tick_time)).await; + let mut slot = cancel + .run_until_cancelled(get_current_slot(target)) + .await + .and_then(|r| r.ok()) + .unwrap_or(1); + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time))) + .await + .is_none() + { + return Default::default(); + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time, tick_time); @@ -1350,17 +1351,21 @@ async fn aggregation_duty( break; } - if let Ok(rtt) = req_get_aggregate_attestations( - target, - slot, - "0x87db5c50a4586fa37662cf332382d56a0eeea688a7d7311a42735683dfdcbfa4", - ) - .await + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_get_aggregate_attestations( + target, + slot, + "0x87db5c50a4586fa37662cf332382d56a0eeea688a7d7311a42735683dfdcbfa4", + )) + .await { - let _ = get_tx.send(rtt).await; + get_all.push(rtt); } - if let Ok(rtt) = req_post_aggregate_and_proofs(target).await { - let _ = submit_tx.send(rtt).await; + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_post_aggregate_and_proofs(target)) + .await + { + submit_all.push(rtt); } tokio::select! { @@ -1371,6 +1376,8 @@ async fn aggregation_duty( } } } + + (get_all, submit_all) } async fn proposal_duty( @@ -1378,14 +1385,24 @@ async fn proposal_duty( target: &str, sim_duration: StdDuration, tick_time: StdDuration, - produce_tx: mpsc::Sender, - publish_tx: mpsc::Sender, -) { +) -> (Vec, Vec) { + let mut produce_all = Vec::new(); + let mut publish_all = Vec::new(); let deadline = sim_deadline(sim_duration); - sleep(randomize_start(tick_time)).await; + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time))) + .await + .is_none() + { + return Default::default(); + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time, tick_time); - let mut slot = get_current_slot(target).await.unwrap_or(1); + let mut slot = cancel + .run_until_cancelled(get_current_slot(target)) + .await + .and_then(|r| r.ok()) + .unwrap_or(1); let randao = "0x1fe79e4193450abda94aec753895cfb2aac2c2a930b6bab00fbb27ef6f4a69f4400ad67b5255b91837982b4c511ae1d94eae1cf169e20c11bd417c1fffdb1f99f4e13e2de68f3b5e73f1de677d73cd43e44bf9b133a79caf8e5fad06738e1b0c"; loop { @@ -1393,11 +1410,17 @@ async fn proposal_duty( break; } - if let Ok(rtt) = req_produce_block(target, slot, randao).await { - let _ = produce_tx.send(rtt).await; + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_produce_block(target, slot, randao)) + .await + { + produce_all.push(rtt); } - if let Ok(rtt) = req_publish_blinded_block(target).await { - let _ = publish_tx.send(rtt).await; + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_publish_blinded_block(target)) + .await + { + publish_all.push(rtt); } tokio::select! { @@ -1408,6 +1431,8 @@ async fn proposal_duty( } } } + + (produce_all, publish_all) } #[allow(clippy::too_many_arguments)] @@ -1445,7 +1470,13 @@ async fn sync_committee_duties( // Subscribe loop let deadline = sim_deadline(sim_duration); - sleep(randomize_start(tick_time_subscribe)).await; + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time_subscribe))) + .await + .is_none() + { + return; + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time_subscribe, tick_time_subscribe); @@ -1454,7 +1485,10 @@ async fn sync_committee_duties( break; } - if let Ok(rtt) = req_sync_committee_subscription(target).await { + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_sync_committee_subscription(target)) + .await + { let _ = sub_tx.send(rtt).await; } @@ -1475,10 +1509,20 @@ async fn sync_committee_contribution_duty( contrib_tx: mpsc::Sender, ) { let deadline = sim_deadline(sim_duration); - sleep(randomize_start(tick_time)).await; + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time))) + .await + .is_none() + { + return; + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time, tick_time); - let mut slot = get_current_slot(target).await.unwrap_or(1); + let mut slot = cancel + .run_until_cancelled(get_current_slot(target)) + .await + .and_then(|r| r.ok()) + .unwrap_or(1); loop { if cancel.is_cancelled() || Instant::now() >= deadline { @@ -1488,12 +1532,21 @@ async fn sync_committee_contribution_duty( let sub_idx = rand::thread_rng().gen_range(0..SUB_COMMITTEE_SIZE); let beacon_block_root = "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"; - if let Ok(rtt) = - req_produce_sync_committee_contribution(target, slot, sub_idx, beacon_block_root).await + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_produce_sync_committee_contribution( + target, + slot, + sub_idx, + beacon_block_root, + )) + .await { let _ = produce_tx.send(rtt).await; } - if let Ok(rtt) = req_submit_sync_committee_contribution(target).await { + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_submit_sync_committee_contribution(target)) + .await + { let _ = contrib_tx.send(rtt).await; } @@ -1515,7 +1568,13 @@ async fn sync_committee_message_duty( msg_tx: mpsc::Sender, ) { let deadline = sim_deadline(sim_duration); - sleep(randomize_start(tick_time)).await; + if cancel + .run_until_cancelled(sleep(randomize_start(tick_time))) + .await + .is_none() + { + return; + } #[allow(clippy::arithmetic_side_effects)] let mut interval = interval_at(Instant::now() + tick_time, tick_time); @@ -1524,7 +1583,10 @@ async fn sync_committee_message_duty( break; } - if let Ok(rtt) = req_submit_sync_committee(target).await { + if let Some(Ok(rtt)) = cancel + .run_until_cancelled(req_submit_sync_committee(target)) + .await + { let _ = msg_tx.send(rtt).await; } diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 785a1f6a..106f5719 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -593,6 +593,10 @@ pub(crate) async fn request_rtt( let rtt = start.elapsed(); let status = response.status(); + if status == expected_status { + return Ok(rtt); + } + if status.as_u16() > 399 { return Err(CliError::Other(format!( "HTTP status code {}", @@ -600,22 +604,20 @@ pub(crate) async fn request_rtt( ))); } - if status != expected_status { - match response.text().await { - Ok(body) if !body.is_empty() => tracing::warn!( - status_code = status.as_u16(), - expected_status_code = expected_status.as_u16(), - endpoint = url.as_ref(), - body = body, - "Unexpected status code" - ), - _ => tracing::warn!( - status_code = status.as_u16(), - expected_status_code = expected_status.as_u16(), - endpoint = url.as_ref(), - "Unexpected status code" - ), - } + match response.text().await { + Ok(body) if !body.is_empty() => tracing::warn!( + status_code = status.as_u16(), + expected_status_code = expected_status.as_u16(), + endpoint = url.as_ref(), + body = body, + "Unexpected status code" + ), + _ => tracing::warn!( + status_code = status.as_u16(), + expected_status_code = expected_status.as_u16(), + endpoint = url.as_ref(), + "Unexpected status code" + ), } Ok(rtt) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 443549c7..79c35e5c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -19,9 +19,10 @@ use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> ExitResult { - let mut builder = pluto_tracing::TracingConfig::builder(); - builder = builder.with_default_console(); - builder.build(); + let config = pluto_tracing::TracingConfig::builder() + .with_default_console() + .build(); + let _ = pluto_tracing::init(&config); let cmd = commands::test::update_test_cases_help(Cli::command()); let matches = cmd.get_matches(); diff --git a/test-infra/kurtosis-params.yaml b/test-infra/kurtosis-params.yaml new file mode 100644 index 00000000..3b9db2d9 --- /dev/null +++ b/test-infra/kurtosis-params.yaml @@ -0,0 +1,15 @@ +participants: + - el_type: nethermind + cl_type: lighthouse + count: 1 + +network_params: + preset: mainnet + +port_publisher: + cl: + enabled: true + public_port_start: 5052 + el: + enabled: true + public_port_start: 32000 From 6162ad6fbab5cd367e6863ab32ae0714b2f70019 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 15:59:17 +0200 Subject: [PATCH 35/41] improved returning failed test --- crates/cli/src/commands/test/beacon.rs | 35 +++++++++++--------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 0d98bd3a..da8e08d9 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -10,10 +10,10 @@ use super::{ SUB_COMMITTEE_SIZE, }, helpers::{ - CategoryScore, TestCaseName, TestCategory, TestCategoryResult, TestResult, TestVerdict, - calculate_score, evaluate_highest_rtt, evaluate_rtt, filter_tests, - must_output_to_file_on_quiet, publish_result_to_obol_api, request_rtt, sort_tests, - write_result_to_file, write_result_to_writer, + AllCategoriesResult, CategoryScore, TestCaseName, TestCategory, TestCategoryResult, + TestResult, TestResultError, TestVerdict, calculate_score, evaluate_highest_rtt, + evaluate_rtt, filter_tests, must_output_to_file_on_quiet, publish_result_to_obol_api, + request_rtt, sort_tests, write_result_to_file, write_result_to_writer, }, }; use crate::{duration::Duration, error::Result as CliResult}; @@ -266,12 +266,9 @@ async fn run_test_case( "Simulate500" => beacon_simulation_500_test(cancel, cfg, target).await, "Simulate1000" => beacon_simulation_1000_test(cancel, cfg, target).await, "SimulateCustom" => beacon_simulation_custom_test(cancel, cfg, target).await, - _ => { - let mut res = TestResult::new(name); - res.verdict = TestVerdict::Fail; - res.error = super::TestResultError::from_string(format!("unknown test case: {name}")); - res - } + _ => TestResult::new(name).fail(TestResultError::from_string(format!( + "unknown test case: {name}" + ))), } } @@ -347,7 +344,7 @@ pub async fn run( } if args.test_config.publish { - let all = super::AllCategoriesResult { + let all = AllCategoriesResult { beacon: Some(res.clone()), ..Default::default() }; @@ -372,12 +369,10 @@ async fn test_single_beacon( for tc in queued.as_ref() { if cancel.is_cancelled() { - results.push(TestResult { - name: tc.name.to_string(), - verdict: TestVerdict::Fail, - error: super::TestResultError::from_string("timeout/interrupted"), - ..TestResult::new(tc.name) - }); + results.push( + TestResult::new(tc.name.to_string()) + .fail(TestResultError::from_string("timeout/interrupted")), + ); break; } @@ -440,7 +435,7 @@ async fn beacon_version_test( // More strict than the Charon check, which requires the status code to be > // 399. if !resp.status().is_success() { - return res.fail(super::TestResultError::from_string(format!( + return res.fail(TestResultError::from_string(format!( "http status {}", resp.status().as_u16() ))); @@ -490,7 +485,7 @@ async fn beacon_is_synced_test( // More strict than the Charon check, which requires the status code to be > // 399. if !resp.status().is_success() { - return res.fail(super::TestResultError::from_string(format!( + return res.fail(TestResultError::from_string(format!( "http status {}", resp.status().as_u16() ))); @@ -535,7 +530,7 @@ async fn beacon_peer_count_test( // More strict than the Charon check, which requires the status code to be > // 399. if !resp.status().is_success() { - return res.fail(super::TestResultError::from_string(format!( + return res.fail(TestResultError::from_string(format!( "http status {}", resp.status().as_u16() ))); From aa03a2027f564d5271cb9cbc71a3ba15c8d1aeab Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Tue, 31 Mar 2026 17:13:05 +0200 Subject: [PATCH 36/41] review cont. --- crates/cli/src/commands/test/beacon.rs | 87 +++++++++++++++---------- crates/cli/src/commands/test/helpers.rs | 11 ++++ 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index da8e08d9..6c3309db 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -121,6 +121,19 @@ struct RequestsIntensity { sync_committee_subscribe: StdDuration, } +impl Default for RequestsIntensity { + fn default() -> Self { + Self { + attestation_duty: SLOT_TIME, + aggregator_duty: SLOT_TIME.saturating_mul(2), + proposal_duty: SLOT_TIME.saturating_mul(4), + sync_committee_submit: SLOT_TIME, + sync_committee_contribution: SLOT_TIME.saturating_mul(4), + sync_committee_subscribe: EPOCH_TIME, + } + } +} + #[derive(Debug, Clone, Copy)] struct DutiesPerformed { attestation: bool, @@ -384,19 +397,28 @@ async fn test_single_beacon( } async fn beacon_ping_test( - _cancel: CancellationToken, + cancel: CancellationToken, _cfg: TestBeaconArgs, target: &str, ) -> TestResult { let mut res = TestResult::new("Ping"); let url = format!("{target}/eth/v1/node/health"); - match request_rtt(&url, Method::GET, None, reqwest::StatusCode::OK).await { - Ok(_) => { + match cancel + .run_until_cancelled(request_rtt( + &url, + Method::GET, + None, + reqwest::StatusCode::OK, + )) + .await + { + Some(Ok(_)) => { res.verdict = TestVerdict::Ok; res } - Err(e) => res.fail(e), + Some(Err(e)) => res.fail(e), + None => res.fail(TestResultError::from_string("timeout/interrupted")), } } @@ -590,7 +612,7 @@ async fn beacon_ping_load_test( target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("PingLoad"); + return TestResult::skip("PingLoad"); } let res = TestResult::new("PingLoad"); @@ -631,24 +653,13 @@ async fn beacon_ping_load_test( ) } -fn default_intensity() -> RequestsIntensity { - RequestsIntensity { - attestation_duty: SLOT_TIME, - aggregator_duty: SLOT_TIME.saturating_mul(2), - proposal_duty: SLOT_TIME.saturating_mul(4), - sync_committee_submit: SLOT_TIME, - sync_committee_contribution: SLOT_TIME.saturating_mul(4), - sync_committee_subscribe: EPOCH_TIME, - } -} - async fn beacon_simulation_1_test( cancel: CancellationToken, cfg: TestBeaconArgs, target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("Simulate1"); + return TestResult::skip("Simulate1"); } let res = TestResult::new("Simulate1"); let params = SimParams { @@ -656,7 +667,7 @@ async fn beacon_simulation_1_test( attestation_validators_count: 0, proposal_validators_count: 0, sync_committee_validators_count: 1, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -667,7 +678,7 @@ async fn beacon_simulation_10_test( target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("Simulate10"); + return TestResult::skip("Simulate10"); } let res = TestResult::new("Simulate10"); let params = SimParams { @@ -675,7 +686,7 @@ async fn beacon_simulation_10_test( attestation_validators_count: 6, proposal_validators_count: 3, sync_committee_validators_count: 1, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -686,7 +697,7 @@ async fn beacon_simulation_100_test( target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("Simulate100"); + return TestResult::skip("Simulate100"); } let res = TestResult::new("Simulate100"); let params = SimParams { @@ -694,7 +705,7 @@ async fn beacon_simulation_100_test( attestation_validators_count: 80, proposal_validators_count: 18, sync_committee_validators_count: 2, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -705,7 +716,7 @@ async fn beacon_simulation_500_test( target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("Simulate500"); + return TestResult::skip("Simulate500"); } let res = TestResult::new("Simulate500"); let params = SimParams { @@ -713,7 +724,7 @@ async fn beacon_simulation_500_test( attestation_validators_count: 450, proposal_validators_count: 45, sync_committee_validators_count: 5, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -724,7 +735,7 @@ async fn beacon_simulation_1000_test( target: &str, ) -> TestResult { if !cfg.load_test { - return skip_result("Simulate1000"); + return TestResult::skip("Simulate1000"); } let res = TestResult::new("Simulate1000"); let params = SimParams { @@ -732,7 +743,7 @@ async fn beacon_simulation_1000_test( attestation_validators_count: 930, proposal_validators_count: 65, sync_committee_validators_count: 5, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -768,7 +779,7 @@ async fn beacon_simulation_custom_test( attestation_validators_count: attestations, proposal_validators_count: proposals, sync_committee_validators_count: sync_committees, - request_intensity: default_intensity(), + request_intensity: RequestsIntensity::default(), }; beacon_simulation_test(cancel, &cfg, target, res, params).await } @@ -869,7 +880,13 @@ async fn beacon_simulation_test( tracing::info!("Waiting for simulation to complete..."); - let cluster_result = cluster_handle.await.unwrap_or_default(); + let cluster_result = match cluster_handle.await { + Ok(result) => result, + Err(e) => { + tracing::warn!("Cluster simulation failed: {:?}", e); + return test_res.fail(TestResultError::from_string(e.to_string())); + } + }; let mut all_validators = Vec::new(); for h in validator_handles { if let Ok(v) = h.await { @@ -905,7 +922,7 @@ async fn beacon_simulation_test( let highest_rtt = all_validators .iter() .map(|v| v.values.max) - .max_by_key(|d| d.as_nanos()) + .max_by_key(|d| *d) .unwrap_or_default(); test_res = evaluate_rtt( @@ -1642,6 +1659,9 @@ fn compute_two_phase_results( (cumulative_vals, first_vals, second_vals) } +/// Computes aggregated statistics (min, max, median, avg) over a slice of +/// durations for a given endpoint. Returns default zeroed values if the slice +/// is empty. fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> SimulationValues { if durations.is_empty() { return SimulationValues { @@ -1655,6 +1675,8 @@ fn generate_simulation_values(durations: &[StdDuration], endpoint: &str) -> Simu let min = sorted[0]; let max = sorted[sorted.len().saturating_sub(1)]; + // For even-length slices this picks the upper-middle element, matching typical + // beacon tooling. let median = sorted[sorted.len() / 2]; let sum: StdDuration = durations.iter().sum(); let count = u32::try_from(durations.len()).unwrap_or_else(|_| { @@ -1789,13 +1811,6 @@ fn average_validators_result( } } -fn skip_result(name: &str) -> TestResult { - TestResult { - verdict: TestVerdict::Skip, - ..TestResult::new(name) - } -} - fn cancel_after(token: &CancellationToken, duration: StdDuration) { let token = token.clone(); tokio::spawn(async move { diff --git a/crates/cli/src/commands/test/helpers.rs b/crates/cli/src/commands/test/helpers.rs index 106f5719..b8945649 100644 --- a/crates/cli/src/commands/test/helpers.rs +++ b/crates/cli/src/commands/test/helpers.rs @@ -184,6 +184,17 @@ impl TestResult { self.verdict = TestVerdict::Ok; self } + + pub fn skip(name: impl Into) -> Self { + Self { + name: name.into(), + verdict: TestVerdict::Skip, + measurement: String::new(), + suggestion: String::new(), + error: TestResultError::empty(), + is_acceptable: false, + } + } } /// Test case name with execution order. From 6f3c8d304cdfd32ae1f92d53bc4482c05039e029 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 10:26:08 +0200 Subject: [PATCH 37/41] unneeded files --- 1-validators.json | 1 - 10-validators.json | 1 - 100-validators.json | 1 - 1000-validators.json | 1 - 5-validators.json | 1 - 500-validators.json | 1 - test-infra/kurtosis-params.yaml | 15 --------------- 7 files changed, 21 deletions(-) delete mode 100644 1-validators.json delete mode 100644 10-validators.json delete mode 100644 100-validators.json delete mode 100644 1000-validators.json delete mode 100644 5-validators.json delete mode 100644 500-validators.json delete mode 100644 test-infra/kurtosis-params.yaml diff --git a/1-validators.json b/1-validators.json deleted file mode 100644 index 8ce6e8d9..00000000 --- a/1-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.300437ms","max":"1.325384ms","median":"1.325384ms","avg":"1.31291ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.18009ms","max":"1.484803ms","median":"1.484803ms","avg":"1.332446ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.592055ms","max":"1.665664ms","median":"1.665664ms","avg":"1.628859ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"1.505151ms","max":"1.505151ms","median":"1.505151ms","avg":"1.505151ms"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"1.133843ms","max":"3.255583ms","median":"2.932225ms","avg":"2.175416ms","attestation_duty":{"min":"2.932225ms","max":"3.255583ms","median":"3.054496ms","avg":"3.080768ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.51544ms","max":"1.745443ms","median":"1.592516ms","avg":"1.617799ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"1.186782ms","max":"1.740143ms","median":"1.46198ms","avg":"1.462968ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.327897ms","max":"1.552059ms","median":"1.552059ms","avg":"1.439978ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"1.133843ms","max":"1.465496ms","median":"1.210858ms","avg":"1.270065ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"1.133843ms","max":"1.465496ms","median":"1.210858ms","avg":"1.270065ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/10-validators.json b/10-validators.json deleted file mode 100644 index 9e92c27e..00000000 --- a/10-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.567537ms","max":"2.25033ms","median":"2.25033ms","avg":"1.908933ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.961307ms","max":"2.051517ms","median":"2.051517ms","avg":"2.006412ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.70756ms","max":"1.73402ms","median":"1.73402ms","avg":"1.72079ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"2.129593ms","max":"77.137333ms","median":"4.4363ms","avg":"12.097771ms","attestation_duty":{"min":"2.546757ms","max":"77.137333ms","median":"4.442362ms","avg":"12.166839ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.099958ms","max":"65.727033ms","median":"3.12753ms","avg":"9.445979ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"875.365µs","max":"26.882427ms","median":"1.540496ms","avg":"2.72086ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.071154ms","max":"4.813499ms","median":"1.548171ms","avg":"1.861265ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"2.129593ms","max":"29.207609ms","median":"2.884082ms","avg":"11.407094ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"2.129593ms","max":"29.207609ms","median":"2.884082ms","avg":"11.407094ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"1.676061ms","max":"1.676061ms","median":"1.676061ms","avg":"1.676061ms"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/100-validators.json b/100-validators.json deleted file mode 100644 index 14d1ca9c..00000000 --- a/100-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"109.096048ms","max":"486.517478ms","median":"486.517478ms","avg":"297.806763ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"898.03µs","max":"142.757209ms","median":"142.757209ms","avg":"71.827619ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.021552ms","max":"1.096433ms","median":"1.096433ms","avg":"1.058992ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"965.627µs","max":"965.627µs","median":"965.627µs","avg":"965.627µs"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/1000-validators.json b/1000-validators.json deleted file mode 100644 index 6bf4649d..00000000 --- a/1000-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"7.261419933s","max":"7.261419933s","median":"7.261419933s","avg":"7.261419933s"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.046480723s","max":"1.046480723s","median":"1.046480723s","avg":"1.046480723s"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.203265ms","max":"4.856420573s","median":"4.856420573s","avg":"2.428811919s"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"461.490808ms","max":"11.076962925s","median":"6.17034467s","avg":"5.660866134s"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/5-validators.json b/5-validators.json deleted file mode 100644 index 56531931..00000000 --- a/5-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"1.547149ms","max":"1.929989ms","median":"1.929989ms","avg":"1.738569ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.425029ms","max":"1.477127ms","median":"1.477127ms","avg":"1.451078ms"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"1.645874ms","max":"1.796427ms","median":"1.796427ms","avg":"1.72115ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"1.066174ms","max":"3.10653ms","median":"2.926622ms","avg":"2.672526ms","attestation_duty":{"min":"2.363654ms","max":"3.10653ms","median":"2.956618ms","avg":"2.87212ms","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"1.212489ms","max":"1.82986ms","median":"1.475063ms","avg":"1.501039ms"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"1.050595ms","max":"1.602804ms","median":"1.391816ms","avg":"1.37108ms"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"1.32953ms","max":"2.768995ms","median":"1.46851ms","avg":"1.604438ms"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"1.066174ms","max":"2.508065ms","median":"1.449425ms","avg":"1.674554ms","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"1.066174ms","max":"2.508065ms","median":"1.449425ms","avg":"1.674554ms"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/500-validators.json b/500-validators.json deleted file mode 100644 index b3591a1b..00000000 --- a/500-validators.json +++ /dev/null @@ -1 +0,0 @@ -{"general_cluster_requests":{"attestations_for_block_request":{"endpoint":"GET /eth/v1/beacon/blocks/{BLOCK}/attestations","min":"930.984293ms","max":"930.984293ms","median":"930.984293ms","avg":"930.984293ms"},"proposal_duties_for_epoch_request":{"endpoint":"GET /eth/v1/validator/duties/proposer/{EPOCH}","min":"1.450179479s","max":"1.450179479s","median":"1.450179479s","avg":"1.450179479s"},"syncing_request":{"endpoint":"GET /eth/v1/node/syncing","min":"991.405µs","max":"386.167233ms","median":"386.167233ms","avg":"193.579319ms"},"peer_count_request":{"endpoint":"GET /eth/v1/node/peer_count","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_committee_subscription_request":{"endpoint":"POST /eth/v1/validator/beacon_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_attester_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/attester/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"duties_sync_committee_for_epoch_request":{"endpoint":"POST /eth/v1/validator/duties/sync/{EPOCH}","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_head_validators_request":{"endpoint":"POST /eth/v1/beacon/states/head/validators","min":"0s","max":"0s","median":"0s","avg":"0s"},"beacon_genesis_request":{"endpoint":"GET /eth/v1/beacon/genesis","min":"0s","max":"0s","median":"0s","avg":"0s"},"prep_beacon_proposer_request":{"endpoint":"POST /eth/v1/validator/prepare_beacon_proposer","min":"0s","max":"0s","median":"0s","avg":"0s"},"config_spec_request":{"endpoint":"GET /eth/v1/config/spec","min":"0s","max":"0s","median":"0s","avg":"0s"},"node_version_request":{"endpoint":"GET /eth/v1/node/version","min":"0s","max":"0s","median":"0s","avg":"0s"}},"validators_requests":{"averaged":{"min":"0s","max":"0s","median":"0s","avg":"0s","attestation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_attestation_data_request":{"endpoint":"GET /eth/v1/validator/attestation_data","min":"653.75µs","max":"3.459796007s","median":"946.913378ms","avg":"1.204859006s"},"post_attestations_request":{"endpoint":"POST /eth/v1/beacon/pool/attestations","min":"0s","max":"0s","median":"0s","avg":"0s"}},"aggregation_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","get_aggregate_attestation_request":{"endpoint":"GET /eth/v1/validator/aggregate_attestation","min":"0s","max":"0s","median":"0s","avg":"0s"},"post_aggregate_and_proofs_request":{"endpoint":"POST /eth/v1/validator/aggregate_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"proposal_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_block_request":{"endpoint":"GET /eth/v3/validator/blocks/{SLOT}","min":"0s","max":"0s","median":"0s","avg":"0s"},"publish_blinded_block_request":{"endpoint":"POST /eth/v2/beacon/blinded","min":"0s","max":"0s","median":"0s","avg":"0s"}},"sync_committee_duties":{"min":"0s","max":"0s","median":"0s","avg":"0s","message_duty":{"submit_sync_committee_message_request":{"endpoint":"POST /eth/v1/beacon/pool/sync_committees","min":"0s","max":"0s","median":"0s","avg":"0s"}},"contribution_duty":{"min":"0s","max":"0s","median":"0s","avg":"0s","produce_sync_committee_contribution_request":{"endpoint":"GET /eth/v1/validator/sync_committee_contribution","min":"0s","max":"0s","median":"0s","avg":"0s"},"submit_sync_committee_contribution_request":{"endpoint":"POST /eth/v1/validator/contribution_and_proofs","min":"0s","max":"0s","median":"0s","avg":"0s"}},"subscribe_sync_committee_request":{"endpoint":"POST /eth/v1/validator/sync_committee_subscriptions","min":"0s","max":"0s","median":"0s","avg":"0s"}}}}} \ No newline at end of file diff --git a/test-infra/kurtosis-params.yaml b/test-infra/kurtosis-params.yaml deleted file mode 100644 index 3b9db2d9..00000000 --- a/test-infra/kurtosis-params.yaml +++ /dev/null @@ -1,15 +0,0 @@ -participants: - - el_type: nethermind - cl_type: lighthouse - count: 1 - -network_params: - preset: mainnet - -port_publisher: - cl: - enabled: true - public_port_start: 5052 - el: - enabled: true - public_port_start: 32000 From 236fe3b5ae942eecff5cc8f70f8e78d988c30618 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 10:48:32 +0200 Subject: [PATCH 38/41] missing join set --- crates/cli/src/commands/test/beacon.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 6c3309db..0bfed298 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -817,7 +817,7 @@ async fn beacon_simulation_test( }); // Validator simulations - let mut validator_handles = Vec::new(); + let mut validator_set = tokio::task::JoinSet::new(); let sync_duties = DutiesPerformed { attestation: true, @@ -833,9 +833,9 @@ async fn beacon_simulation_test( let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; - validator_handles.push(tokio::spawn(async move { + validator_set.spawn(async move { single_validator_simulation(cancel, sim_duration, &target, intensity, sync_duties).await - })); + }); } let proposal_duties = DutiesPerformed { @@ -852,10 +852,10 @@ async fn beacon_simulation_test( let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; - validator_handles.push(tokio::spawn(async move { + validator_set.spawn(async move { single_validator_simulation(cancel, sim_duration, &target, intensity, proposal_duties) .await - })); + }); } let attester_duties = DutiesPerformed { @@ -872,10 +872,10 @@ async fn beacon_simulation_test( let cancel = sim_cancel.clone(); let target = target.to_string(); let intensity = params.request_intensity; - validator_handles.push(tokio::spawn(async move { + validator_set.spawn(async move { single_validator_simulation(cancel, sim_duration, &target, intensity, attester_duties) .await - })); + }); } tracing::info!("Waiting for simulation to complete..."); @@ -888,8 +888,8 @@ async fn beacon_simulation_test( } }; let mut all_validators = Vec::new(); - for h in validator_handles { - if let Ok(v) = h.await { + while let Some(result) = validator_set.join_next().await { + if let Ok(v) = result { all_validators.push(v); } } From aafd7d7f2a1fba7ab9bbbaf1f49fd6cb26fe0bd9 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 12:38:30 +0200 Subject: [PATCH 39/41] removed redundant sleeping in simulation test --- crates/cli/src/commands/test/beacon.rs | 96 ++++---------------------- 1 file changed, 15 insertions(+), 81 deletions(-) diff --git a/crates/cli/src/commands/test/beacon.rs b/crates/cli/src/commands/test/beacon.rs index 0bfed298..d6dfa7d6 100644 --- a/crates/cli/src/commands/test/beacon.rs +++ b/crates/cli/src/commands/test/beacon.rs @@ -25,7 +25,7 @@ use std::{collections::HashMap, io::Write, path::PathBuf, time::Duration as StdD use tokio::{ sync::mpsc, task::JoinSet, - time::{Instant, interval, interval_at, sleep, sleep_until}, + time::{Instant, interval, interval_at, sleep}, }; use tokio_util::sync::CancellationToken; @@ -812,9 +812,10 @@ async fn beacon_simulation_test( tracing::info!("Starting general cluster requests..."); let cluster_cancel = sim_cancel.clone(); let cluster_target = target.to_string(); - let cluster_handle = tokio::spawn(async move { - single_cluster_simulation(cluster_cancel, sim_duration, &cluster_target).await - }); + let cluster_handle = + tokio::spawn( + async move { single_cluster_simulation(cluster_cancel, &cluster_target).await }, + ); // Validator simulations let mut validator_set = tokio::task::JoinSet::new(); @@ -834,7 +835,7 @@ async fn beacon_simulation_test( let target = target.to_string(); let intensity = params.request_intensity; validator_set.spawn(async move { - single_validator_simulation(cancel, sim_duration, &target, intensity, sync_duties).await + single_validator_simulation(cancel, &target, intensity, sync_duties).await }); } @@ -853,8 +854,7 @@ async fn beacon_simulation_test( let target = target.to_string(); let intensity = params.request_intensity; validator_set.spawn(async move { - single_validator_simulation(cancel, sim_duration, &target, intensity, proposal_duties) - .await + single_validator_simulation(cancel, &target, intensity, proposal_duties).await }); } @@ -873,8 +873,7 @@ async fn beacon_simulation_test( let target = target.to_string(); let intensity = params.request_intensity; validator_set.spawn(async move { - single_validator_simulation(cancel, sim_duration, &target, intensity, attester_duties) - .await + single_validator_simulation(cancel, &target, intensity, attester_duties).await }); } @@ -941,11 +940,7 @@ async fn beacon_simulation_test( test_res } -async fn single_cluster_simulation( - cancel: CancellationToken, - sim_duration: StdDuration, - target: &str, -) -> SimulationCluster { +async fn single_cluster_simulation(cancel: CancellationToken, target: &str) -> SimulationCluster { let mut attestations_for_block = Vec::new(); let mut proposal_duties_for_epoch = Vec::new(); let mut syncing = Vec::new(); @@ -961,8 +956,6 @@ async fn single_cluster_simulation( let mut slot = get_current_slot(target).await.unwrap_or(1); - let deadline = sim_deadline(sim_duration); - let now = Instant::now(); #[allow(clippy::arithmetic_side_effects)] let mut slot_interval = interval_at(now + SLOT_TIME, SLOT_TIME); @@ -981,7 +974,6 @@ async fn single_cluster_simulation( loop { tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = slot_interval.tick() => { slot = slot.saturating_add(1); let epoch = slot / SLOTS_IN_EPOCH; @@ -1078,7 +1070,6 @@ async fn single_cluster_simulation( async fn single_validator_simulation( cancel: CancellationToken, - sim_duration: StdDuration, target: &str, intensity: RequestsIntensity, duties: DutiesPerformed, @@ -1093,7 +1084,7 @@ async fn single_validator_simulation( let cancel = cancel.clone(); let target = target.to_string(); Some(tokio::spawn(async move { - attestation_duty(cancel, &target, sim_duration, intensity.attestation_duty).await + attestation_duty(cancel, &target, intensity.attestation_duty).await })) } else { None @@ -1104,7 +1095,7 @@ async fn single_validator_simulation( let cancel = cancel.clone(); let target = target.to_string(); Some(tokio::spawn(async move { - aggregation_duty(cancel, &target, sim_duration, intensity.aggregator_duty).await + aggregation_duty(cancel, &target, intensity.aggregator_duty).await })) } else { None @@ -1115,7 +1106,7 @@ async fn single_validator_simulation( let cancel = cancel.clone(); let target = target.to_string(); Some(tokio::spawn(async move { - proposal_duty(cancel, &target, sim_duration, intensity.proposal_duty).await + proposal_duty(cancel, &target, intensity.proposal_duty).await })) } else { None @@ -1133,7 +1124,6 @@ async fn single_validator_simulation( sync_committee_duties( cancel, &target, - sim_duration, intensity.sync_committee_submit, intensity.sync_committee_subscribe, intensity.sync_committee_contribution, @@ -1152,12 +1142,10 @@ async fn single_validator_simulation( } // Collect results from sync committee channels - let sc_deadline = sim_deadline(sim_duration); loop { tokio::select! { biased; _ = cancel.cancelled() => break, - _ = sleep_until(sc_deadline) => break, Some(v) = sc_sub_rx.recv() => sync_committee_subscription_all.push(v), Some(v) = sc_msg_rx.recv() => submit_sync_committee_message_all.push(v), Some(v) = sc_produce_rx.recv() => produce_sync_committee_contribution_all.push(v), @@ -1282,12 +1270,10 @@ async fn single_validator_simulation( async fn attestation_duty( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time: StdDuration, ) -> (Vec, Vec) { let mut get_all = Vec::new(); let mut submit_all = Vec::new(); - let deadline = sim_deadline(sim_duration); if cancel .run_until_cancelled(sleep(randomize_start(tick_time))) .await @@ -1304,10 +1290,6 @@ async fn attestation_duty( .unwrap_or(1); loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - let committee_index = rand::thread_rng().gen_range(0..COMMITTEE_SIZE_PER_SLOT); if let Some(Ok(rtt)) = cancel .run_until_cancelled(req_get_attestation_data(target, slot, committee_index)) @@ -1324,7 +1306,6 @@ async fn attestation_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1337,12 +1318,10 @@ async fn attestation_duty( async fn aggregation_duty( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time: StdDuration, ) -> (Vec, Vec) { let mut get_all = Vec::new(); let mut submit_all = Vec::new(); - let deadline = sim_deadline(sim_duration); let mut slot = cancel .run_until_cancelled(get_current_slot(target)) .await @@ -1359,10 +1338,6 @@ async fn aggregation_duty( let mut interval = interval_at(Instant::now() + tick_time, tick_time); loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - if let Some(Ok(rtt)) = cancel .run_until_cancelled(req_get_aggregate_attestations( target, @@ -1382,7 +1357,6 @@ async fn aggregation_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1395,12 +1369,10 @@ async fn aggregation_duty( async fn proposal_duty( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time: StdDuration, ) -> (Vec, Vec) { let mut produce_all = Vec::new(); let mut publish_all = Vec::new(); - let deadline = sim_deadline(sim_duration); if cancel .run_until_cancelled(sleep(randomize_start(tick_time))) .await @@ -1418,10 +1390,6 @@ async fn proposal_duty( let randao = "0x1fe79e4193450abda94aec753895cfb2aac2c2a930b6bab00fbb27ef6f4a69f4400ad67b5255b91837982b4c511ae1d94eae1cf169e20c11bd417c1fffdb1f99f4e13e2de68f3b5e73f1de677d73cd43e44bf9b133a79caf8e5fad06738e1b0c"; loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - if let Some(Ok(rtt)) = cancel .run_until_cancelled(req_produce_block(target, slot, randao)) .await @@ -1437,7 +1405,6 @@ async fn proposal_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS).saturating_add(1); // produce block for the next slot, as the current one might have already been proposed } @@ -1451,7 +1418,6 @@ async fn proposal_duty( async fn sync_committee_duties( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time_submit: StdDuration, tick_time_subscribe: StdDuration, tick_time_contribution: StdDuration, @@ -1463,25 +1429,17 @@ async fn sync_committee_duties( let c1 = cancel.clone(); let t1 = target.to_string(); tokio::spawn(async move { - sync_committee_contribution_duty( - c1, - &t1, - sim_duration, - tick_time_contribution, - produce_tx, - contrib_tx, - ) - .await; + sync_committee_contribution_duty(c1, &t1, tick_time_contribution, produce_tx, contrib_tx) + .await; }); let c2 = cancel.clone(); let t2 = target.to_string(); tokio::spawn(async move { - sync_committee_message_duty(c2, &t2, sim_duration, tick_time_submit, msg_tx).await; + sync_committee_message_duty(c2, &t2, tick_time_submit, msg_tx).await; }); // Subscribe loop - let deadline = sim_deadline(sim_duration); if cancel .run_until_cancelled(sleep(randomize_start(tick_time_subscribe))) .await @@ -1493,10 +1451,6 @@ async fn sync_committee_duties( let mut interval = interval_at(Instant::now() + tick_time_subscribe, tick_time_subscribe); loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - if let Some(Ok(rtt)) = cancel .run_until_cancelled(req_sync_committee_subscription(target)) .await @@ -1506,7 +1460,6 @@ async fn sync_committee_duties( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => {} } } @@ -1515,12 +1468,10 @@ async fn sync_committee_duties( async fn sync_committee_contribution_duty( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time: StdDuration, produce_tx: mpsc::Sender, contrib_tx: mpsc::Sender, ) { - let deadline = sim_deadline(sim_duration); if cancel .run_until_cancelled(sleep(randomize_start(tick_time))) .await @@ -1537,10 +1488,6 @@ async fn sync_committee_contribution_duty( .unwrap_or(1); loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - let sub_idx = rand::thread_rng().gen_range(0..SUB_COMMITTEE_SIZE); let beacon_block_root = "0xcf8e0d4e9587369b2301d0790347320302cc0943d5a1884560367e8208d920f2"; @@ -1564,7 +1511,6 @@ async fn sync_committee_contribution_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => { slot = slot.saturating_add(tick_time.as_secs() / SLOT_TIME_SECS); } @@ -1575,11 +1521,9 @@ async fn sync_committee_contribution_duty( async fn sync_committee_message_duty( cancel: CancellationToken, target: &str, - sim_duration: StdDuration, tick_time: StdDuration, msg_tx: mpsc::Sender, ) { - let deadline = sim_deadline(sim_duration); if cancel .run_until_cancelled(sleep(randomize_start(tick_time))) .await @@ -1591,10 +1535,6 @@ async fn sync_committee_message_duty( let mut interval = interval_at(Instant::now() + tick_time, tick_time); loop { - if cancel.is_cancelled() || Instant::now() >= deadline { - break; - } - if let Some(Ok(rtt)) = cancel .run_until_cancelled(req_submit_sync_committee(target)) .await @@ -1604,7 +1544,6 @@ async fn sync_committee_message_duty( tokio::select! { _ = cancel.cancelled() => break, - _ = sleep_until(deadline) => break, _ = interval.tick() => {} } } @@ -1821,11 +1760,6 @@ fn cancel_after(token: &CancellationToken, duration: StdDuration) { }); } -#[allow(clippy::arithmetic_side_effects)] -fn sim_deadline(sim_duration: StdDuration) -> Instant { - Instant::now() + sim_duration -} - fn randomize_start(tick_time: StdDuration) -> StdDuration { let slots = (tick_time.as_secs() / SLOT_TIME_SECS).max(1); let random_slots = rand::thread_rng().gen_range(0..slots); From 400999d033f375f7f6220443d8dba2c4ed022677 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 12:40:45 +0200 Subject: [PATCH 40/41] percent-encoding not needed --- Cargo.lock | 1 - Cargo.toml | 1 - crates/cli/Cargo.toml | 1 - 3 files changed, 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f49bf13..ddefdb18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5452,7 +5452,6 @@ dependencies = [ "humantime", "k256", "libp2p", - "percent-encoding", "pluto-app", "pluto-cluster", "pluto-core", diff --git a/Cargo.toml b/Cargo.toml index ec639c6b..95b8387d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,6 @@ tokio = { version = "1", features = ["full"] } tokio-util = "0.7.11" libp2p = { version = "0.56", features = ["full", "secp256k1"] } url = "2.5" -percent-encoding = "2.3" aes = "0.8.4" ctr = "0.9.2" cipher = "0.4.4" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2f92fd94..5a53e5d4 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -35,7 +35,6 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true -percent-encoding.workspace = true [dev-dependencies] tempfile.workspace = true From 8a2255816f23967b4cd43d3c4ae98fa8849e827c Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 1 Apr 2026 16:26:47 +0200 Subject: [PATCH 41/41] child_token -> clone in cli/src/main.rs --- crates/cli/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 79c35e5c..562dc609 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -47,7 +47,7 @@ async fn main() -> ExitResult { }, Commands::Enr(args) => commands::enr::run(args), Commands::Version(args) => commands::version::run(args), - Commands::Relay(args) => commands::relay::run(*args, ct.child_token()).await, + Commands::Relay(args) => commands::relay::run(*args, ct.clone()).await, Commands::Alpha(args) => match args.command { AlphaCommands::Test(args) => { let mut stdout = std::io::stdout(); @@ -56,7 +56,7 @@ async fn main() -> ExitResult { .await .map(|_| ()), TestCommands::Beacon(args) => { - commands::test::beacon::run(args, &mut stdout, ct.child_token()) + commands::test::beacon::run(args, &mut stdout, ct.clone()) .await .map(|_| ()) }