From 88601b6a494e7433f9bab09764ed9b76a1a887c4 Mon Sep 17 00:00:00 2001 From: PoulavBhowmick03 Date: Sat, 28 Mar 2026 13:09:24 +0530 Subject: [PATCH 01/23] feat: add mev cli tests --- Cargo.lock | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/commands/test/mev.rs | 918 +++++++++++++++++++++++++++- crates/cli/src/commands/test/mod.rs | 4 + 4 files changed, 915 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5a9d0b9..c956890d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5472,6 +5472,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "url", ] [[package]] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 13c7c012..32cbd88f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -35,6 +35,7 @@ serde_with = { workspace = true, features = ["base64"] } rand.workspace = true tempfile.workspace = true reqwest.workspace = true +url.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 473bbd9b..ce8fb2f5 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -1,9 +1,42 @@ //! MEV relay tests. -use super::{TestCategoryResult, TestConfigArgs}; -use crate::error::Result; +use std::{ + collections::HashMap, + io::Write, + time::{Duration, Instant}, +}; + +use reqwest::{Method, StatusCode}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::info; + +use super::{ + AllCategoriesResult, SLOT_TIME, SLOTS_IN_EPOCH, TestCaseName, TestCategory, TestCategoryResult, + TestConfigArgs, TestResult, TestResultError, TestVerdict, calculate_score, 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 as CliDuration, + error::{CliError, Result}, +}; use clap::Args; -use std::io::Write; + +/// MEV-specific errors. +#[derive(Debug, thiserror::Error)] +enum MevError { + /// Relay returned non-200 for the header request. + #[error("status code not 200 OK")] + StatusCodeNot200, + #[error(transparent)] + Cli(#[from] CliError), +} + +/// Thresholds for MEV ping measure test. +const THRESHOLD_MEV_MEASURE_AVG: Duration = Duration::from_millis(40); +/// Threshold for poor MEV ping measure. +const THRESHOLD_MEV_MEASURE_POOR: Duration = Duration::from_millis(100); /// Arguments for the MEV test command. #[derive(Args, Clone, Debug)] @@ -38,13 +71,880 @@ pub struct TestMevArgs { help = "Increases the accuracy of the load test by asking for multiple payloads. Increases test duration." )] pub number_of_payloads: u32, + + /// X-Timeout-Ms header flag for each request in milliseconds. + #[arg( + long = "x-timeout-ms", + default_value = "1000", + help = "X-Timeout-Ms header flag for each request in milliseconds, used by MEVs to compute maximum delay for reply." + )] + pub x_timeout_ms: u32, +} + +/// A MEV test case function. +type TestCaseMev = + for<'a> fn( + token: CancellationToken, + conf: &'a TestMevArgs, + target: &'a str, + ) -> std::pin::Pin + Send + 'a>>; + +/// Returns the supported MEV test cases. +fn supported_mev_test_cases() -> HashMap { + HashMap::from([ + (TestCaseName::new("Ping", 1), mev_ping_test as TestCaseMev), + ( + TestCaseName::new("PingMeasure", 2), + mev_ping_measure_test as TestCaseMev, + ), + ( + TestCaseName::new("CreateBlock", 3), + mev_create_block_test as TestCaseMev, + ), + ]) } /// Runs the MEV relay tests. -pub async fn run(_args: TestMevArgs, _writer: &mut dyn Write) -> Result { - // TODO: Implement MEV tests - // - Ping - // - PingMeasure - // - CreateBlock - unimplemented!("mev test not yet implemented") +pub async fn run(args: TestMevArgs, writer: &mut dyn Write) -> Result { + must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; + + // Validate flag combinations. + if args.load_test && args.beacon_node_endpoint.is_none() { + return Err(CliError::Other( + "beacon-node-endpoint required when load-test enabled".to_string(), + )); + } + if !args.load_test && args.beacon_node_endpoint.is_some() { + return Err(CliError::Other( + "beacon-node-endpoint only supported when load-test enabled".to_string(), + )); + } + + info!("Starting MEV relays test"); + + let test_cases = supported_mev_test_cases(); + let queued_tests = filter_tests(&test_cases, args.test_config.test_cases.as_deref()); + if queued_tests.is_empty() { + return Err(CliError::Other("test case not supported".to_string())); + } + let mut queued_tests = queued_tests; + sort_tests(&mut queued_tests); + + let token = CancellationToken::new(); + let timeout_token = token.clone(); + tokio::spawn(async move { + tokio::time::sleep(args.test_config.timeout).await; + timeout_token.cancel(); + }); + + let start_time = Instant::now(); + let test_results = test_all_mevs(&queued_tests, &test_cases, &args, token).await; + let exec_time = CliDuration::new(start_time.elapsed()); + + let score = test_results + .values() + .map(|results| calculate_score(results)) + .min(); + + let res = TestCategoryResult { + category_name: Some(TestCategory::Mev), + targets: test_results, + execution_time: Some(exec_time), + 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, args.test_config.output_json.as_ref()).await?; + } + + if args.test_config.publish { + publish_result_to_obol_api( + AllCategoriesResult { + mev: Some(res.clone()), + ..Default::default() + }, + &args.test_config.publish_addr, + &args.test_config.publish_private_key_file, + ) + .await?; + } + + Ok(res) +} + +async fn test_all_mevs( + queued_tests: &[TestCaseName], + test_cases: &HashMap, + conf: &TestMevArgs, + token: CancellationToken, +) -> HashMap> { + let (tx, mut rx) = mpsc::channel::<(String, Vec)>(conf.endpoints.len()); + + for endpoint in &conf.endpoints { + let tx = tx.clone(); + let queued_tests = queued_tests.to_vec(); + + let test_cases = test_cases.clone(); + let endpoint = endpoint.clone(); + let conf = conf.clone(); + + let child_token = token.child_token(); + + tokio::spawn(async move { + let results = + test_single_mev(&queued_tests, &test_cases, &conf, &endpoint, child_token).await; + let relay_name = format_mev_relay_name(&endpoint); + let _ = tx.send((relay_name, results)).await; + }); + } + + drop(tx); + + let mut all_results = HashMap::new(); + while let Some((name, results)) = rx.recv().await { + all_results.insert(name, results); + } + + all_results +} + +async fn test_single_mev( + queued_tests: &[TestCaseName], + test_cases: &HashMap, + conf: &TestMevArgs, + target: &str, + token: CancellationToken, +) -> Vec { + let (result_tx, mut result_rx) = mpsc::channel::(queued_tests.len()); + + let queued = queued_tests.to_vec(); + let test_cases = test_cases.clone(); + let conf_clone = conf.clone(); + let target_owned = target.to_string(); + + let runner_token = token.child_token(); + + tokio::spawn(async move { + for t in &queued { + if runner_token.is_cancelled() { + return; + } + if let Some(test_fn) = test_cases.get(t) { + let result = test_fn(runner_token.clone(), &conf_clone, &target_owned).await; + if result_tx.send(result).await.is_err() { + return; + } + } + } + }); + + let mut all_results = Vec::new(); + let mut test_counter = 0usize; + + loop { + tokio::select! { + + _ = token.cancelled() => { + if test_counter < queued_tests.len() { + all_results.push(TestResult { + name: queued_tests[test_counter].name.clone(), + verdict: TestVerdict::Fail, + error: TestResultError::from_string("timeout/interrupted"), + ..TestResult::new("") + }); + } + break; + } + result = result_rx.recv() => { + match result { + Some(r) => { + test_counter = test_counter.saturating_add(1); + all_results.push(r); + } + None => break, + } + } + } + } + + all_results +} + +fn mev_ping_test<'a>( + token: CancellationToken, + _conf: &'a TestMevArgs, + target: &'a str, +) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + let test_res = TestResult::new("Ping"); + let url = format!("{target}/eth/v1/builder/status"); + let client = reqwest::Client::new(); + + let (clean_url, creds) = match parse_endpoint_credentials(&url) { + Ok(v) => v, + Err(e) => return test_res.fail(e), + }; + + let resp = tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + r = apply_basic_auth(client.get(&clean_url), creds).send() => match r { + Ok(r) => r, + Err(e) => return test_res.fail(e), + } + }; + + if resp.status().as_u16() > 399 { + return test_res.fail(CliError::Other(http_status_error(resp.status()))); + } + + test_res.ok() + }) +} + +fn mev_ping_measure_test<'a>( + token: CancellationToken, + _conf: &'a TestMevArgs, + target: &'a str, +) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + let test_res = TestResult::new("PingMeasure"); + let url = format!("{target}/eth/v1/builder/status"); + + let rtt = tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + r = request_rtt(&url, Method::GET, None, StatusCode::OK) => match r { + Ok(r) => r, + Err(e) => return test_res.fail(e), + } + }; + + evaluate_rtt( + rtt, + test_res, + THRESHOLD_MEV_MEASURE_AVG, + THRESHOLD_MEV_MEASURE_POOR, + ) + }) +} + +fn mev_create_block_test<'a>( + token: CancellationToken, + conf: &'a TestMevArgs, + target: &'a str, +) -> std::pin::Pin + Send + 'a>> { + Box::pin(async move { + let test_res = TestResult::new("CreateBlock"); + + if !conf.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..test_res + }; + } + + let beacon_endpoint = match &conf.beacon_node_endpoint { + Some(ep) => ep.as_str(), + None => { + return test_res.fail(CliError::Other("beacon-node-endpoint required".to_string())); + } + }; + + let latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + Ok(b) => b, + Err(e) => return test_res.fail(e), + }; + + let latest_block_ts_unix: i64 = match latest_block.body.execution_payload.timestamp.parse() + { + Ok(v) => v, + Err(e) => return test_res.fail(CliError::Other(format!("parse timestamp: {e}"))), + }; + + let latest_block_ts = std::time::UNIX_EPOCH + .checked_add(Duration::from_secs(latest_block_ts_unix.unsigned_abs())) + .unwrap_or(std::time::UNIX_EPOCH); + let next_block_ts = latest_block_ts + .checked_add(SLOT_TIME) + .unwrap_or(latest_block_ts); + + if let Ok(remaining) = next_block_ts.duration_since(std::time::SystemTime::now()) { + tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + _ = tokio::time::sleep(remaining) => {} + } + } + + let latest_slot: i64 = match latest_block.slot.parse() { + Ok(v) => v, + Err(e) => return test_res.fail(CliError::Other(format!("parse slot: {e}"))), + }; + + let mut next_slot = latest_slot.saturating_add(1); + let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); + let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); + + let mut proposer_duties = + match fetch_proposers_for_epoch(beacon_endpoint, epoch, &token).await { + Ok(d) => d, + Err(e) => return test_res.fail(e), + }; + + let mut all_blocks_rtt: Vec = Vec::new(); + let x_timeout_ms = conf.x_timeout_ms; + + info!( + mev_relay = target, + blocks = conf.number_of_payloads, + x_timeout_ms = x_timeout_ms, + "Starting attempts for block creation" + ); + + let mut latest_block = latest_block; + + loop { + if token.is_cancelled() { + break; + } + + let start_iteration = Instant::now(); + + let rtt = match create_mev_block( + conf, + target, + x_timeout_ms, + next_slot, + &mut latest_block, + &mut proposer_duties, + beacon_endpoint, + &token, + ) + .await + { + Ok(r) => r, + Err(e) => return test_res.fail(e), + }; + + all_blocks_rtt.push(rtt); + if all_blocks_rtt.len() + == usize::try_from(conf.number_of_payloads).unwrap_or(usize::MAX) + { + break; + } + + let elapsed = start_iteration.elapsed(); + let elapsed_nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let slot_nanos = u64::try_from(SLOT_TIME.as_nanos()).unwrap_or(1); + let remainder_nanos = elapsed_nanos.checked_rem(slot_nanos).unwrap_or(0); + let slot_remainder = SLOT_TIME + .checked_sub(Duration::from_nanos(remainder_nanos)) + .unwrap_or_default(); + if let Some(sleep_dur) = slot_remainder.checked_sub(Duration::from_secs(1)) { + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(sleep_dur) => {} + } + } + + let start_beacon_fetch = Instant::now(); + latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + Ok(b) => b, + Err(e) => return test_res.fail(e), + }; + + let latest_slot_parsed: i64 = match latest_block.slot.parse() { + Ok(v) => v, + Err(e) => return test_res.fail(CliError::Other(format!("parse slot: {e}"))), + }; + + next_slot = latest_slot_parsed.saturating_add(1); + + // Wait 1 second minus how long the fetch took. + if let Some(sleep_dur) = + Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) + { + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(sleep_dur) => {} + } + } + } + + if all_blocks_rtt.is_empty() { + return test_res.fail(CliError::Other("timeout/interrupted".to_string())); + } + + let total_rtt: Duration = all_blocks_rtt.iter().sum(); + let count = u32::try_from(all_blocks_rtt.len().max(1)).unwrap_or(u32::MAX); + let average_rtt = total_rtt.checked_div(count).unwrap_or_default(); + + let avg_threshold = Duration::from_millis( + u64::from(x_timeout_ms) + .saturating_mul(9) + .checked_div(10) + .unwrap_or(0), + ); + let poor_threshold = Duration::from_millis(u64::from(x_timeout_ms)); + + evaluate_rtt(average_rtt, test_res, avg_threshold, poor_threshold) + }) +} + +// Helper types +#[derive(Debug, Clone, serde::Deserialize)] +struct BeaconBlock { + data: BeaconBlockData, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct BeaconBlockData { + message: BeaconBlockMessage, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct BeaconBlockMessage { + slot: String, + body: BeaconBlockBody, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct BeaconBlockBody { + execution_payload: BeaconBlockExecPayload, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct BeaconBlockExecPayload { + block_hash: String, + timestamp: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct ProposerDuties { + data: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct ProposerDutiesData { + pubkey: String, + slot: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +struct BuilderBidResponse { + version: String, + data: serde_json::Value, +} + +async fn latest_beacon_block( + endpoint: &str, + token: &CancellationToken, +) -> Result { + let url = format!("{endpoint}/eth/v2/beacon/blocks/head"); + let (clean_url, creds) = parse_endpoint_credentials(&url)?; + let client = reqwest::Client::new(); + + let resp = tokio::select! { + _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), + r = apply_basic_auth(client.get(&clean_url), creds).send() => { + r.map_err(|e| CliError::Other(format!("http request do: {e}")))? + } + }; + + let body = resp + .bytes() + .await + .map_err(|e| CliError::Other(format!("http response body: {e}")))?; + + let block: BeaconBlock = serde_json::from_slice(&body) + .map_err(|e| CliError::Other(format!("http response json: {e}")))?; + + Ok(block.data.message) +} + +async fn fetch_proposers_for_epoch( + beacon_endpoint: &str, + epoch: i64, + token: &CancellationToken, +) -> Result> { + let url = format!("{beacon_endpoint}/eth/v1/validator/duties/proposer/{epoch}"); + let (clean_url, creds) = parse_endpoint_credentials(&url)?; + let client = reqwest::Client::new(); + + let resp = tokio::select! { + _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), + r = apply_basic_auth(client.get(&clean_url), creds).send() => { + r.map_err(|e| CliError::Other(format!("http request do: {e}")))? + } + }; + + let body = resp + .bytes() + .await + .map_err(|e| CliError::Other(format!("http response body: {e}")))?; + + let duties: ProposerDuties = serde_json::from_slice(&body) + .map_err(|e| CliError::Other(format!("http response json: {e}")))?; + + Ok(duties.data) +} + +fn get_validator_pk_for_slot(proposers: &[ProposerDutiesData], slot: i64) -> Option { + let slot_str = slot.to_string(); + proposers + .iter() + .find(|p| p.slot == slot_str) + .map(|p| p.pubkey.clone()) +} + +async fn get_block_header( + target: &str, + x_timeout_ms: u32, + next_slot: i64, + block_hash: &str, + validator_pub_key: &str, + token: &CancellationToken, +) -> std::result::Result<(BuilderBidResponse, Duration), MevError> { + let url = + format!("{target}/eth/v1/builder/header/{next_slot}/{block_hash}/{validator_pub_key}"); + + let (clean_url, creds) = parse_endpoint_credentials(&url) + .map_err(|e| MevError::Cli(CliError::Other(format!("parse url: {e}"))))?; + + let client = reqwest::Client::new(); + let start = Instant::now(); + + let resp = tokio::select! { + _ = token.cancelled() => { + return Err(MevError::Cli(CliError::Other("timeout/interrupted".to_string()))); + } + r = apply_basic_auth(client.get(&clean_url), creds) + .header("X-Timeout-Ms", x_timeout_ms.to_string()) + .header( + "Date-Milliseconds", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .to_string(), + ) + .send() => { + r.map_err(|e| MevError::Cli(CliError::Other(format!("http request rtt: {e}"))))? + } + }; + + let rtt = start.elapsed(); + + if resp.status() != StatusCode::OK { + return Err(MevError::StatusCodeNot200); + } + + let body = resp + .bytes() + .await + .map_err(|e| MevError::Cli(CliError::Other(format!("http response body: {e}"))))?; + + let bid: BuilderBidResponse = serde_json::from_slice(&body) + .map_err(|e| MevError::Cli(CliError::Other(format!("http response json: {e}"))))?; + + Ok((bid, rtt)) +} + +async fn create_mev_block( + _conf: &TestMevArgs, + target: &str, + x_timeout_ms: u32, + mut next_slot: i64, + latest_block: &mut BeaconBlockMessage, + proposer_duties: &mut Vec, + beacon_endpoint: &str, + token: &CancellationToken, +) -> Result { + let rtt_get_header; + let builder_bid; + + loop { + if token.is_cancelled() { + return Err(CliError::Other("timeout/interrupted".to_string())); + } + + let start_iteration = Instant::now(); + let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); + let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); + + let pk = if let Some(pk) = get_validator_pk_for_slot(proposer_duties, next_slot) { + pk + } else { + *proposer_duties = fetch_proposers_for_epoch(beacon_endpoint, epoch, token).await?; + get_validator_pk_for_slot(proposer_duties, next_slot) + .ok_or_else(|| CliError::Other("slot not found".to_string()))? + }; + + match get_block_header( + target, + x_timeout_ms, + next_slot, + &latest_block.body.execution_payload.block_hash, + &pk, + token, + ) + .await + { + Ok((bid, rtt)) => { + builder_bid = bid; + rtt_get_header = rtt; + + info!( + slot = next_slot, + target = target, + "Created block headers for slot" + ); + break; + } + + Err(MevError::StatusCodeNot200) => { + let elapsed = start_iteration.elapsed(); + if let Some(sleep_dur) = SLOT_TIME.checked_sub(elapsed) + && let Some(sleep_dur) = sleep_dur.checked_sub(Duration::from_secs(1)) + { + tokio::select! { + _ = token.cancelled() => { + return Err(CliError::Other("timeout/interrupted".to_string())); + } + _ = tokio::time::sleep(sleep_dur) => {} + } + } + + let start_beacon_fetch = Instant::now(); + *latest_block = latest_beacon_block(beacon_endpoint, token).await?; + next_slot = next_slot.saturating_add(1); + + if let Some(sleep_dur) = + Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) + { + tokio::select! { + _ = token.cancelled() => { + return Err(CliError::Other("timeout/interrupted".to_string())); + } + _ = tokio::time::sleep(sleep_dur) => {} + } + } + + continue; + } + Err(MevError::Cli(e)) => return Err(e), + } + } + + let payload = build_blinded_block_payload(&builder_bid)?; + let payload_json = serde_json::to_vec(&payload).map_err(|e| { + CliError::Other(format!( + "signed blinded beacon block json payload marshal: {e}" + )) + })?; + + let rtt_submit_block = tokio::select! { + _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), + r = request_rtt( + format!("{target}/eth/v1/builder/blinded_blocks"), + Method::POST, + Some(payload_json), + StatusCode::BAD_REQUEST, + ) => r? + }; + + Ok(rtt_get_header + .checked_add(rtt_submit_block) + .unwrap_or(rtt_get_header)) +} + +fn build_blinded_block_payload(bid: &BuilderBidResponse) -> Result { + let sig_hex = "0xb9251a82040d4620b8c5665f328ee6c2eaa02d31d71d153f4abba31a7922a981e541e85283f0ced387d26e86aef9386d18c6982b9b5f8759882fe7f25a328180d86e146994ef19d28bc1432baf29751dec12b5f3d65dbbe224d72cf900c6831a"; + + let header = extract_execution_payload_header(&bid.data, &bid.version)?; + + let zero_hash = "0x0000000000000000000000000000000000000000000000000000000000000000"; + let zero_sig = "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + let mut body = serde_json::json!({ + "randao_reveal": zero_sig, + "eth1_data": { + "deposit_root": zero_hash, + "deposit_count": "0", + "block_hash": zero_hash + }, + "graffiti": zero_hash, + "proposer_slashings": [], + "attester_slashings": [], + "attestations": [], + "deposits": [], + "voluntary_exits": [], + "sync_aggregate": { + "sync_committee_bits": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "sync_committee_signature": zero_sig + }, + "execution_payload_header": header + }); + + let version_lower = bid.version.to_lowercase(); + + if matches!( + version_lower.as_str(), + "capella" | "deneb" | "electra" | "fulu" + ) { + body["bls_to_execution_changes"] = serde_json::json!([]); + } + + if matches!(version_lower.as_str(), "deneb" | "electra" | "fulu") { + body["blob_kzg_commitments"] = serde_json::json!([]); + } + + if matches!(version_lower.as_str(), "electra" | "fulu") { + body["execution_requests"] = serde_json::json!({ + "deposits": [], + "withdrawals": [], + "consolidations": [] + }); + } + + Ok(serde_json::json!({ + "message": { + "slot": "0", + "proposer_index": "0", + "parent_root": zero_hash, + "state_root": zero_hash, + "body": body + }, + "signature": sig_hex + })) +} + +fn extract_execution_payload_header( + data: &serde_json::Value, + version: &str, +) -> Result { + data.get("message") + .and_then(|m| m.get("header")) + .cloned() + .ok_or_else(|| { + CliError::Other(format!( + "not supported version or missing header: {version}" + )) + }) +} + +fn parse_endpoint_credentials(raw_url: &str) -> Result<(String, Option<(String, String)>)> { + let parsed = + url::Url::parse(raw_url).map_err(|e| CliError::Other(format!("parse url: {e}")))?; + + let creds = if !parsed.username().is_empty() { + Some(( + parsed.username().to_string(), + parsed.password().unwrap_or("").to_string(), + )) + } else { + None + }; + + let mut clean = parsed.clone(); + clean + .set_username("") + .map_err(|()| CliError::Other("set username on URL".to_string()))?; + clean + .set_password(None) + .map_err(|()| CliError::Other("set password on URL".to_string()))?; + + Ok((clean.to_string(), creds)) +} + +fn apply_basic_auth( + builder: reqwest::RequestBuilder, + creds: Option<(String, String)>, +) -> reqwest::RequestBuilder { + match creds { + Some((user, pass)) => builder.basic_auth(user, Some(pass)), + None => builder, + } +} + +fn format_mev_relay_name(url_string: &str) -> String { + let Some((scheme, rest)) = url_string.split_once("://") else { + return url_string.to_string(); + }; + + let Some((hash, host)) = rest.split_once('@') else { + return url_string.to_string(); + }; + + if !hash.starts_with("0x") || hash.len() < 18 { + return url_string.to_string(); + } + + let hash_short = format!("{}...{}", &hash[..6], &hash[hash.len().saturating_sub(4)..]); + format!("{scheme}://{hash_short}@{host}") +} + +fn http_status_error(status: StatusCode) -> String { + format!("status code {}", status.as_u16()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_mev_relay_name() { + assert_eq!( + format_mev_relay_name( + "https://0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae@boost-relay.flashbots.net" + ), + "https://0xac6e...37ae@boost-relay.flashbots.net" + ); + + assert_eq!( + format_mev_relay_name("boost-relay.flashbots.net"), + "boost-relay.flashbots.net" + ); + + assert_eq!( + format_mev_relay_name("https://boost-relay.flashbots.net"), + "https://boost-relay.flashbots.net" + ); + + assert_eq!( + format_mev_relay_name("https://0xshort@boost-relay.flashbots.net"), + "https://0xshort@boost-relay.flashbots.net" + ); + + assert_eq!( + format_mev_relay_name("https://noprefixhashvalue1234567890@boost-relay.flashbots.net"), + "https://noprefixhashvalue1234567890@boost-relay.flashbots.net" + ); + } + + #[test] + fn test_get_validator_pk_for_slot() { + let duties = vec![ + ProposerDutiesData { + pubkey: "0xabc".to_string(), + slot: "100".to_string(), + }, + ProposerDutiesData { + pubkey: "0xdef".to_string(), + slot: "101".to_string(), + }, + ]; + + assert_eq!( + get_validator_pk_for_slot(&duties, 100), + Some("0xabc".to_string()) + ); + assert_eq!( + get_validator_pk_for_slot(&duties, 101), + Some("0xdef".to_string()) + ); + assert_eq!(get_validator_pk_for_slot(&duties, 102), None); + } } diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index 774b361d..a6df9b2c 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -221,6 +221,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() } From 1583cc924e8586915896d4253c33b91b765a79c6 Mon Sep 17 00:00:00 2001 From: PoulavBhowmick03 Date: Sat, 28 Mar 2026 13:11:13 +0530 Subject: [PATCH 02/23] clippy --- crates/cli/src/commands/test/mev.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index ce8fb2f5..d0366516 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -652,6 +652,7 @@ async fn get_block_header( Ok((bid, rtt)) } +#[allow(clippy::too_many_arguments)] async fn create_mev_block( _conf: &TestMevArgs, target: &str, From e7f5fef48d737049ff4476a1e48d7f1600b3396e Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:08:50 -0300 Subject: [PATCH 03/23] Remove type aliases for test cases - Follow the `test validator` approach (enum + impl block) - Simplify error handling for timeouts --- crates/cli/src/commands/test/mev.rs | 499 +++++++++++++--------------- crates/cli/src/commands/test/mod.rs | 17 - 2 files changed, 229 insertions(+), 287 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index d0366516..f085a76e 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -7,17 +7,18 @@ use std::{ }; use reqwest::{Method, StatusCode}; -use tokio::sync::mpsc; +use tokio::{sync::mpsc, task::JoinSet}; use tokio_util::sync::CancellationToken; use tracing::info; use super::{ - AllCategoriesResult, SLOT_TIME, SLOTS_IN_EPOCH, TestCaseName, TestCategory, TestCategoryResult, + AllCategoriesResult, SLOT_TIME, SLOTS_IN_EPOCH, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, TestResultError, TestVerdict, calculate_score, 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, + must_output_to_file_on_quiet, publish_result_to_obol_api, request_rtt, write_result_to_file, + write_result_to_writer, }; use crate::{ + commands::test::TestCaseName, duration::Duration as CliDuration, error::{CliError, Result}, }; @@ -81,27 +82,33 @@ pub struct TestMevArgs { pub x_timeout_ms: u32, } -/// A MEV test case function. -type TestCaseMev = - for<'a> fn( - token: CancellationToken, - conf: &'a TestMevArgs, - target: &'a str, - ) -> std::pin::Pin + Send + 'a>>; - -/// Returns the supported MEV test cases. -fn supported_mev_test_cases() -> HashMap { - HashMap::from([ - (TestCaseName::new("Ping", 1), mev_ping_test as TestCaseMev), - ( - TestCaseName::new("PingMeasure", 2), - mev_ping_measure_test as TestCaseMev, - ), - ( - TestCaseName::new("CreateBlock", 3), - mev_create_block_test as TestCaseMev, - ), - ]) +#[derive(Debug, Clone)] +enum TestCaseMev { + Ping, + PingMeasure, + CreateBlock, +} + +impl TestCaseMev { + fn all() -> Vec { + vec![Self::Ping, Self::PingMeasure, Self::CreateBlock] + } + + fn test_case_name(&self) -> TestCaseName { + match self { + TestCaseMev::Ping => TestCaseName::new("Ping", 1), + TestCaseMev::PingMeasure => TestCaseName::new("PingMeasure", 2), + TestCaseMev::CreateBlock => TestCaseName::new("CreateBlock", 3), + } + } + + async fn run(&self, token: &CancellationToken, conf: &TestMevArgs, target: &str) -> TestResult { + match self { + TestCaseMev::Ping => mev_ping_test(token, conf, target).await, + TestCaseMev::PingMeasure => mev_ping_measure_test(token, conf, target).await, + TestCaseMev::CreateBlock => mev_create_block_test(token, conf, target).await, + } + } } /// Runs the MEV relay tests. @@ -122,13 +129,16 @@ pub async fn run(args: TestMevArgs, writer: &mut dyn Write) -> Result Result Result, + queued_tests: &[TestCaseMev], conf: &TestMevArgs, token: CancellationToken, ) -> HashMap> { let (tx, mut rx) = mpsc::channel::<(String, Vec)>(conf.endpoints.len()); for endpoint in &conf.endpoints { - let tx = tx.clone(); let queued_tests = queued_tests.to_vec(); - - let test_cases = test_cases.clone(); - let endpoint = endpoint.clone(); let conf = conf.clone(); - - let child_token = token.child_token(); + let endpoint = endpoint.clone(); + let token = token.clone(); + let tx = tx.clone(); tokio::spawn(async move { - let results = - test_single_mev(&queued_tests, &test_cases, &conf, &endpoint, child_token).await; + let results = test_single_mev(&queued_tests, &conf, &endpoint, token).await; let relay_name = format_mev_relay_name(&endpoint); let _ = tx.send((relay_name, results)).await; }); @@ -213,284 +218,238 @@ async fn test_all_mevs( } async fn test_single_mev( - queued_tests: &[TestCaseName], - test_cases: &HashMap, + queued_tests: &[TestCaseMev], conf: &TestMevArgs, target: &str, token: CancellationToken, ) -> Vec { - let (result_tx, mut result_rx) = mpsc::channel::(queued_tests.len()); - - let queued = queued_tests.to_vec(); - let test_cases = test_cases.clone(); - let conf_clone = conf.clone(); - let target_owned = target.to_string(); + let runner_queued_tests = queued_tests.to_vec(); - let runner_token = token.child_token(); + let mut join_set = JoinSet::new(); + for test_case in runner_queued_tests { + let runner_token = token.clone(); + let conf = conf.clone(); + let target = target.to_string(); - tokio::spawn(async move { - for t in &queued { - if runner_token.is_cancelled() { - return; - } - if let Some(test_fn) = test_cases.get(t) { - let result = test_fn(runner_token.clone(), &conf_clone, &target_owned).await; - if result_tx.send(result).await.is_err() { - return; + join_set.spawn(async move { + let tc_name = test_case.test_case_name(); + tokio::select! { + _ = runner_token.cancelled() => { + let tr = TestResult::new(&tc_name.name); + tr.fail(TestResultError::from_string("timeout/interrupted")) + } + r = test_case.run(&runner_token, &conf, &target) => { + r } } - } - }); + }); + } - let mut all_results = Vec::new(); - let mut test_counter = 0usize; + join_set.join_all().await +} - loop { - tokio::select! { +async fn mev_ping_test(token: &CancellationToken, _conf: &TestMevArgs, target: &str) -> TestResult { + let test_res = TestResult::new("Ping"); + let url = format!("{target}/eth/v1/builder/status"); + let client = reqwest::Client::new(); - _ = token.cancelled() => { - if test_counter < queued_tests.len() { - all_results.push(TestResult { - name: queued_tests[test_counter].name.clone(), - verdict: TestVerdict::Fail, - error: TestResultError::from_string("timeout/interrupted"), - ..TestResult::new("") - }); - } - break; - } - result = result_rx.recv() => { - match result { - Some(r) => { - test_counter = test_counter.saturating_add(1); - all_results.push(r); - } - None => break, - } - } + let (clean_url, creds) = match parse_endpoint_credentials(&url) { + Ok(v) => v, + Err(e) => return test_res.fail(e), + }; + + let resp = tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + r = apply_basic_auth(client.get(&clean_url), creds).send() => match r { + Ok(r) => r, + Err(e) => return test_res.fail(e), } + }; + + if resp.status().as_u16() > 399 { + return test_res.fail(CliError::Other(http_status_error(resp.status()))); } - all_results + test_res.ok() } -fn mev_ping_test<'a>( - token: CancellationToken, - _conf: &'a TestMevArgs, - target: &'a str, -) -> std::pin::Pin + Send + 'a>> { - Box::pin(async move { - let test_res = TestResult::new("Ping"); - let url = format!("{target}/eth/v1/builder/status"); - let client = reqwest::Client::new(); - - let (clean_url, creds) = match parse_endpoint_credentials(&url) { - Ok(v) => v, +async fn mev_ping_measure_test( + token: &CancellationToken, + _conf: &TestMevArgs, + target: &str, +) -> TestResult { + let test_res = TestResult::new("PingMeasure"); + let url = format!("{target}/eth/v1/builder/status"); + + let rtt = tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + r = request_rtt(&url, Method::GET, None, StatusCode::OK) => match r { + Ok(r) => r, Err(e) => return test_res.fail(e), - }; + } + }; - let resp = tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - r = apply_basic_auth(client.get(&clean_url), creds).send() => match r { - Ok(r) => r, - Err(e) => return test_res.fail(e), - } + evaluate_rtt( + rtt, + test_res, + THRESHOLD_MEV_MEASURE_AVG, + THRESHOLD_MEV_MEASURE_POOR, + ) +} + +async fn mev_create_block_test( + token: &CancellationToken, + conf: &TestMevArgs, + target: &str, +) -> TestResult { + let test_res = TestResult::new("CreateBlock"); + + if !conf.load_test { + return TestResult { + verdict: TestVerdict::Skip, + ..test_res }; + } - if resp.status().as_u16() > 399 { - return test_res.fail(CliError::Other(http_status_error(resp.status()))); + let beacon_endpoint = match &conf.beacon_node_endpoint { + Some(ep) => ep.as_str(), + None => { + return test_res.fail(CliError::Other("beacon-node-endpoint required".to_string())); } + }; - test_res.ok() - }) -} + let latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + Ok(b) => b, + Err(e) => return test_res.fail(e), + }; -fn mev_ping_measure_test<'a>( - token: CancellationToken, - _conf: &'a TestMevArgs, - target: &'a str, -) -> std::pin::Pin + Send + 'a>> { - Box::pin(async move { - let test_res = TestResult::new("PingMeasure"); - let url = format!("{target}/eth/v1/builder/status"); - - let rtt = tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - r = request_rtt(&url, Method::GET, None, StatusCode::OK) => match r { - Ok(r) => r, - Err(e) => return test_res.fail(e), - } - }; + let latest_block_ts_unix: i64 = match latest_block.body.execution_payload.timestamp.parse() { + Ok(v) => v, + Err(e) => return test_res.fail(CliError::Other(format!("parse timestamp: {e}"))), + }; - evaluate_rtt( - rtt, - test_res, - THRESHOLD_MEV_MEASURE_AVG, - THRESHOLD_MEV_MEASURE_POOR, - ) - }) -} + let latest_block_ts = std::time::UNIX_EPOCH + .checked_add(Duration::from_secs(latest_block_ts_unix.unsigned_abs())) + .unwrap_or(std::time::UNIX_EPOCH); + let next_block_ts = latest_block_ts + .checked_add(SLOT_TIME) + .unwrap_or(latest_block_ts); -fn mev_create_block_test<'a>( - token: CancellationToken, - conf: &'a TestMevArgs, - target: &'a str, -) -> std::pin::Pin + Send + 'a>> { - Box::pin(async move { - let test_res = TestResult::new("CreateBlock"); - - if !conf.load_test { - return TestResult { - verdict: TestVerdict::Skip, - ..test_res - }; + if let Ok(remaining) = next_block_ts.duration_since(std::time::SystemTime::now()) { + tokio::select! { + _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), + _ = tokio::time::sleep(remaining) => {} } + } - let beacon_endpoint = match &conf.beacon_node_endpoint { - Some(ep) => ep.as_str(), - None => { - return test_res.fail(CliError::Other("beacon-node-endpoint required".to_string())); - } - }; + let latest_slot: i64 = match latest_block.slot.parse() { + Ok(v) => v, + Err(e) => return test_res.fail(CliError::Other(format!("parse slot: {e}"))), + }; - let latest_block = match latest_beacon_block(beacon_endpoint, &token).await { - Ok(b) => b, - Err(e) => return test_res.fail(e), - }; + let mut next_slot = latest_slot.saturating_add(1); + let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); + let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); + + let mut proposer_duties = match fetch_proposers_for_epoch(beacon_endpoint, epoch, &token).await + { + Ok(d) => d, + Err(e) => return test_res.fail(e), + }; + + let mut all_blocks_rtt: Vec = Vec::new(); + let x_timeout_ms = conf.x_timeout_ms; + + info!( + mev_relay = target, + blocks = conf.number_of_payloads, + x_timeout_ms = x_timeout_ms, + "Starting attempts for block creation" + ); + + let mut latest_block = latest_block; + + loop { + if token.is_cancelled() { + break; + } + + let start_iteration = Instant::now(); - let latest_block_ts_unix: i64 = match latest_block.body.execution_payload.timestamp.parse() + let rtt = match create_mev_block( + conf, + target, + x_timeout_ms, + next_slot, + &mut latest_block, + &mut proposer_duties, + beacon_endpoint, + &token, + ) + .await { - Ok(v) => v, - Err(e) => return test_res.fail(CliError::Other(format!("parse timestamp: {e}"))), + Ok(r) => r, + Err(e) => return test_res.fail(e), }; - let latest_block_ts = std::time::UNIX_EPOCH - .checked_add(Duration::from_secs(latest_block_ts_unix.unsigned_abs())) - .unwrap_or(std::time::UNIX_EPOCH); - let next_block_ts = latest_block_ts - .checked_add(SLOT_TIME) - .unwrap_or(latest_block_ts); + all_blocks_rtt.push(rtt); + if all_blocks_rtt.len() == usize::try_from(conf.number_of_payloads).unwrap_or(usize::MAX) { + break; + } - if let Ok(remaining) = next_block_ts.duration_since(std::time::SystemTime::now()) { + let elapsed = start_iteration.elapsed(); + let elapsed_nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); + let slot_nanos = u64::try_from(SLOT_TIME.as_nanos()).unwrap_or(1); + let remainder_nanos = elapsed_nanos.checked_rem(slot_nanos).unwrap_or(0); + let slot_remainder = SLOT_TIME + .checked_sub(Duration::from_nanos(remainder_nanos)) + .unwrap_or_default(); + if let Some(sleep_dur) = slot_remainder.checked_sub(Duration::from_secs(1)) { tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - _ = tokio::time::sleep(remaining) => {} + _ = token.cancelled() => break, + _ = tokio::time::sleep(sleep_dur) => {} } } - let latest_slot: i64 = match latest_block.slot.parse() { + let start_beacon_fetch = Instant::now(); + latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + Ok(b) => b, + Err(e) => return test_res.fail(e), + }; + + let latest_slot_parsed: i64 = match latest_block.slot.parse() { Ok(v) => v, Err(e) => return test_res.fail(CliError::Other(format!("parse slot: {e}"))), }; - let mut next_slot = latest_slot.saturating_add(1); - let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); - let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); - - let mut proposer_duties = - match fetch_proposers_for_epoch(beacon_endpoint, epoch, &token).await { - Ok(d) => d, - Err(e) => return test_res.fail(e), - }; - - let mut all_blocks_rtt: Vec = Vec::new(); - let x_timeout_ms = conf.x_timeout_ms; + next_slot = latest_slot_parsed.saturating_add(1); - info!( - mev_relay = target, - blocks = conf.number_of_payloads, - x_timeout_ms = x_timeout_ms, - "Starting attempts for block creation" - ); - - let mut latest_block = latest_block; - - loop { - if token.is_cancelled() { - break; - } - - let start_iteration = Instant::now(); - - let rtt = match create_mev_block( - conf, - target, - x_timeout_ms, - next_slot, - &mut latest_block, - &mut proposer_duties, - beacon_endpoint, - &token, - ) - .await - { - Ok(r) => r, - Err(e) => return test_res.fail(e), - }; - - all_blocks_rtt.push(rtt); - if all_blocks_rtt.len() - == usize::try_from(conf.number_of_payloads).unwrap_or(usize::MAX) - { - break; - } - - let elapsed = start_iteration.elapsed(); - let elapsed_nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); - let slot_nanos = u64::try_from(SLOT_TIME.as_nanos()).unwrap_or(1); - let remainder_nanos = elapsed_nanos.checked_rem(slot_nanos).unwrap_or(0); - let slot_remainder = SLOT_TIME - .checked_sub(Duration::from_nanos(remainder_nanos)) - .unwrap_or_default(); - if let Some(sleep_dur) = slot_remainder.checked_sub(Duration::from_secs(1)) { - tokio::select! { - _ = token.cancelled() => break, - _ = tokio::time::sleep(sleep_dur) => {} - } - } - - let start_beacon_fetch = Instant::now(); - latest_block = match latest_beacon_block(beacon_endpoint, &token).await { - Ok(b) => b, - Err(e) => return test_res.fail(e), - }; - - let latest_slot_parsed: i64 = match latest_block.slot.parse() { - Ok(v) => v, - Err(e) => return test_res.fail(CliError::Other(format!("parse slot: {e}"))), - }; - - next_slot = latest_slot_parsed.saturating_add(1); - - // Wait 1 second minus how long the fetch took. - if let Some(sleep_dur) = - Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) - { - tokio::select! { - _ = token.cancelled() => break, - _ = tokio::time::sleep(sleep_dur) => {} - } + // Wait 1 second minus how long the fetch took. + if let Some(sleep_dur) = Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) { + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(sleep_dur) => {} } } + } - if all_blocks_rtt.is_empty() { - return test_res.fail(CliError::Other("timeout/interrupted".to_string())); - } + if all_blocks_rtt.is_empty() { + return test_res.fail(CliError::Other("timeout/interrupted".to_string())); + } - let total_rtt: Duration = all_blocks_rtt.iter().sum(); - let count = u32::try_from(all_blocks_rtt.len().max(1)).unwrap_or(u32::MAX); - let average_rtt = total_rtt.checked_div(count).unwrap_or_default(); + let total_rtt: Duration = all_blocks_rtt.iter().sum(); + let count = u32::try_from(all_blocks_rtt.len().max(1)).unwrap_or(u32::MAX); + let average_rtt = total_rtt.checked_div(count).unwrap_or_default(); - let avg_threshold = Duration::from_millis( - u64::from(x_timeout_ms) - .saturating_mul(9) - .checked_div(10) - .unwrap_or(0), - ); - let poor_threshold = Duration::from_millis(u64::from(x_timeout_ms)); + let avg_threshold = Duration::from_millis( + u64::from(x_timeout_ms) + .saturating_mul(9) + .checked_div(10) + .unwrap_or(0), + ); + let poor_threshold = Duration::from_millis(u64::from(x_timeout_ms)); - evaluate_rtt(average_rtt, test_res, avg_threshold, poor_threshold) - }) + evaluate_rtt(average_rtt, test_res, avg_threshold, poor_threshold) } // Helper types diff --git a/crates/cli/src/commands/test/mod.rs b/crates/cli/src/commands/test/mod.rs index a6df9b2c..01a555f7 100644 --- a/crates/cli/src/commands/test/mod.rs +++ b/crates/cli/src/commands/test/mod.rs @@ -642,23 +642,6 @@ pub(crate) fn calculate_score(results: &[TestResult]) -> CategoryScore { } } -/// Filters tests based on configuration. -pub(crate) fn filter_tests( - supported_test_cases: &HashMap, - test_cases: Option<&[String]>, -) -> Vec { - let mut filtered: Vec = supported_test_cases.keys().cloned().collect(); - 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)?) From c65b0a2fa7603f0432d66c024a31a01a7d60f0ca Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:18:13 -0300 Subject: [PATCH 04/23] Use `JoinSet` --- crates/cli/src/commands/test/mev.rs | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index f085a76e..10f46dfa 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -7,7 +7,7 @@ use std::{ }; use reqwest::{Method, StatusCode}; -use tokio::{sync::mpsc, task::JoinSet}; +use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::info; @@ -191,30 +191,23 @@ async fn test_all_mevs( conf: &TestMevArgs, token: CancellationToken, ) -> HashMap> { - let (tx, mut rx) = mpsc::channel::<(String, Vec)>(conf.endpoints.len()); + let mut join_set = JoinSet::new(); for endpoint in &conf.endpoints { let queued_tests = queued_tests.to_vec(); let conf = conf.clone(); let endpoint = endpoint.clone(); let token = token.clone(); - let tx = tx.clone(); - tokio::spawn(async move { + join_set.spawn(async move { let results = test_single_mev(&queued_tests, &conf, &endpoint, token).await; let relay_name = format_mev_relay_name(&endpoint); - let _ = tx.send((relay_name, results)).await; + (relay_name, results) }); } - drop(tx); - - let mut all_results = HashMap::new(); - while let Some((name, results)) = rx.recv().await { - all_results.insert(name, results); - } - - all_results + let all_results = join_set.join_all().await; + all_results.into_iter().collect::>() } async fn test_single_mev( From 5b4d55e2124f3f042a92c6dea40e8df5b429a737 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:20:33 -0300 Subject: [PATCH 05/23] Consistent argument ordering --- crates/cli/src/commands/test/mev.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 10f46dfa..ca27fda8 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -104,9 +104,9 @@ impl TestCaseMev { async fn run(&self, token: &CancellationToken, conf: &TestMevArgs, target: &str) -> TestResult { match self { - TestCaseMev::Ping => mev_ping_test(token, conf, target).await, - TestCaseMev::PingMeasure => mev_ping_measure_test(token, conf, target).await, - TestCaseMev::CreateBlock => mev_create_block_test(token, conf, target).await, + TestCaseMev::Ping => mev_ping_test(target, conf, token).await, + TestCaseMev::PingMeasure => mev_ping_measure_test(target, conf, token).await, + TestCaseMev::CreateBlock => mev_create_block_test(target, conf, token).await, } } } @@ -216,22 +216,21 @@ async fn test_single_mev( target: &str, token: CancellationToken, ) -> Vec { - let runner_queued_tests = queued_tests.to_vec(); - let mut join_set = JoinSet::new(); - for test_case in runner_queued_tests { - let runner_token = token.clone(); + + for test_case in queued_tests.to_owned() { + let token = token.clone(); let conf = conf.clone(); let target = target.to_string(); join_set.spawn(async move { let tc_name = test_case.test_case_name(); tokio::select! { - _ = runner_token.cancelled() => { + _ = token.cancelled() => { let tr = TestResult::new(&tc_name.name); tr.fail(TestResultError::from_string("timeout/interrupted")) } - r = test_case.run(&runner_token, &conf, &target) => { + r = test_case.run(&token, &conf, &target) => { r } } @@ -241,7 +240,7 @@ async fn test_single_mev( join_set.join_all().await } -async fn mev_ping_test(token: &CancellationToken, _conf: &TestMevArgs, target: &str) -> TestResult { +async fn mev_ping_test(target: &str, _conf: &TestMevArgs, token: &CancellationToken) -> TestResult { let test_res = TestResult::new("Ping"); let url = format!("{target}/eth/v1/builder/status"); let client = reqwest::Client::new(); @@ -267,9 +266,9 @@ async fn mev_ping_test(token: &CancellationToken, _conf: &TestMevArgs, target: & } async fn mev_ping_measure_test( - token: &CancellationToken, - _conf: &TestMevArgs, target: &str, + _conf: &TestMevArgs, + token: &CancellationToken, ) -> TestResult { let test_res = TestResult::new("PingMeasure"); let url = format!("{target}/eth/v1/builder/status"); @@ -291,9 +290,9 @@ async fn mev_ping_measure_test( } async fn mev_create_block_test( - token: &CancellationToken, - conf: &TestMevArgs, target: &str, + conf: &TestMevArgs, + token: &CancellationToken, ) -> TestResult { let test_res = TestResult::new("CreateBlock"); From 79dfc877939a80f81373c009c324bc4985d775c7 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:29:41 -0300 Subject: [PATCH 06/23] Remove select with CT - Test is already cancelled by caller appropriately --- crates/cli/src/commands/test/mev.rs | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index ca27fda8..b023279b 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -328,10 +328,7 @@ async fn mev_create_block_test( .unwrap_or(latest_block_ts); if let Ok(remaining) = next_block_ts.duration_since(std::time::SystemTime::now()) { - tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - _ = tokio::time::sleep(remaining) => {} - } + tokio::time::sleep(remaining).await; } let latest_slot: i64 = match latest_block.slot.parse() { @@ -362,10 +359,6 @@ async fn mev_create_block_test( let mut latest_block = latest_block; loop { - if token.is_cancelled() { - break; - } - let start_iteration = Instant::now(); let rtt = match create_mev_block( @@ -397,10 +390,7 @@ async fn mev_create_block_test( .checked_sub(Duration::from_nanos(remainder_nanos)) .unwrap_or_default(); if let Some(sleep_dur) = slot_remainder.checked_sub(Duration::from_secs(1)) { - tokio::select! { - _ = token.cancelled() => break, - _ = tokio::time::sleep(sleep_dur) => {} - } + tokio::time::sleep(sleep_dur).await; } let start_beacon_fetch = Instant::now(); @@ -418,10 +408,7 @@ async fn mev_create_block_test( // Wait 1 second minus how long the fetch took. if let Some(sleep_dur) = Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) { - tokio::select! { - _ = token.cancelled() => break, - _ = tokio::time::sleep(sleep_dur) => {} - } + tokio::time::sleep(sleep_dur).await; } } From dec0653b94754c724935cb04555602064d79cfe7 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:44:24 -0300 Subject: [PATCH 07/23] Reorder parameters --- crates/cli/src/commands/test/mev.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index b023279b..4a8bf428 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -102,7 +102,7 @@ impl TestCaseMev { } } - async fn run(&self, token: &CancellationToken, conf: &TestMevArgs, target: &str) -> TestResult { + async fn run(&self, target: &str, conf: &TestMevArgs, token: &CancellationToken) -> TestResult { match self { TestCaseMev::Ping => mev_ping_test(target, conf, token).await, TestCaseMev::PingMeasure => mev_ping_measure_test(target, conf, token).await, @@ -230,7 +230,7 @@ async fn test_single_mev( let tr = TestResult::new(&tc_name.name); tr.fail(TestResultError::from_string("timeout/interrupted")) } - r = test_case.run(&token, &conf, &target) => { + r = test_case.run(&target, &conf, &token) => { r } } From 97061613342fb8fc9eae5c046c0415d9b84ba692 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:51:45 -0300 Subject: [PATCH 08/23] Remove redundant `CancellationToken`s --- crates/cli/src/commands/test/mev.rs | 148 ++++++++++------------------ 1 file changed, 50 insertions(+), 98 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 4a8bf428..079c02a7 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -102,11 +102,11 @@ impl TestCaseMev { } } - async fn run(&self, target: &str, conf: &TestMevArgs, token: &CancellationToken) -> TestResult { + async fn run(&self, target: &str, conf: &TestMevArgs) -> TestResult { match self { - TestCaseMev::Ping => mev_ping_test(target, conf, token).await, - TestCaseMev::PingMeasure => mev_ping_measure_test(target, conf, token).await, - TestCaseMev::CreateBlock => mev_create_block_test(target, conf, token).await, + TestCaseMev::Ping => mev_ping_test(target, conf).await, + TestCaseMev::PingMeasure => mev_ping_measure_test(target, conf).await, + TestCaseMev::CreateBlock => mev_create_block_test(target, conf).await, } } } @@ -230,7 +230,7 @@ async fn test_single_mev( let tr = TestResult::new(&tc_name.name); tr.fail(TestResultError::from_string("timeout/interrupted")) } - r = test_case.run(&target, &conf, &token) => { + r = test_case.run(&target, &conf) => { r } } @@ -240,7 +240,7 @@ async fn test_single_mev( join_set.join_all().await } -async fn mev_ping_test(target: &str, _conf: &TestMevArgs, token: &CancellationToken) -> TestResult { +async fn mev_ping_test(target: &str, _conf: &TestMevArgs) -> TestResult { let test_res = TestResult::new("Ping"); let url = format!("{target}/eth/v1/builder/status"); let client = reqwest::Client::new(); @@ -250,12 +250,9 @@ async fn mev_ping_test(target: &str, _conf: &TestMevArgs, token: &CancellationTo Err(e) => return test_res.fail(e), }; - let resp = tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - r = apply_basic_auth(client.get(&clean_url), creds).send() => match r { - Ok(r) => r, - Err(e) => return test_res.fail(e), - } + let resp = match apply_basic_auth(client.get(&clean_url), creds).send().await { + Ok(r) => r, + Err(e) => return test_res.fail(e), }; if resp.status().as_u16() > 399 { @@ -265,20 +262,13 @@ async fn mev_ping_test(target: &str, _conf: &TestMevArgs, token: &CancellationTo test_res.ok() } -async fn mev_ping_measure_test( - target: &str, - _conf: &TestMevArgs, - token: &CancellationToken, -) -> TestResult { +async fn mev_ping_measure_test(target: &str, _conf: &TestMevArgs) -> TestResult { let test_res = TestResult::new("PingMeasure"); let url = format!("{target}/eth/v1/builder/status"); - let rtt = tokio::select! { - _ = token.cancelled() => return test_res.fail(CliError::Other("timeout/interrupted".to_string())), - r = request_rtt(&url, Method::GET, None, StatusCode::OK) => match r { - Ok(r) => r, - Err(e) => return test_res.fail(e), - } + let rtt = match request_rtt(&url, Method::GET, None, StatusCode::OK).await { + Ok(r) => r, + Err(e) => return test_res.fail(e), }; evaluate_rtt( @@ -289,11 +279,7 @@ async fn mev_ping_measure_test( ) } -async fn mev_create_block_test( - target: &str, - conf: &TestMevArgs, - token: &CancellationToken, -) -> TestResult { +async fn mev_create_block_test(target: &str, conf: &TestMevArgs) -> TestResult { let test_res = TestResult::new("CreateBlock"); if !conf.load_test { @@ -310,7 +296,7 @@ async fn mev_create_block_test( } }; - let latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + let latest_block = match latest_beacon_block(beacon_endpoint).await { Ok(b) => b, Err(e) => return test_res.fail(e), }; @@ -340,8 +326,7 @@ async fn mev_create_block_test( let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); - let mut proposer_duties = match fetch_proposers_for_epoch(beacon_endpoint, epoch, &token).await - { + let mut proposer_duties = match fetch_proposers_for_epoch(beacon_endpoint, epoch).await { Ok(d) => d, Err(e) => return test_res.fail(e), }; @@ -369,7 +354,6 @@ async fn mev_create_block_test( &mut latest_block, &mut proposer_duties, beacon_endpoint, - &token, ) .await { @@ -394,7 +378,7 @@ async fn mev_create_block_test( } let start_beacon_fetch = Instant::now(); - latest_block = match latest_beacon_block(beacon_endpoint, &token).await { + latest_block = match latest_beacon_block(beacon_endpoint).await { Ok(b) => b, Err(e) => return test_res.fail(e), }; @@ -476,20 +460,15 @@ struct BuilderBidResponse { data: serde_json::Value, } -async fn latest_beacon_block( - endpoint: &str, - token: &CancellationToken, -) -> Result { +async fn latest_beacon_block(endpoint: &str) -> Result { let url = format!("{endpoint}/eth/v2/beacon/blocks/head"); let (clean_url, creds) = parse_endpoint_credentials(&url)?; let client = reqwest::Client::new(); - let resp = tokio::select! { - _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), - r = apply_basic_auth(client.get(&clean_url), creds).send() => { - r.map_err(|e| CliError::Other(format!("http request do: {e}")))? - } - }; + let resp = apply_basic_auth(client.get(&clean_url), creds) + .send() + .await + .map_err(|e| CliError::Other(format!("http request do: {e}")))?; let body = resp .bytes() @@ -505,18 +484,15 @@ async fn latest_beacon_block( async fn fetch_proposers_for_epoch( beacon_endpoint: &str, epoch: i64, - token: &CancellationToken, ) -> Result> { let url = format!("{beacon_endpoint}/eth/v1/validator/duties/proposer/{epoch}"); let (clean_url, creds) = parse_endpoint_credentials(&url)?; let client = reqwest::Client::new(); - let resp = tokio::select! { - _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), - r = apply_basic_auth(client.get(&clean_url), creds).send() => { - r.map_err(|e| CliError::Other(format!("http request do: {e}")))? - } - }; + let resp = apply_basic_auth(client.get(&clean_url), creds) + .send() + .await + .map_err(|e| CliError::Other(format!("http request do: {e}")))?; let body = resp .bytes() @@ -543,7 +519,6 @@ async fn get_block_header( next_slot: i64, block_hash: &str, validator_pub_key: &str, - token: &CancellationToken, ) -> std::result::Result<(BuilderBidResponse, Duration), MevError> { let url = format!("{target}/eth/v1/builder/header/{next_slot}/{block_hash}/{validator_pub_key}"); @@ -554,24 +529,19 @@ async fn get_block_header( let client = reqwest::Client::new(); let start = Instant::now(); - let resp = tokio::select! { - _ = token.cancelled() => { - return Err(MevError::Cli(CliError::Other("timeout/interrupted".to_string()))); - } - r = apply_basic_auth(client.get(&clean_url), creds) - .header("X-Timeout-Ms", x_timeout_ms.to_string()) - .header( - "Date-Milliseconds", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .to_string(), - ) - .send() => { - r.map_err(|e| MevError::Cli(CliError::Other(format!("http request rtt: {e}"))))? - } - }; + let resp = apply_basic_auth(client.get(&clean_url), creds) + .header("X-Timeout-Ms", x_timeout_ms.to_string()) + .header( + "Date-Milliseconds", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .to_string(), + ) + .send() + .await + .map_err(|e| MevError::Cli(CliError::Other(format!("http request rtt: {e}"))))?; let rtt = start.elapsed(); @@ -599,16 +569,11 @@ async fn create_mev_block( latest_block: &mut BeaconBlockMessage, proposer_duties: &mut Vec, beacon_endpoint: &str, - token: &CancellationToken, ) -> Result { let rtt_get_header; let builder_bid; loop { - if token.is_cancelled() { - return Err(CliError::Other("timeout/interrupted".to_string())); - } - let start_iteration = Instant::now(); let slots_in_epoch_i64 = i64::try_from(SLOTS_IN_EPOCH).unwrap_or(i64::MAX); let epoch = next_slot.checked_div(slots_in_epoch_i64).unwrap_or(0); @@ -616,7 +581,7 @@ async fn create_mev_block( let pk = if let Some(pk) = get_validator_pk_for_slot(proposer_duties, next_slot) { pk } else { - *proposer_duties = fetch_proposers_for_epoch(beacon_endpoint, epoch, token).await?; + *proposer_duties = fetch_proposers_for_epoch(beacon_endpoint, epoch).await?; get_validator_pk_for_slot(proposer_duties, next_slot) .ok_or_else(|| CliError::Other("slot not found".to_string()))? }; @@ -627,7 +592,6 @@ async fn create_mev_block( next_slot, &latest_block.body.execution_payload.block_hash, &pk, - token, ) .await { @@ -648,27 +612,17 @@ async fn create_mev_block( if let Some(sleep_dur) = SLOT_TIME.checked_sub(elapsed) && let Some(sleep_dur) = sleep_dur.checked_sub(Duration::from_secs(1)) { - tokio::select! { - _ = token.cancelled() => { - return Err(CliError::Other("timeout/interrupted".to_string())); - } - _ = tokio::time::sleep(sleep_dur) => {} - } + tokio::time::sleep(sleep_dur).await; } let start_beacon_fetch = Instant::now(); - *latest_block = latest_beacon_block(beacon_endpoint, token).await?; + *latest_block = latest_beacon_block(beacon_endpoint).await?; next_slot = next_slot.saturating_add(1); if let Some(sleep_dur) = Duration::from_secs(1).checked_sub(start_beacon_fetch.elapsed()) { - tokio::select! { - _ = token.cancelled() => { - return Err(CliError::Other("timeout/interrupted".to_string())); - } - _ = tokio::time::sleep(sleep_dur) => {} - } + tokio::time::sleep(sleep_dur).await; } continue; @@ -684,15 +638,13 @@ async fn create_mev_block( )) })?; - let rtt_submit_block = tokio::select! { - _ = token.cancelled() => return Err(CliError::Other("timeout/interrupted".to_string())), - r = request_rtt( - format!("{target}/eth/v1/builder/blinded_blocks"), - Method::POST, - Some(payload_json), - StatusCode::BAD_REQUEST, - ) => r? - }; + let rtt_submit_block = request_rtt( + format!("{target}/eth/v1/builder/blinded_blocks"), + Method::POST, + Some(payload_json), + StatusCode::BAD_REQUEST, + ) + .await?; Ok(rtt_get_header .checked_add(rtt_submit_block) From 9c690e39acdf7dad391bbf5bf0edc9b4c27fc101 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 17:57:56 -0300 Subject: [PATCH 09/23] Pass main CT --- crates/cli/src/commands/test/mev.rs | 18 ++++++++++++------ crates/cli/src/main.rs | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 079c02a7..eba8b857 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -112,7 +112,11 @@ impl TestCaseMev { } /// Runs the MEV relay tests. -pub async fn run(args: TestMevArgs, writer: &mut dyn Write) -> Result { +pub async fn run( + args: TestMevArgs, + writer: &mut dyn Write, + token: &CancellationToken, +) -> Result { must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; // Validate flag combinations. @@ -140,11 +144,13 @@ pub async fn run(args: TestMevArgs, writer: &mut dyn Write) -> Result ExitResult { .await .map(|_| ()) } - TestCommands::Mev(args) => commands::test::mev::run(args, &mut stdout) + TestCommands::Mev(args) => commands::test::mev::run(args, &mut stdout, &ct) .await .map(|_| ()), TestCommands::Infra(args) => commands::test::infra::run(args, &mut stdout) From 0c11a442189190abd5862f9c52d94359e45e411c Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Fri, 3 Apr 2026 18:08:02 -0300 Subject: [PATCH 10/23] Fix clippy lints - Workaround for false positive --- crates/cli/src/commands/test/mev.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index eba8b857..52607ee9 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -224,7 +224,8 @@ async fn test_single_mev( ) -> Vec { let mut join_set = JoinSet::new(); - for test_case in queued_tests.to_owned() { + let queued_tests = queued_tests.to_vec(); + for test_case in queued_tests { let token = token.clone(); let conf = conf.clone(); let target = target.to_string(); From aad679bb0c90a7a3ae0613fd103f56e1f4d35a2f Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:08:36 +0200 Subject: [PATCH 11/23] fix(cli/mev): address PR review comments for mev test command - Use tokio::time::Instant instead of std::time::Instant in async code - Replace CliError::Other string literals with typed variants TimeoutInterrupted and TestCaseNotSupported - Remove TestResultError from imports; use CliError::TimeoutInterrupted directly - Simplify latest_beacon_block and fetch_proposers_for_epoch to use ? operator, leveraging existing From impls on CliError - Simplify get_block_header error conversions with .map_err(MevError::from) and .map_err(|e| MevError::Cli(e.into())) Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- crates/cli/src/commands/test/mev.rs | 56 +++++++++-------------------- crates/cli/src/error.rs | 4 +-- 2 files changed, 19 insertions(+), 41 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 52607ee9..3dc1f74d 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -1,19 +1,15 @@ //! MEV relay tests. -use std::{ - collections::HashMap, - io::Write, - time::{Duration, Instant}, -}; +use std::{collections::HashMap, io::Write, time::Duration}; use reqwest::{Method, StatusCode}; -use tokio::task::JoinSet; +use tokio::{task::JoinSet, time::Instant}; use tokio_util::sync::CancellationToken; use tracing::info; use super::{ AllCategoriesResult, SLOT_TIME, SLOTS_IN_EPOCH, TestCategory, TestCategoryResult, - TestConfigArgs, TestResult, TestResultError, TestVerdict, calculate_score, evaluate_rtt, + TestConfigArgs, TestResult, TestVerdict, calculate_score, evaluate_rtt, must_output_to_file_on_quiet, publish_result_to_obol_api, request_rtt, write_result_to_file, write_result_to_writer, }; @@ -141,7 +137,7 @@ pub async fn run( filtered }; if queued_tests.is_empty() { - return Err(CliError::Other("test case not supported".to_string())); + return Err(CliError::TestCaseNotSupported); } let token = token.child_token(); @@ -235,7 +231,7 @@ async fn test_single_mev( tokio::select! { _ = token.cancelled() => { let tr = TestResult::new(&tc_name.name); - tr.fail(TestResultError::from_string("timeout/interrupted")) + tr.fail(CliError::TimeoutInterrupted) } r = test_case.run(&target, &conf) => { r @@ -404,7 +400,7 @@ async fn mev_create_block_test(target: &str, conf: &TestMevArgs) -> TestResult { } if all_blocks_rtt.is_empty() { - return test_res.fail(CliError::Other("timeout/interrupted".to_string())); + return test_res.fail(CliError::TimeoutInterrupted); } let total_rtt: Duration = all_blocks_rtt.iter().sum(); @@ -474,16 +470,9 @@ async fn latest_beacon_block(endpoint: &str) -> Result { let resp = apply_basic_auth(client.get(&clean_url), creds) .send() - .await - .map_err(|e| CliError::Other(format!("http request do: {e}")))?; - - let body = resp - .bytes() - .await - .map_err(|e| CliError::Other(format!("http response body: {e}")))?; - - let block: BeaconBlock = serde_json::from_slice(&body) - .map_err(|e| CliError::Other(format!("http response json: {e}")))?; + .await?; + let body = resp.bytes().await?; + let block: BeaconBlock = serde_json::from_slice(&body)?; Ok(block.data.message) } @@ -498,16 +487,9 @@ async fn fetch_proposers_for_epoch( let resp = apply_basic_auth(client.get(&clean_url), creds) .send() - .await - .map_err(|e| CliError::Other(format!("http request do: {e}")))?; - - let body = resp - .bytes() - .await - .map_err(|e| CliError::Other(format!("http response body: {e}")))?; - - let duties: ProposerDuties = serde_json::from_slice(&body) - .map_err(|e| CliError::Other(format!("http response json: {e}")))?; + .await?; + let body = resp.bytes().await?; + let duties: ProposerDuties = serde_json::from_slice(&body)?; Ok(duties.data) } @@ -530,8 +512,7 @@ async fn get_block_header( let url = format!("{target}/eth/v1/builder/header/{next_slot}/{block_hash}/{validator_pub_key}"); - let (clean_url, creds) = parse_endpoint_credentials(&url) - .map_err(|e| MevError::Cli(CliError::Other(format!("parse url: {e}"))))?; + let (clean_url, creds) = parse_endpoint_credentials(&url).map_err(MevError::from)?; let client = reqwest::Client::new(); let start = Instant::now(); @@ -548,7 +529,7 @@ async fn get_block_header( ) .send() .await - .map_err(|e| MevError::Cli(CliError::Other(format!("http request rtt: {e}"))))?; + .map_err(|e| MevError::Cli(e.into()))?; let rtt = start.elapsed(); @@ -556,13 +537,10 @@ async fn get_block_header( return Err(MevError::StatusCodeNot200); } - let body = resp - .bytes() - .await - .map_err(|e| MevError::Cli(CliError::Other(format!("http response body: {e}"))))?; + let body = resp.bytes().await.map_err(|e| MevError::Cli(e.into()))?; - let bid: BuilderBidResponse = serde_json::from_slice(&body) - .map_err(|e| MevError::Cli(CliError::Other(format!("http response json: {e}"))))?; + let bid: BuilderBidResponse = + serde_json::from_slice(&body).map_err(|e| MevError::Cli(e.into()))?; Ok((bid, rtt)) } diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index e049863e..eb461a1e 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -81,11 +81,11 @@ pub enum CliError { /// Test timeout or interrupted. #[error("timeout/interrupted")] - _TimeoutInterrupted, + TimeoutInterrupted, /// Test case not supported. #[error("test case not supported")] - _TestCaseNotSupported, + TestCaseNotSupported, /// Generic error with message. #[error("{0}")] From e961db0c72b3f7e76f52338e3bb3ac48d34a38a3 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:09:16 +0200 Subject: [PATCH 12/23] fix(deny): ignore RUSTSEC advisories for transitive deps RUSTSEC-2026-0097: rand 0.8.x unsoundness (transitive dep) RUSTSEC-2026-0098: rustls-webpki URI name constraint bypass (transitive dep) Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- deny.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 20794394..c45849a8 100644 --- a/deny.toml +++ b/deny.toml @@ -23,7 +23,12 @@ feature-depth = 1 db-path = "~/.cargo/advisory-db" db-urls = ["https://github.com/rustsec/advisory-db"] yanked = "deny" -ignore = [] +ignore = [ + # rand 0.8.x unsoundness — transitive dep, cannot upgrade immediately + { id = "RUSTSEC-2026-0097" }, + # rustls-webpki URI name constraint bypass — transitive dep + { id = "RUSTSEC-2026-0098" }, +] unmaintained = "workspace" [licenses] From bc7c639dbc1943f9a6b8d62f61b871dccc53e717 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:39:35 +0200 Subject: [PATCH 13/23] fix(cli/mev): remove x-timeout-ms, custom headers, and credentials These features are absent from Charon v1.7.1: - Remove x-timeout-ms CLI flag and x_timeout_ms field from TestMevArgs - Remove X-Timeout-Ms and Date-Milliseconds headers from get_block_header - Remove credential parsing (parse_endpoint_credentials / apply_basic_auth) from mev_ping_test, latest_beacon_block, fetch_proposers_for_epoch, and get_block_header - Replace x_timeout_ms-based thresholds in mev_create_block_test with fixed 500ms/800ms constants matching Go reference thresholds Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- crates/cli/src/commands/test/mev.rs | 104 ++++------------------------ 1 file changed, 15 insertions(+), 89 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 3dc1f74d..6dfa4d38 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -34,6 +34,10 @@ enum MevError { const THRESHOLD_MEV_MEASURE_AVG: Duration = Duration::from_millis(40); /// Threshold for poor MEV ping measure. const THRESHOLD_MEV_MEASURE_POOR: Duration = Duration::from_millis(100); +/// Threshold for average MEV block creation RTT. +const THRESHOLD_MEV_BLOCK_AVG: Duration = Duration::from_millis(500); +/// Threshold for poor MEV block creation RTT. +const THRESHOLD_MEV_BLOCK_POOR: Duration = Duration::from_millis(800); /// Arguments for the MEV test command. #[derive(Args, Clone, Debug)] @@ -68,14 +72,6 @@ pub struct TestMevArgs { help = "Increases the accuracy of the load test by asking for multiple payloads. Increases test duration." )] pub number_of_payloads: u32, - - /// X-Timeout-Ms header flag for each request in milliseconds. - #[arg( - long = "x-timeout-ms", - default_value = "1000", - help = "X-Timeout-Ms header flag for each request in milliseconds, used by MEVs to compute maximum delay for reply." - )] - pub x_timeout_ms: u32, } #[derive(Debug, Clone)] @@ -248,12 +244,7 @@ async fn mev_ping_test(target: &str, _conf: &TestMevArgs) -> TestResult { let url = format!("{target}/eth/v1/builder/status"); let client = reqwest::Client::new(); - let (clean_url, creds) = match parse_endpoint_credentials(&url) { - Ok(v) => v, - Err(e) => return test_res.fail(e), - }; - - let resp = match apply_basic_auth(client.get(&clean_url), creds).send().await { + let resp = match client.get(&url).send().await { Ok(r) => r, Err(e) => return test_res.fail(e), }; @@ -335,12 +326,10 @@ async fn mev_create_block_test(target: &str, conf: &TestMevArgs) -> TestResult { }; let mut all_blocks_rtt: Vec = Vec::new(); - let x_timeout_ms = conf.x_timeout_ms; info!( mev_relay = target, blocks = conf.number_of_payloads, - x_timeout_ms = x_timeout_ms, "Starting attempts for block creation" ); @@ -352,7 +341,6 @@ async fn mev_create_block_test(target: &str, conf: &TestMevArgs) -> TestResult { let rtt = match create_mev_block( conf, target, - x_timeout_ms, next_slot, &mut latest_block, &mut proposer_duties, @@ -407,15 +395,12 @@ async fn mev_create_block_test(target: &str, conf: &TestMevArgs) -> TestResult { let count = u32::try_from(all_blocks_rtt.len().max(1)).unwrap_or(u32::MAX); let average_rtt = total_rtt.checked_div(count).unwrap_or_default(); - let avg_threshold = Duration::from_millis( - u64::from(x_timeout_ms) - .saturating_mul(9) - .checked_div(10) - .unwrap_or(0), - ); - let poor_threshold = Duration::from_millis(u64::from(x_timeout_ms)); - - evaluate_rtt(average_rtt, test_res, avg_threshold, poor_threshold) + evaluate_rtt( + average_rtt, + test_res, + THRESHOLD_MEV_BLOCK_AVG, + THRESHOLD_MEV_BLOCK_POOR, + ) } // Helper types @@ -465,12 +450,7 @@ struct BuilderBidResponse { async fn latest_beacon_block(endpoint: &str) -> Result { let url = format!("{endpoint}/eth/v2/beacon/blocks/head"); - let (clean_url, creds) = parse_endpoint_credentials(&url)?; - let client = reqwest::Client::new(); - - let resp = apply_basic_auth(client.get(&clean_url), creds) - .send() - .await?; + let resp = reqwest::Client::new().get(&url).send().await?; let body = resp.bytes().await?; let block: BeaconBlock = serde_json::from_slice(&body)?; @@ -482,12 +462,7 @@ async fn fetch_proposers_for_epoch( epoch: i64, ) -> Result> { let url = format!("{beacon_endpoint}/eth/v1/validator/duties/proposer/{epoch}"); - let (clean_url, creds) = parse_endpoint_credentials(&url)?; - let client = reqwest::Client::new(); - - let resp = apply_basic_auth(client.get(&clean_url), creds) - .send() - .await?; + let resp = reqwest::Client::new().get(&url).send().await?; let body = resp.bytes().await?; let duties: ProposerDuties = serde_json::from_slice(&body)?; @@ -504,7 +479,6 @@ fn get_validator_pk_for_slot(proposers: &[ProposerDutiesData], slot: i64) -> Opt async fn get_block_header( target: &str, - x_timeout_ms: u32, next_slot: i64, block_hash: &str, validator_pub_key: &str, @@ -512,21 +486,10 @@ async fn get_block_header( let url = format!("{target}/eth/v1/builder/header/{next_slot}/{block_hash}/{validator_pub_key}"); - let (clean_url, creds) = parse_endpoint_credentials(&url).map_err(MevError::from)?; - - let client = reqwest::Client::new(); let start = Instant::now(); - let resp = apply_basic_auth(client.get(&clean_url), creds) - .header("X-Timeout-Ms", x_timeout_ms.to_string()) - .header( - "Date-Milliseconds", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .to_string(), - ) + let resp = reqwest::Client::new() + .get(&url) .send() .await .map_err(|e| MevError::Cli(e.into()))?; @@ -545,11 +508,9 @@ async fn get_block_header( Ok((bid, rtt)) } -#[allow(clippy::too_many_arguments)] async fn create_mev_block( _conf: &TestMevArgs, target: &str, - x_timeout_ms: u32, mut next_slot: i64, latest_block: &mut BeaconBlockMessage, proposer_duties: &mut Vec, @@ -573,7 +534,6 @@ async fn create_mev_block( match get_block_header( target, - x_timeout_ms, next_slot, &latest_block.body.execution_payload.block_hash, &pk, @@ -711,40 +671,6 @@ fn extract_execution_payload_header( }) } -fn parse_endpoint_credentials(raw_url: &str) -> Result<(String, Option<(String, String)>)> { - let parsed = - url::Url::parse(raw_url).map_err(|e| CliError::Other(format!("parse url: {e}")))?; - - let creds = if !parsed.username().is_empty() { - Some(( - parsed.username().to_string(), - parsed.password().unwrap_or("").to_string(), - )) - } else { - None - }; - - let mut clean = parsed.clone(); - clean - .set_username("") - .map_err(|()| CliError::Other("set username on URL".to_string()))?; - clean - .set_password(None) - .map_err(|()| CliError::Other("set password on URL".to_string()))?; - - Ok((clean.to_string(), creds)) -} - -fn apply_basic_auth( - builder: reqwest::RequestBuilder, - creds: Option<(String, String)>, -) -> reqwest::RequestBuilder { - match creds { - Some((user, pass)) => builder.basic_auth(user, Some(pass)), - None => builder, - } -} - fn format_mev_relay_name(url_string: &str) -> String { let Some((scheme, rest)) = url_string.split_once("://") else { return url_string.to_string(); From 6f918ba866661efda422595d74a5643533620a7a Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:17:09 +0200 Subject: [PATCH 14/23] ci: use local /review-pr skill for PR reviews (#331) * ci: use local /review-pr skill for PR reviews Replace the external code-review plugin with the repo's own /review-pr skill, and upgrade pull-requests permission to write so the skill can post inline review comments via the GitHub API. Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> * ci: trigger review via @claude-review comment Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> * ci: use /claude-review as comment trigger Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> * ci: remove claude-code reviewer trigger, keep only /claude-review comment Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --------- Co-authored-by: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 2d3e142f..c3ca3296 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,26 +1,19 @@ name: Claude Code Review on: - pull_request: - types: [review_requested] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + issue_comment: + types: [created] jobs: claude-review: - # Only run when a specific reviewer is requested (e.g., "claude-code" or specific team) if: | - contains(github.event.pull_request.requested_reviewers.*.login, 'claude-code') || - contains(github.event.pull_request.requested_teams.*.slug, 'claude-code') + github.event.issue.pull_request != null && + contains(github.event.comment.body, '/claude-review') runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write @@ -50,8 +43,4 @@ jobs: with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} track_progress: true - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options + prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From dda7858f35140ee2f88212dc01b95ed02d500ae9 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:43:45 +0200 Subject: [PATCH 15/23] ci: fix permissions and pass GITHUB_TOKEN for review comments (#333) Co-authored-by: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c3ca3296..526d4333 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read pull-requests: write - issues: read + issues: write id-token: write steps: @@ -42,5 +42,6 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From 3c8821d6175e4c386d4c2db41fd6c425338e1e53 Mon Sep 17 00:00:00 2001 From: Maciej Skrzypkowski Date: Wed, 15 Apr 2026 22:15:01 +0200 Subject: [PATCH 16/23] chore: fix cargo deny check (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fix cargo deny check Upgrade rustls-webpki 0.103.10 → 0.103.12 (RUSTSEC-2026-0098, RUSTSEC-2026-0099) and multihash 0.19.3 → 0.19.4 (drops yanked core2 0.4.0). * updated rand to 0.9.4 from 0.9.2 * updated other dependencies --- Cargo.lock | 193 +++++++++++++++++++++++++++-------------------------- 1 file changed, 99 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e16f158..9d162211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,9 +104,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.33" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e9e31d834fe25fe991b8884e4b9f0e59db4a97d86e05d1464d6899c013cd62" +checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096" dependencies = [ "alloy-primitives", "num_enum", @@ -373,13 +373,13 @@ dependencies = [ "derive_more", "foldhash 0.2.0", "hashbrown 0.16.1", - "indexmap 2.13.1", + "indexmap 2.14.0", "itoa", "k256", "keccak-asm", "paste", "proptest", - "rand 0.9.3", + "rand 0.9.4", "rapidhash", "ruint", "rustc-hash", @@ -581,7 +581,7 @@ dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.13.1", + "indexmap 2.14.0", "proc-macro-error2", "proc-macro2", "quote", @@ -1175,9 +1175,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -1326,9 +1326,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -1398,7 +1398,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.3", + "rand 0.9.4", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -1592,9 +1592,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.59" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -1855,15 +1855,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -2187,7 +2178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2527,9 +2518,9 @@ dependencies = [ [[package]] name = "ethereum_ssz" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2128a84f7a3850d54ee343334e3392cca61f9f6aa9441eec481b9394b43c238b" +checksum = "368a4a4e4273b0135111fe9464e35465067766a8f664615b5a86338b73864407" dependencies = [ "alloy-primitives", "ethereum_serde_utils", @@ -2607,7 +2598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" dependencies = [ "portable-atomic", - "rand 0.9.3", + "rand 0.9.4", "web-time", ] @@ -2999,7 +2990,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.1", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3054,6 +3045,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.9.1" @@ -3136,7 +3133,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.3", + "rand 0.9.4", "ring", "socket2 0.5.10", "thiserror 2.0.18", @@ -3159,7 +3156,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.3", + "rand 0.9.4", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -3284,15 +3281,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3554,7 +3550,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.9.3", + "rand 0.9.4", "tokio", "url", "xmltree", @@ -3593,12 +3589,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -3736,9 +3732,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "cfg-if", "futures-util", @@ -3794,9 +3790,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libgit2-sys" @@ -4530,14 +4526,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -4591,9 +4587,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -4750,11 +4746,11 @@ dependencies = [ [[package]] name = "multihash" -version = "0.19.3" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" dependencies = [ - "core2", + "no_std_io2", "serde", "unsigned-varint 0.8.0", ] @@ -4856,6 +4852,15 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -5046,7 +5051,7 @@ dependencies = [ "eventsource-stream", "futures-core", "http", - "indexmap 2.13.1", + "indexmap 2.14.0", "oas3", "prettyplease", "proc-macro2", @@ -5103,9 +5108,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ "bitflags", "cfg-if", @@ -5135,9 +5140,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -5319,7 +5324,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.1", + "indexmap 2.14.0", ] [[package]] @@ -5366,9 +5371,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -5995,7 +6000,7 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand 0.9.3", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -6156,7 +6161,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -6236,9 +6241,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -6332,9 +6337,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -6374,9 +6379,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ "bitflags", ] @@ -6589,7 +6594,7 @@ dependencies = [ "primitive-types", "proptest", "rand 0.8.5", - "rand 0.9.3", + "rand 0.9.4", "rlp", "ruint-macro", "serde_core", @@ -6657,9 +6662,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -6722,9 +6727,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -6974,7 +6979,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -7035,7 +7040,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.1", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -7062,7 +7067,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -7628,9 +7633,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -7735,7 +7740,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -7749,7 +7754,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap 2.13.1", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.1", @@ -7818,7 +7823,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.1", + "indexmap 2.14.0", "pin-project-lite", "slab", "sync_wrapper", @@ -8311,9 +8316,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -8324,9 +8329,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ "js-sys", "wasm-bindgen", @@ -8334,9 +8339,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8344,9 +8349,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -8357,9 +8362,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -8381,7 +8386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.1", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -8407,7 +8412,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.1", + "indexmap 2.14.0", "semver 1.0.28", ] @@ -8427,9 +8432,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -8974,7 +8979,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.1", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -9005,7 +9010,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.1", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -9024,7 +9029,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.1", + "indexmap 2.14.0", "log", "semver 1.0.28", "serde", @@ -9129,7 +9134,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "pin-project", - "rand 0.9.3", + "rand 0.9.4", "static_assertions", "web-time", ] From d18ae87f7c78f2224c54b3e42b40b0b21b69f5fa Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:45:24 +0200 Subject: [PATCH 17/23] revert: ci: fix permissions and pass GITHUB_TOKEN for review comments (#333) (#334) Co-authored-by: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 526d4333..c3ca3296 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read pull-requests: write - issues: write + issues: read id-token: write steps: @@ -42,6 +42,5 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From 2b8e09bf18877bd0fc3e355f8fd35ab3843fa76e Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 15 Apr 2026 18:47:25 -0300 Subject: [PATCH 18/23] Revert deny `ignore` --- deny.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/deny.toml b/deny.toml index c8246c12..93786586 100644 --- a/deny.toml +++ b/deny.toml @@ -30,8 +30,6 @@ ignore = [ # (stuck on 0.7.3) and `alloy-signer-local` (stuck on 0.8.5). Neither is # reachable from Pluto's loggers. Remove once upstream bumps to >=0.9.3. { id = "RUSTSEC-2026-0097", reason = "transitive rand <0.9.3 via cuckoofilter and alloy-signer-local; not triggerable from our code" }, - # rustls-webpki URI name constraint bypass — transitive dep - { id = "RUSTSEC-2026-0098" }, ] unmaintained = "workspace" From 5cb1ddaba76a219a1995e875126c6868814d87c3 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Wed, 15 Apr 2026 18:49:41 -0300 Subject: [PATCH 19/23] Pass CT as value instead of ref - Remove unnecessary clones --- crates/cli/src/commands/test/mev.rs | 2 +- crates/cli/src/main.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/commands/test/mev.rs b/crates/cli/src/commands/test/mev.rs index 891f6417..305ed499 100644 --- a/crates/cli/src/commands/test/mev.rs +++ b/crates/cli/src/commands/test/mev.rs @@ -108,7 +108,7 @@ impl TestCaseMev { pub async fn run( args: TestMevArgs, writer: &mut dyn Write, - token: &CancellationToken, + token: CancellationToken, ) -> Result { must_output_to_file_on_quiet(args.test_config.quiet, &args.test_config.output_json)?; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 7ea93592..dfa06ca6 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -55,7 +55,7 @@ async fn run() -> std::result::Result<(), CliError> { Commands::Relay(args) => { let config: pluto_relay_server::config::Config = (*args).clone().try_into()?; pluto_tracing::init(&config.log_config).expect("Failed to initialize tracing"); - commands::relay::run(config, ct.clone()).await + commands::relay::run(config, ct).await } Commands::Alpha(args) => match args.command { AlphaCommands::Test(args) => { @@ -67,7 +67,7 @@ async fn run() -> std::result::Result<(), CliError> { TestCommands::Beacon(args) => { pluto_tracing::init(&pluto_tracing::TracingConfig::default()) .expect("Failed to initialize tracing"); - commands::test::beacon::run(args, &mut stdout, ct.clone()) + commands::test::beacon::run(args, &mut stdout, ct) .await .map(|_| ()) } @@ -76,7 +76,7 @@ async fn run() -> std::result::Result<(), CliError> { .await .map(|_| ()) } - TestCommands::Mev(args) => commands::test::mev::run(args, &mut stdout, &ct) + TestCommands::Mev(args) => commands::test::mev::run(args, &mut stdout, ct) .await .map(|_| ()), TestCommands::Infra(args) => commands::test::infra::run(args, &mut stdout) From be748694fde2c177cccf31b4baacfa208b9e8397 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:43:45 +0200 Subject: [PATCH 20/23] ci: fix permissions and pass GITHUB_TOKEN for review comments (#333) Co-authored-by: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c3ca3296..526d4333 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read pull-requests: write - issues: read + issues: write id-token: write steps: @@ -42,5 +42,6 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From 736332844afd86e25a5208381182df55ac6ab876 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:45:24 +0200 Subject: [PATCH 21/23] revert: ci: fix permissions and pass GITHUB_TOKEN for review comments (#333) (#334) Co-authored-by: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 526d4333..c3ca3296 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read pull-requests: write - issues: write + issues: read id-token: write steps: @@ -42,6 +42,5 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From eb4d1b2529e57a91d36b9c96603ec487b1ccf77e Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:55:45 +0200 Subject: [PATCH 22/23] chore: restore workflow file to match main branch Co-Authored-By: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c3ca3296..526d4333 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -14,7 +14,7 @@ jobs: permissions: contents: read pull-requests: write - issues: read + issues: write id-token: write steps: @@ -42,5 +42,6 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' From 3337bd5f07396286b4a7e768361518005d5362a5 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <273991985+varex83agent@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:06:39 +0200 Subject: [PATCH 23/23] fix: revert workflow file changes --- .github/workflows/claude-code-review.yml | 26 ++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 526d4333..2d3e142f 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,20 +1,27 @@ name: Claude Code Review on: - issue_comment: - types: [created] + pull_request: + types: [review_requested] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" jobs: claude-review: + # Only run when a specific reviewer is requested (e.g., "claude-code" or specific team) if: | - github.event.issue.pull_request != null && - contains(github.event.comment.body, '/claude-review') + contains(github.event.pull_request.requested_reviewers.*.login, 'claude-code') || + contains(github.event.pull_request.requested_teams.*.slug, 'claude-code') runs-on: ubuntu-latest permissions: contents: read - pull-requests: write - issues: write + pull-requests: read + issues: read id-token: write steps: @@ -42,6 +49,9 @@ jobs: uses: anthropics/claude-code-action@v1 with: anthropic_api_key: ${{ secrets.CLAUDE_CODE_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true - prompt: '/review-pr ${{ github.event.pull_request.number || github.event.issue.number }}' + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options