Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

- added contender version to bottom of reports ([#452](https://github.com/flashbots/contender/pull/452/changes))
- enable custom data dir at runtime ([453](https://github.com/flashbots/contender/pull/453/changes))

## [0.8.1](https://github.com/flashbots/contender/releases/tag/v0.8.1) - 2026-02-09

Expand Down
2 changes: 2 additions & 0 deletions crates/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

- changed internal erc20 defaults (didn't match cli defaults) ([#443](https://github.com/flashbots/contender/pull/443/changes))
- use `std::path::Path` instead of `str` where applicable, add data_dir arg to enable custom data dir at runtime ([453](https://github.com/flashbots/contender/pull/453/changes))
- add json option to `report` ([453](https://github.com/flashbots/contender/pull/453/changes))

## [0.8.0](https://github.com/flashbots/contender/releases/tag/v0.8.0) - 2026-02-02

Expand Down
54 changes: 34 additions & 20 deletions crates/cli/src/commands/admin.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use crate::commands::error::ArgsError;
use crate::commands::Result;
use crate::util::data_dir;
use crate::util::error::UtilError;
use alloy::hex::{self, ToHexExt};
use alloy::network::{EthereumWallet, TransactionBuilder};
Expand All @@ -15,21 +14,21 @@ use contender_core::{
};
use contender_sqlite::SqliteDb;
use contender_testfile::TestConfig;
use std::path::Path;
use std::path::{Path, PathBuf};
use thiserror::Error;
use tracing::{info, warn};
use url::Url;

#[derive(Debug, Error)]
pub enum AdminError {
#[error("seed file is empty. path: {0}")]
SeedFileEmpty(String),
SeedFileEmpty(PathBuf),

#[error("failed to read seed file at path: {0}")]
SeedFileDoesNotExist(String),
SeedFileDoesNotExist(PathBuf),

#[error("invalid data in seed file at path: {0}")]
SeedFileInvalid(String),
SeedFileInvalid(PathBuf),

#[error("failed to read input from stdin")]
Readline(std::io::Error),
Expand Down Expand Up @@ -121,9 +120,8 @@ pub enum AdminCommand {
}

/// Reads and validates the seed file
fn read_seed_file() -> Result<Vec<u8>> {
let data_dir = data_dir()?;
let seed_path = format!("{data_dir}/seed");
fn read_seed_file(data_dir: &Path) -> Result<Vec<u8>> {
let seed_path = data_dir.join("seed");
let seed_hex = std::fs::read_to_string(&seed_path)
.map_err(|_| AdminError::SeedFileDoesNotExist(seed_path.to_owned()))?;
let decoded = hex::decode(seed_hex.trim())
Expand All @@ -147,8 +145,8 @@ fn confirm_sensitive_operation(_operation: &str) -> Result<()> {
}

/// Handles the accounts subcommand
fn handle_accounts(from_pool: String, num_signers: usize) -> Result<()> {
let seed_bytes = read_seed_file()?;
fn handle_accounts(from_pool: String, num_signers: usize, data_dir: &Path) -> Result<()> {
let seed_bytes = read_seed_file(data_dir)?;
let seed = RandSeed::seed_from_bytes(&seed_bytes);
print_accounts_for_pool(&from_pool, num_signers, &seed)?;
Ok(())
Expand All @@ -174,16 +172,16 @@ fn print_accounts_for_pool(pool: &str, num_signers: usize, seed: &RandSeed) -> R
}

/// Handles the seed subcommand
fn handle_seed() -> Result<()> {
fn handle_seed(data_dir: &Path) -> Result<()> {
confirm_sensitive_operation("displaying seed value")?;
let seed_bytes = read_seed_file()?;
let seed_bytes = read_seed_file(data_dir)?;
println!("{}", hex::encode(seed_bytes));
Ok(())
}

fn print_setcode_account() -> Result<()> {
fn print_setcode_account(data_dir: &Path) -> Result<()> {
confirm_sensitive_operation("displaying private key")?;
let seed_bytes = read_seed_file()?;
let seed_bytes = read_seed_file(data_dir)?;
let seed = RandSeed::seed_from_bytes(&seed_bytes);
let (signer, key) = generate_setcode_signer(&seed);
println!("Address:\t{}", signer.address());
Expand All @@ -198,6 +196,7 @@ async fn handle_reclaim_eth(
from_pool: Vec<String>,
num_accounts: usize,
scenario_file: Option<String>,
data_dir: &Path,
db: &SqliteDb,
) -> Result<()> {
// Determine RPC URL and from_pools
Expand Down Expand Up @@ -295,7 +294,7 @@ async fn handle_reclaim_eth(
ProviderBuilder::new().connect_http(rpc_url.parse().map_err(ArgsError::UrlParse)?);

// Generate accounts from seed
let seed_bytes = read_seed_file()?;
let seed_bytes = read_seed_file(data_dir)?;
let seed = RandSeed::seed_from_bytes(&seed_bytes);

let mut total_reclaimed = U256::ZERO;
Expand Down Expand Up @@ -406,27 +405,42 @@ async fn handle_contract_address(contract_name: String, rpc_url: Url, db: &Sqlit
Ok(())
}

pub async fn handle_admin_command(command: AdminCommand, db: SqliteDb) -> Result<()> {
pub async fn handle_admin_command(
command: AdminCommand,
data_dir: &Path,
db: SqliteDb,
) -> Result<()> {
match command {
AdminCommand::Accounts {
from_pool,
num_signers,
} => handle_accounts(from_pool, num_signers),
} => handle_accounts(from_pool, num_signers, data_dir),
AdminCommand::LatestRunId => {
let num_runs = db.num_runs()?;
info!("Latest run ID: {num_runs}");
println!("{num_runs}");
Ok(())
}
AdminCommand::Seed => handle_seed(),
AdminCommand::SetCodeSigner => print_setcode_account(),
AdminCommand::Seed => handle_seed(data_dir),
AdminCommand::SetCodeSigner => print_setcode_account(data_dir),
AdminCommand::ReclaimEth {
to,
rpc,
from_pool,
num_signers,
scenario_file,
} => handle_reclaim_eth(to, rpc, from_pool, num_signers, scenario_file, &db).await,
} => {
handle_reclaim_eth(
to,
rpc,
from_pool,
num_signers,
scenario_file,
data_dir,
&db,
)
.await
}
AdminCommand::ContractAddress {
contract_name,
rpc_url,
Expand Down
71 changes: 44 additions & 27 deletions crates/cli/src/commands/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ use crate::commands::{
};
use crate::error::CliError;
use crate::util::load_testconfig;
use crate::util::{data_dir, load_seedfile, parse_duration};
use crate::util::{load_seedfile, parse_duration};
use crate::BuiltinScenarioCli;
use alloy::primitives::{keccak256, U256};
use clap::Args;
use contender_core::db::DbOps;
use contender_core::error::RuntimeParamErrorKind;
use contender_testfile::{CampaignConfig, CampaignMode, ResolvedMixEntry, ResolvedStage};
use std::path::Path;
use std::time::Duration;
use tracing::{debug, info, warn};
use url::Url;
Expand Down Expand Up @@ -116,6 +117,7 @@ fn bump_seed(base_seed: &str, stage_name: &str) -> String {

pub async fn run_campaign(
db: &(impl DbOps + Clone + Send + Sync + 'static),
data_dir: &Path,
args: CampaignCliArgs,
) -> Result<(), CliError> {
let campaign = CampaignConfig::from_file(&args.campaign)?;
Expand All @@ -128,7 +130,7 @@ pub async fn run_campaign(
.seed
.clone()
.or_else(|| campaign.spam.seed.map(|s| s.to_string()))
.unwrap_or(load_seedfile()?);
.unwrap_or(load_seedfile(data_dir)?);

// Setup phase: run setup for each (stage, mix) with the same derived seed that spam will use.
// This ensures setup creates accounts matching what spam expects.
Expand All @@ -154,8 +156,8 @@ pub async fn run_campaign(
if setup_args.accounts_per_agent.is_none() {
setup_args.accounts_per_agent = Some(10);
}
let setup_cmd = SetupCommandArgs::new(scenario, setup_args)?;
commands::setup(db, setup_cmd).await?;
let setup_cmd = SetupCommandArgs::new(scenario, setup_args, data_dir)?;
commands::setup(db, setup_cmd).await?
}
}
}
Expand Down Expand Up @@ -194,7 +196,7 @@ pub async fn run_campaign(
let timeout_duration = std::time::Duration::from_secs(timeout_secs);
match tokio::time::timeout(
timeout_duration,
execute_stage(db, &campaign, stage, &args, &campaign_id, &stage_seed),
execute_stage(db, &campaign, stage, &args, &campaign_id, &stage_seed, data_dir),
)
.await
{
Expand All @@ -208,7 +210,7 @@ pub async fn run_campaign(
}
}
} else {
execute_stage(db, &campaign, stage, &args, &campaign_id, &stage_seed).await?
execute_stage(db, &campaign, stage, &args, &campaign_id, &stage_seed, data_dir).await?
};

run_ids.extend(stage_run_ids);
Expand Down Expand Up @@ -242,7 +244,8 @@ pub async fn run_campaign(
Some(last_run),
last_run - first_run,
db,
&data_dir()?,
data_dir,
false, // use HTML format by default for campaign reports
)
.await?;
}
Expand Down Expand Up @@ -386,18 +389,24 @@ impl ScenarioMeta {
}
}

/// Context for preparing and executing a scenario within a campaign stage.
struct ScenarioContext<'a> {
args: &'a CampaignCliArgs,
campaign: &'a CampaignConfig,
stage: &'a ResolvedStage,
campaign_id: &'a str,
stage_seed: &'a str,
data_dir: &'a Path,
}

/// Prepares a scenario for execution, returning the spam args and metadata
async fn prepare_scenario(
args: &CampaignCliArgs,
campaign: &CampaignConfig,
stage: &ResolvedStage,
campaign_id: &str,
stage_seed: &str,
ctx: &ScenarioContext<'_>,
mix_idx: usize,
mix: &ResolvedMixEntry,
) -> Result<(SpamCommandArgs, ScenarioMeta), CliError> {
let scenario_seed = bump_seed(stage_seed, &mix_idx.to_string());
let mut args = args.to_owned();
let scenario_seed = bump_seed(ctx.stage_seed, &mix_idx.to_string());
let mut args = ctx.args.to_owned();
args.eth_json_rpc_args.seed = Some(scenario_seed.clone());
debug!("mix {mix_idx} seed: {}", scenario_seed);

Expand All @@ -410,32 +419,32 @@ async fn prepare_scenario(
let spam_cli_args = create_spam_cli_args(
Some(mix.scenario.clone()),
&args,
campaign.spam.mode,
ctx.campaign.spam.mode,
mix.rate,
stage.duration,
ctx.stage.duration,
skip_setup,
);

let spam_scenario = if let Some(builtin_cli) = parse_builtin_reference(&mix.scenario) {
let provider = args.eth_json_rpc_args.new_rpc_provider()?;
let builtin = builtin_cli
.to_builtin_scenario(&provider, &spam_cli_args)
.to_builtin_scenario(&provider, &spam_cli_args, ctx.data_dir)
.await?;
SpamScenario::Builtin(builtin)
} else {
SpamScenario::Testfile(mix.scenario.clone())
};

let spam_args = SpamCommandArgs::new(spam_scenario, spam_cli_args)?;
let spam_args = SpamCommandArgs::new(spam_scenario, spam_cli_args, ctx.data_dir)?;

let meta = ScenarioMeta {
campaign_id: campaign_id.to_owned(),
campaign_name: campaign.name.clone(),
stage_name: stage.name.clone(),
campaign_id: ctx.campaign_id.to_owned(),
campaign_name: ctx.campaign.name.clone(),
stage_name: ctx.stage.name.clone(),
scenario_label: mix.scenario.clone(),
mode: campaign.spam.mode,
mode: ctx.campaign.spam.mode,
rate: mix.rate,
duration: stage.duration,
duration: ctx.stage.duration,
};

Ok((spam_args, meta))
Expand All @@ -448,6 +457,7 @@ async fn execute_stage(
args: &CampaignCliArgs,
campaign_id: &str,
stage_seed: &str,
data_dir: &Path,
) -> Result<Vec<u64>, CliError> {
// Collect active scenarios (non-zero rate) with their indices
let active_scenarios: Vec<_> = stage
Expand All @@ -466,12 +476,20 @@ async fn execute_stage(
.into());
}

let ctx = ScenarioContext {
args,
campaign,
stage,
campaign_id,
stage_seed,
data_dir,
};

// FAST PATH: Single mix scenario - call spam_inner directly (same as spam mode)
// This avoids barrier synchronization and tokio::spawn overhead
if active_scenarios.len() == 1 {
let (mix_idx, mix) = active_scenarios[0];
let (spam_args, meta) =
prepare_scenario(args, campaign, stage, campaign_id, stage_seed, mix_idx, mix).await?;
let (spam_args, meta) = prepare_scenario(&ctx, mix_idx, mix).await?;

meta.log_start(true);

Expand All @@ -498,8 +516,7 @@ async fn execute_stage(
let mut handles = vec![];

for (mix_idx, mix) in active_scenarios {
let (spam_args, meta) =
prepare_scenario(args, campaign, stage, campaign_id, stage_seed, mix_idx, mix).await?;
let (spam_args, meta) = prepare_scenario(&ctx, mix_idx, mix).await?;

meta.log_start(false);

Expand Down
5 changes: 5 additions & 0 deletions crates/cli/src/commands/contender_subcommand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::default_scenarios::BuiltinScenarioCli;

use super::admin::AdminCommand;
use super::spam::SpamCliArgs;
use super::ReportFormat;

#[derive(Debug, Subcommand)]
pub enum ContenderSubcommand {
Expand Down Expand Up @@ -84,6 +85,10 @@ pub enum ContenderSubcommand {
default_missing_value = "__LATEST_CAMPAIGN__"
)]
campaign_id: Option<String>,

/// Output format: html (default, opens browser) or json (machine-readable).
#[arg(long, short = 'f', default_value = "html", value_enum)]
format: ReportFormat,
},

#[command(
Expand Down
Loading