Skip to content

Commit 79e216b

Browse files
authored
feat/anvil integration tests (#430)
* (WIP) add test runner for scenario files * fix gas estimate in uniV3.toml scenario * add timeout to send_transaction calls in setup&spam * add tracing_subscriber logging to scenario tests * track contents of scenarios/ in build.rs; re-runs on changes * apply required tx types to tx-type-specific scenarios * chore: clippy * suppress snake_case warnings for macro-generated tests * chore: clippy * chore: update changelog * chore: fix comment
1 parent 6821da2 commit 79e216b

6 files changed

Lines changed: 257 additions & 6 deletions

File tree

crates/cli/build.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use std::{
2+
env, fs,
3+
path::{Path, PathBuf},
4+
};
5+
6+
fn main() {
7+
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
8+
9+
// Same as your collect_scenario_files: go up twice to project root,
10+
// then into "scenarios".
11+
let project_root = manifest_dir.parent().unwrap().parent().unwrap();
12+
let scenarios_dir = project_root.join("scenarios");
13+
14+
let mut files = Vec::new();
15+
let mut dirs = Vec::new();
16+
collect_files(&scenarios_dir, &mut files, &mut dirs);
17+
18+
// Tell Cargo to rerun build.rs if scenarios/ directory or any subdirectory changes
19+
// (this detects new/deleted files)
20+
for dir in &dirs {
21+
println!("cargo:rerun-if-changed={}", dir.display());
22+
}
23+
// Also watch each individual file (detects modifications)
24+
for path in &files {
25+
println!("cargo:rerun-if-changed={}", path.display());
26+
}
27+
28+
let mut out = String::from("scenario_tests! {\n");
29+
30+
for path in files {
31+
// Store paths relative to project_root so runtime resolution is identical
32+
// to your current helper.
33+
let rel = path.strip_prefix(project_root).unwrap();
34+
let rel_str = rel.to_string_lossy();
35+
36+
// Turn "scenarios/foo/bar.toml" into a legal test name
37+
let test_name: String = rel_str
38+
.chars()
39+
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
40+
.collect();
41+
42+
out.push_str(&format!(" {} => {:?},\n", test_name, rel_str));
43+
}
44+
45+
out.push_str("}\n");
46+
47+
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
48+
fs::write(out_dir.join("generated_scenario_tests.rs"), out).unwrap();
49+
}
50+
51+
fn collect_files(dir: &Path, acc: &mut Vec<PathBuf>, dirs: &mut Vec<PathBuf>) {
52+
dirs.push(dir.to_path_buf());
53+
for entry in fs::read_dir(dir).unwrap() {
54+
let entry = entry.unwrap();
55+
let path = entry.path();
56+
if path.is_dir() {
57+
collect_files(&path, acc, dirs);
58+
} else {
59+
acc.push(path);
60+
}
61+
}
62+
}

crates/cli/src/commands/common.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,17 @@ Required if --auth-rpc-url is set.",
220220
message_version: EngineMessageVersion,
221221
}
222222

223+
impl Default for AuthCliArgs {
224+
fn default() -> Self {
225+
Self {
226+
auth_rpc_url: None,
227+
jwt_secret: None,
228+
use_op: false,
229+
message_version: EngineMessageVersion::V4,
230+
}
231+
}
232+
}
233+
223234
#[derive(Copy, Debug, Clone, clap::ValueEnum)]
224235
enum EngineMessageVersion {
225236
V1,

crates/cli/src/commands/spam.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,3 +818,167 @@ pub async fn spam<D: DbOps + Clone + Send + Sync + 'static>(
818818
let mut test_scenario = args.init_scenario(db).await?;
819819
spam_inner(db, &mut test_scenario, args, run_context).await
820820
}
821+
822+
#[cfg(test)]
823+
mod tests {
824+
use crate::commands::common::AuthCliArgs;
825+
use crate::commands::SetupCommandArgs;
826+
827+
use super::*;
828+
use alloy::node_bindings::{Anvil, AnvilInstance, WEI_IN_ETHER};
829+
use contender_sqlite::SqliteDb;
830+
use std::{
831+
collections::HashMap,
832+
path::{Path, PathBuf},
833+
};
834+
835+
fn create_send_args(sf: &Path, anvil: &AnvilInstance) -> ScenarioSendTxsCliArgs {
836+
// map scenario files to custom tx types if needed
837+
// this might be replaced with a more robust solution in the future
838+
// e.g. mapping the entire ScenarioSendTxsCliArgs structure instead of just tx types
839+
let custom_tx_types = HashMap::<&str, TxTypeCli>::from_iter([
840+
("blobs.toml", TxTypeCli::Eip4844),
841+
("setCode.toml", TxTypeCli::Eip7702),
842+
]);
843+
844+
// use last components of the path after "scenarios/" as a scenario ID
845+
// this supports nested directories under "scenarios/"
846+
let relative_path = sf
847+
.components()
848+
.rev()
849+
.take_while(|component| component.as_os_str() != "scenarios")
850+
.collect::<Vec<_>>()
851+
.into_iter()
852+
.rev()
853+
.collect::<PathBuf>();
854+
855+
ScenarioSendTxsCliArgs {
856+
testfile: Some(sf.to_str().unwrap().to_owned()),
857+
rpc_args: SendTxsCliArgsInner {
858+
rpc_url: anvil.endpoint_url(),
859+
seed: None,
860+
private_keys: None,
861+
min_balance: WEI_IN_ETHER * U256::from(10),
862+
tx_type: custom_tx_types
863+
.get(relative_path.as_os_str().to_str().unwrap())
864+
.cloned()
865+
.unwrap_or(TxTypeCli::Eip1559),
866+
bundle_type: crate::commands::common::BundleTypeCli::L1,
867+
auth_args: AuthCliArgs::default(),
868+
call_forkchoice: false,
869+
env: None,
870+
override_senders: false,
871+
gas_price: None,
872+
accounts_per_agent: None,
873+
},
874+
}
875+
}
876+
877+
async fn run_scenario(
878+
sf: &Path,
879+
anvil: &AnvilInstance,
880+
db: &SqliteDb,
881+
rand_seed: &RandSeed,
882+
) -> Result<()> {
883+
// special case: skip bundle scenario (anvil doesn't support it)
884+
if sf.ends_with("bundles.toml") {
885+
println!("Skipping bundle scenario (anvil doesn't support bundles)");
886+
return Ok(());
887+
}
888+
889+
// initialize a logger
890+
let _ = tracing_subscriber::fmt()
891+
.with_env_filter("contender_core=debug,info")
892+
.with_test_writer() // captures output properly in tests
893+
.try_init(); // try_init() won't panic if already initialized
894+
895+
// initialize scenario
896+
let scenario = SpamScenario::Testfile(sf.to_str().unwrap().to_owned());
897+
let send_args = create_send_args(sf, anvil);
898+
899+
// run setup
900+
crate::commands::setup(
901+
db,
902+
SetupCommandArgs {
903+
scenario: scenario.clone(),
904+
eth_json_rpc_args: send_args.rpc_args.clone(),
905+
seed: rand_seed.clone(),
906+
},
907+
)
908+
.await?;
909+
910+
// do a quick spam run
911+
let res = spam(
912+
db,
913+
&SpamCommandArgs {
914+
scenario,
915+
spam_args: SpamCliArgs {
916+
eth_json_rpc_args: send_args,
917+
spam_args: SendSpamCliArgs {
918+
builder_url: None,
919+
txs_per_second: Some(50),
920+
txs_per_block: None,
921+
duration: 4,
922+
pending_timeout: 10,
923+
run_forever: false,
924+
},
925+
ignore_receipts: false,
926+
optimistic_nonces: true,
927+
gen_report: false,
928+
redeploy: true,
929+
skip_setup: false,
930+
rpc_batch_size: 0,
931+
spam_timeout: Duration::from_secs(5),
932+
},
933+
seed: rand_seed.clone(),
934+
},
935+
SpamCampaignContext {
936+
campaign_id: None,
937+
campaign_name: None,
938+
stage_name: None,
939+
scenario_name: None,
940+
},
941+
)
942+
.await?;
943+
944+
println!("spam run successful. run id: {:?}", res);
945+
Ok(())
946+
}
947+
948+
/// Spin up a fresh anvil instance, DB, & seed, then run the scenario file given at `path`.
949+
async fn run_scenario_file(path: &Path) -> Result<()> {
950+
let anvil = Anvil::new().block_time(1).spawn();
951+
let db = SqliteDb::new_memory();
952+
db.create_tables()?;
953+
let rand_seed = RandSeed::new();
954+
955+
run_scenario(path, &anvil, &db, &rand_seed).await
956+
}
957+
958+
/// Generates individual spam test for each scenario given in the macro input.
959+
/// NOTE: paths are relative to the project root. See `build.rs` for usage.
960+
macro_rules! scenario_tests {
961+
($($name:ident => $relative_path:expr),* $(,)?) => {
962+
$(
963+
#[tokio::test]
964+
async fn $name() -> std::result::Result<(), CliError> {
965+
let project_root = Path::new(env!("CARGO_MANIFEST_DIR"))
966+
.parent()
967+
.unwrap()
968+
.parent()
969+
.unwrap();
970+
let path: PathBuf = project_root.join($relative_path);
971+
run_scenario_file(&path).await?;
972+
Ok(())
973+
}
974+
)*
975+
};
976+
}
977+
978+
#[allow(non_snake_case)]
979+
mod generated_scenario_tests {
980+
use super::*;
981+
// Generate tests for all scenario files identified by build.rs
982+
include!(concat!(env!("OUT_DIR"), "/generated_scenario_tests.rs"));
983+
}
984+
}

crates/core/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
- added timeout for send_transaction calls ([#430](https://github.com/flashbots/contender/pull/430/files))
11+
812
## [0.7.3](https://github.com/flashbots/contender/releases/tag/v0.7.3) - 2026-01-20
913

1014
- transactions that revert onchain now store error as "execution reverted" DB, rather than NULL ([#418](https://github.com/flashbots/contender/pull/418/files))

crates/core/src/test_scenario.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,9 @@ where
610610

611611
let res = wallet_client
612612
.send_transaction(WithOtherFields::new(tx))
613-
.await?;
613+
.await?
614+
.with_timeout(Some(Duration::from_secs(30)));
615+
614616
// watch pending transaction
615617
let receipt = res.get_receipt().await.expect("failed to get receipt");
616618
debug!(
@@ -664,6 +666,7 @@ where
664666
.wallet(signer)
665667
.network::<AnyNetwork>()
666668
.connect_http(rpc_url.to_owned());
669+
debug!("connecting wallet to rpc at {}", rpc_url);
667670

668671
let tx_label = tx_req
669672
.name
@@ -698,8 +701,12 @@ where
698701
);
699702

700703
// wallet will assign nonce before sending
701-
let res = wallet.send_transaction(tx.into()).await?;
704+
let res = wallet
705+
.send_transaction(tx.into())
706+
.await?
707+
.with_timeout(Some(Duration::from_secs(30)));
702708

709+
debug!("sent setup tx {:?}: {}", tx_req.kind, res.tx_hash());
703710
// get receipt using provider (not wallet) to allow any receipt type (support non-eth chains)
704711
let receipt = res.get_receipt().await?;
705712
debug!(
@@ -1315,7 +1322,7 @@ where
13151322
});
13161323

13171324
format!(
1318-
"running setup: from={} to={} {}",
1325+
"running setup: from={} to={} gas={:?} {}",
13191326
tx_req
13201327
.tx
13211328
.from
@@ -1327,6 +1334,7 @@ where
13271334
} else {
13281335
to_address.map(|a| a.encode_hex()).unwrap_or_default()
13291336
},
1337+
tx_req.tx.gas,
13301338
if let Some(kind) = &tx_req.kind {
13311339
format!("kind={kind}")
13321340
} else {

scenarios/uniV3.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,17 @@ args = [
7171
"3000", # 0.3% fee tier
7272
]
7373

74+
# Token approvals for position manager are not needed, because we're using
75+
# tokens that auto-approve on `transferFrom`
76+
7477
## Initialize pools
7578

7679
[[setup]]
7780
kind = "unicheat_init_pool_token1_token2"
7881
to = "{unicheatV3}"
7982
signature = "function initPool(address tokenA, address tokenB, uint24 fee)"
8083
args = ["{testToken}", "{testToken2}", "3000"]
81-
82-
# Token approvals for position manager are not needed, because we're using
83-
# tokens that auto-approve on `transferFrom`
84+
gas_limit = 1000000
8485

8586
## Send all tokens to Unicheat
8687

@@ -109,6 +110,7 @@ args = [
109110
"100000000000000000000",
110111
"100000000000000000000",
111112
]
113+
gas_limit = 1000000
112114

113115
### Spam Steps
114116
### (sends trades via UnicheatV3 contract so senders don't have to manage their own token balances)

0 commit comments

Comments
 (0)