diff --git a/crates/cli/src/commands/spamd.rs b/crates/cli/src/commands/spamd.rs new file mode 100644 index 00000000..9f410a6c --- /dev/null +++ b/crates/cli/src/commands/spamd.rs @@ -0,0 +1,137 @@ +use super::{SpamCampaignContext, SpamCommandArgs}; +use crate::CliError; +use crate::{ + commands::{self}, + util::data_dir, +}; +use contender_core::db::DbOps; +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; +use tracing::{debug, error, info, warn}; + +/// Runs spam in a loop, potentially executing multiple spam runs. +/// +/// If `limit_loops` is `None`, it will run indefinitely. +/// +/// If `limit_loops` is `Some(n)`, it will run `n` times. +/// +/// If `gen_report` is `true`, it will generate a report at the end. +pub async fn spamd( + db: &(impl DbOps + Clone + Send + Sync + 'static), + args: SpamCommandArgs, + gen_report: bool, + limit_loops: Option, +) -> Result<(), CliError> { + let is_done = Arc::new(AtomicBool::new(false)); + let mut scenario = args.init_scenario(db).await?; + + // collects run IDs from the spam command + let mut run_ids = vec![]; + + // if CTRL-C signal is received, set `is_done` to true + { + let is_done = is_done.clone(); + tokio::task::spawn(async move { + tokio::signal::ctrl_c() + .await + .expect("Failed to listen for CTRL-C"); + info!( + "CTRL-C received. Spam daemon will shut down as soon as current batch finishes..." + ); + is_done.store(true, Ordering::SeqCst); + }); + } + + // runs spam command in a loop + let mut i = 0; + // this holds a Some value only when a timeout has been started. + let mut timeout_start = None; + loop { + let mut do_finish = false; + if let Some(loops) = &limit_loops { + if i >= *loops { + do_finish = true; + } + i += 1; + } + if is_done.load(Ordering::SeqCst) { + do_finish = true; + } + if do_finish { + debug!("spamd loop finished"); + break; + } + + let db = db.clone(); + let spam_res = + commands::spam(&db, &args, &mut scenario, SpamCampaignContext::default()).await; + let wait_time = Duration::from_secs(3); + + if let Err(e) = spam_res { + error!("spam run failed: {e:?}"); + + if timeout_start.is_none() { + let start_time = std::time::Instant::now(); + timeout_start = Some(start_time); + warn!("retrying in {} seconds...", wait_time.as_secs()); + tokio::time::sleep(wait_time).await; + continue; + } + + if let Some(timeout_start) = timeout_start { + if std::time::Instant::now().duration_since(timeout_start) + > args.spam_args.spam_timeout + { + warn!("timeout reached, quitting spam loop..."); + scenario.ctx.cancel_token.cancel(); + break; + } else { + tokio::time::sleep(wait_time).await; + } + } else { + scenario.ctx.cancel_token.cancel(); + break; + } + } else { + timeout_start = None; + let run_id = spam_res.expect("spam"); + if let Some(run_id) = run_id { + run_ids.push(run_id); + } + } + } + + // generate a report if requested; in closure for tokio::select to handle CTRL-C + let run_report = || async move { + if gen_report { + if run_ids.is_empty() { + warn!("No runs found, exiting."); + return Ok::<_, CliError>(()); + } + let first_run_id = run_ids.iter().min().expect("no run IDs found"); + let last_run_id = *run_ids.iter().max().expect("no run IDs found"); + contender_report::command::report( + Some(last_run_id), + last_run_id - first_run_id, + db, + &data_dir()?, + ) + .await?; + } + Ok(()) + }; + + tokio::select! { + _ = run_report() => {}, + _ = tokio::signal::ctrl_c() => { + info!("CTRL-C received, cancelling report..."); + } + } + + Ok(()) +} diff --git a/crates/cli/src/default_scenarios/blobs.rs b/crates/cli/src/default_scenarios/blobs.rs index e42b164e..1b4cca0f 100644 --- a/crates/cli/src/default_scenarios/blobs.rs +++ b/crates/cli/src/default_scenarios/blobs.rs @@ -37,11 +37,6 @@ fn blob_txs(blob_data: impl AsRef, recipient: Option) -> Vec contender_testfile::TestConfig { - TestConfig { - env: None, - create: None, - setup: None, - spam: Some(blob_txs(&self.blob_data, self.recipient.to_owned())), - } + TestConfig::new().with_spam(blob_txs(&self.blob_data, self.recipient.to_owned())) } } diff --git a/crates/cli/src/default_scenarios/custom_contract.rs b/crates/cli/src/default_scenarios/custom_contract.rs index d947919b..ec2e4b55 100644 --- a/crates/cli/src/default_scenarios/custom_contract.rs +++ b/crates/cli/src/default_scenarios/custom_contract.rs @@ -417,7 +417,7 @@ impl NameAndArgs { impl ToTestConfig for CustomContractArgs { fn to_testconfig(&self) -> contender_testfile::TestConfig { TestConfig::new() - .with_create(vec![CreateDefinition::new(&self.contract)]) + .with_create(vec![CreateDefinition::new(&self.contract.clone().into())]) .with_setup(self.setup.to_owned()) .with_spam(self.spam.iter().map(SpamRequest::new_tx).collect()) } diff --git a/crates/cli/src/default_scenarios/erc20.rs b/crates/cli/src/default_scenarios/erc20.rs index c9a3d08b..7af486be 100644 --- a/crates/cli/src/default_scenarios/erc20.rs +++ b/crates/cli/src/default_scenarios/erc20.rs @@ -69,48 +69,47 @@ impl ToTestConfig for Erc20Args { .with_args(&[recipient.to_string(), self.fund_amount.to_string()]) }) .collect(); + let spam_steps = vec![SpamRequest::new_tx(&{ + let mut func_def = FunctionCallDefinition::new(token.template_name()) + .with_from_pool("spammers") // Senders from limited pool + .with_signature("transfer(address guy, uint256 wad)") + .with_args(&[ + // Use token_recipient if provided (via --recipient flag), + // otherwise this is a placeholder for fuzzing + self.token_recipient + .as_ref() + .map(|addr| addr.to_string()) + .unwrap_or_else(|| { + "0x0000000000000000000000000000000000000000".to_string() + }), + self.send_amount.to_string(), + ]) + .with_gas_limit(55000); - TestConfig { - env: None, - create: Some(vec![CreateDefinition { - contract: token.to_owned(), + // Only add fuzzing if token_recipient is NOT provided + if self.token_recipient.is_none() { + func_def = func_def.with_fuzz(&[FuzzParam { + param: Some("guy".to_string()), + value: None, + min: Some(U256::from(1)), + max: Some( + U256::from_str("0x0000000000ffffffffffffffffffffffffffffffff").unwrap(), + ), + }]); + } + + func_def + })]; + + TestConfig::new() + .with_create(vec![CreateDefinition { + contract: token.to_owned().into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: Some(setup_steps), - spam: Some(vec![SpamRequest::new_tx(&{ - let mut func_def = FunctionCallDefinition::new(token.template_name()) - .with_from_pool("spammers") // Senders from limited pool - .with_signature("transfer(address guy, uint256 wad)") - .with_args(&[ - // Use token_recipient if provided (via --recipient flag), - // otherwise this is a placeholder for fuzzing - self.token_recipient - .as_ref() - .map(|addr| addr.to_string()) - .unwrap_or_else(|| { - "0x0000000000000000000000000000000000000000".to_string() - }), - self.send_amount.to_string(), - ]) - .with_gas_limit(55000); - - // Only add fuzzing if token_recipient is NOT provided - if self.token_recipient.is_none() { - func_def = func_def.with_fuzz(&[FuzzParam { - param: Some("guy".to_string()), - value: None, - min: Some(U256::from(1)), - max: Some( - U256::from_str("0x0000000000ffffffffffffffffffffffffffffffff").unwrap(), - ), - }]); - } - - func_def - })]), - } + }]) + .with_setup(setup_steps) + .with_spam(spam_steps) } } diff --git a/crates/cli/src/default_scenarios/eth_functions/command.rs b/crates/cli/src/default_scenarios/eth_functions/command.rs index 5cac9660..63e03ee3 100644 --- a/crates/cli/src/default_scenarios/eth_functions/command.rs +++ b/crates/cli/src/default_scenarios/eth_functions/command.rs @@ -73,17 +73,14 @@ impl ToTestConfig for EthFunctionsArgs { let opcode_txs = opcode_txs(opcodes, *num_iterations); let txs = [precompile_txs, opcode_txs].concat(); - TestConfig { - env: None, - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: contracts::SPAM_ME.into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: None, - spam: Some(txs), - } + }]) + .with_spam(txs) } } diff --git a/crates/cli/src/default_scenarios/fill_block.rs b/crates/cli/src/default_scenarios/fill_block.rs index b5173e52..ed405f25 100644 --- a/crates/cli/src/default_scenarios/fill_block.rs +++ b/crates/cli/src/default_scenarios/fill_block.rs @@ -81,17 +81,14 @@ impl ToTestConfig for FillBlockArgs { }) .collect::>(); - TestConfig { - env: None, - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: contracts::SPAM_ME.into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: None, - spam: Some(spam_txs), - } + }]) + .with_spam(spam_txs) } } diff --git a/crates/cli/src/default_scenarios/revert.rs b/crates/cli/src/default_scenarios/revert.rs index 13e1e00f..511f015f 100644 --- a/crates/cli/src/default_scenarios/revert.rs +++ b/crates/cli/src/default_scenarios/revert.rs @@ -23,22 +23,19 @@ impl Default for RevertCliArgs { impl ToTestConfig for RevertCliArgs { fn to_testconfig(&self) -> contender_testfile::TestConfig { - TestConfig { - env: None, - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: SPAM_ME_6.into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: None, - spam: Some(vec![SpamRequest::new_tx( + }]) + .with_spam(vec![SpamRequest::new_tx( &FunctionCallDefinition::new(SPAM_ME_6.template_name()) .with_signature("consumeGasAndRevert(uint256 gas)") .with_args(&[self.gas_use.to_string()]) .with_gas_limit(self.gas_use + 35000), - )]), - } + )]) } } diff --git a/crates/cli/src/default_scenarios/storage.rs b/crates/cli/src/default_scenarios/storage.rs index 3d3f5f69..d57ba8af 100644 --- a/crates/cli/src/default_scenarios/storage.rs +++ b/crates/cli/src/default_scenarios/storage.rs @@ -62,17 +62,14 @@ impl ToTestConfig for StorageStressArgs { .map(SpamRequest::Tx) .collect::>(); - TestConfig { - env: None, - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: contracts::SPAM_ME.into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: None, - spam: Some(txs), - } + }]) + .with_spam(txs) } } diff --git a/crates/cli/src/default_scenarios/stress.rs b/crates/cli/src/default_scenarios/stress.rs index 726e02af..5b18f9a0 100644 --- a/crates/cli/src/default_scenarios/stress.rs +++ b/crates/cli/src/default_scenarios/stress.rs @@ -206,17 +206,14 @@ impl ToTestConfig for StressCliArgs { .flat_map(|config| config.spam.unwrap_or_default()) .collect::>(); - TestConfig { - env: None, - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: contracts::SPAM_ME.into(), signature: None, args: None, from: None, from_pool: Some("admin".to_owned()), - }]), - setup: None, - spam: Some(txs), - } + }]) + .with_spam(txs) } } diff --git a/crates/cli/src/default_scenarios/transfers.rs b/crates/cli/src/default_scenarios/transfers.rs index 8dd5b3e9..56e9d56f 100644 --- a/crates/cli/src/default_scenarios/transfers.rs +++ b/crates/cli/src/default_scenarios/transfers.rs @@ -59,11 +59,6 @@ impl ToTestConfig for TransferStressArgs { .map(Box::new) .map(SpamRequest::Tx) .collect::>(); - contender_testfile::TestConfig { - env: None, - create: None, - setup: None, - spam: Some(txs), - } + contender_testfile::TestConfig::new().with_spam(txs) } } diff --git a/crates/cli/src/default_scenarios/uni_v2.rs b/crates/cli/src/default_scenarios/uni_v2.rs index 2dc3c1ef..ea39ae00 100644 --- a/crates/cli/src/default_scenarios/uni_v2.rs +++ b/crates/cli/src/default_scenarios/uni_v2.rs @@ -135,7 +135,7 @@ impl ToTestConfig for UniV2Args { let mut add_create_steps = vec![]; for i in 0..self.num_tokens { let deployment = CreateDefinition { - contract: test_token(i + 1, self.initial_token_supply), + contract: test_token(i + 1, self.initial_token_supply).into(), signature: None, args: None, from: None, @@ -147,14 +147,18 @@ impl ToTestConfig for UniV2Args { // now that contract updates are done, we can update the config & use contracts in setup steps config.create = Some(create_steps.to_owned()); - let find_contract = |name: &str| { + let find_contract = |name: &str| -> Result { let contract = create_steps .iter() .find(|c| c.contract.name == name) .ok_or(UniV2Error::ContractNameNotFound(name.to_owned()))? .contract .to_owned(); - Ok::<_, UniV2Error>(contract) + Ok::<_, UniV2Error>( + contract + .to_compiled_contract(Default::default()) + .expect("contract is static, this can't fail"), + ) }; let weth = find_contract("weth").expect("contract"); diff --git a/crates/cli/src/util/error.rs b/crates/cli/src/util/error.rs index f8f5d751..9b00b8b6 100644 --- a/crates/cli/src/util/error.rs +++ b/crates/cli/src/util/error.rs @@ -13,6 +13,9 @@ pub enum UtilError { #[error("io error")] Io(#[from] std::io::Error), + #[error("invalid scenario path: {0}")] + InvalidScenarioPath(String), + #[error("failed to parse duration")] ParseDuration(#[from] ParseDurationError), diff --git a/crates/cli/src/util/utils.rs b/crates/cli/src/util/utils.rs index 95fcb47a..167db059 100644 --- a/crates/cli/src/util/utils.rs +++ b/crates/cli/src/util/utils.rs @@ -22,6 +22,7 @@ use contender_engine_provider::DEFAULT_BLOCK_TIME; use contender_testfile::TestConfig; use nu_ansi_term::{AnsiGenericString, Style as ANSIStyle}; use rand::Rng; +use std::path::PathBuf; use std::{str::FromStr, sync::Arc, time::Duration}; use tracing::{debug, info, warn}; @@ -53,7 +54,11 @@ pub async fn load_testconfig(testfile: &str) -> Result = String> { pub name: S, } +pub enum ContractFileType { + Json { + /// Relative file path to the file containing bytecode. + path: PathBuf, + /// Determines where in the json the bytecode is located. Example: `.bytecode.object` (for forge builds) + bytecode_filter: String, + }, + Hex { + /// Relative file path to the file containing bytecode. + path: PathBuf, + }, +} + +struct MiniJq { + /// JSON file contents. + input: serde_json::Value, +} + +fn extract_string( + json: &serde_json::Value, + filter: &[String], +) -> Result> { + if filter.len() == 0 { + // desired element is in `json` + let contents: String = json.to_owned().to_string(); + return Ok(contents); + } + + let key = &filter[0]; + let val = json.get(key); + if let Some(val) = val { + // recurse w/ first element of filter removed + let new_filter = &filter[1..]; + return extract_string(val, new_filter); + } else { + return Err("invalid filter, key not found".into()); + } +} + +impl MiniJq { + pub fn new(input: &str) -> Result { + Ok(Self { + input: serde_json::from_str(input)?, + }) + } + + fn path(&self, filter: &str) -> Vec { + filter.split('.').map(|x| x.to_owned()).collect::>() + } + + pub fn value(&self, filter: &str) -> Result> { + extract_string(&self.input, &self.path(filter)) + } +} + +impl ContractFileType { + fn read_file( + scenario_path: &PathBuf, + relative_path: &PathBuf, + ) -> Result { + std::fs::read_to_string(scenario_path.join(relative_path)) + .map_err(|e| ContractError::ReadFile(e, relative_path.to_owned())) + } + + pub fn bytecode(&self, scenario_path: &PathBuf) -> Result { + use ContractFileType::*; + + match self { + Json { + path, + bytecode_filter, + } => { + // read file + let file_contents = Self::read_file(scenario_path, path)?; + // extract bytecode string from json + MiniJq::new(&file_contents) + .map_err(ContractError::JsonParse)? + .value(&bytecode_filter) + .map_err(|_| ContractError::InvalidJsonFilter(bytecode_filter.to_owned())) + } + Hex { path } => Self::read_file(scenario_path, path), + } + } +} + +#[derive(Debug, Error)] +pub enum ContractError { + #[error("invalid JSON")] + JsonParse(#[from] serde_json::Error), + + #[error("invalid contract bytecode configuration: {0}")] + InvalidConfig(&'static str), + + #[error("failed to read file at {1}: {0}")] + ReadFile(std::io::Error, PathBuf), + + #[error("'filter' containing location of raw bytecode is required for json files (example: \".bytecode.object\")")] + FilterRequiredForJson, + + #[error("invalid json filter: \"{0}\"")] + InvalidJsonFilter(String), + + #[error("unsupported file type, .hex and .json are supported")] + UnsupportedType, +} + +#[derive(Clone, Deserialize, Debug, Serialize)] +/// File spec for contracts. +pub struct ContractFile { + path: String, + filter: Option, +} + +impl ContractFile { + pub fn identify(&self) -> Result { + use ContractError::*; + let path = self.path.to_lowercase(); + if path.ends_with(".hex") { + return Ok(ContractFileType::Hex { path: path.into() }); + } + if path.ends_with(".json") { + if let Some(filter) = self.filter.clone() { + return Ok(ContractFileType::Json { + path: path.into(), + bytecode_filter: filter, + }); + } + return Err(FilterRequiredForJson); + } + return Err(UnsupportedType); + } +} + +#[derive(Clone, Deserialize, Debug, Serialize)] +/// Specification for config files to specify raw bytecode or a file containing raw bytecode. +/// ((Open to changes on the name)) +pub struct CompiledContractOrFile = String> { + pub bytecode: Option, + pub bytecode_file: Option, + pub name: S, +} + +impl> CompiledContractOrFile { + fn source(&self) -> Result { + if self.bytecode.is_some() && self.bytecode_file.is_some() { + return Err(ContractError::InvalidConfig( + "cannot specify both 'bytecode' & 'file'", + )); + } + if let Some(bytecode) = &self.bytecode { + return Ok(ContractSource::Bytecode(bytecode.as_ref().to_string())); + } + if let Some(file) = &self.bytecode_file { + return Ok(ContractSource::File(file.identify()?)); + } + return Err(ContractError::InvalidConfig( + "must specify 'bytecode' or 'file' in contract creation", + )); + } +} + +enum ContractSource { + Bytecode(String), + File(ContractFileType), +} + +impl CompiledContractOrFile { + pub fn to_compiled_contract( + &self, + scenario_path: PathBuf, + ) -> Result { + Ok(CompiledContract { + bytecode: match self.source()? { + ContractSource::Bytecode(bytecode) => bytecode, + ContractSource::File(file) => file.bytecode(&scenario_path).map(|s| s)?, + }, + name: self.name.clone(), + }) + } +} + impl> CompiledContract { pub fn new(bytecode: T, name: T) -> Self { CompiledContract { bytecode, name } @@ -30,6 +213,26 @@ impl<'a> From> for CompiledContract { } } +impl<'a> From> for CompiledContractOrFile { + fn from(value: CompiledContract<&'a str>) -> Self { + Self { + bytecode: Some(value.bytecode.to_owned()), + bytecode_file: None, + name: value.name.to_owned(), + } + } +} + +impl + Clone> From> for CompiledContractOrFile { + fn from(value: CompiledContract) -> Self { + Self { + bytecode: Some(value.bytecode.clone()), + bytecode_file: None, + name: value.name.clone(), + } + } +} + impl CompiledContract { pub fn with_constructor_args( mut self, @@ -61,10 +264,14 @@ impl CompiledContract { } } +pub struct CreateDefinitionUnresolved { + pub contract: CompiledContractOrFile, +} + #[derive(Clone, Deserialize, Debug, Serialize)] pub struct CreateDefinition { #[serde(flatten)] - pub contract: CompiledContract, + pub contract: CompiledContractOrFile, /// Constructor signature. Formats supported: "constructor(type1,type2,...)" or "(type1,type2,...)". pub signature: Option, /// Constructor arguments. May include placeholders. @@ -76,7 +283,7 @@ pub struct CreateDefinition { } impl CreateDefinition { - pub fn new(contract: &CompiledContract) -> Self { + pub fn new(contract: &CompiledContractOrFile) -> Self { CreateDefinition { contract: contract.to_owned(), signature: None, diff --git a/crates/core/src/generator/templater.rs b/crates/core/src/generator/templater.rs index adba5956..6e3aba3f 100644 --- a/crates/core/src/generator/templater.rs +++ b/crates/core/src/generator/templater.rs @@ -4,7 +4,7 @@ use crate::{ constants::{SENDER_KEY, SETCODE_KEY}, function_def::{FunctionCallDefinition, FunctionCallDefinitionStrict}, util::{encode_calldata, UtilError}, - CreateDefinition, + ContractError, CreateDefinition, }, }; use alloy::{ @@ -12,7 +12,7 @@ use alloy::{ primitives::{Address, Bytes, FixedBytes, TxKind, U256}, rpc::types::TransactionRequest, }; -use std::collections::HashMap; +use std::{collections::HashMap, path::PathBuf}; use thiserror::Error; use super::CreateDefinitionStrict; @@ -21,6 +21,9 @@ pub type Result = std::result::Result; #[derive(Debug, Error)] pub enum TemplaterError { + #[error("error from contract creation")] + CreateError(#[from] ContractError), + #[error("failed to find placeholder key '{0}'")] KeyNotFound(String), @@ -48,6 +51,7 @@ where fn copy_end(&self, input: &str, last_end: usize) -> String; fn num_placeholders(&self, input: &str) -> usize; fn find_key(&self, input: &str) -> Option<(K, usize)>; + fn scenario_parent_directory(&self) -> PathBuf; /// Looks for {placeholders} in `arg` and updates `env` with the values found by querying the DB. fn find_placeholder_values( @@ -145,8 +149,11 @@ where self.find_placeholder_values(from, placeholder_map, db, rpc_url, genesis_hash)?; } // also scan bytecode for placeholders + let compiled_contract = createdef + .contract + .to_compiled_contract(self.scenario_parent_directory())?; self.find_placeholder_values( - &createdef.contract.bytecode, + &compiled_contract.bytecode, placeholder_map, db, rpc_url, diff --git a/crates/core/src/generator/trait.rs b/crates/core/src/generator/trait.rs index 1ee2e2cd..14e64816 100644 --- a/crates/core/src/generator/trait.rs +++ b/crates/core/src/generator/trait.rs @@ -7,7 +7,7 @@ use crate::{ function_def::{FunctionCallDefinition, FunctionCallDefinitionStrict, FuzzParam}, named_txs::{ExecutionRequest, NamedTxRequest, NamedTxRequestBuilder}, seeder::{SeedValue, Seeder}, - templater::Templater, + templater::{Templater, TemplaterError}, types::{AnyProvider, AsyncCallbackResult, PlanType, SpamRequest}, util::{parse_value, UtilError}, CreateDefinition, CreateDefinitionStrict, RandSeed, @@ -185,13 +185,17 @@ where // handle direct variable injection // (backwards-compatible for bytecode defs that include placeholders, // rather than using `args` + `signature` in the `CreateDefinition`) - let bytecode = create_def.contract.bytecode.to_owned().replace( + let compiled_contract = create_def + .contract + .to_compiled_contract(self.get_templater().scenario_parent_directory()) + .map_err(|e| TemplaterError::CreateError(e))?; + let bytecode = compiled_contract.bytecode.to_owned().replace( "{_sender}", &format!("{}{}", "0".repeat(24), from_address.encode_hex()), ); // inject address WITHOUT 0x prefix, padded with 24 zeroes Ok(CreateDefinitionStrict { - name: create_def.contract.name.to_owned(), + name: compiled_contract.name.to_owned(), bytecode, from: from_address, signature: create_def.signature.to_owned(), diff --git a/crates/core/src/spammer/spammer_trait.rs b/crates/core/src/spammer/spammer_trait.rs index 3d8c9f20..98d32a04 100644 --- a/crates/core/src/spammer/spammer_trait.rs +++ b/crates/core/src/spammer/spammer_trait.rs @@ -114,6 +114,9 @@ where let actor_ctx = ActorContext::new(start_block, run_id); scenario.tx_actor().init_ctx(actor_ctx).await?; + let actor_ctx = ActorContext::new(start_block, run_id); + scenario.tx_actor().init_ctx(actor_ctx).await?; + // run spammer within tokio::select! to allow for graceful shutdown let do_quit = scenario.ctx.cancel_token.clone(); let spam_finished: bool = tokio::select! { diff --git a/crates/core/src/test_scenario.rs b/crates/core/src/test_scenario.rs index 957dec80..9f3e003a 100644 --- a/crates/core/src/test_scenario.rs +++ b/crates/core/src/test_scenario.rs @@ -1026,7 +1026,7 @@ where .send(e) .await .unwrap_or_else(|e| trace!("failed to send error signal: {e:?}")); - } else { + } else if !cancel_token.is_cancelled() { success_sender .send(()) .await @@ -1571,7 +1571,7 @@ async fn handle_tx_outcome<'a, F: SpamCallback + 'static>( } warn!("error from tx {tx_hash}: {msg}"); extra = extra.with_error(msg.to_string()); - } else { + } else if !ctx.cancel_token.is_cancelled() { // success path if let Err(e) = ctx.success_sender.send(()).await { // this error can safely be ignored; it just means the receiver was closed (e.g. by CTRL-C) @@ -1761,7 +1761,8 @@ pub mod tests { contract: CompiledContract { bytecode: COUNTER_BYTECODE.to_string(), name: "test_counter2".to_string(), - }, + } + .into(), signature: None, args: None, from: None, @@ -1771,7 +1772,8 @@ pub mod tests { contract: CompiledContract { bytecode: UNI_V2_FACTORY_BYTECODE.to_string(), name: "univ2_factory".to_string(), - }, + } + .into(), signature: None, args: None, from: None, @@ -1875,6 +1877,9 @@ pub mod tests { } impl Templater for MockConfig { + fn scenario_parent_directory(&self) -> std::path::PathBuf { + Default::default() + } fn copy_end(&self, input: &str, _last_end: usize) -> String { input.to_owned() } diff --git a/crates/testfile/src/lib.rs b/crates/testfile/src/lib.rs index b36dcac5..40251805 100644 --- a/crates/testfile/src/lib.rs +++ b/crates/testfile/src/lib.rs @@ -69,12 +69,7 @@ pub mod tests { "0xdead".to_owned(), ]); - TestConfig { - env: None, - create: None, - setup: None, - spam: vec![SpamRequest::Tx(Box::new(fncall))].into(), - } + TestConfig::new().with_spam(vec![SpamRequest::Tx(Box::new(fncall))].into()) } pub fn get_fuzzy_testconfig() -> TestConfig { @@ -95,11 +90,8 @@ pub mod tests { max: None, }]) }; - TestConfig { - env: None, - create: None, - setup: None, - spam: vec![ + TestConfig::new().with_spam( + vec![ SpamRequest::Tx(Box::new(fn_call( "0xbeef", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", @@ -121,15 +113,12 @@ pub mod tests { }), ] .into(), - } + ) } pub fn get_setup_testconfig() -> TestConfig { - TestConfig { - env: None, - create: None, - spam: None, - setup: vec![ + TestConfig::new().with_setup( + vec![ FunctionCallDefinition::new("0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D") .with_from("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266") .with_value(U256::from(4096)) @@ -142,40 +131,37 @@ pub mod tests { .with_args(&["1", "2", &Address::repeat_byte(0x11).encode_hex(), "0xbeef"]), ] .into(), - } + ) } pub fn get_create_testconfig() -> TestConfig { let mut env = HashMap::new(); env.insert("test1".to_owned(), "0xbeef".to_owned()); env.insert("test2".to_owned(), "0x9001".to_owned()); - TestConfig { - env: Some(env), - create: Some(vec![CreateDefinition { + TestConfig::new() + .with_create(vec![CreateDefinition { contract: CompiledContract::new( COUNTER_BYTECODE.to_string(), "test_counter".to_string(), - ), + ) + .into(), signature: None, args: None, from: Some("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_owned()), from_pool: None, - }]), - spam: None, - setup: None, - } + }]) + .with_env(env) } pub fn get_composite_testconfig() -> TestConfig { let tc_fuzz = get_fuzzy_testconfig(); let tc_setup = get_setup_testconfig(); let tc_create = get_create_testconfig(); - TestConfig { - env: tc_create.env, // TODO: add something here - create: tc_create.create, - spam: tc_fuzz.spam, - setup: tc_setup.setup, - } + TestConfig::new() + .with_create(tc_create.create.unwrap()) + .with_env(tc_create.env.unwrap()) + .with_setup(tc_setup.setup.unwrap()) + .with_spam(tc_fuzz.spam.unwrap()) } #[tokio::test] diff --git a/crates/testfile/src/test_config.rs b/crates/testfile/src/test_config.rs index 238e1d4d..b8e1b695 100644 --- a/crates/testfile/src/test_config.rs +++ b/crates/testfile/src/test_config.rs @@ -6,6 +6,7 @@ use contender_core::generator::{ FunctionCallDefinition, PlanConfig, }; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use std::{collections::HashMap, str::FromStr}; use std::{fs::read, ops::Deref}; @@ -24,6 +25,8 @@ pub struct TestConfig { /// Function to call in spam txs. pub spam: Option>, + + _scenario_directory: Option, } impl TestConfig { @@ -33,9 +36,15 @@ impl TestConfig { create: None, setup: None, spam: None, + _scenario_directory: None, } } + pub fn with_scenario_directory(mut self, dir: PathBuf) -> Self { + self._scenario_directory = Some(dir); + self + } + pub async fn from_remote_url(url: &str) -> Result { let file_contents = reqwest::get(url).await?.text().await?; let test_file: TestConfig = toml::from_str(&file_contents)?; @@ -220,6 +229,14 @@ impl PlanConfig for TestConfig { } impl Templater for TestConfig { + fn scenario_parent_directory(&self) -> std::path::PathBuf { + if let Some(dir) = &self._scenario_directory { + dir.to_owned() + } else { + Default::default() + } + } + /// Find values wrapped in brackets in a string and replace them with values from a hashmap whose key match the value in the brackets. /// example: "hello {world}" with hashmap {"world": "earth"} will return "hello earth" fn replace_placeholders(&self, input: &str, template_map: &HashMap) -> String {