Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
7f149d3
add server crate
zeroXbrock Mar 19, 2026
10c9d4e
organize code
zeroXbrock Mar 19, 2026
f7ee486
consolidate info for RPC/serde, add rpc methods...
zeroXbrock Mar 19, 2026
425858b
(demo mode) initialize contender when adding a session
zeroXbrock Mar 19, 2026
03d7bd5
accept scenario file as base64 str
zeroXbrock Mar 20, 2026
30d3d72
export lib contents of contender_cli for server to use
zeroXbrock Mar 20, 2026
9161beb
support default scenarios in add_session
zeroXbrock Mar 20, 2026
ed0ad69
initialize scenarios in bg, return from RPC calls quickly
zeroXbrock Mar 20, 2026
8c12a2b
deserialize eth values w/ serde (as well)
zeroXbrock Mar 20, 2026
68e228d
subscribe to session logs over websocket
zeroXbrock Mar 20, 2026
d4317b4
serve logs over SSE as well as WS
zeroXbrock Mar 20, 2026
88eea28
cleanup main; consolidate code, set server addrs w/ env
zeroXbrock Mar 20, 2026
22b6dfa
simplify ContenderSession constructor
zeroXbrock Mar 20, 2026
6cc7919
(wip) rename confusing var, add dummy spam method, make rpc_server mod
zeroXbrock Mar 20, 2026
c472ccf
break up contents of rpc mod into sub-modules
zeroXbrock Mar 20, 2026
2e1e38b
fix logs, spam for real
zeroXbrock Mar 21, 2026
ba0f781
(bugfix: orchestrator) shutdown scenario after spamming
zeroXbrock Mar 23, 2026
4a8a232
add "Spamming" status; enforce 1 spam run per session
zeroXbrock Mar 23, 2026
0d84914
session: wait for receipt collection to finish...
zeroXbrock Mar 23, 2026
9d52f02
cleanup rpc error messages
zeroXbrock Mar 23, 2026
92fa00b
shutdown log streams when remove_session is called
zeroXbrock Mar 23, 2026
915c462
add 'stop' method to terminate a session's spammer
zeroXbrock Mar 24, 2026
b391cb1
cancel spammer when calling remove
zeroXbrock Mar 24, 2026
d3cabc6
reduce streamed log verbosity
zeroXbrock Mar 24, 2026
ebabd21
add basic web interface for contender API
zeroXbrock Mar 24, 2026
4472035
(web) 2-column ui, auto log subscription & aggregate status polling
zeroXbrock Mar 24, 2026
46c23f3
move stop button to session cabinet
zeroXbrock Mar 24, 2026
71b58c1
cleanup spam status readout
zeroXbrock Mar 24, 2026
d0c8e42
disable spam button when target session is already spamming
zeroXbrock Mar 24, 2026
a3629b6
confirm before removing session
zeroXbrock Mar 24, 2026
1f2d31d
use random nums as session ids, stop array indexing
zeroXbrock Mar 24, 2026
9011fac
use dropdown menu for session selection
zeroXbrock Mar 24, 2026
22c95e7
fix sessionId type in spam button
zeroXbrock Mar 25, 2026
df81514
disable spam button if status is anything but Ready
zeroXbrock Mar 25, 2026
ea6e0e3
Merge branch 'main' into feat/api-daemon
zeroXbrock Mar 31, 2026
94ea396
clear some fields on button press
zeroXbrock Apr 1, 2026
5c2f6bb
(WIP) add more options to spam API
zeroXbrock Apr 2, 2026
162ff21
add more options to the API, find more things to add
zeroXbrock Apr 2, 2026
3433476
add API support for agent config, rename RPC methods to camelCase
zeroXbrock Apr 2, 2026
2e1c867
support env additions in RPC API
zeroXbrock Apr 3, 2026
f832864
support run_forever in RPC API
zeroXbrock Apr 3, 2026
b4619bc
support spam progress reports in API logs
zeroXbrock Apr 3, 2026
f5d205d
add missing fields to web UI
zeroXbrock Apr 3, 2026
7a37e75
handle & relay errors via the RPC spam API
zeroXbrock Apr 3, 2026
c437ab2
adopt name from builtin session name if not specified explicitly
zeroXbrock Apr 3, 2026
97aa112
clean up streamed logs in UI
zeroXbrock Apr 3, 2026
ff8135b
Merge branch 'main' into feat/api-daemon
zeroXbrock Apr 7, 2026
40f9f59
add fundAccounts to RPC API, classify agents for simpler internal API
zeroXbrock Apr 7, 2026
f41fd69
add funding controls to web UI
zeroXbrock Apr 7, 2026
d1c102e
use TestScenario optimistic nonce assignment instead of sync_nonces
zeroXbrock Apr 8, 2026
f36ad5f
bugfix: replace flush receiver after finishing a batch (since we're r…
zeroXbrock Apr 8, 2026
dd3aa31
bugfix: don't call scenario.shutdown() when triggering cancel_token (…
zeroXbrock Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 290 additions & 46 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"crates/core/",
"crates/engine_provider",
"crates/report",
"crates/server",
"crates/sqlite_db/",
"crates/testfile/",
]
Expand All @@ -21,12 +22,14 @@ homepage = "https://github.com/flashbots/contender"
repository = "https://github.com/flashbots/contender"

[workspace.dependencies]
contender_cli = { path = "crates/cli" }
contender_core = { path = "crates/core/" }
contender_sqlite = { path = "crates/sqlite_db/" }
contender_testfile = { path = "crates/testfile/" }
contender_bundle_provider = { path = "crates/bundle_provider/" }
contender_engine_provider = { path = "crates/engine_provider/" }
contender_report = { path = "crates/report/" }
contender_server = { path = "crates/server/" }

tokio = { version = "1.40.0" }
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
Expand All @@ -49,6 +52,11 @@ csv = "1.3.0"
miette = { version = "7.6.0" }
url = "2.5.7"
uuid = "1.19.0"
base64 = "0.22"

## server
axum = "0.8"
tokio-stream = "0.1"

## core
futures = "0.3.30"
Expand All @@ -57,6 +65,7 @@ jsonrpsee = { version = "0.24" }
alloy-serde = "0.5.4"
serde_json = "1.0.132"
tower = "0.5.2"
tower-http = { version = "0.6", features = ["cors"] }
alloy-rpc-types-engine = { version = "1.0.22", default-features = false }
alloy-json-rpc = { version = "1.0.22", default-features = false }
alloy-chains = { version = "0.2.5", default-features = false }
Expand Down
5 changes: 3 additions & 2 deletions crates/cli/src/commands/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use alloy::primitives::{Address, U256};
use alloy::providers::{Provider, ProviderBuilder};
use alloy::rpc::types::BlockId;
use clap::Subcommand;
use contender_core::agent_controller::AgentClass;
use contender_core::{
agent_controller::SignerStore,
db::DbOps,
Expand Down Expand Up @@ -155,7 +156,7 @@ fn handle_accounts(from_pool: String, num_signers: usize, data_dir: &Path) -> Re
/// Prints accounts for a specific pool
fn print_accounts_for_pool(pool: &str, num_signers: usize, seed: &RandSeed) -> Result<()> {
info!("Generating addresses for pool: {}", pool);
let agent = SignerStore::new(num_signers, seed, pool);
let agent = SignerStore::new(num_signers, seed, pool, AgentClass::default()); // AgentClass is irrelevant here since we're only interested in the generated accounts, not their roles
let mut private_keys = vec![];
for (i, address) in agent.all_addresses().iter().enumerate() {
private_keys.push(format!(
Expand Down Expand Up @@ -304,7 +305,7 @@ async fn handle_reclaim_eth(
info!("Processing pool: {}", pool_name);

// Generate signers for this pool
let signer_store = SignerStore::new(num_accounts, &seed, &pool_name);
let signer_store = SignerStore::new(num_accounts, &seed, &pool_name, AgentClass::default()); // AgentClass is irrelevant here since we're only interested in the generated accounts, not their roles

for (i, signer) in signer_store.signers.iter().enumerate() {
let address = signer.address();
Expand Down
17 changes: 15 additions & 2 deletions crates/cli/src/commands/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ use crate::commands::{
},
SpamCliArgs,
};
use crate::default_scenarios::fill_block::SpamRate;
use crate::default_scenarios::{BuiltinOptions, BuiltinScenarioCli};
use crate::error::CliError;
use crate::util::load_testconfig;
use crate::util::{load_seedfile, parse_duration};
use crate::BuiltinScenarioCli;
use alloy::primitives::{keccak256, U256};
use clap::Args;
use contender_core::error::RuntimeParamErrorKind;
use contender_core::generator::RandSeed;
use contender_report::command::ReportParams;
use contender_testfile::{CampaignConfig, CampaignMode, ResolvedMixEntry, ResolvedStage};
use std::path::Path;
Expand Down Expand Up @@ -478,10 +480,21 @@ async fn prepare_scenario(
skip_setup,
);

let rand_seed = RandSeed::seed_from_str(&scenario_seed);
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, ctx.data_dir)
.to_builtin_scenario(
&provider,
BuiltinOptions {
accounts_per_agent: ctx.args.eth_json_rpc_args.accounts_per_agent,
seed: rand_seed,
spam_rate: Some(match ctx.campaign.spam.mode {
CampaignMode::Tps => SpamRate::TxsPerSecond(mix.rate),
CampaignMode::Tpb => SpamRate::TxsPerBlock(mix.rate),
}),
},
)
.await?;
SpamScenario::Builtin(builtin)
} else {
Expand Down
44 changes: 33 additions & 11 deletions crates/cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use super::EngineArgs;
use crate::commands::error::ArgsError;
use crate::commands::SpamScenario;
use crate::default_scenarios::fill_block::SpamRate;
use crate::error::CliError;
use crate::util::get_signers_with_defaults;
use alloy::consensus::TxType;
Expand All @@ -16,6 +17,7 @@ use contender_engine_provider::reth_node_api::EngineApiMessageVersion;
use contender_engine_provider::ControlChain;
use contender_testfile::TestConfig;
use op_alloy_network::AnyNetwork;
use serde::Deserialize;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
Expand Down Expand Up @@ -261,15 +263,28 @@ impl Default for AuthCliArgs {
}
}

#[derive(Copy, Debug, Clone, clap::ValueEnum)]
enum EngineMessageVersion {
#[derive(Copy, Debug, Clone, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EngineMessageVersion {
V1,
V2,
V3,
V4,
// V5,
}

impl From<EngineMessageVersion> for EngineApiMessageVersion {
fn from(value: EngineMessageVersion) -> Self {
match value {
EngineMessageVersion::V1 => EngineApiMessageVersion::V1,
EngineMessageVersion::V2 => EngineApiMessageVersion::V2,
EngineMessageVersion::V3 => EngineApiMessageVersion::V3,
EngineMessageVersion::V4 => EngineApiMessageVersion::V4,
// EngineMessageVersion::V5 => EngineApiMessageVersion::V5,
}
}
}

impl AuthCliArgs {
pub async fn engine_params(&self, call_forkchoice: bool) -> Result<EngineParams, CliError> {
if call_forkchoice && (self.auth_rpc_url.is_none() || self.jwt_secret.is_none()) {
Expand All @@ -285,13 +300,7 @@ impl AuthCliArgs {
auth_rpc_url: self.auth_rpc_url.to_owned().expect("auth_rpc_url"),
jwt_secret: self.jwt_secret.to_owned().expect("jwt_secret"),
use_op: self.use_op,
message_version: match self.message_version {
EngineMessageVersion::V1 => EngineApiMessageVersion::V1,
EngineMessageVersion::V2 => EngineApiMessageVersion::V2,
EngineMessageVersion::V3 => EngineApiMessageVersion::V3,
EngineMessageVersion::V4 => EngineApiMessageVersion::V4,
// EngineMessageVersion::V5 => EngineApiMessageVersion::V5,
},
message_version: self.message_version.into(),
};
EngineParams::new(Arc::new(args.new_provider().await?), call_forkchoice)
} else {
Expand Down Expand Up @@ -370,7 +379,19 @@ Requires --priv-key to be set for each 'from' address in the given testfile.",
pub run_forever: bool,
}

#[derive(Copy, Debug, Clone, clap::ValueEnum)]
impl SendSpamCliArgs {
pub fn spam_rate(&self) -> Result<SpamRate, ArgsError> {
match (self.txs_per_second, self.txs_per_block) {
(Some(_), Some(_)) => Err(ArgsError::SpamRateNotFound),
(None, None) => Err(ArgsError::SpamRateNotFound),
(Some(tps), None) => Ok(SpamRate::TxsPerSecond(tps)),
(None, Some(tpb)) => Ok(SpamRate::TxsPerBlock(tpb)),
}
}
}

#[derive(Copy, Debug, Clone, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TxTypeCli {
/// Legacy transaction (type `0x0`)
Legacy,
Expand Down Expand Up @@ -400,7 +421,8 @@ impl std::fmt::Display for TxTypeCli {
}
}

#[derive(Copy, Debug, Clone, clap::ValueEnum)]
#[derive(Copy, Debug, Clone, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BundleTypeCli {
L1,
#[clap(name = "no-revert")]
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/commands/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub async fn setup(
agent_spec,
tx_type: tx_type.into(),
bundle_type: bundle_type.into(),
pending_tx_timeout_secs: 12,
pending_tx_timeout: Duration::from_secs(12),
extra_msg_handles: None,
sync_nonces_after_batch: true,
rpc_batch_size: 0,
Expand Down
121 changes: 21 additions & 100 deletions crates/cli/src/commands/spam.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::{
error::ArgsError,
GenericDb, Result,
},
default_scenarios::BuiltinScenario,
default_scenarios::{BuiltinOptions, BuiltinScenario},
error::CliError,
util::{
bold, check_private_keys, fund_accounts, load_seedfile, load_testconfig, parse_duration,
Expand Down Expand Up @@ -36,15 +36,14 @@ use contender_core::{
tx_actor::ActorContext, BlockwiseSpammer, LogCallback, NilCallback, Spammer, TimedSpammer,
},
test_scenario::{TestScenario, TestScenarioParams},
util::get_block_time,
util::{get_block_time, print_progress_report, spawn_spam_report_task},
};
use contender_engine_provider::{
reth_node_api::EngineApiMessageVersion, AuthProvider, ControlChain,
};
use contender_report::command::ReportParams;
use contender_testfile::TestConfig;
use op_alloy_network::{Ethereum, Optimism};
use serde::Serialize;
use std::{
path::{Path, PathBuf},
sync::atomic::AtomicBool,
Expand All @@ -53,68 +52,6 @@ use std::{sync::Arc, time::Duration};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};

/// Structured JSON report emitted periodically during spam runs.
#[derive(Debug, Clone, Serialize)]
pub struct SpamProgressReport {
pub elapsed_s: u64,
pub txs_sent: u64,
pub txs_confirmed: u64,
pub txs_failed: u64,
pub current_tps: f64,
}

/// Prints incremental progress report for a spam run. Returns None if call to db's `get_run_txs` fails.
fn print_progress_report<D: GenericDb>(
db: &D,
run_id: u64,
start: std::time::Instant,
planned_tx_count: Option<u64>,
) -> Option<()> {
let elapsed = start.elapsed();
let elapsed_s = elapsed.as_secs();

let Ok((txs_confirmed, txs_failed)) = db.get_run_txs(run_id).map(|txs| {
let confirmed = txs
.iter()
.filter(|tx| tx.block_number.is_some() && tx.error.is_none())
.count() as u64;
let failed = txs.iter().filter(|tx| tx.error.is_some()).count() as u64;
(confirmed, failed)
}) else {
return None;
};

// txs_sent is the planned count capped by elapsed time,
// or confirmed + failed if we have more data than planned
let txs_sent = (txs_confirmed + txs_failed).max(planned_tx_count.unwrap_or(0).min(
// rough estimate based on elapsed time
txs_confirmed + txs_failed,
));

let current_tps = if elapsed_s > 0 {
txs_confirmed as f64 / elapsed_s as f64
} else {
0.0
};

let report = SpamProgressReport {
elapsed_s,
txs_sent,
txs_confirmed,
txs_failed,
current_tps: (current_tps * 10.0).round() / 10.0,
};

// tracing span annotates the log for easy identification later
let span = tracing::info_span!("spam_progress", run_id = run_id);
if let Ok(json) = serde_json::to_string(&report) {
let _enter = span.enter();
info!("{json}");
}

Some(())
}

/// Computes how often (in seconds) to re-fund spammer accounts.
///
/// Estimates how long before accounts drain, then returns 90% of that,
Expand Down Expand Up @@ -183,40 +120,6 @@ fn spawn_funding_task(
cancel
}

/// Spawns a background task that periodically queries the DB and prints
/// a structured JSON progress report to stdout.
/// Returns a cancellation token that should be cancelled when spam is done.
fn spawn_spam_report_task<D: GenericDb>(
db: &D,
run_id: u64,
interval_secs: u64,
planned_tx_count: u64,
) -> CancellationToken {
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let db = Arc::new(db.to_owned());

tokio::task::spawn(async move {
let start = std::time::Instant::now();
let mut interval = tokio::time::interval(Duration::from_secs(interval_secs));
// Skip the first immediate tick
interval.tick().await;

loop {
tokio::select! {
_ = cancel_clone.cancelled() => break,
_ = interval.tick() => {
if print_progress_report(db.clone().as_ref(), run_id, start, Some(planned_tx_count)).is_none() {
continue;
}
}
}
}
});

cancel
}

#[derive(Debug)]
pub struct EngineArgs {
pub auth_rpc_url: Url,
Expand Down Expand Up @@ -348,6 +251,24 @@ pub struct SpamCliArgs {
)]
pub report_interval: Option<u64>,
}

impl SpamCliArgs {
pub fn builtin_options(&self, data_dir: &PathBuf) -> Result<BuiltinOptions> {
let seed = self
.eth_json_rpc_args
.rpc_args
.seed
.clone()
.unwrap_or(load_seedfile(data_dir)?);
let seed = RandSeed::seed_from_str(&seed);
Ok(BuiltinOptions {
accounts_per_agent: self.eth_json_rpc_args.rpc_args.accounts_per_agent,
seed,
spam_rate: Some(self.spam_args.spam_rate()?),
})
}
}

#[derive(Clone)]
pub enum SpamScenario {
Testfile(String),
Expand Down Expand Up @@ -617,7 +538,7 @@ impl SpamCommandArgs {
agent_spec,
tx_type,
bundle_type: bundle_type.into(),
pending_tx_timeout_secs: pending_timeout * block_time,
pending_tx_timeout: Duration::from_secs(pending_timeout * block_time),
extra_msg_handles: None,
sync_nonces_after_batch: !self.spam_args.optimistic_nonces,
rpc_batch_size,
Expand Down
3 changes: 2 additions & 1 deletion crates/cli/src/default_scenarios/blobs.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use clap::Parser;
use contender_core::generator::{types::SpamRequest, FunctionCallDefinition};
use contender_testfile::TestConfig;
use serde::{Deserialize, Serialize};

use crate::default_scenarios::builtin::ToTestConfig;

#[derive(Parser, Clone, Debug)]
#[derive(Parser, Clone, Debug, Deserialize, Serialize)]
/// Send blob transactions. Note: the tx type will always be overridden to eip4844.
pub struct BlobsCliArgs {
#[arg(
Expand Down
Loading
Loading