From 070957f6ca0ba9cb82f362a37d5de346ef402b49 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Wed, 11 Dec 2024 18:25:29 -0800 Subject: [PATCH 1/2] move subcommand definitions out of main.rs, into individual mods --- crates/cli/src/commands/mod.rs | 25 + crates/cli/src/commands/report.rs | 43 ++ crates/cli/src/commands/run.rs | 121 ++++ crates/cli/src/commands/setup.rs | 64 ++ crates/cli/src/commands/spam.rs | 157 +++++ .../src/{commands.rs => commands/types.rs} | 14 +- crates/cli/src/main.rs | 601 +----------------- crates/cli/src/util.rs | 277 ++++++++ scenarios/univ2ConfigTest.toml | 2 +- 9 files changed, 718 insertions(+), 586 deletions(-) create mode 100644 crates/cli/src/commands/mod.rs create mode 100644 crates/cli/src/commands/report.rs create mode 100644 crates/cli/src/commands/run.rs create mode 100644 crates/cli/src/commands/setup.rs create mode 100644 crates/cli/src/commands/spam.rs rename crates/cli/src/{commands.rs => commands/types.rs} (96%) create mode 100644 crates/cli/src/util.rs diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs new file mode 100644 index 0000000..c8a79f2 --- /dev/null +++ b/crates/cli/src/commands/mod.rs @@ -0,0 +1,25 @@ +mod report; +mod run; +mod setup; +mod spam; +mod types; + +use clap::Parser; + +pub use report::report; +pub use run::run; +pub use setup::setup; +pub use spam::spam; +pub use types::ContenderSubcommand; + +#[derive(Parser, Debug)] +pub struct ContenderCli { + #[command(subcommand)] + pub command: ContenderSubcommand, +} + +impl ContenderCli { + pub fn parse_args() -> Self { + Self::parse() + } +} diff --git a/crates/cli/src/commands/report.rs b/crates/cli/src/commands/report.rs new file mode 100644 index 0000000..37b02f4 --- /dev/null +++ b/crates/cli/src/commands/report.rs @@ -0,0 +1,43 @@ +use contender_core::db::DbOps; +use csv::WriterBuilder; + +use crate::util::write_run_txs; + +pub fn report( + db: &(impl DbOps + Clone + Send + Sync + 'static), + id: Option, + out_file: Option, +) -> Result<(), Box> { + let num_runs = db.num_runs()?; + let id = if let Some(id) = id { + if id == 0 || id > num_runs { + panic!("Invalid run ID: {}", id); + } + id + } else { + if num_runs == 0 { + panic!("No runs to report"); + } + // get latest run + println!("No run ID provided. Using latest run ID: {}", num_runs); + num_runs + }; + let txs = db.get_run_txs(id)?; + println!("found {} txs", txs.len()); + println!( + "Exporting report for run ID {:?} to out_file {:?}", + id, out_file + ); + + if let Some(out_file) = out_file { + let mut writer = WriterBuilder::new().has_headers(true).from_path(out_file)?; + write_run_txs(&mut writer, &txs)?; + } else { + let mut writer = WriterBuilder::new() + .has_headers(true) + .from_writer(std::io::stdout()); + write_run_txs(&mut writer, &txs)?; // TODO: write a macro that lets us generalize the writer param to write_run_txs, then refactor this duplication + }; + + Ok(()) +} diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs new file mode 100644 index 0000000..6438254 --- /dev/null +++ b/crates/cli/src/commands/run.rs @@ -0,0 +1,121 @@ +use std::{env, str::FromStr, sync::Arc}; + +use alloy::{ + eips::BlockId, + network::AnyNetwork, + providers::{Provider, ProviderBuilder}, + rpc::types::BlockTransactionsKind, + transports::http::reqwest::Url, +}; +use contender_core::{ + agent_controller::AgentStore, + db::DbOps, + error::ContenderError, + generator::RandSeed, + spammer::{LogCallback, Spammer, TimedSpammer}, + test_scenario::TestScenario, +}; +use contender_testfile::TestConfig; + +use crate::{ + default_scenarios::{BuiltinScenario, BuiltinScenarioConfig}, + util::{check_private_keys, get_signers_with_defaults, prompt_cli}, +}; + +pub async fn run( + db: &(impl DbOps + Clone + Send + Sync + 'static), + scenario: BuiltinScenario, + rpc_url: String, + private_key: Option, + interval: usize, + duration: usize, + txs_per_duration: usize, +) -> Result<(), Box> { + let user_signers = get_signers_with_defaults(private_key.map(|s| vec![s])); + let admin_signer = &user_signers[0]; + let rand_seed = RandSeed::default(); + let provider = ProviderBuilder::new() + .network::() + .on_http(Url::parse(&rpc_url).expect("Invalid RPC URL")); + let block_gas_limit = provider + .get_block(BlockId::latest(), BlockTransactionsKind::Hashes) + .await? + .map(|b| b.header.gas_limit) + .ok_or(ContenderError::SetupError( + "failed getting gas limit from block", + None, + ))?; + + let fill_percent = env::var("C_FILL_PERCENT") + .map(|s| u16::from_str(&s).expect("invalid u16: fill_percent")) + .unwrap_or(100u16); + + let scenario_config = match scenario { + BuiltinScenario::FillBlock => BuiltinScenarioConfig::fill_block( + block_gas_limit, + txs_per_duration as u64, + admin_signer.address(), + fill_percent, + ), + }; + let testconfig: TestConfig = scenario_config.into(); + check_private_keys(&testconfig, &user_signers); + + let rpc_url = Url::parse(&rpc_url).expect("Invalid RPC URL"); + let mut scenario = TestScenario::new( + testconfig, + db.clone().into(), + rpc_url.to_owned(), + None, + rand_seed, + &user_signers, + AgentStore::default(), + ) + .await?; + + let contract_name = "SpamMe"; + let contract_result = db.get_named_tx(contract_name)?; + let do_deploy_contracts = if contract_result.is_some() { + let input = prompt_cli(format!( + "{} deployment already detected. Re-deploy? [y/N]", + contract_name + )); + input.to_lowercase() == "y" + } else { + true + }; + + if do_deploy_contracts { + println!("deploying contracts..."); + scenario.deploy_contracts().await?; + } + + println!("running setup..."); + scenario.run_setup().await?; + + let wait_duration = std::time::Duration::from_secs(interval as u64); + let spammer = TimedSpammer::new(wait_duration); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_millis(); + let run_id = db.insert_run(timestamp as u64, duration * txs_per_duration)?; + let callback = LogCallback::new(Arc::new( + ProviderBuilder::new() + .network::() + .on_http(rpc_url), + )); + + println!("starting spammer..."); + spammer + .spam_rpc( + &mut scenario, + txs_per_duration, + duration, + Some(run_id), + callback.into(), + ) + .await?; + + Ok(()) +} diff --git a/crates/cli/src/commands/setup.rs b/crates/cli/src/commands/setup.rs new file mode 100644 index 0000000..05161e7 --- /dev/null +++ b/crates/cli/src/commands/setup.rs @@ -0,0 +1,64 @@ +use alloy::{ + network::AnyNetwork, primitives::utils::parse_ether, providers::ProviderBuilder, + signers::local::PrivateKeySigner, transports::http::reqwest::Url, +}; +use contender_core::{generator::RandSeed, test_scenario::TestScenario}; +use contender_testfile::TestConfig; +use std::str::FromStr; + +use crate::util::{ + check_private_keys_fns, find_insufficient_balance_addrs, get_signers_with_defaults, +}; + +pub async fn setup( + db: &(impl contender_core::db::DbOps + Clone + Send + Sync + 'static), + testfile: impl AsRef, + rpc_url: impl AsRef, + private_keys: Option>, + min_balance: String, +) -> Result<(), Box> { + let url = Url::parse(rpc_url.as_ref()).expect("Invalid RPC URL"); + let rpc_client = ProviderBuilder::new() + .network::() + .on_http(url.to_owned()); + let testconfig: TestConfig = TestConfig::from_file(testfile.as_ref())?; + let min_balance = parse_ether(&min_balance)?; + + let user_signers = private_keys + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|key| PrivateKeySigner::from_str(key).expect("invalid private key")) + .collect::>(); + let signers = get_signers_with_defaults(private_keys); + check_private_keys_fns( + &testconfig.setup.to_owned().unwrap_or_default(), + signers.as_slice(), + ); + let broke_accounts = find_insufficient_balance_addrs( + &user_signers.iter().map(|s| s.address()).collect::>(), + min_balance, + &rpc_client, + ) + .await?; + if !broke_accounts.is_empty() { + panic!("Some accounts do not have sufficient balance"); + } + + let mut scenario = TestScenario::new( + testconfig.to_owned(), + db.clone().into(), + url, + None, + RandSeed::new(), + &signers, + Default::default(), + ) + .await?; + + scenario.deploy_contracts().await?; + scenario.run_setup().await?; + // TODO: catch failures and prompt user to retry specific steps + + Ok(()) +} diff --git a/crates/cli/src/commands/spam.rs b/crates/cli/src/commands/spam.rs new file mode 100644 index 0000000..5a15220 --- /dev/null +++ b/crates/cli/src/commands/spam.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use alloy::{ + network::AnyNetwork, primitives::utils::parse_ether, providers::ProviderBuilder, + transports::http::reqwest::Url, +}; +use contender_core::{ + agent_controller::{AgentStore, SignerStore}, + db::DbOps, + generator::RandSeed, + spammer::{BlockwiseSpammer, Spammer, TimedSpammer}, + test_scenario::TestScenario, +}; +use contender_testfile::TestConfig; + +use crate::util::{ + check_private_keys, fund_accounts, get_from_pools, get_signers_with_defaults, + spam_callback_default, SpamCallbackType, +}; + +pub async fn spam( + db: &(impl DbOps + Clone + Send + Sync + 'static), + testfile: impl AsRef, + rpc_url: impl AsRef, + builder_url: Option, + txs_per_block: Option, + txs_per_second: Option, + duration: Option, + seed: Option, + private_keys: Option>, + disable_reports: bool, + min_balance: String, +) -> Result<(), Box> { + let testconfig = TestConfig::from_file(testfile.as_ref())?; + let rand_seed = seed + .map(|s| RandSeed::seed_from_str(s.as_ref())) + .unwrap_or_default(); + let url = Url::parse(rpc_url.as_ref()).expect("Invalid RPC URL"); + let rpc_client = ProviderBuilder::new() + .network::() + .on_http(url.to_owned()); + let eth_client = ProviderBuilder::new().on_http(url.to_owned()); + + let duration = duration.unwrap_or_default(); + let min_balance = parse_ether(min_balance.as_ref())?; + + let user_signers = get_signers_with_defaults(private_keys); + let spam = testconfig + .spam + .as_ref() + .expect("No spam function calls found in testfile"); + + // distill all from_pool arguments from the spam requests + let from_pools = get_from_pools(&testconfig); + + let mut agents = AgentStore::new(); + let signers_per_period = + txs_per_block.unwrap_or(txs_per_second.unwrap_or(spam.len())) / spam.len(); + + let mut all_signers = vec![]; + all_signers.extend_from_slice(&user_signers); + + for from_pool in &from_pools { + if agents.has_agent(from_pool) { + continue; + } + + let agent = SignerStore::new_random(signers_per_period, &rand_seed, from_pool); + all_signers.extend_from_slice(&agent.signers); + agents.add_agent(from_pool, agent); + } + + check_private_keys(&testconfig, &all_signers); + + fund_accounts( + &rpc_client, + ð_client, + min_balance, + &all_signers, + &user_signers[0], + ) + .await?; + + if txs_per_block.is_some() && txs_per_second.is_some() { + panic!("Cannot set both --txs-per-block and --txs-per-second"); + } + if txs_per_block.is_none() && txs_per_second.is_none() { + panic!("Must set either --txs-per-block (--tpb) or --txs-per-second (--tps)"); + } + + let mut scenario = TestScenario::new( + testconfig, + db.clone().into(), + url, + builder_url.map(|url| Url::parse(&url).expect("Invalid builder URL")), + rand_seed, + &user_signers, + agents, + ) + .await?; + + if let Some(txs_per_block) = txs_per_block { + println!("Blockwise spamming with {} txs per block", txs_per_block); + let spammer = BlockwiseSpammer {}; + + match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { + SpamCallbackType::Log(cback) => { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_millis(); + let run_id = db.insert_run(timestamp as u64, txs_per_block * duration)?; + spammer + .spam_rpc( + &mut scenario, + txs_per_block, + duration, + Some(run_id), + cback.into(), + ) + .await?; + } + SpamCallbackType::Nil(cback) => { + spammer + .spam_rpc(&mut scenario, txs_per_block, duration, None, cback.into()) + .await?; + } + }; + return Ok(()); + } + + let tps = txs_per_second.unwrap_or(10); + println!("Timed spamming with {} txs per second", tps); + + let interval = std::time::Duration::from_secs(1); + let spammer = TimedSpammer::new(interval); + + match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { + SpamCallbackType::Log(cback) => { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_millis(); + let run_id = db.insert_run(timestamp as u64, tps * duration)?; + spammer + .spam_rpc(&mut scenario, tps, duration, Some(run_id), cback.into()) + .await?; + } + SpamCallbackType::Nil(cback) => { + spammer + .spam_rpc(&mut scenario, tps, duration, None, cback.into()) + .await?; + } + }; + + Ok(()) +} diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands/types.rs similarity index 96% rename from crates/cli/src/commands.rs rename to crates/cli/src/commands/types.rs index 3c9465d..6dbd98b 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands/types.rs @@ -1,19 +1,7 @@ -use clap::{Parser, Subcommand}; +use clap::Subcommand; use crate::default_scenarios::BuiltinScenario; -#[derive(Parser, Debug)] -pub struct ContenderCli { - #[command(subcommand)] - pub command: ContenderSubcommand, -} - -impl ContenderCli { - pub fn parse_args() -> Self { - Self::parse() - } -} - #[derive(Debug, Subcommand)] pub enum ContenderSubcommand { #[command( diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8b374dc..84ac387 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,44 +1,12 @@ mod commands; mod default_scenarios; +mod util; -use std::{ - env, - io::Write, - str::FromStr, - sync::{Arc, LazyLock}, -}; +use std::sync::LazyLock; -use alloy::{ - eips::BlockId, - network::{AnyNetwork, EthereumWallet, TransactionBuilder}, - primitives::{ - utils::{format_ether, parse_ether}, - Address, U256, - }, - providers::{PendingTransactionConfig, Provider, ProviderBuilder}, - rpc::types::{BlockTransactionsKind, TransactionRequest}, - signers::local::PrivateKeySigner, - transports::http::reqwest::Url, -}; use commands::{ContenderCli, ContenderSubcommand}; -use contender_core::{ - agent_controller::{AgentStore, SignerStore}, - db::{DbOps, RunTx}, - error::ContenderError, - generator::{ - types::{AnyProvider, EthProvider, FunctionCallDefinition, SpamRequest}, - RandSeed, - }, - spammer::{ - blockwise::BlockwiseSpammer, timed::TimedSpammer, LogCallback, NilCallback, Spammer, - }, - test_scenario::TestScenario, -}; +use contender_core::db::DbOps; use contender_sqlite::SqliteDb; -use contender_testfile::TestConfig; -use csv::{Writer, WriterBuilder}; -use default_scenarios::{BuiltinScenario, BuiltinScenarioConfig}; -use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor}; static DB: LazyLock = std::sync::LazyLock::new(|| { SqliteDb::from_file("contender.db").expect("failed to open contender.db") @@ -48,56 +16,15 @@ static DB: LazyLock = std::sync::LazyLock::new(|| { async fn main() -> Result<(), Box> { let args = ContenderCli::parse_args(); let _ = DB.create_tables(); // ignore error; tables already exist + match args.command { ContenderSubcommand::Setup { testfile, rpc_url, private_keys, min_balance, - } => { - let url = Url::parse(rpc_url.as_ref()).expect("Invalid RPC URL"); - let rpc_client = ProviderBuilder::new() - .network::() - .on_http(url.to_owned()); - let testconfig: TestConfig = TestConfig::from_file(&testfile)?; - let min_balance = parse_ether(&min_balance)?; - - let user_signers = private_keys - .as_ref() - .unwrap_or(&vec![]) - .iter() - .map(|key| PrivateKeySigner::from_str(key).expect("invalid private key")) - .collect::>(); - let signers = get_signers_with_defaults(private_keys); - check_private_keys_fns( - &testconfig.setup.to_owned().unwrap_or_default(), - signers.as_slice(), - ); - let broke_accounts = find_insufficient_balance_addrs( - &user_signers.iter().map(|s| s.address()).collect::>(), - min_balance, - &rpc_client, - ) - .await?; - if !broke_accounts.is_empty() { - panic!("Some accounts do not have sufficient balance"); - } - - let mut scenario = TestScenario::new( - testconfig.to_owned(), - Arc::new(DB.clone()), - url, - None, - RandSeed::new(), - &signers, - Default::default(), - ) - .await?; + } => commands::setup(&DB.clone(), testfile, rpc_url, private_keys, min_balance).await?, - scenario.deploy_contracts().await?; - scenario.run_setup().await?; - // TODO: catch failures and prompt user to retry specific steps - } ContenderSubcommand::Spam { testfile, rpc_url, @@ -110,160 +37,26 @@ async fn main() -> Result<(), Box> { disable_reports, min_balance, } => { - let testconfig = TestConfig::from_file(&testfile)?; - let rand_seed = seed - .map(|s| RandSeed::seed_from_str(&s)) - .unwrap_or_default(); - let url = Url::parse(rpc_url.as_ref()).expect("Invalid RPC URL"); - let rpc_client = ProviderBuilder::new() - .network::() - .on_http(url.to_owned()); - let eth_client = ProviderBuilder::new().on_http(url.to_owned()); - - let duration = duration.unwrap_or_default(); - let min_balance = parse_ether(&min_balance)?; - - let user_signers = get_signers_with_defaults(private_keys); - let spam = testconfig - .spam - .as_ref() - .expect("No spam function calls found in testfile"); - - // distill all from_pool arguments from the spam requests - let from_pools = get_from_pools(&testconfig); - - let mut agents = AgentStore::new(); - let signers_per_period = - txs_per_block.unwrap_or(txs_per_second.unwrap_or(spam.len())) / spam.len(); - - let mut all_signers = vec![]; - all_signers.extend_from_slice(&user_signers); - - for from_pool in &from_pools { - if agents.has_agent(from_pool) { - continue; - } - - let agent = SignerStore::new_random(signers_per_period, &rand_seed, from_pool); - all_signers.extend_from_slice(&agent.signers); - agents.add_agent(from_pool, agent); - } - - check_private_keys(&testconfig, &all_signers); - - fund_accounts( - &rpc_client, - ð_client, + commands::spam( + &DB.clone(), + testfile, + rpc_url, + builder_url, + txs_per_block, + txs_per_second, + duration, + seed, + private_keys, + disable_reports, min_balance, - &all_signers, - &user_signers[0], - ) - .await?; - - if txs_per_block.is_some() && txs_per_second.is_some() { - panic!("Cannot set both --txs-per-block and --txs-per-second"); - } - if txs_per_block.is_none() && txs_per_second.is_none() { - panic!("Must set either --txs-per-block (--tpb) or --txs-per-second (--tps)"); - } - - let mut scenario = TestScenario::new( - testconfig, - DB.clone().into(), - url, - builder_url.map(|url| Url::parse(&url).expect("Invalid builder URL")), - rand_seed, - &user_signers, - agents, ) - .await?; - - if let Some(txs_per_block) = txs_per_block { - println!("Blockwise spamming with {} txs per block", txs_per_block); - let spammer = BlockwiseSpammer {}; - - match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { - SpamCallbackType::Log(cback) => { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards") - .as_millis(); - let run_id = DB.insert_run(timestamp as u64, txs_per_block * duration)?; - spammer - .spam_rpc( - &mut scenario, - txs_per_block, - duration, - Some(run_id), - cback.into(), - ) - .await?; - } - SpamCallbackType::Nil(cback) => { - spammer - .spam_rpc(&mut scenario, txs_per_block, duration, None, cback.into()) - .await?; - } - }; - return Ok(()); - } - - let tps = txs_per_second.unwrap_or(10); - println!("Timed spamming with {} txs per second", tps); - - let interval = std::time::Duration::from_secs(1); - let spammer = TimedSpammer::new(interval); - - match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { - SpamCallbackType::Log(cback) => { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards") - .as_millis(); - let run_id = DB.insert_run(timestamp as u64, tps * duration)?; - spammer - .spam_rpc(&mut scenario, tps, duration, Some(run_id), cback.into()) - .await?; - } - SpamCallbackType::Nil(cback) => { - spammer - .spam_rpc(&mut scenario, tps, duration, None, cback.into()) - .await?; - } - }; + .await? } - ContenderSubcommand::Report { id, out_file } => { - let num_runs = DB.clone().num_runs()?; - let id = if let Some(id) = id { - if id == 0 || id > num_runs { - panic!("Invalid run ID: {}", id); - } - id - } else { - if num_runs == 0 { - panic!("No runs to report"); - } - // get latest run - println!("No run ID provided. Using latest run ID: {}", num_runs); - num_runs - }; - let txs = DB.clone().get_run_txs(id)?; - println!("found {} txs", txs.len()); - println!( - "Exporting report for run ID {:?} to out_file {:?}", - id, out_file - ); - if let Some(out_file) = out_file { - let mut writer = WriterBuilder::new().has_headers(true).from_path(out_file)?; - write_run_txs(&mut writer, &txs)?; - } else { - let mut writer = WriterBuilder::new() - .has_headers(true) - .from_writer(std::io::stdout()); - write_run_txs(&mut writer, &txs)?; // TODO: write a macro that lets us generalize the writer param to write_run_txs, then refactor this duplication - }; + ContenderSubcommand::Report { id, out_file } => { + commands::report(&DB.clone(), id, out_file)? } + ContenderSubcommand::Run { scenario, rpc_url, @@ -272,353 +65,17 @@ async fn main() -> Result<(), Box> { duration, txs_per_duration, } => { - let user_signers = get_signers_with_defaults(private_key.map(|s| vec![s])); - let admin_signer = &user_signers[0]; - let rand_seed = RandSeed::default(); - let provider = ProviderBuilder::new() - .network::() - .on_http(Url::parse(&rpc_url).expect("Invalid RPC URL")); - let block_gas_limit = provider - .get_block(BlockId::latest(), BlockTransactionsKind::Hashes) - .await? - .map(|b| b.header.gas_limit) - .ok_or(ContenderError::SetupError( - "failed getting gas limit from block", - None, - ))?; - - let fill_percent = env::var("C_FILL_PERCENT") - .map(|s| u16::from_str(&s).expect("invalid u16: fill_percent")) - .unwrap_or(100u16); - - let scenario_config = match scenario { - BuiltinScenario::FillBlock => BuiltinScenarioConfig::fill_block( - block_gas_limit, - txs_per_duration as u64, - admin_signer.address(), - fill_percent, - ), - }; - let testconfig: TestConfig = scenario_config.into(); - check_private_keys(&testconfig, &user_signers); - - let rpc_url = Url::parse(&rpc_url).expect("Invalid RPC URL"); - let mut scenario = TestScenario::new( - testconfig, - DB.clone().into(), - rpc_url.to_owned(), - None, - rand_seed, - &user_signers, - AgentStore::default(), - ) - .await?; - - let contract_name = "SpamMe"; - let contract_result = DB.clone().get_named_tx(contract_name)?; - let do_deploy_contracts = if contract_result.is_some() { - let input = prompt_cli(format!( - "{} deployment already detected. Re-deploy? [y/N]", - contract_name - )); - input.to_lowercase() == "y" - } else { - true - }; - - if do_deploy_contracts { - println!("deploying contracts..."); - scenario.deploy_contracts().await?; - } - - println!("running setup..."); - scenario.run_setup().await?; - - let wait_duration = std::time::Duration::from_secs(interval as u64); - let spammer = TimedSpammer::new(wait_duration); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards") - .as_millis(); - let run_id = DB.insert_run(timestamp as u64, duration * txs_per_duration)?; - let callback = LogCallback::new(Arc::new( - ProviderBuilder::new() - .network::() - .on_http(rpc_url), - )); - - println!("starting spammer..."); - spammer - .spam_rpc( - &mut scenario, - txs_per_duration, - duration, - Some(run_id), - callback.into(), - ) - .await?; - } - } - Ok(()) -} - -enum SpamCallbackType { - Log(LogCallback), - Nil(NilCallback), -} - -fn prompt_cli(msg: impl AsRef) -> String { - let mut stdout = StandardStream::stdout(ColorChoice::Always); - stdout - .set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Rgb(252, 186, 3)))) - .expect("failed to set stdout color"); - writeln!(&mut stdout, "{}", msg.as_ref()).expect("failed to write to stdout"); - stdout.reset().expect("failed to reset color"); - - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .expect("Failed to read line"); - input.trim().to_owned() -} - -fn check_private_keys(testconfig: &TestConfig, prv_keys: &[PrivateKeySigner]) { - let setup = testconfig.setup.to_owned().unwrap_or_default(); - let spam = testconfig - .spam - .as_ref() - .expect("No spam function calls found in testfile"); - - // distill all FunctionCallDefinitions from the spam requests - let mut fn_calls = vec![]; - - for s in setup { - fn_calls.push(s.to_owned()); - } - - for s in spam { - match s { - SpamRequest::Tx(fn_call) => { - fn_calls.push(fn_call.to_owned()); - } - SpamRequest::Bundle(bundle) => { - fn_calls.extend(bundle.txs.iter().map(|s| s.to_owned())); - } - } - } - - check_private_keys_fns(&fn_calls, prv_keys); -} - -/// Panics if any of the function calls' `from` addresses do not have a corresponding private key. -fn check_private_keys_fns(fn_calls: &[FunctionCallDefinition], prv_keys: &[PrivateKeySigner]) { - for fn_call in fn_calls { - if let Some(from) = &fn_call.from { - let address = from.parse::
().expect("invalid 'from' address"); - if prv_keys.iter().all(|k| k.address() != address) { - panic!("No private key found for address: {}", address); - } - } - } -} - -fn get_from_pools(testconfig: &TestConfig) -> Vec { - let mut from_pools = vec![]; - let spam = testconfig - .spam - .as_ref() - .expect("No spam function calls found in testfile"); - - for s in spam { - match s { - SpamRequest::Tx(fn_call) => { - if let Some(from_pool) = &fn_call.from_pool { - from_pools.push(from_pool.to_owned()); - } - } - SpamRequest::Bundle(bundle) => { - for tx in &bundle.txs { - if let Some(from_pool) = &tx.from_pool { - from_pools.push(from_pool.to_owned()); - } - } - } - } - } - - from_pools -} - -const DEFAULT_PRV_KEYS: [&str; 10] = [ - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", - "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", - "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", - "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", - "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", - "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", - "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", - "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", -]; - -fn get_signers_with_defaults(private_keys: Option>) -> Vec { - if private_keys.is_none() { - println!("No private keys provided. Using default private keys."); - } - let private_keys = private_keys.unwrap_or_default(); - let private_keys = [ - private_keys, - DEFAULT_PRV_KEYS - .into_iter() - .map(|s| s.to_owned()) - .collect::>(), - ] - .concat(); - - private_keys - .into_iter() - .map(|k| PrivateKeySigner::from_str(&k).expect("Invalid private key")) - .collect::>() -} - -async fn fund_accounts( - rpc_client: &AnyProvider, - eth_client: &EthProvider, - min_balance: U256, - all_signers: &[PrivateKeySigner], - admin_signer: &PrivateKeySigner, -) -> Result<(), Box> { - let insufficient_balance_addrs = find_insufficient_balance_addrs( - &all_signers.iter().map(|s| s.address()).collect::>(), - min_balance, - rpc_client, - ) - .await?; - - let mut pending_fund_txs = vec![]; - let admin_nonce = rpc_client - .get_transaction_count(admin_signer.address()) - .await?; - for (idx, address) in insufficient_balance_addrs.iter().enumerate() { - if !is_balance_sufficient(&admin_signer.address(), min_balance, rpc_client).await? { - // panic early if admin account runs out of funds - return Err(format!( - "Admin account {} has insufficient balance to fund this account.", - admin_signer.address() + commands::run( + &DB.clone(), + scenario, + rpc_url, + private_key, + interval, + duration, + txs_per_duration, ) - .into()); + .await? } - - let balance = rpc_client.get_balance(*address).await?; - println!( - "Account {} has insufficient balance. (has {}, needed {})", - address, - format_ether(balance), - format_ether(min_balance) - ); - - let fund_amount = min_balance; - pending_fund_txs.push( - fund_account( - admin_signer, - *address, - fund_amount, - eth_client, - Some(admin_nonce + idx as u64), - ) - .await?, - ); - } - - for tx in pending_fund_txs { - let pending = rpc_client.watch_pending_transaction(tx).await?; - println!("funding tx confirmed ({})", pending.await?); - } - - Ok(()) -} - -async fn fund_account( - admin_signer: &PrivateKeySigner, - recipient: Address, - amount: U256, - rpc_client: &EthProvider, - nonce: Option, -) -> Result> { - println!( - "funding account {} with user account {}", - recipient, - admin_signer.address() - ); - - let gas_price = rpc_client.get_gas_price().await?; - let nonce = nonce.unwrap_or( - rpc_client - .get_transaction_count(admin_signer.address()) - .await?, - ); - let chain_id = rpc_client.get_chain_id().await?; - let tx_req = TransactionRequest { - from: Some(admin_signer.address()), - to: Some(alloy::primitives::TxKind::Call(recipient)), - value: Some(amount), - gas: Some(21000), - gas_price: Some(gas_price + 4_200_000_000), - nonce: Some(nonce), - chain_id: Some(chain_id), - ..Default::default() - }; - let eth_wallet = EthereumWallet::from(admin_signer.to_owned()); - let tx = tx_req.build(ð_wallet).await?; - let res = rpc_client.send_tx_envelope(tx).await?; - - Ok(res.into_inner()) -} - -async fn spam_callback_default( - log_txs: bool, - rpc_client: Option>, -) -> SpamCallbackType { - if let Some(rpc_client) = rpc_client { - if log_txs { - return SpamCallbackType::Log(LogCallback::new(rpc_client.clone())); - } - } - SpamCallbackType::Nil(NilCallback) -} - -async fn is_balance_sufficient( - address: &Address, - min_balance: U256, - rpc_client: &AnyProvider, -) -> Result> { - let balance = rpc_client.get_balance(*address).await?; - Ok(balance >= min_balance) -} - -/// Returns an error if any of the private keys do not have sufficient balance. -async fn find_insufficient_balance_addrs( - addresses: &[Address], - min_balance: U256, - rpc_client: &AnyProvider, -) -> Result, Box> { - let mut insufficient_balance_addrs = vec![]; - for address in addresses { - if !is_balance_sufficient(address, min_balance, rpc_client).await? { - insufficient_balance_addrs.push(*address); - } - } - Ok(insufficient_balance_addrs) -} - -fn write_run_txs( - writer: &mut Writer, - txs: &[RunTx], -) -> Result<(), Box> { - for tx in txs { - writer.serialize(tx)?; } - writer.flush()?; Ok(()) } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs new file mode 100644 index 0000000..5691b51 --- /dev/null +++ b/crates/cli/src/util.rs @@ -0,0 +1,277 @@ +use alloy::{ + network::{EthereumWallet, TransactionBuilder}, + primitives::{utils::format_ether, Address, U256}, + providers::{PendingTransactionConfig, Provider}, + rpc::types::TransactionRequest, + signers::local::PrivateKeySigner, +}; +use contender_core::{ + db::RunTx, + generator::types::{AnyProvider, EthProvider, FunctionCallDefinition, SpamRequest}, + spammer::{LogCallback, NilCallback}, +}; +use contender_testfile::TestConfig; +use csv::Writer; +use std::{io::Write, str::FromStr, sync::Arc}; +use termcolor::{ColorChoice, ColorSpec, StandardStream, WriteColor}; + +pub enum SpamCallbackType { + Log(LogCallback), + Nil(NilCallback), +} + +pub const DEFAULT_PRV_KEYS: [&str; 10] = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6", +]; + +pub fn get_from_pools(testconfig: &TestConfig) -> Vec { + let mut from_pools = vec![]; + let spam = testconfig + .spam + .as_ref() + .expect("No spam function calls found in testfile"); + + for s in spam { + match s { + SpamRequest::Tx(fn_call) => { + if let Some(from_pool) = &fn_call.from_pool { + from_pools.push(from_pool.to_owned()); + } + } + SpamRequest::Bundle(bundle) => { + for tx in &bundle.txs { + if let Some(from_pool) = &tx.from_pool { + from_pools.push(from_pool.to_owned()); + } + } + } + } + } + + from_pools +} + +pub fn get_signers_with_defaults(private_keys: Option>) -> Vec { + if private_keys.is_none() { + println!("No private keys provided. Using default private keys."); + } + let private_keys = private_keys.unwrap_or_default(); + let private_keys = [ + private_keys, + DEFAULT_PRV_KEYS + .into_iter() + .map(|s| s.to_owned()) + .collect::>(), + ] + .concat(); + + private_keys + .into_iter() + .map(|k| PrivateKeySigner::from_str(&k).expect("Invalid private key")) + .collect::>() +} + +pub fn check_private_keys(testconfig: &TestConfig, prv_keys: &[PrivateKeySigner]) { + let setup = testconfig.setup.to_owned().unwrap_or_default(); + let spam = testconfig + .spam + .as_ref() + .expect("No spam function calls found in testfile"); + + // distill all FunctionCallDefinitions from the spam requests + let mut fn_calls = vec![]; + + for s in setup { + fn_calls.push(s.to_owned()); + } + + for s in spam { + match s { + SpamRequest::Tx(fn_call) => { + fn_calls.push(fn_call.to_owned()); + } + SpamRequest::Bundle(bundle) => { + fn_calls.extend(bundle.txs.iter().map(|s| s.to_owned())); + } + } + } + + check_private_keys_fns(&fn_calls, prv_keys); +} + +/// Panics if any of the function calls' `from` addresses do not have a corresponding private key. +pub fn check_private_keys_fns(fn_calls: &[FunctionCallDefinition], prv_keys: &[PrivateKeySigner]) { + for fn_call in fn_calls { + if let Some(from) = &fn_call.from { + let address = from.parse::
().expect("invalid 'from' address"); + if prv_keys.iter().all(|k| k.address() != address) { + panic!("No private key found for address: {}", address); + } + } + } +} + +async fn is_balance_sufficient( + address: &Address, + min_balance: U256, + rpc_client: &AnyProvider, +) -> Result> { + let balance = rpc_client.get_balance(*address).await?; + Ok(balance >= min_balance) +} + +pub async fn fund_accounts( + rpc_client: &AnyProvider, + eth_client: &EthProvider, + min_balance: U256, + all_signers: &[PrivateKeySigner], + admin_signer: &PrivateKeySigner, +) -> Result<(), Box> { + let insufficient_balance_addrs = find_insufficient_balance_addrs( + &all_signers.iter().map(|s| s.address()).collect::>(), + min_balance, + rpc_client, + ) + .await?; + + let mut pending_fund_txs = vec![]; + let admin_nonce = rpc_client + .get_transaction_count(admin_signer.address()) + .await?; + for (idx, address) in insufficient_balance_addrs.iter().enumerate() { + if !is_balance_sufficient(&admin_signer.address(), min_balance, rpc_client).await? { + // panic early if admin account runs out of funds + return Err(format!( + "Admin account {} has insufficient balance to fund this account.", + admin_signer.address() + ) + .into()); + } + + let balance = rpc_client.get_balance(*address).await?; + println!( + "Account {} has insufficient balance. (has {}, needed {})", + address, + format_ether(balance), + format_ether(min_balance) + ); + + let fund_amount = min_balance; + pending_fund_txs.push( + fund_account( + admin_signer, + *address, + fund_amount, + eth_client, + Some(admin_nonce + idx as u64), + ) + .await?, + ); + } + + for tx in pending_fund_txs { + let pending = rpc_client.watch_pending_transaction(tx).await?; + println!("funding tx confirmed ({})", pending.await?); + } + + Ok(()) +} + +pub async fn fund_account( + admin_signer: &PrivateKeySigner, + recipient: Address, + amount: U256, + rpc_client: &EthProvider, + nonce: Option, +) -> Result> { + println!( + "funding account {} with user account {}", + recipient, + admin_signer.address() + ); + + let gas_price = rpc_client.get_gas_price().await?; + let nonce = nonce.unwrap_or( + rpc_client + .get_transaction_count(admin_signer.address()) + .await?, + ); + let chain_id = rpc_client.get_chain_id().await?; + let tx_req = TransactionRequest { + from: Some(admin_signer.address()), + to: Some(alloy::primitives::TxKind::Call(recipient)), + value: Some(amount), + gas: Some(21000), + gas_price: Some(gas_price + 4_200_000_000), + nonce: Some(nonce), + chain_id: Some(chain_id), + ..Default::default() + }; + let eth_wallet = EthereumWallet::from(admin_signer.to_owned()); + let tx = tx_req.build(ð_wallet).await?; + let res = rpc_client.send_tx_envelope(tx).await?; + + Ok(res.into_inner()) +} + +/// Returns an error if any of the private keys do not have sufficient balance. +pub async fn find_insufficient_balance_addrs( + addresses: &[Address], + min_balance: U256, + rpc_client: &AnyProvider, +) -> Result, Box> { + let mut insufficient_balance_addrs = vec![]; + for address in addresses { + if !is_balance_sufficient(address, min_balance, rpc_client).await? { + insufficient_balance_addrs.push(*address); + } + } + Ok(insufficient_balance_addrs) +} + +pub async fn spam_callback_default( + log_txs: bool, + rpc_client: Option>, +) -> SpamCallbackType { + if let Some(rpc_client) = rpc_client { + if log_txs { + return SpamCallbackType::Log(LogCallback::new(rpc_client.clone())); + } + } + SpamCallbackType::Nil(NilCallback) +} + +pub fn write_run_txs( + writer: &mut Writer, + txs: &[RunTx], +) -> Result<(), Box> { + for tx in txs { + writer.serialize(tx)?; + } + writer.flush()?; + Ok(()) +} + +pub fn prompt_cli(msg: impl AsRef) -> String { + let mut stdout = StandardStream::stdout(ColorChoice::Always); + stdout + .set_color(ColorSpec::new().set_fg(Some(termcolor::Color::Rgb(252, 186, 3)))) + .expect("failed to set stdout color"); + writeln!(&mut stdout, "{}", msg.as_ref()).expect("failed to write to stdout"); + stdout.reset().expect("failed to reset color"); + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .expect("Failed to read line"); + input.trim().to_owned() +} diff --git a/scenarios/univ2ConfigTest.toml b/scenarios/univ2ConfigTest.toml index d32ed1b..977d899 100644 --- a/scenarios/univ2ConfigTest.toml +++ b/scenarios/univ2ConfigTest.toml @@ -140,7 +140,7 @@ args = [ "10000000000000" ] -## SPAM ######################################################################## +### SPAM [[spam]] From c925eebcf5b3f82a5888fc4482a1a405aa0091b0 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Wed, 11 Dec 2024 18:37:53 -0800 Subject: [PATCH 2/2] chore: clippy --- crates/cli/src/commands/mod.rs | 2 +- crates/cli/src/commands/spam.rs | 59 +++++++++++++++++++-------------- crates/cli/src/main.rs | 24 ++++++++------ 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index c8a79f2..f41e287 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -9,7 +9,7 @@ use clap::Parser; pub use report::report; pub use run::run; pub use setup::setup; -pub use spam::spam; +pub use spam::{spam, SpamCommandArgs}; pub use types::ContenderSubcommand; #[derive(Parser, Debug)] diff --git a/crates/cli/src/commands/spam.rs b/crates/cli/src/commands/spam.rs index 5a15220..a982902 100644 --- a/crates/cli/src/commands/spam.rs +++ b/crates/cli/src/commands/spam.rs @@ -18,33 +18,39 @@ use crate::util::{ spam_callback_default, SpamCallbackType, }; +#[derive(Debug)] +pub struct SpamCommandArgs { + pub testfile: String, + pub rpc_url: String, + pub builder_url: Option, + pub txs_per_block: Option, + pub txs_per_second: Option, + pub duration: Option, + pub seed: Option, + pub private_keys: Option>, + pub disable_reports: bool, + pub min_balance: String, +} + pub async fn spam( db: &(impl DbOps + Clone + Send + Sync + 'static), - testfile: impl AsRef, - rpc_url: impl AsRef, - builder_url: Option, - txs_per_block: Option, - txs_per_second: Option, - duration: Option, - seed: Option, - private_keys: Option>, - disable_reports: bool, - min_balance: String, + args: SpamCommandArgs, ) -> Result<(), Box> { - let testconfig = TestConfig::from_file(testfile.as_ref())?; - let rand_seed = seed + let testconfig = TestConfig::from_file(&args.testfile)?; + let rand_seed = args + .seed .map(|s| RandSeed::seed_from_str(s.as_ref())) .unwrap_or_default(); - let url = Url::parse(rpc_url.as_ref()).expect("Invalid RPC URL"); + let url = Url::parse(&args.rpc_url).expect("Invalid RPC URL"); let rpc_client = ProviderBuilder::new() .network::() .on_http(url.to_owned()); let eth_client = ProviderBuilder::new().on_http(url.to_owned()); - let duration = duration.unwrap_or_default(); - let min_balance = parse_ether(min_balance.as_ref())?; + let duration = args.duration.unwrap_or_default(); + let min_balance = parse_ether(&args.min_balance)?; - let user_signers = get_signers_with_defaults(private_keys); + let user_signers = get_signers_with_defaults(args.private_keys); let spam = testconfig .spam .as_ref() @@ -54,8 +60,10 @@ pub async fn spam( let from_pools = get_from_pools(&testconfig); let mut agents = AgentStore::new(); - let signers_per_period = - txs_per_block.unwrap_or(txs_per_second.unwrap_or(spam.len())) / spam.len(); + let signers_per_period = args + .txs_per_block + .unwrap_or(args.txs_per_second.unwrap_or(spam.len())) + / spam.len(); let mut all_signers = vec![]; all_signers.extend_from_slice(&user_signers); @@ -81,10 +89,10 @@ pub async fn spam( ) .await?; - if txs_per_block.is_some() && txs_per_second.is_some() { + if args.txs_per_block.is_some() && args.txs_per_second.is_some() { panic!("Cannot set both --txs-per-block and --txs-per-second"); } - if txs_per_block.is_none() && txs_per_second.is_none() { + if args.txs_per_block.is_none() && args.txs_per_second.is_none() { panic!("Must set either --txs-per-block (--tpb) or --txs-per-second (--tps)"); } @@ -92,18 +100,19 @@ pub async fn spam( testconfig, db.clone().into(), url, - builder_url.map(|url| Url::parse(&url).expect("Invalid builder URL")), + args.builder_url + .map(|url| Url::parse(&url).expect("Invalid builder URL")), rand_seed, &user_signers, agents, ) .await?; - if let Some(txs_per_block) = txs_per_block { + if let Some(txs_per_block) = args.txs_per_block { println!("Blockwise spamming with {} txs per block", txs_per_block); let spammer = BlockwiseSpammer {}; - match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { + match spam_callback_default(!args.disable_reports, Arc::new(rpc_client).into()).await { SpamCallbackType::Log(cback) => { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -129,13 +138,13 @@ pub async fn spam( return Ok(()); } - let tps = txs_per_second.unwrap_or(10); + let tps = args.txs_per_second.unwrap_or(10); println!("Timed spamming with {} txs per second", tps); let interval = std::time::Duration::from_secs(1); let spammer = TimedSpammer::new(interval); - match spam_callback_default(!disable_reports, Arc::new(rpc_client).into()).await { + match spam_callback_default(!args.disable_reports, Arc::new(rpc_client).into()).await { SpamCallbackType::Log(cback) => { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 84ac387..e25fa11 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -4,7 +4,7 @@ mod util; use std::sync::LazyLock; -use commands::{ContenderCli, ContenderSubcommand}; +use commands::{ContenderCli, ContenderSubcommand, SpamCommandArgs}; use contender_core::db::DbOps; use contender_sqlite::SqliteDb; @@ -39,16 +39,18 @@ async fn main() -> Result<(), Box> { } => { commands::spam( &DB.clone(), - testfile, - rpc_url, - builder_url, - txs_per_block, - txs_per_second, - duration, - seed, - private_keys, - disable_reports, - min_balance, + SpamCommandArgs { + testfile, + rpc_url, + builder_url, + txs_per_block, + txs_per_second, + duration, + seed, + private_keys, + disable_reports, + min_balance, + }, ) .await? }