From 7f149d385446af36d570c89f7c1d8fa35cb3d332 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:31:18 -0700 Subject: [PATCH 01/34] add server crate --- Cargo.lock | 173 ++++++++++++++++++++++++++++++-------- Cargo.toml | 2 + crates/cli/src/main.rs | 20 +++-- crates/server/Cargo.toml | 23 +++++ crates/server/src/lib.rs | 16 ++++ crates/server/src/main.rs | 29 +++++++ 6 files changed, 220 insertions(+), 43 deletions(-) create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/src/lib.rs create mode 100644 crates/server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index cb3cd084..67b4d8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2245,6 +2245,18 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "contender_server" +version = "0.9.0" +dependencies = [ + "async-trait", + "contender_core", + "jsonrpsee 0.24.10", + "tokio", + "tracing", + "tracing-subscriber 0.3.20", +] + [[package]] name = "contender_sqlite" version = "0.9.0" @@ -2597,7 +2609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.108", ] [[package]] @@ -4162,6 +4174,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonrpsee" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281ae70cc3b98dac15fced3366a880949e65fc66e345ce857a5682d152f3e62" +dependencies = [ + "jsonrpsee-core 0.24.10", + "jsonrpsee-proc-macros 0.24.10", + "jsonrpsee-server 0.24.10", + "jsonrpsee-types 0.24.10", + "tokio", + "tracing", +] + [[package]] name = "jsonrpsee" version = "0.26.0" @@ -4169,11 +4195,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" dependencies = [ "jsonrpsee-client-transport", - "jsonrpsee-core", + "jsonrpsee-core 0.26.0", "jsonrpsee-http-client", - "jsonrpsee-proc-macros", - "jsonrpsee-server", - "jsonrpsee-types", + "jsonrpsee-proc-macros 0.26.0", + "jsonrpsee-server 0.26.0", + "jsonrpsee-types 0.26.0", "jsonrpsee-wasm-client", "jsonrpsee-ws-client", "tokio", @@ -4191,7 +4217,7 @@ dependencies = [ "futures-util", "gloo-net", "http", - "jsonrpsee-core", + "jsonrpsee-core 0.26.0", "pin-project", "rustls", "rustls-pki-types", @@ -4205,6 +4231,29 @@ dependencies = [ "url", ] +[[package]] +name = "jsonrpsee-core" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348ee569eaed52926b5e740aae20863762b16596476e943c9e415a6479021622" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "jsonrpsee-types 0.24.10", + "parking_lot", + "rand 0.8.5", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", +] + [[package]] name = "jsonrpsee-core" version = "0.26.0" @@ -4218,7 +4267,7 @@ dependencies = [ "http", "http-body", "http-body-util", - "jsonrpsee-types", + "jsonrpsee-types 0.26.0", "parking_lot", "pin-project", "rand 0.9.2", @@ -4244,8 +4293,8 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "rustls", "rustls-platform-verifier", "serde", @@ -4256,6 +4305,19 @@ dependencies = [ "url", ] +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7398cddf5013cca4702862a2692b66c48a3bd6cf6ec681a47453c93d63cf8de5" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "jsonrpsee-proc-macros" version = "0.26.0" @@ -4269,6 +4331,33 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "jsonrpsee-server" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21429bcdda37dcf2d43b68621b994adede0e28061f816b038b0f18c70c143d51" +dependencies = [ + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "jsonrpsee-core 0.24.10", + "jsonrpsee-types 0.24.10", + "pin-project", + "route-recognizer", + "serde", + "serde_json", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tracing", +] + [[package]] name = "jsonrpsee-server" version = "0.26.0" @@ -4281,8 +4370,8 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "pin-project", "route-recognizer", "serde", @@ -4296,6 +4385,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "jsonrpsee-types" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f05e0028e55b15dbd2107163b3c744cd3bb4474f193f95d9708acbf5677e44" +dependencies = [ + "http", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonrpsee-types" version = "0.26.0" @@ -4315,8 +4416,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7902885de4779f711a95d82c8da2d7e5f9f3a7c7cfa44d51c067fd1c29d72a3c" dependencies = [ "jsonrpsee-client-transport", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "tower 0.5.2", ] @@ -4328,8 +4429,8 @@ checksum = "9b6fceceeb05301cc4c065ab3bd2fa990d41ff4eb44e4ca1b30fa99c057c3e79" dependencies = [ "http", "jsonrpsee-client-transport", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "tower 0.5.2", "url", ] @@ -5258,7 +5359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8eb878fc5ea95adb5abe55fb97475b3eb0dcc77dfcd6f61bd626a68ae0bdba1" dependencies = [ "alloy-primitives", - "jsonrpsee", + "jsonrpsee 0.26.0", ] [[package]] @@ -7123,7 +7224,7 @@ dependencies = [ "alloy-rpc-types-debug", "eyre", "futures", - "jsonrpsee", + "jsonrpsee 0.26.0", "pretty_assertions", "reth-engine-primitives", "reth-evm", @@ -7148,7 +7249,7 @@ dependencies = [ "futures", "futures-util", "interprocess", - "jsonrpsee", + "jsonrpsee 0.26.0", "pin-project", "serde_json", "thiserror 2.0.17", @@ -7405,7 +7506,7 @@ dependencies = [ "eyre", "fdlimit", "futures", - "jsonrpsee", + "jsonrpsee 0.26.0", "rayon", "reth-basic-payload-builder", "reth-chain-state", @@ -7565,7 +7666,7 @@ source = "git+https://github.com/paradigmxyz/reth?tag=v1.8.2#9c30bf7af5e0d45deaf dependencies = [ "eyre", "http", - "jsonrpsee-server", + "jsonrpsee-server 0.26.0", "metrics", "metrics-exporter-prometheus", "metrics-process", @@ -7838,9 +7939,9 @@ dependencies = [ "derive_more", "eyre", "futures", - "jsonrpsee", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee 0.26.0", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "metrics", "op-alloy-consensus", "op-alloy-network", @@ -8160,8 +8261,8 @@ dependencies = [ "http-body", "hyper", "itertools 0.14.0", - "jsonrpsee", - "jsonrpsee-types", + "jsonrpsee 0.26.0", + "jsonrpsee-types 0.26.0", "jsonwebtoken", "parking_lot", "pin-project", @@ -8225,7 +8326,7 @@ dependencies = [ "alloy-rpc-types-trace", "alloy-rpc-types-txpool", "alloy-serde", - "jsonrpsee", + "jsonrpsee 0.26.0", "reth-chain-state", "reth-engine-primitives", "reth-network-peers", @@ -8242,7 +8343,7 @@ dependencies = [ "alloy-provider", "dyn-clone", "http", - "jsonrpsee", + "jsonrpsee 0.26.0", "metrics", "pin-project", "reth-chain-state", @@ -8285,7 +8386,7 @@ dependencies = [ "alloy-signer", "auto_impl", "dyn-clone", - "jsonrpsee-types", + "jsonrpsee-types 0.26.0", "op-alloy-consensus", "op-alloy-network", "op-alloy-rpc-types", @@ -8308,8 +8409,8 @@ dependencies = [ "alloy-primitives", "alloy-rpc-types-engine", "async-trait", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "metrics", "parking_lot", "reth-chainspec", @@ -8349,8 +8450,8 @@ dependencies = [ "auto_impl", "dyn-clone", "futures", - "jsonrpsee", - "jsonrpsee-types", + "jsonrpsee 0.26.0", + "jsonrpsee-types 0.26.0", "parking_lot", "reth-chain-state", "reth-chainspec", @@ -8390,8 +8491,8 @@ dependencies = [ "derive_more", "futures", "itertools 0.14.0", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "metrics", "rand 0.9.2", "reqwest", @@ -8442,8 +8543,8 @@ dependencies = [ "alloy-eips", "alloy-primitives", "alloy-rpc-types-engine", - "jsonrpsee-core", - "jsonrpsee-types", + "jsonrpsee-core 0.26.0", + "jsonrpsee-types 0.26.0", "reth-errors", "reth-network-api", "serde", diff --git a/Cargo.toml b/Cargo.toml index c38a486e..a24c0e51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/core/", "crates/engine_provider", "crates/report", + "crates/server", "crates/sqlite_db/", "crates/testfile/", ] @@ -27,6 +28,7 @@ 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"] } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index edaa7923..d99ac5a2 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -230,22 +230,28 @@ fn init_db(command: &ContenderSubcommand, db: &SqliteDb) -> Result<(), CliError> Ok(()) } -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().ok(); // fallback if RUST_LOG is unset - - let mut opts = TracingOptions::default(); +/// Reads the RUST_LOG environment variable and extracts log levels. +pub fn read_rust_log() -> Vec { let rustlog = std::env::var("RUST_LOG").unwrap_or_default().to_lowercase(); // interpret log levels from words matching `=[a-zA-Z]+` let level_regex = Regex::new(r"=[a-zA-Z]+").unwrap(); - let matches: Vec = level_regex + level_regex .find_iter(&rustlog) .map(|m| m.as_str().trim_start_matches('=')) .map(|m| tracing::Level::from_str(m).unwrap_or(tracing::Level::INFO)) - .collect(); + .collect() +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().ok(); // fallback if RUST_LOG is unset + let mut opts = TracingOptions::default(); // if user provides any log level > info, print line num & source file in logs - if matches.iter().any(|lvl| *lvl > tracing::Level::INFO) { + if read_rust_log() + .iter() + .any(|lvl| *lvl > tracing::Level::INFO) + { opts = opts.with_line_number(true).with_target(true); } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 00000000..6ff1d61c --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "contender_server" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +description = "Contender server" + +[[bin]] +name = "contender-server" +path = "src/main.rs" + +[dependencies] +contender_core.workspace = true + +async-trait = { workspace = true } +jsonrpsee = { workspace = true, features = ["server", "macros"] } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 00000000..ed399868 --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,16 @@ +use jsonrpsee::proc_macros::rpc; + +#[rpc(server)] +pub trait ContenderRpc { + #[method(name = "contender_status")] + async fn status(&self) -> jsonrpsee::core::RpcResult; +} + +pub struct ContenderServer; + +#[async_trait::async_trait] +impl ContenderRpcServer for ContenderServer { + async fn status(&self) -> jsonrpsee::core::RpcResult { + Ok("system has become self-aware".to_string()) + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 00000000..2e716bcf --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,29 @@ +use contender_core::util::TracingOptions; +use contender_server::{ContenderRpcServer as _, ContenderServer}; +use jsonrpsee::server::Server; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_tracing(); + + let addr = "127.0.0.1:3000"; + let server = Server::builder().build(addr).await?; + + let module = ContenderServer.into_rpc(); + let handle = server.start(module); + + info!("JSON-RPC server listening on {addr}"); + + handle.stopped().await; + + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().ok(); // fallback if RUST_LOG is unset + let mut opts = TracingOptions::default(); + opts = opts.with_line_number(true).with_target(true); + contender_core::util::init_core_tracing(filter, opts); +} From 10c9d4ea27e08f91b730758e1c61bcfbebdaceaa Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:52:45 -0700 Subject: [PATCH 02/34] organize code --- crates/server/src/lib.rs | 18 ++------------- crates/server/src/main.rs | 20 +++++++++++------ crates/server/src/rpc.rs | 16 ++++++++++++++ crates/server/src/sessions.rs | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 23 deletions(-) create mode 100644 crates/server/src/rpc.rs create mode 100644 crates/server/src/sessions.rs diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index ed399868..11475b6b 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,16 +1,2 @@ -use jsonrpsee::proc_macros::rpc; - -#[rpc(server)] -pub trait ContenderRpc { - #[method(name = "contender_status")] - async fn status(&self) -> jsonrpsee::core::RpcResult; -} - -pub struct ContenderServer; - -#[async_trait::async_trait] -impl ContenderRpcServer for ContenderServer { - async fn status(&self) -> jsonrpsee::core::RpcResult { - Ok("system has become self-aware".to_string()) - } -} +pub mod rpc; +pub mod sessions; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 2e716bcf..ea5be6e1 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,21 +1,27 @@ use contender_core::util::TracingOptions; -use contender_server::{ContenderRpcServer as _, ContenderServer}; -use jsonrpsee::server::Server; +use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; +// use contender_server::sessions::ContenderSessionCache; +use jsonrpsee::server::{Server, ServerHandle}; use tracing::info; use tracing_subscriber::EnvFilter; -#[tokio::main] -async fn main() -> Result<(), Box> { - init_tracing(); - +async fn start_rpc_server() -> std::io::Result { let addr = "127.0.0.1:3000"; let server = Server::builder().build(addr).await?; - let module = ContenderServer.into_rpc(); let handle = server.start(module); info!("JSON-RPC server listening on {addr}"); + Ok(handle) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + init_tracing(); + + // let mut sessions = ContenderSessionCache::new(); + let handle = start_rpc_server().await?; handle.stopped().await; Ok(()) diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs new file mode 100644 index 00000000..1f8475fb --- /dev/null +++ b/crates/server/src/rpc.rs @@ -0,0 +1,16 @@ +use jsonrpsee::proc_macros::rpc; + +#[rpc(server)] +pub trait ContenderRpc { + #[method(name = "status")] + async fn status(&self) -> jsonrpsee::core::RpcResult; +} + +pub struct ContenderServer; + +#[async_trait::async_trait] +impl ContenderRpcServer for ContenderServer { + async fn status(&self) -> jsonrpsee::core::RpcResult { + Ok("system has become self-aware".to_string()) + } +} diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs new file mode 100644 index 00000000..4511763d --- /dev/null +++ b/crates/server/src/sessions.rs @@ -0,0 +1,41 @@ +#[derive(Debug)] +pub struct ContenderSession { + pub id: usize, + pub name: String, + // TODO: add contender stuff here (ContenderCtx?) +} + +#[derive(Debug)] +pub struct ContenderSessionCache { + sessions: Vec, +} + +impl ContenderSessionCache { + pub fn new() -> Self { + Self { + sessions: Vec::new(), + } + } + + /// Add a new session to the cache and return its ID. The ID is simply the index of the session in the vector. + pub fn add_session( + &mut self, + name: String, /* TODO: here we add TestScenario-related params */ + ) -> usize { + let session = ContenderSession { + id: self.sessions.len(), + name, + }; + let id = session.id; + self.sessions.push(session); + id + } + + pub fn get_session(&self, id: usize) -> Option<&ContenderSession> { + self.sessions.iter().find(|s| s.id == id) + } + + pub fn remove_session(&mut self, id: usize) { + self.sessions.retain(|s| s.id != id); + } +} From f7ee4862696d2e4ef630cd081adfa52dcc46fe2f Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:15:39 -0700 Subject: [PATCH 03/34] consolidate info for RPC/serde, add rpc methods... - get_session - remove_session --- Cargo.lock | 1 + crates/server/Cargo.toml | 1 + crates/server/src/main.rs | 25 +++++++++++++----- crates/server/src/rpc.rs | 50 +++++++++++++++++++++++++++++++++-- crates/server/src/sessions.rs | 39 ++++++++++++++++++++------- 5 files changed, 98 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67b4d8b3..6775cc30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2252,6 +2252,7 @@ dependencies = [ "async-trait", "contender_core", "jsonrpsee 0.24.10", + "serde", "tokio", "tracing", "tracing-subscriber 0.3.20", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 6ff1d61c..0390f2e8 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -21,3 +21,4 @@ jsonrpsee = { workspace = true, features = ["server", "macros"] } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +serde.workspace = true diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index ea5be6e1..c7174ef9 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,14 +1,19 @@ +use std::sync::Arc; + use contender_core::util::TracingOptions; use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; -// use contender_server::sessions::ContenderSessionCache; +use contender_server::sessions::ContenderSessionCache; use jsonrpsee::server::{Server, ServerHandle}; +use tokio::sync::RwLock; use tracing::info; use tracing_subscriber::EnvFilter; -async fn start_rpc_server() -> std::io::Result { +async fn start_rpc_server( + sessions: Arc>, +) -> std::io::Result { let addr = "127.0.0.1:3000"; let server = Server::builder().build(addr).await?; - let module = ContenderServer.into_rpc(); + let module = ContenderServer::new(sessions).into_rpc(); let handle = server.start(module); info!("JSON-RPC server listening on {addr}"); @@ -19,10 +24,18 @@ async fn start_rpc_server() -> std::io::Result { async fn main() -> Result<(), Box> { init_tracing(); - // let mut sessions = ContenderSessionCache::new(); + let sessions = Arc::new(RwLock::new(ContenderSessionCache::new())); + + let handle = start_rpc_server(sessions).await?; - let handle = start_rpc_server().await?; - handle.stopped().await; + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received Ctrl+C, shutting down..."); + } + _ = handle.stopped() => { + info!("RPC server stopped"); + } + } Ok(()) } diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 1f8475fb..2af515bf 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -1,16 +1,62 @@ +use std::sync::Arc; + use jsonrpsee::proc_macros::rpc; +use tokio::sync::RwLock; + +use crate::sessions::{ContenderSessionCache, ContenderSessionInfo}; #[rpc(server)] pub trait ContenderRpc { #[method(name = "status")] async fn status(&self) -> jsonrpsee::core::RpcResult; + + #[method(name = "add_session")] + async fn add_session(&self, name: String) -> jsonrpsee::core::RpcResult; + + #[method(name = "get_session")] + async fn get_session( + &self, + id: usize, + ) -> jsonrpsee::core::RpcResult>; + + #[method(name = "remove_session")] + async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; } -pub struct ContenderServer; +pub struct ContenderServer { + pub sessions: Arc>, +} + +impl ContenderServer { + pub fn new(sessions: Arc>) -> Self { + Self { sessions } + } +} #[async_trait::async_trait] impl ContenderRpcServer for ContenderServer { async fn status(&self) -> jsonrpsee::core::RpcResult { - Ok("system has become self-aware".to_string()) + let sessions = self.sessions.read().await; + Ok(format!("{} session(s) active", sessions.num_sessions())) + } + + async fn add_session(&self, name: String) -> jsonrpsee::core::RpcResult { + let mut sessions = self.sessions.write().await; + let id = sessions.add_session(name); + Ok(id) + } + + async fn get_session( + &self, + id: usize, + ) -> jsonrpsee::core::RpcResult> { + let sessions = self.sessions.read().await; + Ok(sessions.get_session(id).map(|s| s.info.clone())) + } + + async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()> { + let mut sessions = self.sessions.write().await; + sessions.remove_session(id); + Ok(()) } } diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 4511763d..56ba063c 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -1,8 +1,26 @@ +use serde::{Deserialize, Serialize}; + #[derive(Debug)] pub struct ContenderSession { + pub info: ContenderSessionInfo, + // TODO: add contender stuff here (ContenderCtx?) +} + +impl ContenderSession { + fn new(name: String, sessions: &[ContenderSession]) -> Self { + Self { + info: ContenderSessionInfo { + id: sessions.len(), + name, + }, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ContenderSessionInfo { pub id: usize, pub name: String, - // TODO: add contender stuff here (ContenderCtx?) } #[derive(Debug)] @@ -21,21 +39,22 @@ impl ContenderSessionCache { pub fn add_session( &mut self, name: String, /* TODO: here we add TestScenario-related params */ - ) -> usize { - let session = ContenderSession { - id: self.sessions.len(), - name, - }; - let id = session.id; + ) -> ContenderSessionInfo { + let session = ContenderSession::new(name, &self.sessions); + let info = session.info.clone(); self.sessions.push(session); - id + info } pub fn get_session(&self, id: usize) -> Option<&ContenderSession> { - self.sessions.iter().find(|s| s.id == id) + self.sessions.iter().find(|s| s.info.id == id) } pub fn remove_session(&mut self, id: usize) { - self.sessions.retain(|s| s.id != id); + self.sessions.retain(|s| s.info.id != id); + } + + pub fn num_sessions(&self) -> usize { + self.sessions.len() } } From 425858b4c6e3f1e29191b9afecdc32e62c9d6bc7 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:51:25 -0700 Subject: [PATCH 04/34] (demo mode) initialize contender when adding a session --- Cargo.lock | 3 ++ crates/server/Cargo.toml | 3 ++ crates/server/src/rpc.rs | 61 ++++++++++++++++++++++++++++---- crates/server/src/sessions.rs | 65 ++++++++++++++++++++++++++--------- 4 files changed, 109 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6775cc30..e9d25963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,8 +2251,11 @@ version = "0.9.0" dependencies = [ "async-trait", "contender_core", + "contender_sqlite", + "contender_testfile", "jsonrpsee 0.24.10", "serde", + "thiserror 2.0.17", "tokio", "tracing", "tracing-subscriber 0.3.20", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0390f2e8..bc15ff55 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -15,9 +15,12 @@ path = "src/main.rs" [dependencies] contender_core.workspace = true +contender_testfile.workspace = true +contender_sqlite.workspace = true async-trait = { workspace = true } jsonrpsee = { workspace = true, features = ["server", "macros"] } +thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 2af515bf..8066f377 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -1,7 +1,13 @@ +use contender_core::test_scenario::Url; +use jsonrpsee::{ + proc_macros::rpc, + types::{ErrorObject, ErrorObjectOwned}, +}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; - -use jsonrpsee::proc_macros::rpc; +use thiserror::Error; use tokio::sync::RwLock; +use tracing::info; use crate::sessions::{ContenderSessionCache, ContenderSessionInfo}; @@ -11,7 +17,10 @@ pub trait ContenderRpc { async fn status(&self) -> jsonrpsee::core::RpcResult; #[method(name = "add_session")] - async fn add_session(&self, name: String) -> jsonrpsee::core::RpcResult; + async fn add_session( + &self, + name: AddSessionParams, + ) -> jsonrpsee::core::RpcResult; #[method(name = "get_session")] async fn get_session( @@ -33,6 +42,30 @@ impl ContenderServer { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AddSessionParams { + pub name: String, + pub rpc_url: Url, +} + +#[derive(Debug, Error)] +enum ContenderRpcError { + #[error("Failed to initialize contender session: {0}")] + SessionInitializationFailed(contender_core::Error), +} + +impl From for ErrorObjectOwned { + fn from(err: ContenderRpcError) -> Self { + match err { + ContenderRpcError::SessionInitializationFailed(e) => ErrorObject::owned( + 1, + "Failed to initialize contender session".to_string(), + Some(e.to_string()), + ), + } + } +} + #[async_trait::async_trait] impl ContenderRpcServer for ContenderServer { async fn status(&self) -> jsonrpsee::core::RpcResult { @@ -40,10 +73,26 @@ impl ContenderRpcServer for ContenderServer { Ok(format!("{} session(s) active", sessions.num_sessions())) } - async fn add_session(&self, name: String) -> jsonrpsee::core::RpcResult { + async fn add_session( + &self, + params: AddSessionParams, + ) -> jsonrpsee::core::RpcResult { let mut sessions = self.sessions.write().await; - let id = sessions.add_session(name); - Ok(id) + let session = sessions.add_session(params); + let info = session.info.clone(); + + info!( + "Initializing session {} with RPC URL {}", + info.name, info.rpc_url + ); + session + .contender + .initialize() + .await + .map_err(ContenderRpcError::SessionInitializationFailed) + .map_err(ErrorObjectOwned::from)?; + info!("Session {} initialized successfully", info.name); + Ok(info) } async fn get_session( diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 56ba063c..c1302194 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -1,19 +1,32 @@ +use std::str::FromStr; + +use contender_core::{generator::RandSeed, test_scenario::Url, Contender}; +use contender_sqlite::SqliteDb; +use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; -#[derive(Debug)] +use crate::rpc::AddSessionParams; + pub struct ContenderSession { pub info: ContenderSessionInfo, - // TODO: add contender stuff here (ContenderCtx?) + pub contender: Contender, } impl ContenderSession { - fn new(name: String, sessions: &[ContenderSession]) -> Self { - Self { - info: ContenderSessionInfo { - id: sessions.len(), - name, - }, - } + /// Should only be called by ContenderSessionCache when adding a new session, since the session ID is determined by the cache + fn new(sessions: &[ContenderSession], params: &AddSessionParams) -> Self { + let info = ContenderSessionInfo { + id: sessions.len(), + name: params.name.clone(), + rpc_url: params.rpc_url.clone(), + }; + + // TODO: get TestConfig params and put them here + let config = TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) + .expect("valid test config"); + + let contender = info.create_contender(config); + Self { info, contender } } } @@ -21,9 +34,25 @@ impl ContenderSession { pub struct ContenderSessionInfo { pub id: usize, pub name: String, + pub rpc_url: Url, +} + +impl ContenderSessionInfo { + pub fn create_contender( + &self, + testconfig: TestConfig, + ) -> Contender { + // using in-memory SQLite for now; will switch to file-based if we need persistence across server restarts + let db = contender_sqlite::SqliteDb::new_memory(); + let seeder = contender_core::generator::RandSeed::seed_from_bytes(&self.id.to_be_bytes()); + let contender_ctx = + contender_core::ContenderCtx::builder(testconfig, db, seeder, self.rpc_url.clone()) + .build(); + + Contender::new(contender_ctx) + } } -#[derive(Debug)] pub struct ContenderSessionCache { sessions: Vec, } @@ -35,15 +64,17 @@ impl ContenderSessionCache { } } - /// Add a new session to the cache and return its ID. The ID is simply the index of the session in the vector. - pub fn add_session( - &mut self, - name: String, /* TODO: here we add TestScenario-related params */ - ) -> ContenderSessionInfo { - let session = ContenderSession::new(name, &self.sessions); + /// Add a new session to the cache. The ID is simply the index of the session in the vector. + /// The session is not initialized yet, the caller is responsible for calling initialize on the session's contender before it's returned by the RPC provider. + /// + /// Returns a mutable reference to the newly added session, + /// which can be used to call initialize on it before it's returned by the RPC provider. + pub fn add_session(&mut self, params: AddSessionParams) -> &mut ContenderSession { + let session = ContenderSession::new(&self.sessions, ¶ms); let info = session.info.clone(); + self.sessions.push(session); - info + &mut self.sessions[info.id] } pub fn get_session(&self, id: usize) -> Option<&ContenderSession> { From 03d7bd592662982d80dda2ea3ceab05486507eaa Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:34:18 -0700 Subject: [PATCH 05/34] accept scenario file as base64 str --- Cargo.lock | 1 + Cargo.toml | 1 + crates/core/src/orchestrator.rs | 5 +++ crates/server/Cargo.toml | 1 + crates/server/src/error.rs | 46 ++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/rpc.rs | 57 ++++++++++++++++++++------------- crates/server/src/sessions.rs | 26 +++++++-------- 8 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 crates/server/src/error.rs diff --git a/Cargo.lock b/Cargo.lock index e9d25963..88dc0d91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2250,6 +2250,7 @@ name = "contender_server" version = "0.9.0" dependencies = [ "async-trait", + "base64 0.22.1", "contender_core", "contender_sqlite", "contender_testfile", diff --git a/Cargo.toml b/Cargo.toml index a24c0e51..76aa7467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ csv = "1.3.0" miette = { version = "7.6.0" } url = "2.5.7" uuid = "1.19.0" +base64 = "0.22" ## core futures = "0.3.30" diff --git a/crates/core/src/orchestrator.rs b/crates/core/src/orchestrator.rs index 04af46ae..52111300 100644 --- a/crates/core/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -534,4 +534,9 @@ where ) .await } + + /// Materialize a fresh `TestScenario` using the context which was used to create this `Contender` instance. + pub async fn build_scenario(&self) -> Result> { + self.ctx.build_scenario().await + } } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index bc15ff55..57385a1b 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -14,6 +14,7 @@ name = "contender-server" path = "src/main.rs" [dependencies] +base64.workspace = true contender_core.workspace = true contender_testfile.workspace = true contender_sqlite.workspace = true diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 00000000..b66415c6 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,46 @@ +use base64::DecodeError; +use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ContenderRpcError { + #[error("Failed to initialize contender session: {0}")] + SessionInitializationFailed(contender_core::Error), + + #[error("Invalid test config: {0}")] + InvalidTestConfig(#[from] contender_testfile::Error), + + #[error("Invalid base64: {0}")] + InvalidBase64(#[from] DecodeError), + + #[error("Invalid UTF-8 in decoded config: {0}")] + InvalidUtf8(std::string::FromUtf8Error), +} + +impl From for ErrorObjectOwned { + fn from(err: ContenderRpcError) -> Self { + match err { + ContenderRpcError::SessionInitializationFailed(e) => ErrorObject::owned( + 1, + "Failed to initialize contender session".to_string(), + Some(e.to_string()), + ), + + ContenderRpcError::InvalidTestConfig(e) => { + ErrorObject::owned(2, "Invalid test config".to_string(), Some(e.to_string())) + } + + ContenderRpcError::InvalidBase64(e) => ErrorObject::owned( + 3, + "Invalid base64 encoding".to_string(), + Some(e.to_string()), + ), + + ContenderRpcError::InvalidUtf8(e) => ErrorObject::owned( + 4, + "Invalid UTF-8 in config".to_string(), + Some(e.to_string()), + ), + } + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 11475b6b..be62c063 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,2 +1,3 @@ +pub mod error; pub mod rpc; pub mod sessions; diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 8066f377..3921258b 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -1,15 +1,16 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use contender_core::test_scenario::Url; -use jsonrpsee::{ - proc_macros::rpc, - types::{ErrorObject, ErrorObjectOwned}, -}; +use contender_testfile::TestConfig; +use jsonrpsee::{proc_macros::rpc, types::ErrorObjectOwned}; use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use thiserror::Error; +use std::{str::FromStr, sync::Arc}; use tokio::sync::RwLock; -use tracing::info; +use tracing::{debug, info}; -use crate::sessions::{ContenderSessionCache, ContenderSessionInfo}; +use crate::{ + error::ContenderRpcError, + sessions::{ContenderSessionCache, ContenderSessionInfo, NewSessionParams}, +}; #[rpc(server)] pub trait ContenderRpc { @@ -42,27 +43,36 @@ impl ContenderServer { } } +/// RPC parameters for adding a new contender session. #[derive(Clone, Debug, Deserialize, Serialize)] pub struct AddSessionParams { pub name: String, pub rpc_url: Url, + /// Base64-encoded TOML test config. If omitted, the default uniV2 scenario is used. + pub test_config_b64: Option, + // TODO: support builtin scenarios } -#[derive(Debug, Error)] -enum ContenderRpcError { - #[error("Failed to initialize contender session: {0}")] - SessionInitializationFailed(contender_core::Error), -} +impl AddSessionParams { + pub fn to_new_session_params(self) -> Result { + let config_str = match self.test_config_b64 { + Some(b64) => { + let bytes = BASE64.decode(&b64)?; + debug!( + "Decoded test config from base64, length {} bytes", + bytes.len() + ); + String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)? + } + None => include_str!("../../../scenarios/uniV2.toml").to_string(), + }; + let test_config = TestConfig::from_str(&config_str)?; -impl From for ErrorObjectOwned { - fn from(err: ContenderRpcError) -> Self { - match err { - ContenderRpcError::SessionInitializationFailed(e) => ErrorObject::owned( - 1, - "Failed to initialize contender session".to_string(), - Some(e.to_string()), - ), - } + Ok(NewSessionParams { + name: self.name.clone(), + rpc_url: self.rpc_url.clone(), + test_config, + }) } } @@ -78,7 +88,8 @@ impl ContenderRpcServer for ContenderServer { params: AddSessionParams, ) -> jsonrpsee::core::RpcResult { let mut sessions = self.sessions.write().await; - let session = sessions.add_session(params); + + let session = sessions.add_session(params.to_new_session_params()?); let info = session.info.clone(); info!( diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index c1302194..6e4f6676 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -1,31 +1,29 @@ -use std::str::FromStr; - use contender_core::{generator::RandSeed, test_scenario::Url, Contender}; use contender_sqlite::SqliteDb; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; -use crate::rpc::AddSessionParams; - pub struct ContenderSession { pub info: ContenderSessionInfo, pub contender: Contender, } +pub struct NewSessionParams { + pub name: String, + pub rpc_url: Url, + pub test_config: TestConfig, +} + impl ContenderSession { /// Should only be called by ContenderSessionCache when adding a new session, since the session ID is determined by the cache - fn new(sessions: &[ContenderSession], params: &AddSessionParams) -> Self { + fn new(sessions: &[ContenderSession], params: NewSessionParams) -> Self { let info = ContenderSessionInfo { id: sessions.len(), - name: params.name.clone(), - rpc_url: params.rpc_url.clone(), + name: params.name, + rpc_url: params.rpc_url, }; - // TODO: get TestConfig params and put them here - let config = TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) - .expect("valid test config"); - - let contender = info.create_contender(config); + let contender = info.create_contender(params.test_config); Self { info, contender } } } @@ -69,8 +67,8 @@ impl ContenderSessionCache { /// /// Returns a mutable reference to the newly added session, /// which can be used to call initialize on it before it's returned by the RPC provider. - pub fn add_session(&mut self, params: AddSessionParams) -> &mut ContenderSession { - let session = ContenderSession::new(&self.sessions, ¶ms); + pub fn add_session(&mut self, params: NewSessionParams) -> &mut ContenderSession { + let session = ContenderSession::new(&self.sessions, params); let info = session.info.clone(); self.sessions.push(session); From 30d3d72e8055188f4147662dffe5ba1de7adf002 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:18:06 -0700 Subject: [PATCH 06/34] export lib contents of contender_cli for server to use --- Cargo.lock | 1 + Cargo.toml | 1 + crates/cli/src/commands/campaign.rs | 2 +- crates/cli/src/default_scenarios/blobs.rs | 3 +- crates/cli/src/default_scenarios/builtin.rs | 3 +- .../src/default_scenarios/custom_contract.rs | 3 +- crates/cli/src/default_scenarios/erc20.rs | 3 +- .../eth_functions/command.rs | 3 +- .../eth_functions/opcodes.rs | 3 +- .../eth_functions/precompiles.rs | 3 +- .../cli/src/default_scenarios/fill_block.rs | 3 +- crates/cli/src/default_scenarios/revert.rs | 3 +- .../cli/src/default_scenarios/setcode/base.rs | 3 +- .../src/default_scenarios/setcode/execute.rs | 5 +- crates/cli/src/default_scenarios/storage.rs | 3 +- crates/cli/src/default_scenarios/stress.rs | 3 +- crates/cli/src/default_scenarios/transfers.rs | 3 +- crates/cli/src/default_scenarios/uni_v2.rs | 3 +- crates/cli/src/lib.rs | 11 ++++ crates/cli/src/main.rs | 61 ++++++++----------- crates/cli/src/util/utils.rs | 2 +- crates/server/Cargo.toml | 1 + crates/server/src/error.rs | 12 ++++ crates/server/src/rpc.rs | 60 +++++++++++++----- 24 files changed, 131 insertions(+), 67 deletions(-) create mode 100644 crates/cli/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 88dc0d91..27c5ac0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,6 +2251,7 @@ version = "0.9.0" dependencies = [ "async-trait", "base64 0.22.1", + "contender_cli", "contender_core", "contender_sqlite", "contender_testfile", diff --git a/Cargo.toml b/Cargo.toml index 76aa7467..52f67730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ 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/" } diff --git a/crates/cli/src/commands/campaign.rs b/crates/cli/src/commands/campaign.rs index 153d3471..53d79eef 100644 --- a/crates/cli/src/commands/campaign.rs +++ b/crates/cli/src/commands/campaign.rs @@ -5,10 +5,10 @@ use crate::commands::{ common::{ScenarioSendTxsCliArgs, SendTxsCliArgsInner}, SpamCliArgs, }; +use crate::default_scenarios::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::db::DbOps; diff --git a/crates/cli/src/default_scenarios/blobs.rs b/crates/cli/src/default_scenarios/blobs.rs index e42b164e..adeeefa5 100644 --- a/crates/cli/src/default_scenarios/blobs.rs +++ b/crates/cli/src/default_scenarios/blobs.rs @@ -1,10 +1,11 @@ use clap::Parser; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; use crate::default_scenarios::builtin::ToTestConfig; -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Deserialize)] /// Send blob transactions. Note: the tx type will always be overridden to eip4844. pub struct BlobsCliArgs { #[arg( diff --git a/crates/cli/src/default_scenarios/builtin.rs b/crates/cli/src/default_scenarios/builtin.rs index aec91422..5d4fabc3 100644 --- a/crates/cli/src/default_scenarios/builtin.rs +++ b/crates/cli/src/default_scenarios/builtin.rs @@ -26,10 +26,11 @@ use contender_core::{ generator::{constants::setcode_placeholder, types::AnyProvider, RandSeed}, }; use contender_testfile::TestConfig; +use serde::Deserialize; use strum::IntoEnumIterator; use tracing::warn; -#[derive(Clone, Debug, Subcommand)] +#[derive(Clone, Debug, Subcommand, Deserialize)] pub enum BuiltinScenarioCli { /// Send EIP-4844 blob transactions. Blobs(BlobsCliArgs), diff --git a/crates/cli/src/default_scenarios/custom_contract.rs b/crates/cli/src/default_scenarios/custom_contract.rs index d947919b..c37bb79a 100644 --- a/crates/cli/src/default_scenarios/custom_contract.rs +++ b/crates/cli/src/default_scenarios/custom_contract.rs @@ -6,13 +6,14 @@ use contender_core::generator::types::SpamRequest; use contender_core::generator::util::encode_calldata; use contender_core::generator::{CompiledContract, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; use std::process::Command; use thiserror::Error; use tracing::debug; const ARTIFACTS_PATH: &str = "/tmp/contender-contracts"; -#[derive(Clone, Debug, clap::Parser)] +#[derive(Clone, Debug, clap::Parser, Deserialize)] pub struct CustomContractCliArgs { /// Path to smart contract source. Format: : contract_path: std::path::PathBuf, diff --git a/crates/cli/src/default_scenarios/erc20.rs b/crates/cli/src/default_scenarios/erc20.rs index 2493c15b..2d885c91 100644 --- a/crates/cli/src/default_scenarios/erc20.rs +++ b/crates/cli/src/default_scenarios/erc20.rs @@ -3,6 +3,7 @@ use contender_core::generator::{ types::SpamRequest, util::parse_value, CreateDefinition, FunctionCallDefinition, FuzzParam, }; use contender_testfile::TestConfig; +use serde::Deserialize; use std::str::FromStr; use crate::default_scenarios::{builtin::ToTestConfig, contracts::test_token}; @@ -10,7 +11,7 @@ use crate::default_scenarios::{builtin::ToTestConfig, contracts::test_token}; pub static DEFAULT_TOKENS_SENT: &str = "0.00001 ether"; pub static DEFAULT_TOKENS_FUNDED: &str = "1000000 ether"; -#[derive(Clone, Debug, clap::Parser)] +#[derive(Clone, Debug, clap::Parser, Deserialize)] pub struct Erc20CliArgs { #[arg( short, diff --git a/crates/cli/src/default_scenarios/eth_functions/command.rs b/crates/cli/src/default_scenarios/eth_functions/command.rs index 5cac9660..9bbffe97 100644 --- a/crates/cli/src/default_scenarios/eth_functions/command.rs +++ b/crates/cli/src/default_scenarios/eth_functions/command.rs @@ -9,8 +9,9 @@ use crate::default_scenarios::{ use clap::Parser; use contender_core::generator::CreateDefinition; use contender_testfile::TestConfig; +use serde::Deserialize; -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Deserialize)] pub struct EthFunctionsCliArgs { #[arg( short, diff --git a/crates/cli/src/default_scenarios/eth_functions/opcodes.rs b/crates/cli/src/default_scenarios/eth_functions/opcodes.rs index 7dab15ce..4cdec4a0 100644 --- a/crates/cli/src/default_scenarios/eth_functions/opcodes.rs +++ b/crates/cli/src/default_scenarios/eth_functions/opcodes.rs @@ -1,9 +1,10 @@ use crate::default_scenarios::contracts::SPAM_ME; use clap::ValueEnum; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; +use serde::Deserialize; use strum::EnumIter; -#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq)] +#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize)] pub enum EthereumOpcode { Stop, Add, diff --git a/crates/cli/src/default_scenarios/eth_functions/precompiles.rs b/crates/cli/src/default_scenarios/eth_functions/precompiles.rs index e5662378..8432226e 100644 --- a/crates/cli/src/default_scenarios/eth_functions/precompiles.rs +++ b/crates/cli/src/default_scenarios/eth_functions/precompiles.rs @@ -1,9 +1,10 @@ use crate::default_scenarios::contracts::SPAM_ME; use clap::ValueEnum; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; +use serde::Deserialize; use strum::EnumIter; -#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq)] +#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize)] // TODO: add missing precompiles to SpamMe contract & here. pub enum EthereumPrecompile { #[clap(aliases = ["sha256"])] diff --git a/crates/cli/src/default_scenarios/fill_block.rs b/crates/cli/src/default_scenarios/fill_block.rs index 0713d0f4..ded5380d 100644 --- a/crates/cli/src/default_scenarios/fill_block.rs +++ b/crates/cli/src/default_scenarios/fill_block.rs @@ -10,9 +10,10 @@ use contender_core::generator::{ CreateDefinition, FunctionCallDefinition, }; use contender_testfile::TestConfig; +use serde::Deserialize; use tracing::{info, warn}; -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Deserialize)] /// Taken from the CLI, this is used to fill a block with transactions. pub struct FillBlockCliArgs { #[arg(short = 'g', long, long_help = "Override gas used per block. By default, the block limit is used.", visible_aliases = ["gas"])] diff --git a/crates/cli/src/default_scenarios/revert.rs b/crates/cli/src/default_scenarios/revert.rs index 13e1e00f..562c8625 100644 --- a/crates/cli/src/default_scenarios/revert.rs +++ b/crates/cli/src/default_scenarios/revert.rs @@ -1,9 +1,10 @@ use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; use crate::default_scenarios::{builtin::ToTestConfig, contracts::SPAM_ME_6}; -#[derive(Clone, Debug, clap::Parser)] +#[derive(Clone, Debug, clap::Parser, Deserialize)] pub struct RevertCliArgs { /// Amount of gas to use before reverting. #[arg( diff --git a/crates/cli/src/default_scenarios/setcode/base.rs b/crates/cli/src/default_scenarios/setcode/base.rs index e04f22b7..b812365f 100644 --- a/crates/cli/src/default_scenarios/setcode/base.rs +++ b/crates/cli/src/default_scenarios/setcode/base.rs @@ -12,9 +12,10 @@ use crate::{ use clap::Parser; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; use tracing::warn; -#[derive(Clone, Debug, Parser)] +#[derive(Clone, Debug, Parser, Deserialize)] pub struct SetCodeCliArgs { #[command(subcommand)] pub command: Option, diff --git a/crates/cli/src/default_scenarios/setcode/execute.rs b/crates/cli/src/default_scenarios/setcode/execute.rs index 8c20d1e2..e1f2a0f0 100644 --- a/crates/cli/src/default_scenarios/setcode/execute.rs +++ b/crates/cli/src/default_scenarios/setcode/execute.rs @@ -5,11 +5,12 @@ use contender_core::generator::{ error::GeneratorError, util::{encode_calldata, parse_value}, }; +use serde::Deserialize; pub const DEFAULT_SIG: &str = "execute((address,uint256,bytes)[])"; pub const DEFAULT_ARGS: &str = "[(0x{Counter},0,0xd09de08a)]"; -#[derive(Clone, Debug, Parser)] +#[derive(Clone, Debug, Parser, Deserialize)] pub struct SetCodeExecuteCliArgs { /// The address to call via the smart-wallet's execute function. #[arg( @@ -54,7 +55,7 @@ Example: pub value: Option, } -#[derive(clap::Subcommand, Clone, Debug)] +#[derive(clap::Subcommand, Clone, Debug, Deserialize)] pub enum SetCodeSubCommand { /// Helper function to delegate function calls via `execute(Call[])` on a smart-wallet contract. Execute(SetCodeExecuteCliArgs), diff --git a/crates/cli/src/default_scenarios/storage.rs b/crates/cli/src/default_scenarios/storage.rs index 3d3f5f69..4e27e68c 100644 --- a/crates/cli/src/default_scenarios/storage.rs +++ b/crates/cli/src/default_scenarios/storage.rs @@ -1,8 +1,9 @@ use crate::default_scenarios::{builtin::ToTestConfig, contracts}; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; -#[derive(Debug, Clone, clap::Parser)] +#[derive(Debug, Clone, clap::Parser, Deserialize)] pub struct StorageStressCliArgs { #[arg( short = 's', diff --git a/crates/cli/src/default_scenarios/stress.rs b/crates/cli/src/default_scenarios/stress.rs index 726e02af..507fcf3c 100644 --- a/crates/cli/src/default_scenarios/stress.rs +++ b/crates/cli/src/default_scenarios/stress.rs @@ -1,6 +1,7 @@ use clap::Parser; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; +use serde::Deserialize; use strum::IntoEnumIterator; use crate::default_scenarios::{ @@ -14,7 +15,7 @@ use crate::default_scenarios::{ transfers::{TransferStressArgs, TransferStressCliArgs}, }; -#[derive(Debug, Clone, Parser)] +#[derive(Debug, Clone, Parser, Deserialize)] pub struct StressCliArgs { #[arg( long, diff --git a/crates/cli/src/default_scenarios/transfers.rs b/crates/cli/src/default_scenarios/transfers.rs index 8dd5b3e9..6b9e587d 100644 --- a/crates/cli/src/default_scenarios/transfers.rs +++ b/crates/cli/src/default_scenarios/transfers.rs @@ -2,8 +2,9 @@ use crate::default_scenarios::builtin::ToTestConfig; use alloy::primitives::{Address, U256}; use clap::Parser; use contender_core::generator::{types::SpamRequest, util::parse_value, FunctionCallDefinition}; +use serde::Deserialize; -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Deserialize)] pub struct TransferStressCliArgs { #[arg( short = 'a', diff --git a/crates/cli/src/default_scenarios/uni_v2.rs b/crates/cli/src/default_scenarios/uni_v2.rs index 2dc3c1ef..b42f427a 100644 --- a/crates/cli/src/default_scenarios/uni_v2.rs +++ b/crates/cli/src/default_scenarios/uni_v2.rs @@ -8,9 +8,10 @@ use contender_core::generator::{ types::SpamRequest, CompiledContract, CreateDefinition, FunctionCallDefinition, }; use contender_testfile::TestConfig; +use serde::Deserialize; use thiserror::Error; -#[derive(Debug, Clone, Parser)] +#[derive(Debug, Clone, Parser, Deserialize)] pub struct UniV2CliArgs { #[arg( short, diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 00000000..1ef4b6ec --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,11 @@ +pub mod commands; +pub mod default_scenarios; +pub mod error; +pub mod util; + +pub use error::CliError as Error; + +// prometheus +use tokio::sync::OnceCell; +pub static PROM: OnceCell = OnceCell::const_new(); +pub static LATENCY_HIST: OnceCell = OnceCell::const_new(); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index d99ac5a2..6698fdf7 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,43 +1,36 @@ -mod commands; -mod default_scenarios; -mod error; -mod util; - -use crate::commands::{error::ArgsError, ReportFormat, SpamCampaignContext}; use alloy::{ network::AnyNetwork, providers::{DynProvider, ProviderBuilder}, rpc::client::ClientBuilder, }; -use commands::{ - admin::handle_admin_command, - common::ScenarioSendTxsCliArgs, - db::{drop_db, export_db, import_db, reset_db}, - replay::ReplayArgs, - ContenderCli, ContenderSubcommand, DbCommand, SetupCommandArgs, SpamCliArgs, SpamCommandArgs, - SpamScenario, +use contender_cli::commands; +use contender_cli::{ + commands::{ + admin::handle_admin_command, + common::ScenarioSendTxsCliArgs, + db::{drop_db, export_db, import_db, reset_db}, + error::ArgsError, + replay::ReplayArgs, + ContenderCli, ContenderSubcommand, DbCommand, ReportFormat, SetupCommandArgs, + SpamCampaignContext, SpamCliArgs, SpamCommandArgs, SpamScenario, + }, + default_scenarios::{fill_block::FillBlockCliArgs, BuiltinScenarioCli}, + util::{db_file_in, init_reports_dir, resolve_data_dir}, + Error, }; use contender_core::{db::DbOps, util::TracingOptions}; use contender_sqlite::{SqliteDb, DB_VERSION}; -use default_scenarios::{fill_block::FillBlockCliArgs, BuiltinScenarioCli}; -use error::CliError; use regex::Regex; use std::str::FromStr; -use tokio::sync::OnceCell; use tracing::{debug, info, warn}; use tracing_subscriber::EnvFilter; -use util::{db_file_in, init_reports_dir, resolve_data_dir}; - -// prometheus -static PROM: OnceCell = OnceCell::const_new(); -static LATENCY_HIST: OnceCell = OnceCell::const_new(); #[tokio::main(flavor = "multi_thread")] async fn main() -> miette::Result<()> { run().await.map_err(|e| e.into()) } -async fn run() -> Result<(), CliError> { +async fn run() -> Result<(), contender_cli::Error> { init_tracing(); let args = ContenderCli::parse_args(); @@ -135,13 +128,11 @@ async fn run() -> Result<(), CliError> { } => { if let Some(campaign_id) = campaign_id { let resolved_campaign_id = if campaign_id == "__LATEST_CAMPAIGN__" { - db.latest_campaign_id() - .map_err(CliError::Db)? - .ok_or_else(|| { - CliError::Report(contender_report::Error::CampaignNotFound( - "latest".to_string(), - )) - })? + db.latest_campaign_id().map_err(Error::Db)?.ok_or_else(|| { + Error::Report(contender_report::Error::CampaignNotFound( + "latest".to_string(), + )) + })? } else { campaign_id }; @@ -155,7 +146,7 @@ async fn run() -> Result<(), CliError> { skip_tx_traces, ) .await - .map_err(CliError::Report)?; + .map_err(Error::Report)?; } else { let use_json = matches!(format, ReportFormat::Json); contender_report::command::report( @@ -167,7 +158,7 @@ async fn run() -> Result<(), CliError> { skip_tx_traces, ) .await - .map_err(CliError::Report)?; + .map_err(Error::Report)?; } } @@ -195,8 +186,8 @@ async fn run() -> Result<(), CliError> { } /// Check DB version, throw error if version is incompatible with currently-running version of contender. -fn init_db(command: &ContenderSubcommand, db: &SqliteDb) -> Result<(), CliError> { - if db.table_exists("run_txs").map_err(CliError::Db)? { +fn init_db(command: &ContenderSubcommand, db: &SqliteDb) -> Result<(), Error> { + if db.table_exists("run_txs").map_err(Error::Db)? { // check version and exit if DB version is incompatible let quit_early = db.version() != DB_VERSION && !matches!( @@ -221,11 +212,11 @@ fn init_db(command: &ContenderSubcommand, db: &SqliteDb) -> Result<(), CliError> DB_VERSION ); warn!("{recommendation}"); - return Err(CliError::DbVersion); + return Err(Error::DbVersion); } } else { info!("no DB found, creating new DB"); - db.create_tables().map_err(CliError::Db)?; + db.create_tables().map_err(Error::Db)?; } Ok(()) } diff --git a/crates/cli/src/util/utils.rs b/crates/cli/src/util/utils.rs index 1f3cc1bf..efb6a407 100644 --- a/crates/cli/src/util/utils.rs +++ b/crates/cli/src/util/utils.rs @@ -50,7 +50,7 @@ const DEFAULT_SCENARIOS_URL: &str = /// If the testfile starts with `scenario:`, it is treated as a builtin scenario. /// Otherwise, it is treated as a file path. /// Built-in scenarios are fetched relative to the default URL: [`DEFAULT_SCENARIOS_URL`](crate::util::DEFAULT_SCENARIOS_URL). -pub async fn load_testconfig(testfile: &str) -> Result { +pub async fn load_testconfig(testfile: &str) -> Result { Ok(if testfile.starts_with("scenario:") { let remote_url = format!( "{DEFAULT_SCENARIOS_URL}/{}", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 57385a1b..dc288fc5 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -18,6 +18,7 @@ base64.workspace = true contender_core.workspace = true contender_testfile.workspace = true contender_sqlite.workspace = true +contender_cli.workspace = true async-trait = { workspace = true } jsonrpsee = { workspace = true, features = ["server", "macros"] } diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index b66415c6..2b26aa0a 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -10,6 +10,9 @@ pub enum ContenderRpcError { #[error("Invalid test config: {0}")] InvalidTestConfig(#[from] contender_testfile::Error), + #[error("Invalid arguments: {0}")] + InvalidArguments(String), + #[error("Invalid base64: {0}")] InvalidBase64(#[from] DecodeError), @@ -20,6 +23,11 @@ pub enum ContenderRpcError { impl From for ErrorObjectOwned { fn from(err: ContenderRpcError) -> Self { match err { + /* TODO + standardize error codes and messages, + and decide what info to include in the data field + (e.g. stack traces for internal errors, but not for user errors) + */ ContenderRpcError::SessionInitializationFailed(e) => ErrorObject::owned( 1, "Failed to initialize contender session".to_string(), @@ -41,6 +49,10 @@ impl From for ErrorObjectOwned { "Invalid UTF-8 in config".to_string(), Some(e.to_string()), ), + + ContenderRpcError::InvalidArguments(msg) => { + ErrorObject::owned(400, "Invalid arguments".to_string(), Some(msg)) + } } } } diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 3921258b..625f219c 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -1,8 +1,9 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use contender_cli::default_scenarios::BuiltinScenarioCli; use contender_core::test_scenario::Url; use contender_testfile::TestConfig; use jsonrpsee::{proc_macros::rpc, types::ErrorObjectOwned}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::{str::FromStr, sync::Arc}; use tokio::sync::RwLock; use tracing::{debug, info}; @@ -44,29 +45,60 @@ impl ContenderServer { } /// RPC parameters for adding a new contender session. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize)] pub struct AddSessionParams { pub name: String, pub rpc_url: Url, /// Base64-encoded TOML test config. If omitted, the default uniV2 scenario is used. - pub test_config_b64: Option, + pub test_config_toml_b64: Option, + /// JSON-encoded test config. If both this and the base64 version are provided, this takes precedence. + pub test_config: Option, // TODO: support builtin scenarios + pub test_config_builtin: Option, } impl AddSessionParams { + fn decode_test_config_toml_b64(&self) -> Result { + if let Some(b64) = &self.test_config_toml_b64 { + let bytes = BASE64.decode(b64)?; + debug!( + "Decoded test config from base64, length {} bytes", + bytes.len() + ); + let config_str = String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)?; + TestConfig::from_str(&config_str).map_err(ContenderRpcError::InvalidTestConfig) + } else { + Ok( + TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) + .expect("default config should be valid"), + ) + } + } + + fn decode_test_config_builtin(&self) -> Result { + if let Some(builtin) = &self.test_config_builtin { + // builtin.to_builtin_scenario(provider, spam_args, data_dir) + todo!() + } else { + Ok( + TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) + .expect("default config should be valid"), + ) + } + } + pub fn to_new_session_params(self) -> Result { - let config_str = match self.test_config_b64 { - Some(b64) => { - let bytes = BASE64.decode(&b64)?; - debug!( - "Decoded test config from base64, length {} bytes", - bytes.len() - ); - String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)? - } - None => include_str!("../../../scenarios/uniV2.toml").to_string(), + if self.test_config.is_some() && self.test_config_toml_b64.is_some() { + debug!("Both test_config and test_config_b64 provided, returning error"); + return Err(ContenderRpcError::InvalidArguments( + "Cannot provide both test_config and test_config_b64".into(), + )); + } + let test_config = if let Some(config) = self.test_config { + config + } else { + self.decode_test_config_toml_b64()? }; - let test_config = TestConfig::from_str(&config_str)?; Ok(NewSessionParams { name: self.name.clone(), From 9161beb690cd0e5a41a0d4b5e50f67914e5144e9 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:54:13 -0700 Subject: [PATCH 07/34] support default scenarios in add_session --- Cargo.lock | 1 + crates/cli/src/commands/campaign.rs | 17 +- crates/cli/src/commands/common.rs | 12 ++ crates/cli/src/commands/spam.rs | 20 ++- crates/cli/src/default_scenarios/blobs.rs | 4 +- crates/cli/src/default_scenarios/builtin.rs | 37 ++-- .../src/default_scenarios/custom_contract.rs | 4 +- crates/cli/src/default_scenarios/erc20.rs | 4 +- .../eth_functions/command.rs | 4 +- .../eth_functions/opcodes.rs | 6 +- .../eth_functions/precompiles.rs | 6 +- .../cli/src/default_scenarios/fill_block.rs | 62 ++++--- crates/cli/src/default_scenarios/mod.rs | 2 +- crates/cli/src/default_scenarios/revert.rs | 4 +- .../cli/src/default_scenarios/setcode/base.rs | 4 +- .../src/default_scenarios/setcode/execute.rs | 6 +- crates/cli/src/default_scenarios/storage.rs | 4 +- crates/cli/src/default_scenarios/stress.rs | 4 +- crates/cli/src/default_scenarios/transfers.rs | 4 +- crates/cli/src/default_scenarios/uni_v2.rs | 4 +- crates/cli/src/main.rs | 4 +- crates/core/src/generator/create_def.rs | 4 + crates/core/src/generator/function_def.rs | 12 +- crates/server/Cargo.toml | 1 + crates/server/src/rpc.rs | 163 +++++++++++++----- crates/server/src/sessions.rs | 4 + 26 files changed, 274 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27c5ac0d..31b04053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2257,6 +2257,7 @@ dependencies = [ "contender_testfile", "jsonrpsee 0.24.10", "serde", + "serde_json", "thiserror 2.0.17", "tokio", "tracing", diff --git a/crates/cli/src/commands/campaign.rs b/crates/cli/src/commands/campaign.rs index 53d79eef..ba159006 100644 --- a/crates/cli/src/commands/campaign.rs +++ b/crates/cli/src/commands/campaign.rs @@ -5,7 +5,8 @@ use crate::commands::{ common::{ScenarioSendTxsCliArgs, SendTxsCliArgsInner}, SpamCliArgs, }; -use crate::default_scenarios::BuiltinScenarioCli; +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}; @@ -13,6 +14,7 @@ use alloy::primitives::{keccak256, U256}; use clap::Args; use contender_core::db::DbOps; use contender_core::error::RuntimeParamErrorKind; +use contender_core::generator::RandSeed; use contender_testfile::{CampaignConfig, CampaignMode, ResolvedMixEntry, ResolvedStage}; use std::path::Path; use std::time::Duration; @@ -445,10 +447,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 { diff --git a/crates/cli/src/commands/common.rs b/crates/cli/src/commands/common.rs index 1e3e87c2..2eb3b7d9 100644 --- a/crates/cli/src/commands/common.rs +++ b/crates/cli/src/commands/common.rs @@ -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; @@ -336,6 +337,17 @@ Requires --priv-key to be set for each 'from' address in the given testfile.", pub run_forever: bool, } +impl SendSpamCliArgs { + pub fn spam_rate(&self) -> Result { + 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)] pub enum TxTypeCli { /// Legacy transaction (type `0x0`) diff --git a/crates/cli/src/commands/spam.rs b/crates/cli/src/commands/spam.rs index faa88770..d9f4b0f7 100644 --- a/crates/cli/src/commands/spam.rs +++ b/crates/cli/src/commands/spam.rs @@ -5,7 +5,7 @@ use crate::{ error::ArgsError, Result, }, - default_scenarios::BuiltinScenario, + default_scenarios::{BuiltinOptions, BuiltinScenario}, error::CliError, util::{ bold, check_private_keys, fund_accounts, load_seedfile, load_testconfig, parse_duration, @@ -151,6 +151,24 @@ pub struct SpamCliArgs { )] pub flashblocks_ws_url: Option, } + +impl SpamCliArgs { + pub fn builtin_options(&self, data_dir: &PathBuf) -> Result { + 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), diff --git a/crates/cli/src/default_scenarios/blobs.rs b/crates/cli/src/default_scenarios/blobs.rs index adeeefa5..bb7a88d9 100644 --- a/crates/cli/src/default_scenarios/blobs.rs +++ b/crates/cli/src/default_scenarios/blobs.rs @@ -1,11 +1,11 @@ use clap::Parser; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::default_scenarios::builtin::ToTestConfig; -#[derive(Parser, Clone, Debug, Deserialize)] +#[derive(Parser, Clone, Debug, Deserialize, Serialize)] /// Send blob transactions. Note: the tx type will always be overridden to eip4844. pub struct BlobsCliArgs { #[arg( diff --git a/crates/cli/src/default_scenarios/builtin.rs b/crates/cli/src/default_scenarios/builtin.rs index 5d4fabc3..dff571e4 100644 --- a/crates/cli/src/default_scenarios/builtin.rs +++ b/crates/cli/src/default_scenarios/builtin.rs @@ -1,13 +1,11 @@ -use std::path::Path; - use super::fill_block::{fill_block, FillBlockArgs, FillBlockCliArgs}; use crate::{ - commands::SpamCliArgs, default_scenarios::{ blobs::BlobsCliArgs, custom_contract::{CustomContractArgs, CustomContractCliArgs}, erc20::{Erc20Args, Erc20CliArgs}, eth_functions::{opcodes::EthereumOpcode, EthFunctionsArgs, EthFunctionsCliArgs}, + fill_block::SpamRate, revert::RevertCliArgs, setcode::{SetCodeArgs, SetCodeCliArgs, SetCodeSubCommand}, storage::{StorageStressArgs, StorageStressCliArgs}, @@ -16,7 +14,7 @@ use crate::{ uni_v2::{UniV2Args, UniV2CliArgs}, }, error::CliError, - util::{bold, load_seedfile}, + util::bold, }; use alloy::primitives::U256; use clap::Subcommand; @@ -26,11 +24,12 @@ use contender_core::{ generator::{constants::setcode_placeholder, types::AnyProvider, RandSeed}, }; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use tracing::warn; -#[derive(Clone, Debug, Subcommand, Deserialize)] +#[derive(Clone, Debug, Subcommand, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum BuiltinScenarioCli { /// Send EIP-4844 blob transactions. Blobs(BlobsCliArgs), @@ -77,12 +76,18 @@ pub trait ToTestConfig { fn to_testconfig(&self) -> TestConfig; } +#[derive(Default)] +pub struct BuiltinOptions { + pub accounts_per_agent: Option, + pub seed: RandSeed, + pub spam_rate: Option, +} + impl BuiltinScenarioCli { pub async fn to_builtin_scenario( &self, provider: &AnyProvider, - spam_args: &SpamCliArgs, - data_dir: &Path, + options: BuiltinOptions, ) -> Result { match self.to_owned() { BuiltinScenarioCli::Blobs(args) => Ok(BuiltinScenario::Blobs(args)), @@ -92,21 +97,11 @@ impl BuiltinScenarioCli { )), BuiltinScenarioCli::Erc20(args) => { - let seed = spam_args - .eth_json_rpc_args - .rpc_args - .seed - .to_owned() - .unwrap_or(load_seedfile(data_dir)?); - let seed = RandSeed::seed_from_str(&seed); let mut agents = AgentStore::new(); agents.init( &["spammers"], - spam_args - .eth_json_rpc_args - .rpc_args - .accounts_per_agent_or(10) as usize, - &seed, + options.accounts_per_agent.unwrap_or(10) as usize, + &options.seed, ); let spammers = agents .get_agent("spammers") @@ -119,7 +114,7 @@ impl BuiltinScenarioCli { } BuiltinScenarioCli::FillBlock(args) => { - fill_block(provider, &spam_args.spam_args, &args).await + fill_block(provider, options.spam_rate.unwrap_or_default(), &args).await } BuiltinScenarioCli::EthFunctions(args) => { diff --git a/crates/cli/src/default_scenarios/custom_contract.rs b/crates/cli/src/default_scenarios/custom_contract.rs index c37bb79a..734823bd 100644 --- a/crates/cli/src/default_scenarios/custom_contract.rs +++ b/crates/cli/src/default_scenarios/custom_contract.rs @@ -6,14 +6,14 @@ use contender_core::generator::types::SpamRequest; use contender_core::generator::util::encode_calldata; use contender_core::generator::{CompiledContract, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::process::Command; use thiserror::Error; use tracing::debug; const ARTIFACTS_PATH: &str = "/tmp/contender-contracts"; -#[derive(Clone, Debug, clap::Parser, Deserialize)] +#[derive(Clone, Debug, clap::Parser, Deserialize, Serialize)] pub struct CustomContractCliArgs { /// Path to smart contract source. Format: : contract_path: std::path::PathBuf, diff --git a/crates/cli/src/default_scenarios/erc20.rs b/crates/cli/src/default_scenarios/erc20.rs index 2d885c91..0ba17393 100644 --- a/crates/cli/src/default_scenarios/erc20.rs +++ b/crates/cli/src/default_scenarios/erc20.rs @@ -3,7 +3,7 @@ use contender_core::generator::{ types::SpamRequest, util::parse_value, CreateDefinition, FunctionCallDefinition, FuzzParam, }; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::str::FromStr; use crate::default_scenarios::{builtin::ToTestConfig, contracts::test_token}; @@ -11,7 +11,7 @@ use crate::default_scenarios::{builtin::ToTestConfig, contracts::test_token}; pub static DEFAULT_TOKENS_SENT: &str = "0.00001 ether"; pub static DEFAULT_TOKENS_FUNDED: &str = "1000000 ether"; -#[derive(Clone, Debug, clap::Parser, Deserialize)] +#[derive(Clone, Debug, clap::Parser, Deserialize, Serialize)] pub struct Erc20CliArgs { #[arg( short, diff --git a/crates/cli/src/default_scenarios/eth_functions/command.rs b/crates/cli/src/default_scenarios/eth_functions/command.rs index 9bbffe97..38c3381e 100644 --- a/crates/cli/src/default_scenarios/eth_functions/command.rs +++ b/crates/cli/src/default_scenarios/eth_functions/command.rs @@ -9,9 +9,9 @@ use crate::default_scenarios::{ use clap::Parser; use contender_core::generator::CreateDefinition; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Parser, Clone, Debug, Deserialize)] +#[derive(Parser, Clone, Debug, Deserialize, Serialize)] pub struct EthFunctionsCliArgs { #[arg( short, diff --git a/crates/cli/src/default_scenarios/eth_functions/opcodes.rs b/crates/cli/src/default_scenarios/eth_functions/opcodes.rs index 4cdec4a0..99c0cdef 100644 --- a/crates/cli/src/default_scenarios/eth_functions/opcodes.rs +++ b/crates/cli/src/default_scenarios/eth_functions/opcodes.rs @@ -1,10 +1,12 @@ use crate::default_scenarios::contracts::SPAM_ME; use clap::ValueEnum; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum::EnumIter; -#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize)] +#[derive( + ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize, Serialize, +)] pub enum EthereumOpcode { Stop, Add, diff --git a/crates/cli/src/default_scenarios/eth_functions/precompiles.rs b/crates/cli/src/default_scenarios/eth_functions/precompiles.rs index 8432226e..a06cfae1 100644 --- a/crates/cli/src/default_scenarios/eth_functions/precompiles.rs +++ b/crates/cli/src/default_scenarios/eth_functions/precompiles.rs @@ -1,10 +1,12 @@ use crate::default_scenarios::contracts::SPAM_ME; use clap::ValueEnum; use contender_core::generator::{types::SpamRequest, FunctionCallDefinition}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum::EnumIter; -#[derive(ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize)] +#[derive( + ValueEnum, Clone, Debug, strum::Display, EnumIter, PartialEq, Eq, Deserialize, Serialize, +)] // TODO: add missing precompiles to SpamMe contract & here. pub enum EthereumPrecompile { #[clap(aliases = ["sha256"])] diff --git a/crates/cli/src/default_scenarios/fill_block.rs b/crates/cli/src/default_scenarios/fill_block.rs index ded5380d..309d9a3c 100644 --- a/crates/cli/src/default_scenarios/fill_block.rs +++ b/crates/cli/src/default_scenarios/fill_block.rs @@ -1,5 +1,4 @@ use crate::{ - commands::common::SendSpamCliArgs, default_scenarios::{builtin::ToTestConfig, contracts, BuiltinScenario}, error::CliError, }; @@ -10,10 +9,10 @@ use contender_core::generator::{ CreateDefinition, FunctionCallDefinition, }; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tracing::{info, warn}; -#[derive(Parser, Clone, Debug, Deserialize)] +#[derive(Parser, Clone, Debug, Deserialize, Serialize)] /// Taken from the CLI, this is used to fill a block with transactions. pub struct FillBlockCliArgs { #[arg(short = 'g', long, long_help = "Override gas used per block. By default, the block limit is used.", visible_aliases = ["gas"])] @@ -27,17 +26,31 @@ pub struct FillBlockArgs { pub num_txs: u64, } +pub enum SpamRate { + TxsPerBlock(u64), + TxsPerSecond(u64), +} + +impl Default for SpamRate { + fn default() -> Self { + SpamRate::TxsPerSecond(10) + } +} + +impl SpamRate { + /// Get the number of transactions to send based on the spam rate. + pub fn num_txs(&self) -> u64 { + match self { + SpamRate::TxsPerBlock(n) | SpamRate::TxsPerSecond(n) => *n, + } + } +} + pub async fn fill_block( provider: &AnyProvider, - spam_args: &SendSpamCliArgs, + spam_rate: SpamRate, args: &FillBlockCliArgs, ) -> Result { - let SendSpamCliArgs { - txs_per_block, - txs_per_second, - .. - } = spam_args.to_owned(); - // determine gas limit let gas_limit = if let Some(max_gas) = args.max_gas_per_block { max_gas @@ -52,20 +65,21 @@ pub async fn fill_block( block_gas_limit.unwrap_or(30_000_000) }; - let num_txs = match (txs_per_block, txs_per_second) { - (Some(0), _) | (_, Some(0)) => { - return Err(CliError::Args( - crate::commands::error::ArgsError::SpamRateNotFound, - )); - } - (Some(n), _) => n, - (_, Some(n)) => n, - (None, None) => { - return Err(CliError::Args( - crate::commands::error::ArgsError::SpamRateNotFound, - )); - } - }; + // let num_txs = match (txs_per_block, txs_per_second) { + // (Some(0), _) | (_, Some(0)) => { + // return Err(CliError::Args( + // crate::commands::error::ArgsError::SpamRateNotFound, + // )); + // } + // (Some(n), _) => n, + // (_, Some(n)) => n, + // (None, None) => { + // return Err(CliError::Args( + // crate::commands::error::ArgsError::SpamRateNotFound, + // )); + // } + // }; + let num_txs = spam_rate.num_txs(); let gas_per_tx = gas_limit / num_txs; info!("Attempting to fill blocks with {gas_limit} gas; sending {num_txs} txs, each with gas limit {gas_per_tx}."); diff --git a/crates/cli/src/default_scenarios/mod.rs b/crates/cli/src/default_scenarios/mod.rs index 639d5f37..5cba4eab 100644 --- a/crates/cli/src/default_scenarios/mod.rs +++ b/crates/cli/src/default_scenarios/mod.rs @@ -12,4 +12,4 @@ pub mod stress; pub mod transfers; pub mod uni_v2; -pub use builtin::{BuiltinScenario, BuiltinScenarioCli}; +pub use builtin::{BuiltinOptions, BuiltinScenario, BuiltinScenarioCli, ToTestConfig}; diff --git a/crates/cli/src/default_scenarios/revert.rs b/crates/cli/src/default_scenarios/revert.rs index 562c8625..09dcafac 100644 --- a/crates/cli/src/default_scenarios/revert.rs +++ b/crates/cli/src/default_scenarios/revert.rs @@ -1,10 +1,10 @@ use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::default_scenarios::{builtin::ToTestConfig, contracts::SPAM_ME_6}; -#[derive(Clone, Debug, clap::Parser, Deserialize)] +#[derive(Clone, Debug, clap::Parser, Deserialize, Serialize)] pub struct RevertCliArgs { /// Amount of gas to use before reverting. #[arg( diff --git a/crates/cli/src/default_scenarios/setcode/base.rs b/crates/cli/src/default_scenarios/setcode/base.rs index b812365f..85839899 100644 --- a/crates/cli/src/default_scenarios/setcode/base.rs +++ b/crates/cli/src/default_scenarios/setcode/base.rs @@ -12,10 +12,10 @@ use crate::{ use clap::Parser; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tracing::warn; -#[derive(Clone, Debug, Parser, Deserialize)] +#[derive(Clone, Debug, Parser, Deserialize, Serialize)] pub struct SetCodeCliArgs { #[command(subcommand)] pub command: Option, diff --git a/crates/cli/src/default_scenarios/setcode/execute.rs b/crates/cli/src/default_scenarios/setcode/execute.rs index e1f2a0f0..a156dbea 100644 --- a/crates/cli/src/default_scenarios/setcode/execute.rs +++ b/crates/cli/src/default_scenarios/setcode/execute.rs @@ -5,12 +5,12 @@ use contender_core::generator::{ error::GeneratorError, util::{encode_calldata, parse_value}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; pub const DEFAULT_SIG: &str = "execute((address,uint256,bytes)[])"; pub const DEFAULT_ARGS: &str = "[(0x{Counter},0,0xd09de08a)]"; -#[derive(Clone, Debug, Parser, Deserialize)] +#[derive(Clone, Debug, Parser, Deserialize, Serialize)] pub struct SetCodeExecuteCliArgs { /// The address to call via the smart-wallet's execute function. #[arg( @@ -55,7 +55,7 @@ Example: pub value: Option, } -#[derive(clap::Subcommand, Clone, Debug, Deserialize)] +#[derive(clap::Subcommand, Clone, Debug, Deserialize, Serialize)] pub enum SetCodeSubCommand { /// Helper function to delegate function calls via `execute(Call[])` on a smart-wallet contract. Execute(SetCodeExecuteCliArgs), diff --git a/crates/cli/src/default_scenarios/storage.rs b/crates/cli/src/default_scenarios/storage.rs index 4e27e68c..d186fe56 100644 --- a/crates/cli/src/default_scenarios/storage.rs +++ b/crates/cli/src/default_scenarios/storage.rs @@ -1,9 +1,9 @@ use crate::default_scenarios::{builtin::ToTestConfig, contracts}; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, clap::Parser, Deserialize)] +#[derive(Debug, Clone, clap::Parser, Deserialize, Serialize)] pub struct StorageStressCliArgs { #[arg( short = 's', diff --git a/crates/cli/src/default_scenarios/stress.rs b/crates/cli/src/default_scenarios/stress.rs index 507fcf3c..475dc755 100644 --- a/crates/cli/src/default_scenarios/stress.rs +++ b/crates/cli/src/default_scenarios/stress.rs @@ -1,7 +1,7 @@ use clap::Parser; use contender_core::generator::{types::SpamRequest, CreateDefinition, FunctionCallDefinition}; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use crate::default_scenarios::{ @@ -15,7 +15,7 @@ use crate::default_scenarios::{ transfers::{TransferStressArgs, TransferStressCliArgs}, }; -#[derive(Debug, Clone, Parser, Deserialize)] +#[derive(Debug, Clone, Parser, Deserialize, Serialize)] pub struct StressCliArgs { #[arg( long, diff --git a/crates/cli/src/default_scenarios/transfers.rs b/crates/cli/src/default_scenarios/transfers.rs index 6b9e587d..3478c72a 100644 --- a/crates/cli/src/default_scenarios/transfers.rs +++ b/crates/cli/src/default_scenarios/transfers.rs @@ -2,9 +2,9 @@ use crate::default_scenarios::builtin::ToTestConfig; use alloy::primitives::{Address, U256}; use clap::Parser; use contender_core::generator::{types::SpamRequest, util::parse_value, FunctionCallDefinition}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Parser, Clone, Debug, Deserialize)] +#[derive(Parser, Clone, Debug, Deserialize, Serialize)] pub struct TransferStressCliArgs { #[arg( short = 'a', diff --git a/crates/cli/src/default_scenarios/uni_v2.rs b/crates/cli/src/default_scenarios/uni_v2.rs index b42f427a..d8412a1e 100644 --- a/crates/cli/src/default_scenarios/uni_v2.rs +++ b/crates/cli/src/default_scenarios/uni_v2.rs @@ -8,10 +8,10 @@ use contender_core::generator::{ types::SpamRequest, CompiledContract, CreateDefinition, FunctionCallDefinition, }; use contender_testfile::TestConfig; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error; -#[derive(Debug, Clone, Parser, Deserialize)] +#[derive(Debug, Clone, Parser, Deserialize, Serialize)] pub struct UniV2CliArgs { #[arg( short, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 6698fdf7..6e6e7f6c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -96,7 +96,7 @@ async fn run() -> Result<(), contender_cli::Error> { } else if let Some(config) = builtin_scenario_config { SpamScenario::Builtin( config - .to_builtin_scenario(&provider, &args, &data_dir) + .to_builtin_scenario(&provider, args.builtin_options(&data_dir)?) .await?, ) } else { @@ -105,7 +105,7 @@ async fn run() -> Result<(), contender_cli::Error> { BuiltinScenarioCli::FillBlock(FillBlockCliArgs { max_gas_per_block: None, }) - .to_builtin_scenario(&provider, &args, &data_dir) + .to_builtin_scenario(&provider, args.builtin_options(&data_dir)?) .await?, ) }; diff --git a/crates/core/src/generator/create_def.rs b/crates/core/src/generator/create_def.rs index 036ef285..25abbfed 100644 --- a/crates/core/src/generator/create_def.rs +++ b/crates/core/src/generator/create_def.rs @@ -66,12 +66,16 @@ pub struct CreateDefinition { #[serde(flatten)] pub contract: CompiledContract, /// Constructor signature. Formats supported: "constructor(type1,type2,...)" or "(type1,type2,...)". + #[serde(skip_serializing_if = "Option::is_none")] pub signature: Option, /// Constructor arguments. May include placeholders. + #[serde(skip_serializing_if = "Option::is_none")] pub args: Option>, /// Address of the tx sender. + #[serde(skip_serializing_if = "Option::is_none")] pub from: Option, /// Get a `from` address from the pool of signers specified here. + #[serde(skip_serializing_if = "Option::is_none")] pub from_pool: Option, } diff --git a/crates/core/src/generator/function_def.rs b/crates/core/src/generator/function_def.rs index fdb5391d..3cc307cd 100644 --- a/crates/core/src/generator/function_def.rs +++ b/crates/core/src/generator/function_def.rs @@ -13,28 +13,38 @@ pub struct FunctionCallDefinition { /// Address of the contract to call. pub to: String, /// Address of the tx sender. + #[serde(skip_serializing_if = "Option::is_none")] pub from: Option, /// Get a `from` address from the pool of signers specified here. + #[serde(skip_serializing_if = "Option::is_none")] pub from_pool: Option, /// Name of the function to call. + #[serde(skip_serializing_if = "Option::is_none")] pub signature: Option, /// Parameters to pass to the function. + #[serde(skip_serializing_if = "Option::is_none")] pub args: Option>, /// Value in wei to send with the tx. + #[serde(skip_serializing_if = "Option::is_none")] pub value: Option, /// Parameters to fuzz during the test. + #[serde(skip_serializing_if = "Option::is_none")] pub fuzz: Option>, /// Optional type of the spam transaction for categorization. + #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, /// Optional gas limit, which will skip gas estimation. This allows reverting txs to be sent. + #[serde(skip_serializing_if = "Option::is_none")] pub gas_limit: Option, /// Optional blob data; tx type must be set to EIP4844 by spammer + #[serde(skip_serializing_if = "Option::is_none")] pub blob_data: Option, /// Optional setCode data; tx type must be set to EIP7702 by spammer + #[serde(skip_serializing_if = "Option::is_none")] pub authorization_address: Option, /// If true and `from_pool` is set, run this setup transaction for all accounts in the pool. /// Defaults to false (only runs for the first account). - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub for_all_accounts: bool, } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index dc288fc5..ac12ae7f 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -27,3 +27,4 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } serde.workspace = true +serde_json.workspace = true diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 625f219c..80b3e9da 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -1,9 +1,16 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use contender_cli::default_scenarios::BuiltinScenarioCli; -use contender_core::test_scenario::Url; +use contender_cli::default_scenarios::{BuiltinOptions, BuiltinScenarioCli}; +use contender_core::{ + alloy::{ + network::AnyNetwork, + providers::{DynProvider, ProviderBuilder}, + }, + generator::RandSeed, + test_scenario::Url, +}; use contender_testfile::TestConfig; use jsonrpsee::{proc_macros::rpc, types::ErrorObjectOwned}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::{str::FromStr, sync::Arc}; use tokio::sync::RwLock; use tracing::{debug, info}; @@ -49,55 +56,72 @@ impl ContenderServer { pub struct AddSessionParams { pub name: String, pub rpc_url: Url, - /// Base64-encoded TOML test config. If omitted, the default uniV2 scenario is used. - pub test_config_toml_b64: Option, - /// JSON-encoded test config. If both this and the base64 version are provided, this takes precedence. - pub test_config: Option, - // TODO: support builtin scenarios - pub test_config_builtin: Option, + pub test_config: Option, } -impl AddSessionParams { - fn decode_test_config_toml_b64(&self) -> Result { - if let Some(b64) = &self.test_config_toml_b64 { - let bytes = BASE64.decode(b64)?; - debug!( - "Decoded test config from base64, length {} bytes", - bytes.len() - ); - let config_str = String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)?; - TestConfig::from_str(&config_str).map_err(ContenderRpcError::InvalidTestConfig) - } else { - Ok( - TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) - .expect("default config should be valid"), - ) - } - } +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum TestConfigSource { + TomlBase64(String), + Json(TestConfig), + Builtin(BuiltinScenarioCli), +} - fn decode_test_config_builtin(&self) -> Result { - if let Some(builtin) = &self.test_config_builtin { - // builtin.to_builtin_scenario(provider, spam_args, data_dir) - todo!() - } else { - Ok( - TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) - .expect("default config should be valid"), - ) +impl TestConfigSource { + pub async fn to_testconfig( + self, + builtin_options: Option, + provider: &DynProvider, + ) -> Result { + match self { + TestConfigSource::TomlBase64(b64) => { + let bytes = BASE64.decode(b64)?; + debug!( + "Decoded test config from base64, length {} bytes", + bytes.len() + ); + let config_str = + String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)?; + TestConfig::from_str(&config_str).map_err(ContenderRpcError::InvalidTestConfig) + } + + TestConfigSource::Json(config) => Ok(config), + + TestConfigSource::Builtin(builtin) => { + let scenario = builtin + .to_builtin_scenario(provider, builtin_options.unwrap_or_default()) + .await + .unwrap() + .into(); + Ok(scenario) + } } } +} - pub fn to_new_session_params(self) -> Result { - if self.test_config.is_some() && self.test_config_toml_b64.is_some() { - debug!("Both test_config and test_config_b64 provided, returning error"); - return Err(ContenderRpcError::InvalidArguments( - "Cannot provide both test_config and test_config_b64".into(), - )); - } +impl AddSessionParams { + pub async fn to_new_session_params( + self, + seed: RandSeed, + ) -> Result { let test_config = if let Some(config) = self.test_config { + let provider = DynProvider::new( + ProviderBuilder::new() + .network::() + .connect_http(self.rpc_url.clone()), + ); config + .to_testconfig( + Some(BuiltinOptions { + accounts_per_agent: None, + seed, + spam_rate: None, + }), + &provider, + ) + .await? } else { - self.decode_test_config_toml_b64()? + TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) + .expect("default config should be valid") }; Ok(NewSessionParams { @@ -121,7 +145,8 @@ impl ContenderRpcServer for ContenderServer { ) -> jsonrpsee::core::RpcResult { let mut sessions = self.sessions.write().await; - let session = sessions.add_session(params.to_new_session_params()?); + let session_seed = RandSeed::seed_from_bytes(&sessions.num_sessions().to_be_bytes()); + let session = sessions.add_session(params.to_new_session_params(session_seed).await?); let info = session.info.clone(); info!( @@ -152,3 +177,53 @@ impl ContenderRpcServer for ContenderServer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64; + use contender_cli::default_scenarios::transfers::TransferStressCliArgs; + use contender_core::alloy::{ + consensus::constants::ETH_TO_WEI, + primitives::{Address, U256}, + }; + + #[test] + fn test_toml_base64_variant() { + let toml_content = include_str!("../../../scenarios/uniV2.toml"); + let b64 = BASE64.encode(toml_content); + let json = serde_json::json!({ "TomlBase64": b64 }); + + // println!( + // "TomlBase64:\n{}\n", + // serde_json::to_string_pretty(&json).unwrap() + // ); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::TomlBase64(_))); + } + + #[test] + fn test_json_variant() { + let config = TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")).unwrap(); + let json = serde_json::json!({ "Json": config }); + // println!("Json:\n{}\n", serde_json::to_string_pretty(&json).unwrap()); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::Json(_))); + } + + #[tokio::test] + async fn test_builtin_variant() { + let builtin = + TestConfigSource::Builtin(BuiltinScenarioCli::Transfers(TransferStressCliArgs { + amount: U256::from(ETH_TO_WEI), + recipient: Some(Address::ZERO), + })); + let json = serde_json::json!(builtin); + println!("{}", serde_json::to_string_pretty(&json).unwrap()); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::Builtin(_))); + } +} diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 6e4f6676..b7348efe 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -62,6 +62,10 @@ impl ContenderSessionCache { } } + pub fn next_session_id(&self) -> usize { + self.sessions.len() + } + /// Add a new session to the cache. The ID is simply the index of the session in the vector. /// The session is not initialized yet, the caller is responsible for calling initialize on the session's contender before it's returned by the RPC provider. /// From ed0ad69c6e2bf26d1e3bd0fbf4268aebffb050af Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:19:46 -0700 Subject: [PATCH 08/34] initialize scenarios in bg, return from RPC calls quickly --- crates/server/src/rpc.rs | 59 ++++++++++++++++++++++++++--------- crates/server/src/sessions.rs | 39 +++++++++++++++++++++-- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 80b3e9da..16c6689d 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -9,7 +9,7 @@ use contender_core::{ test_scenario::Url, }; use contender_testfile::TestConfig; -use jsonrpsee::{proc_macros::rpc, types::ErrorObjectOwned}; +use jsonrpsee::proc_macros::rpc; use serde::{Deserialize, Serialize}; use std::{str::FromStr, sync::Arc}; use tokio::sync::RwLock; @@ -17,7 +17,7 @@ use tracing::{debug, info}; use crate::{ error::ContenderRpcError, - sessions::{ContenderSessionCache, ContenderSessionInfo, NewSessionParams}, + sessions::{ContenderSessionCache, ContenderSessionInfo, NewSessionParams, SessionStatus}, }; #[rpc(server)] @@ -143,23 +143,54 @@ impl ContenderRpcServer for ContenderServer { &self, params: AddSessionParams, ) -> jsonrpsee::core::RpcResult { - let mut sessions = self.sessions.write().await; + let session_seed; + let info; + { + let mut sessions = self.sessions.write().await; + session_seed = RandSeed::seed_from_bytes(&sessions.num_sessions().to_be_bytes()); + let session = sessions.add_session(params.to_new_session_params(session_seed).await?); + info = session.info.clone(); + } - let session_seed = RandSeed::seed_from_bytes(&sessions.num_sessions().to_be_bytes()); - let session = sessions.add_session(params.to_new_session_params(session_seed).await?); - let info = session.info.clone(); + let session_id = info.id; + let sessions = Arc::clone(&self.sessions); info!( - "Initializing session {} with RPC URL {}", + "Spawning initialization for session {} with RPC URL {}", info.name, info.rpc_url ); - session - .contender - .initialize() - .await - .map_err(ContenderRpcError::SessionInitializationFailed) - .map_err(ErrorObjectOwned::from)?; - info!("Session {} initialized successfully", info.name); + + tokio::spawn(async move { + // Take the contender out so we can initialize without holding the lock. + let contender = { + let mut lock = sessions.write().await; + lock.take_contender(session_id) + }; + + let Some(mut contender) = contender else { + return; + }; + + let result = contender.initialize().await; + + // Put the contender back and update status. + let mut lock = sessions.write().await; + lock.put_contender(session_id, contender); + if let Some(session) = lock.get_session_mut(session_id) { + match result { + Ok(()) => { + session.info.status = SessionStatus::Ready; + info!("Session {} initialized successfully", session_id); + } + Err(e) => { + let msg = e.to_string(); + session.info.status = SessionStatus::Failed(msg.clone()); + tracing::error!("Session {} initialization failed: {}", session_id, msg); + } + } + } + }); + Ok(info) } diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index b7348efe..ba9eeb98 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -3,9 +3,16 @@ use contender_sqlite::SqliteDb; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum SessionStatus { + Initializing, + Ready, + Failed(String), +} + pub struct ContenderSession { pub info: ContenderSessionInfo, - pub contender: Contender, + pub contender: Option>, } pub struct NewSessionParams { @@ -21,10 +28,14 @@ impl ContenderSession { id: sessions.len(), name: params.name, rpc_url: params.rpc_url, + status: SessionStatus::Initializing, }; let contender = info.create_contender(params.test_config); - Self { info, contender } + Self { + info, + contender: Some(contender), + } } } @@ -33,6 +44,7 @@ pub struct ContenderSessionInfo { pub id: usize, pub name: String, pub rpc_url: Url, + pub status: SessionStatus, } impl ContenderSessionInfo { @@ -83,6 +95,29 @@ impl ContenderSessionCache { self.sessions.iter().find(|s| s.info.id == id) } + pub fn get_session_mut(&mut self, id: usize) -> Option<&mut ContenderSession> { + self.sessions.iter_mut().find(|s| s.info.id == id) + } + + /// Take the Contender out of a session so it can be used outside the lock. + pub fn take_contender( + &mut self, + id: usize, + ) -> Option> { + self.get_session_mut(id).and_then(|s| s.contender.take()) + } + + /// Put the Contender back into a session after initialization. + pub fn put_contender( + &mut self, + id: usize, + contender: Contender, + ) { + if let Some(session) = self.get_session_mut(id) { + session.contender = Some(contender); + } + } + pub fn remove_session(&mut self, id: usize) { self.sessions.retain(|s| s.info.id != id); } From 8c12a2b1dff4f513b594f4940a6ab7fefb9a4e38 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Thu, 19 Mar 2026 22:35:22 -0700 Subject: [PATCH 09/34] deserialize eth values w/ serde (as well) --- crates/cli/src/default_scenarios/erc20.rs | 5 ++++- crates/cli/src/default_scenarios/transfers.rs | 9 ++++++-- crates/cli/src/default_scenarios/uni_v2.rs | 6 ++++- crates/core/src/generator/util.rs | 22 +++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/default_scenarios/erc20.rs b/crates/cli/src/default_scenarios/erc20.rs index 0ba17393..2f1f691d 100644 --- a/crates/cli/src/default_scenarios/erc20.rs +++ b/crates/cli/src/default_scenarios/erc20.rs @@ -1,6 +1,7 @@ use alloy::primitives::{Address, U256}; use contender_core::generator::{ - types::SpamRequest, util::parse_value, CreateDefinition, FunctionCallDefinition, FuzzParam, + types::SpamRequest, util::parse_value, util::deserialize_value, CreateDefinition, + FunctionCallDefinition, FuzzParam, }; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; @@ -20,6 +21,7 @@ pub struct Erc20CliArgs { default_value = DEFAULT_TOKENS_SENT, value_parser = parse_value, )] + #[serde(deserialize_with = "deserialize_value")] pub send_amount: U256, #[arg( @@ -29,6 +31,7 @@ pub struct Erc20CliArgs { default_value = DEFAULT_TOKENS_FUNDED, value_parser = parse_value, )] + #[serde(deserialize_with = "deserialize_value")] pub fund_amount: U256, #[arg( diff --git a/crates/cli/src/default_scenarios/transfers.rs b/crates/cli/src/default_scenarios/transfers.rs index 3478c72a..dd6eacaa 100644 --- a/crates/cli/src/default_scenarios/transfers.rs +++ b/crates/cli/src/default_scenarios/transfers.rs @@ -1,7 +1,11 @@ use crate::default_scenarios::builtin::ToTestConfig; use alloy::primitives::{Address, U256}; use clap::Parser; -use contender_core::generator::{types::SpamRequest, util::parse_value, FunctionCallDefinition}; +use contender_core::generator::{ + types::SpamRequest, + util::{deserialize_value, parse_value}, + FunctionCallDefinition, +}; use serde::{Deserialize, Serialize}; #[derive(Parser, Clone, Debug, Deserialize, Serialize)] @@ -14,13 +18,14 @@ pub struct TransferStressCliArgs { value_parser = parse_value, help = "Amount of tokens to transfer in each transaction." )] + #[serde(deserialize_with = "deserialize_value")] pub amount: U256, + #[arg( short, long = "transfer.recipient", visible_aliases = ["tr", "recipient"], help = "Address to receive ether sent from spammers.", - value_parser = |s: &str| s.parse::
().map_err(|_| "Invalid address format".to_string()) )] pub recipient: Option
, } diff --git a/crates/cli/src/default_scenarios/uni_v2.rs b/crates/cli/src/default_scenarios/uni_v2.rs index d8412a1e..3332c17d 100644 --- a/crates/cli/src/default_scenarios/uni_v2.rs +++ b/crates/cli/src/default_scenarios/uni_v2.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use crate::default_scenarios::{builtin::ToTestConfig, contracts::test_token}; use alloy::primitives::U256; use clap::Parser; -use contender_core::generator::util::parse_value; +use contender_core::generator::util::{deserialize_value, deserialize_value_opt, parse_value}; use contender_core::generator::{ types::SpamRequest, CompiledContract, CreateDefinition, FunctionCallDefinition, }; @@ -32,6 +32,7 @@ pub struct UniV2CliArgs { value_parser = parse_value, visible_aliases = ["weth"] )] + #[serde(deserialize_with = "deserialize_value")] pub weth_per_token: U256, #[arg( @@ -43,6 +44,7 @@ pub struct UniV2CliArgs { visible_aliases = ["mint"], value_name = "TOKEN_AMOUNT" )] + #[serde(deserialize_with = "deserialize_value")] pub initial_token_supply: U256, #[arg( @@ -52,6 +54,7 @@ pub struct UniV2CliArgs { value_name = "WETH_AMOUNT", visible_aliases = ["trade-weth"] )] + #[serde(deserialize_with = "deserialize_value_opt")] pub weth_trade_amount: Option, #[arg( @@ -61,6 +64,7 @@ pub struct UniV2CliArgs { value_name = "TOKEN_AMOUNT", visible_aliases = ["trade-token"] )] + #[serde(deserialize_with = "deserialize_value_opt")] pub token_trade_amount: Option, } diff --git a/crates/core/src/generator/util.rs b/crates/core/src/generator/util.rs index f2d2b9ea..a8fa54a3 100644 --- a/crates/core/src/generator/util.rs +++ b/crates/core/src/generator/util.rs @@ -173,6 +173,28 @@ pub fn generate_setcode_signer(seed: &impl Seeder) -> (PrivateKeySigner, [u8; 32 ) } +/// Serde deserializer that parses a `U256` using [`parse_value`], +/// supporting both raw numbers and human-readable strings like `"0.00001 ether"`. +pub fn deserialize_value<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s: String = serde::Deserialize::deserialize(deserializer)?; + parse_value(&s).map_err(serde::de::Error::custom) +} + +/// Like [`deserialize_value`] but for `Option`. Returns `None` if the field is absent or null. +pub fn deserialize_value_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: Option = serde::Deserialize::deserialize(deserializer)?; + match s { + Some(s) => parse_value(&s).map(Some).map_err(serde::de::Error::custom), + None => Ok(None), + } +} + /// Parses a string like "1eth" or "20 gwei" into a U256. pub fn parse_value(input: &str) -> Result { let input = input.trim().to_lowercase(); From 68e228dfa265817b49ffea3d9d7733844991281a Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:26:08 -0700 Subject: [PATCH 10/34] subscribe to session logs over websocket --- Cargo.lock | 48 +++++++- crates/core/src/generator/trait.rs | 2 +- crates/core/src/lib.rs | 18 +++ crates/core/src/spammer/spammer_trait.rs | 48 ++++---- crates/core/src/spammer/tx_actor.rs | 6 +- crates/core/src/spammer/tx_callback.rs | 4 +- crates/core/src/test_scenario.rs | 10 +- crates/server/Cargo.toml | 6 +- crates/server/src/lib.rs | 1 + crates/server/src/log_client.rs | 52 +++++++++ crates/server/src/log_layer.rs | 135 +++++++++++++++++++++++ crates/server/src/main.rs | 30 +++-- crates/server/src/rpc.rs | 108 +++++++++++++----- crates/server/src/sessions.rs | 20 +++- 14 files changed, 409 insertions(+), 79 deletions(-) create mode 100644 crates/server/src/log_client.rs create mode 100644 crates/server/src/log_layer.rs diff --git a/Cargo.lock b/Cargo.lock index 31b04053..1eb5513a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4191,6 +4191,7 @@ dependencies = [ "jsonrpsee-proc-macros 0.24.10", "jsonrpsee-server 0.24.10", "jsonrpsee-types 0.24.10", + "jsonrpsee-ws-client 0.24.10", "tokio", "tracing", ] @@ -4201,18 +4202,41 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3f48dc3e6b8bd21e15436c1ddd0bc22a6a54e8ec46fedd6adf3425f396ec6a" dependencies = [ - "jsonrpsee-client-transport", + "jsonrpsee-client-transport 0.26.0", "jsonrpsee-core 0.26.0", "jsonrpsee-http-client", "jsonrpsee-proc-macros 0.26.0", "jsonrpsee-server 0.26.0", "jsonrpsee-types 0.26.0", "jsonrpsee-wasm-client", - "jsonrpsee-ws-client", + "jsonrpsee-ws-client 0.26.0", "tokio", "tracing", ] +[[package]] +name = "jsonrpsee-client-transport" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4280b709ac3bb5e16cf3bad5056a0ec8df55fa89edfe996361219aadc2c7ea" +dependencies = [ + "base64 0.22.1", + "futures-util", + "http", + "jsonrpsee-core 0.24.10", + "pin-project", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "url", +] + [[package]] name = "jsonrpsee-client-transport" version = "0.26.0" @@ -4246,18 +4270,21 @@ checksum = "348ee569eaed52926b5e740aae20863762b16596476e943c9e415a6479021622" dependencies = [ "async-trait", "bytes", + "futures-timer", "futures-util", "http", "http-body", "http-body-util", "jsonrpsee-types 0.24.10", "parking_lot", + "pin-project", "rand 0.8.5", "rustc-hash", "serde", "serde_json", "thiserror 1.0.69", "tokio", + "tokio-stream", "tracing", ] @@ -4422,12 +4449,25 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7902885de4779f711a95d82c8da2d7e5f9f3a7c7cfa44d51c067fd1c29d72a3c" dependencies = [ - "jsonrpsee-client-transport", + "jsonrpsee-client-transport 0.26.0", "jsonrpsee-core 0.26.0", "jsonrpsee-types 0.26.0", "tower 0.5.2", ] +[[package]] +name = "jsonrpsee-ws-client" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78fc744f17e7926d57f478cf9ca6e1ee5d8332bf0514860b1a3cdf1742e614cc" +dependencies = [ + "http", + "jsonrpsee-client-transport 0.24.10", + "jsonrpsee-core 0.24.10", + "jsonrpsee-types 0.24.10", + "url", +] + [[package]] name = "jsonrpsee-ws-client" version = "0.26.0" @@ -4435,7 +4475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6fceceeb05301cc4c065ab3bd2fa990d41ff4eb44e4ca1b30fa99c057c3e79" dependencies = [ "http", - "jsonrpsee-client-transport", + "jsonrpsee-client-transport 0.26.0", "jsonrpsee-core 0.26.0", "jsonrpsee-types 0.26.0", "tower 0.5.2", diff --git a/crates/core/src/generator/trait.rs b/crates/core/src/generator/trait.rs index 99435c05..5b5c647d 100644 --- a/crates/core/src/generator/trait.rs +++ b/crates/core/src/generator/trait.rs @@ -478,7 +478,7 @@ where if let Some(handle) = handle { // Wait for sender's previous task, then run this one let prev_handle = pending_per_sender.remove(&from); - let chained = tokio::task::spawn(async move { + let chained = crate::spawn_with_session(async move { if let Some(prev) = prev_handle { // Ignore errors from previous task - they'll be reported separately let _ = prev.await; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 833d3920..95ecf249 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,3 +19,21 @@ pub use contender_bundle_provider::bundle::BundleType; pub use orchestrator::{Contender, ContenderCtx, RunOpts}; pub use tokio::task as tokio_task; pub use tokio_util::sync::CancellationToken; + +tokio::task_local! { + /// The session ID for the current task, used by the server's log routing layer + /// to route tracing events to the correct per-session broadcast channel. + pub static CURRENT_SESSION_ID: usize; +} + +/// Spawn a future that inherits the current `CURRENT_SESSION_ID` task-local (if set). +pub fn spawn_with_session(future: F) -> tokio::task::JoinHandle +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + match CURRENT_SESSION_ID.try_with(|id| *id) { + Ok(id) => tokio::task::spawn(CURRENT_SESSION_ID.scope(id, future)), + Err(_) => tokio::task::spawn(future), + } +} diff --git a/crates/core/src/spammer/spammer_trait.rs b/crates/core/src/spammer/spammer_trait.rs index 941c6055..c82f5d2e 100644 --- a/crates/core/src/spammer/spammer_trait.rs +++ b/crates/core/src/spammer/spammer_trait.rs @@ -71,31 +71,33 @@ where let auth_provider = scenario.auth_provider.clone(); // run loop in background to call fcu when spamming is done - let fcu_handle: tokio::task::JoinHandle> = tokio::task::spawn(async move { - if let Some(auth_client) = &auth_provider { - loop { - let fcu_done = is_fcu_done.load(std::sync::atomic::Ordering::SeqCst); - let sending_done = - is_sending_done.load(std::sync::atomic::Ordering::SeqCst); - if fcu_done { - info!("FCU is done, stopping block production..."); - break; - } - if sending_done { - auth_client - .advance_chain(DEFAULT_BLOCK_TIME) - .await - .map_err(|e| { - is_fcu_done.store(true, std::sync::atomic::Ordering::SeqCst); - CallbackError::AuthProvider(e) - })?; - } else { - tokio::time::sleep(Duration::from_secs(1)).await; + let fcu_handle: tokio::task::JoinHandle> = + crate::spawn_with_session(async move { + if let Some(auth_client) = &auth_provider { + loop { + let fcu_done = is_fcu_done.load(std::sync::atomic::Ordering::SeqCst); + let sending_done = + is_sending_done.load(std::sync::atomic::Ordering::SeqCst); + if fcu_done { + info!("FCU is done, stopping block production..."); + break; + } + if sending_done { + auth_client + .advance_chain(DEFAULT_BLOCK_TIME) + .await + .map_err(|e| { + is_fcu_done + .store(true, std::sync::atomic::Ordering::SeqCst); + CallbackError::AuthProvider(e) + })?; + } else { + tokio::time::sleep(Duration::from_secs(1)).await; + } } } - } - Ok(()) - }); + Ok(()) + }); let tx_req_chunks = scenario .get_spam_tx_chunks(txs_per_period, num_periods) diff --git a/crates/core/src/spammer/tx_actor.rs b/crates/core/src/spammer/tx_actor.rs index e33f121c..f785f754 100644 --- a/crates/core/src/spammer/tx_actor.rs +++ b/crates/core/src/spammer/tx_actor.rs @@ -596,20 +596,20 @@ impl TxActorHandle { let mut actor = TxActor::new(receiver, flush_receiver, fb_receiver, db.clone()); // Spawn the message handler task (owns the cache) - tokio::task::spawn(async move { + crate::spawn_with_session(async move { if let Err(e) = actor.run().await { error!("TxActor message handler terminated with error: {:?}", e); } }); // Spawn the independent flush task (communicates via channels) - tokio::task::spawn(async move { + crate::spawn_with_session(async move { flush_loop(flush_sender, db, rpc).await; }); // Spawn the flashblocks listener task if URL is provided if let Some(ws_url) = flashblocks_ws_url { - tokio::task::spawn(async move { + crate::spawn_with_session(async move { if let Err(e) = FlashblocksClient::listen(&ws_url, fb_sender, cancel_token).await { error!("{}", e); } diff --git a/crates/core/src/spammer/tx_callback.rs b/crates/core/src/spammer/tx_callback.rs index fc077272..de5a2cb7 100644 --- a/crates/core/src/spammer/tx_callback.rs +++ b/crates/core/src/spammer/tx_callback.rs @@ -136,7 +136,7 @@ impl OnTxSent for LogCallback { tx_actors: Option>>, ) -> Option>> { let cancel_token = self.cancel_token.clone(); - let handle = tokio::task::spawn(async move { + let handle = crate::spawn_with_session(async move { if let Some(tx_actors) = tx_actors { let tx_actor = tx_actors["default"].clone(); let tx = CacheTx { @@ -165,7 +165,7 @@ impl OnBatchSent for LogCallback { } if let Some(provider) = &self.auth_provider { let provider = provider.clone(); - return Some(tokio::task::spawn(async move { + return Some(crate::spawn_with_session(async move { provider .advance_chain(DEFAULT_BLOCK_TIME) .await diff --git a/crates/core/src/test_scenario.rs b/crates/core/src/test_scenario.rs index e866e96d..c8445f66 100644 --- a/crates/core/src/test_scenario.rs +++ b/crates/core/src/test_scenario.rs @@ -558,7 +558,7 @@ where let http_client = self.http_client.clone(); let scenario_label = self.scenario_label.clone(); - let handle = tokio::task::spawn(async move { + let handle = crate::spawn_with_session(async move { Self::deploy_contract(DeployContractParams { db: &db, tx_req: &tx_req, @@ -717,7 +717,7 @@ where let http_client = self.http_client.clone(); let sem = semaphore.clone(); - let handle = tokio::task::spawn(async move { + let handle = crate::spawn_with_session(async move { let _permit = sem.acquire().await.expect("semaphore closed"); let transport = Http::with_client(http_client, rpc_url.to_owned()); let rpc_client = ClientBuilder::default().transport(transport, false); @@ -1030,7 +1030,7 @@ where let cancel_token = self.ctx.cancel_token.clone(); let error_sender = error_sender.clone(); - tasks.push(tokio::task::spawn(async move { + tasks.push(crate::spawn_with_session(async move { let extra = RuntimeTxInfo::now(); let handles = match payload { ExecutionPayload::SignedTx(signed_tx, req) => { @@ -1190,7 +1190,7 @@ where .collect(); let hist = self.prometheus.hist.get(); - tasks.push(tokio::task::spawn(async move { + tasks.push(crate::spawn_with_session(async move { // Build json-rpc batch payload with multiple eth_sendRawTransaction requests let mut requests = Vec::with_capacity(signed_chunk.len()); for (i, (signed_tx, _)) in signed_chunk.iter().enumerate() { @@ -1772,7 +1772,7 @@ async fn sync_nonces( for addr in all_addrs { let send = sender.clone(); let rpc_client = Arc::new(rpc_client.clone()); - tasks.push(tokio::task::spawn(async move { + tasks.push(crate::spawn_with_session(async move { let nonce = rpc_client.get_transaction_count(addr).await?; send.send((addr, nonce)) .await diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ac12ae7f..0598f815 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -13,6 +13,10 @@ description = "Contender server" name = "contender-server" path = "src/main.rs" +[[bin]] +name = "contender-log-client" +path = "src/log_client.rs" + [dependencies] base64.workspace = true contender_core.workspace = true @@ -21,7 +25,7 @@ contender_sqlite.workspace = true contender_cli.workspace = true async-trait = { workspace = true } -jsonrpsee = { workspace = true, features = ["server", "macros"] } +jsonrpsee = { workspace = true, features = ["server", "macros", "ws-client"] } thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index be62c063..852efe30 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,3 +1,4 @@ pub mod error; +pub mod log_layer; pub mod rpc; pub mod sessions; diff --git a/crates/server/src/log_client.rs b/crates/server/src/log_client.rs new file mode 100644 index 00000000..cefdc7fd --- /dev/null +++ b/crates/server/src/log_client.rs @@ -0,0 +1,52 @@ +//! Simple test client that subscribes to session logs via JSON-RPC over websocket. +//! +//! Usage: +//! contender-log-client [ws_url] +//! +//! Examples: +//! contender-log-client 0 +//! contender-log-client 2 ws://127.0.0.1:3000 + +use jsonrpsee::core::client::SubscriptionClientT; +use jsonrpsee::rpc_params; +use jsonrpsee::ws_client::WsClientBuilder; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let session_id: usize = args + .get(1) + .expect("Usage: contender-log-client [ws_url]") + .parse() + .expect("session_id must be a number"); + let url = args + .get(2) + .map(|s| s.as_str()) + .unwrap_or("ws://127.0.0.1:3000"); + + eprintln!("Connecting to {url}, subscribing to session {session_id}..."); + + let client = WsClientBuilder::default().build(url).await?; + + let mut sub = client + .subscribe::( + "subscribe_logs", + rpc_params![session_id], + "unsubscribe_logs", + ) + .await?; + + eprintln!("Subscribed. Waiting for logs...\n"); + + while let Some(msg) = sub.next().await { + match msg { + Ok(line) => println!("{line}"), + Err(e) => { + eprintln!("Subscription error: {e}"); + break; + } + } + } + + Ok(()) +} diff --git a/crates/server/src/log_layer.rs b/crates/server/src/log_layer.rs new file mode 100644 index 00000000..f28466bf --- /dev/null +++ b/crates/server/src/log_layer.rs @@ -0,0 +1,135 @@ +use std::{collections::HashMap, fmt, sync::Arc}; + +use tokio::sync::{broadcast, RwLock}; +use tracing::{field::Visit, Event, Subscriber}; +use tracing_subscriber::{fmt::time::FormatTime, layer::Context, registry::LookupSpan, Layer}; + +/// A shared registry mapping session IDs to broadcast senders. +/// The `SessionLogRouter` layer uses this to route log events to the correct session stream. +pub type SessionLogSinks = Arc>>>; + +/// Creates a new empty sink map. +pub fn new_log_sinks() -> SessionLogSinks { + Arc::new(RwLock::new(HashMap::new())) +} + +/// A `tracing` layer that inspects span context for a `session` span with an `id` field, +/// and routes formatted log events to the corresponding broadcast channel. +pub struct SessionLogRouter { + sinks: SessionLogSinks, +} + +impl SessionLogRouter { + pub fn new(sinks: SessionLogSinks) -> Self { + Self { sinks } + } +} + +impl Layer for SessionLogRouter +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + // Walk the span scope to find a span named "session" or "session_init" with an "id" field. + let session_id = ctx + .event_scope(event) + .and_then(|scope| { + for span in scope { + let name = span.name(); + if name.starts_with("session") { + let extensions = span.extensions(); + if let Some(fields) = extensions.get::() { + return Some(fields.id); + } + } + } + None + }) + .or_else(|| { + // Fall back to the task-local session ID (set by spawn_with_session). + contender_core::CURRENT_SESSION_ID.try_with(|id| *id).ok() + }); + + let Some(session_id) = session_id else { + return; + }; + + // Format the event. + let formatted = format_event(event); + + // Try to send non-blocking (don't await the RwLock — use try_read). + if let Ok(sinks) = self.sinks.try_read() { + if let Some(tx) = sinks.get(&session_id) { + let _ = tx.send(formatted); + } + } + } + + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::span::Id, + ctx: Context<'_, S>, + ) { + // When a span named "session*" is created, extract the `id` field and store it. + let span = ctx.span(id).expect("span not found"); + if span.name().starts_with("session") { + let mut fields = SessionSpanFields { id: 0 }; + attrs.record(&mut fields); + span.extensions_mut().insert(fields); + } + } +} + +/// Stored in span extensions to carry the session ID. +struct SessionSpanFields { + id: usize, +} + +impl Visit for SessionSpanFields { + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + if field.name() == "id" { + self.id = value as usize; + } + } + + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { + if field.name() == "id" { + self.id = value as usize; + } + } + + fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn fmt::Debug) {} +} + +fn format_event(event: &Event<'_>) -> String { + let metadata = event.metadata(); + let mut visitor = MessageVisitor { + message: String::new(), + }; + event.record(&mut visitor); + + let mut timestamp = String::new(); + let _ = tracing_subscriber::fmt::time::SystemTime.format_time( + &mut tracing_subscriber::fmt::format::Writer::new(&mut timestamp), + ); + + format!("{} {}: {}", timestamp, metadata.level(), visitor.message) +} + +struct MessageVisitor { + message: String, +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } else if !self.message.is_empty() { + self.message + .push_str(&format!(" {}={:?}", field.name(), value)); + } else { + self.message = format!("{}={:?}", field.name(), value); + } + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index c7174ef9..62275e17 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,12 +1,12 @@ use std::sync::Arc; -use contender_core::util::TracingOptions; +use contender_server::log_layer::{new_log_sinks, SessionLogRouter}; use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; use contender_server::sessions::ContenderSessionCache; use jsonrpsee::server::{Server, ServerHandle}; use tokio::sync::RwLock; use tracing::info; -use tracing_subscriber::EnvFilter; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; async fn start_rpc_server( sessions: Arc>, @@ -22,9 +22,11 @@ async fn start_rpc_server( #[tokio::main] async fn main() -> Result<(), Box> { - init_tracing(); + let log_sinks = new_log_sinks(); - let sessions = Arc::new(RwLock::new(ContenderSessionCache::new())); + init_tracing(log_sinks.clone()); + + let sessions = Arc::new(RwLock::new(ContenderSessionCache::new(log_sinks))); let handle = start_rpc_server(sessions).await?; @@ -40,9 +42,19 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn init_tracing() { - let filter = EnvFilter::try_from_default_env().ok(); // fallback if RUST_LOG is unset - let mut opts = TracingOptions::default(); - opts = opts.with_line_number(true).with_target(true); - contender_core::util::init_core_tracing(filter, opts); +fn init_tracing(log_sinks: contender_server::log_layer::SessionLogSinks) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(true) + .with_target(true) + .with_line_number(true); + + let session_layer = SessionLogRouter::new(log_sinks); + + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(session_layer) + .init(); } diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 16c6689d..03e444d5 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -9,11 +9,11 @@ use contender_core::{ test_scenario::Url, }; use contender_testfile::TestConfig; -use jsonrpsee::proc_macros::rpc; +use jsonrpsee::{proc_macros::rpc, PendingSubscriptionSink, SubscriptionMessage}; use serde::{Deserialize, Serialize}; use std::{str::FromStr, sync::Arc}; use tokio::sync::RwLock; -use tracing::{debug, info}; +use tracing::{debug, info, Instrument}; use crate::{ error::ContenderRpcError, @@ -39,6 +39,9 @@ pub trait ContenderRpc { #[method(name = "remove_session")] async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; + + #[subscription(name = "subscribe_logs" => "session_log", item = String)] + async fn subscribe_logs(&self, session_id: usize) -> jsonrpsee::core::SubscriptionResult; } pub struct ContenderServer { @@ -160,36 +163,47 @@ impl ContenderRpcServer for ContenderServer { info.name, info.rpc_url ); - tokio::spawn(async move { - // Take the contender out so we can initialize without holding the lock. - let contender = { - let mut lock = sessions.write().await; - lock.take_contender(session_id) - }; - - let Some(mut contender) = contender else { - return; - }; - - let result = contender.initialize().await; - - // Put the contender back and update status. - let mut lock = sessions.write().await; - lock.put_contender(session_id, contender); - if let Some(session) = lock.get_session_mut(session_id) { - match result { - Ok(()) => { - session.info.status = SessionStatus::Ready; - info!("Session {} initialized successfully", session_id); - } - Err(e) => { - let msg = e.to_string(); - session.info.status = SessionStatus::Failed(msg.clone()); - tracing::error!("Session {} initialization failed: {}", session_id, msg); + let span = tracing::info_span!("session_init", id = session_id); + tokio::spawn( + contender_core::CURRENT_SESSION_ID.scope( + session_id, + async move { + // Take the contender out so we can initialize without holding the lock. + let contender = { + let mut lock = sessions.write().await; + lock.take_contender(session_id) + }; + + let Some(mut contender) = contender else { + return; + }; + + let result = contender.initialize().await; + + // Put the contender back and update status. + let mut lock = sessions.write().await; + lock.put_contender(session_id, contender); + if let Some(session) = lock.get_session_mut(session_id) { + match result { + Ok(()) => { + session.info.status = SessionStatus::Ready; + info!("Session {} initialized successfully", session_id); + } + Err(e) => { + let msg = e.to_string(); + session.info.status = SessionStatus::Failed(msg.clone()); + tracing::error!( + "Session {} initialization failed: {}", + session_id, + msg + ); + } + } } } - } - }); + .instrument(span), + ), + ); Ok(info) } @@ -207,6 +221,40 @@ impl ContenderRpcServer for ContenderServer { sessions.remove_session(id); Ok(()) } + + async fn subscribe_logs( + &self, + pending: PendingSubscriptionSink, + session_id: usize, + ) -> jsonrpsee::core::SubscriptionResult { + let sessions = self.sessions.read().await; // TODO: replace self.sessions calls with wrappers to avoid accidental improper locking patterns + let Some(session) = sessions.get_session(session_id) else { + pending + .reject(jsonrpsee::types::ErrorObject::owned( + 5, + format!("Session {session_id} not found"), + None::<()>, + )) + .await; + return Ok(()); + }; + let mut rx = session.log_tx.subscribe(); + drop(sessions); + + let sink = pending.accept().await?; + + tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + let sub_msg = + SubscriptionMessage::from_json(&msg).expect("failed to serialize log message"); + if sink.send(sub_msg).await.is_err() { + break; + } + } + }); + + Ok(()) + } } #[cfg(test)] diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index ba9eeb98..315dfeb5 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -2,6 +2,9 @@ use contender_core::{generator::RandSeed, test_scenario::Url, Contender}; use contender_sqlite::SqliteDb; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; + +use crate::log_layer::SessionLogSinks; #[derive(Clone, Debug, Deserialize, Serialize)] pub enum SessionStatus { @@ -13,6 +16,7 @@ pub enum SessionStatus { pub struct ContenderSession { pub info: ContenderSessionInfo, pub contender: Option>, + pub log_tx: broadcast::Sender, } pub struct NewSessionParams { @@ -32,9 +36,11 @@ impl ContenderSession { }; let contender = info.create_contender(params.test_config); + let (log_tx, _) = broadcast::channel(256); Self { info, contender: Some(contender), + log_tx, } } } @@ -65,12 +71,14 @@ impl ContenderSessionInfo { pub struct ContenderSessionCache { sessions: Vec, + log_sinks: SessionLogSinks, } impl ContenderSessionCache { - pub fn new() -> Self { + pub fn new(log_sinks: SessionLogSinks) -> Self { Self { sessions: Vec::new(), + log_sinks, } } @@ -86,6 +94,12 @@ impl ContenderSessionCache { pub fn add_session(&mut self, params: NewSessionParams) -> &mut ContenderSession { let session = ContenderSession::new(&self.sessions, params); let info = session.info.clone(); + let log_tx = session.log_tx.clone(); + + // Register the broadcast sender in the log sinks so the tracing layer can route to it. + if let Ok(mut sinks) = self.log_sinks.try_write() { + sinks.insert(info.id, log_tx); + } self.sessions.push(session); &mut self.sessions[info.id] @@ -119,6 +133,10 @@ impl ContenderSessionCache { } pub fn remove_session(&mut self, id: usize) { + // Deregister the log sink. + if let Ok(mut sinks) = self.log_sinks.try_write() { + sinks.remove(&id); + } self.sessions.retain(|s| s.info.id != id); } From d4317b4bef7f61c63eae8a12b5c39fdd66539b51 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:47:28 -0700 Subject: [PATCH 11/34] serve logs over SSE as well as WS --- Cargo.lock | 77 +++++++++++++++++++++++++++++++++++++-- Cargo.toml | 4 ++ crates/server/Cargo.toml | 2 + crates/server/src/lib.rs | 1 + crates/server/src/main.rs | 12 +++++- crates/server/src/sse.rs | 52 ++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 crates/server/src/sse.rs diff --git a/Cargo.lock b/Cargo.lock index 1eb5513a..f9b180fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1492,14 +1492,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.5", "bytes", "futures-util", "http", "http-body", "http-body-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -1512,6 +1512,39 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core 0.5.6", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -1532,6 +1565,25 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "az" version = "1.2.1" @@ -2250,6 +2302,7 @@ name = "contender_server" version = "0.9.0" dependencies = [ "async-trait", + "axum 0.8.8", "base64 0.22.1", "contender_cli", "contender_core", @@ -2260,6 +2313,7 @@ dependencies = [ "serde_json", "thiserror 2.0.17", "tokio", + "tokio-stream", "tracing", "tracing-subscriber 0.3.20", ] @@ -4825,6 +4879,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" @@ -9746,6 +9806,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -10495,7 +10566,7 @@ checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" dependencies = [ "async-stream", "async-trait", - "axum", + "axum 0.7.9", "base64 0.22.1", "bytes", "h2", diff --git a/Cargo.toml b/Cargo.toml index 52f67730..32af9bbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ url = "2.5.7" uuid = "1.19.0" base64 = "0.22" +## server +axum = "0.8" +tokio-stream = "0.1" + ## core futures = "0.3.30" async-trait = "0.1.82" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0598f815..968b9e97 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -32,3 +32,5 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } serde.workspace = true serde_json.workspace = true +axum = { workspace = true } +tokio-stream.workspace = true diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 852efe30..5ec5a4ea 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2,3 +2,4 @@ pub mod error; pub mod log_layer; pub mod rpc; pub mod sessions; +pub mod sse; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 62275e17..24ccd4d7 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use contender_server::log_layer::{new_log_sinks, SessionLogRouter}; use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; use contender_server::sessions::ContenderSessionCache; +use contender_server::sse::sse_router; use jsonrpsee::server::{Server, ServerHandle}; use tokio::sync::RwLock; use tracing::info; @@ -28,7 +29,13 @@ async fn main() -> Result<(), Box> { let sessions = Arc::new(RwLock::new(ContenderSessionCache::new(log_sinks))); - let handle = start_rpc_server(sessions).await?; + let handle = start_rpc_server(sessions.clone()).await?; + + // SSE endpoint for log streaming (port 3001) + let sse_app = sse_router(sessions); + let sse_listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?; + info!("SSE server listening on 127.0.0.1:3001"); + let sse_handle = tokio::spawn(async move { axum::serve(sse_listener, sse_app).await }); tokio::select! { _ = tokio::signal::ctrl_c() => { @@ -37,6 +44,9 @@ async fn main() -> Result<(), Box> { _ = handle.stopped() => { info!("RPC server stopped"); } + res = sse_handle => { + info!("SSE server stopped: {:?}", res); + } } Ok(()) diff --git a/crates/server/src/sse.rs b/crates/server/src/sse.rs new file mode 100644 index 00000000..68f9af65 --- /dev/null +++ b/crates/server/src/sse.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + response::sse::{Event, Sse}, + routing::get, + Router, +}; +use tokio::sync::RwLock; +use tokio_stream::{wrappers::BroadcastStream, StreamExt}; +use tracing::warn; + +use crate::sessions::ContenderSessionCache; + +pub type SharedSessions = Arc>; + +/// Build an axum router that serves SSE log streams. +/// +/// `GET /logs/:session_id` — returns an SSE stream of log lines for the given session. +pub fn sse_router(sessions: SharedSessions) -> Router { + Router::new() + .route("/logs/{session_id}", get(logs_handler)) + .with_state(sessions) +} + +async fn logs_handler( + Path(session_id): Path, + State(sessions): State, +) -> Result< + Sse>>, + (axum::http::StatusCode, String), +> { + let sessions = sessions.read().await; + let session = sessions.get_session(session_id).ok_or_else(|| { + ( + axum::http::StatusCode::NOT_FOUND, + format!("Session {session_id} not found"), + ) + })?; + let rx = session.log_tx.subscribe(); + drop(sessions); + + let stream = BroadcastStream::new(rx).filter_map(|res| match res { + Ok(msg) => Some(Ok(Event::default().data(msg))), + Err(e) => { + warn!("SSE broadcast lag: {e}"); + None + } + }); + + Ok(Sse::new(stream)) +} From 88eea288502e855ae85a515c9687aa860b126283 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:23:13 -0700 Subject: [PATCH 12/34] cleanup main; consolidate code, set server addrs w/ env --- crates/server/src/config.rs | 35 ++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/main.rs | 72 +++++++++++++++++++------------------ 3 files changed, 73 insertions(+), 35 deletions(-) create mode 100644 crates/server/src/config.rs diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 00000000..015ec89f --- /dev/null +++ b/crates/server/src/config.rs @@ -0,0 +1,35 @@ +use crate::log_layer::{new_log_sinks, SessionLogRouter, SessionLogSinks}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +pub struct ServerConfig { + pub rpc_addr: String, + pub sse_addr: String, +} + +/// Load server configuration from environment variables, with defaults. +pub fn load_server_config() -> ServerConfig { + let rpc_addr = std::env::var("RPC_HOST").unwrap_or("127.0.0.1:3000".to_string()); + let sse_addr = std::env::var("SSE_HOST").unwrap_or("127.0.0.1:3001".to_string()); + ServerConfig { rpc_addr, sse_addr } +} + +/// Initialize tracing with a custom layer for routing logs to session-specific sinks. +pub fn init_tracing() -> SessionLogSinks { + let log_sinks = new_log_sinks(); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + let fmt_layer = tracing_subscriber::fmt::layer() + .with_ansi(true) + .with_target(true) + .with_line_number(true); + + let session_layer = SessionLogRouter::new(log_sinks.clone()); + + tracing_subscriber::registry() + .with(filter) + .with(fmt_layer) + .with(session_layer) + .init(); + + log_sinks +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 5ec5a4ea..81bdc6b6 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod error; pub mod log_layer; pub mod rpc; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 24ccd4d7..4de3ef1c 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,41 +1,30 @@ use std::sync::Arc; -use contender_server::log_layer::{new_log_sinks, SessionLogRouter}; +use contender_server::config::{init_tracing, load_server_config}; use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; use contender_server::sessions::ContenderSessionCache; use contender_server::sse::sse_router; use jsonrpsee::server::{Server, ServerHandle}; use tokio::sync::RwLock; +use tokio::task::JoinHandle; use tracing::info; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - -async fn start_rpc_server( - sessions: Arc>, -) -> std::io::Result { - let addr = "127.0.0.1:3000"; - let server = Server::builder().build(addr).await?; - let module = ContenderServer::new(sessions).into_rpc(); - let handle = server.start(module); - - info!("JSON-RPC server listening on {addr}"); - Ok(handle) -} #[tokio::main] async fn main() -> Result<(), Box> { - let log_sinks = new_log_sinks(); + // initialize logging w/ a custom layer that pipes logs to session-specific broadcast channels + let log_sinks = init_tracing(); - init_tracing(log_sinks.clone()); + // load server config + let config = load_server_config(); + // shared session cache let sessions = Arc::new(RwLock::new(ContenderSessionCache::new(log_sinks))); - let handle = start_rpc_server(sessions.clone()).await?; + // RPC server for session management and log subscription + let handle = start_rpc_server(sessions.clone(), &config.rpc_addr).await?; - // SSE endpoint for log streaming (port 3001) - let sse_app = sse_router(sessions); - let sse_listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?; - info!("SSE server listening on 127.0.0.1:3001"); - let sse_handle = tokio::spawn(async move { axum::serve(sse_listener, sse_app).await }); + // SSE endpoint for log streaming + let sse_handle = start_sse_server(sessions, &config.sse_addr).await?; tokio::select! { _ = tokio::signal::ctrl_c() => { @@ -52,19 +41,32 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn init_tracing(log_sinks: contender_server::log_layer::SessionLogSinks) { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - - let fmt_layer = tracing_subscriber::fmt::layer() - .with_ansi(true) - .with_target(true) - .with_line_number(true); +/// Starts a JSON-RPC HTTP server for managing contender sessions, +/// which includes a websocket server for subscribing to session logs. +/// +/// Returns a handle to the RPC server; awaiting `.stopped()` on this handle will wait until the server shuts down. +async fn start_rpc_server( + sessions: Arc>, + addr: &str, +) -> std::io::Result { + let server = Server::builder().build(addr).await?; + let module = ContenderServer::new(sessions).into_rpc(); + let handle = server.start(module); - let session_layer = SessionLogRouter::new(log_sinks); + info!("JSON-RPC server listening on {addr}"); + Ok(handle) +} - tracing_subscriber::registry() - .with(filter) - .with(fmt_layer) - .with(session_layer) - .init(); +/// Starts a simple SSE server that serves session logs at `/logs/:session_id`. +/// +/// Returns a handle to the server task; awaiting this handle will wait until the server shuts down. +async fn start_sse_server( + sessions: Arc>, + addr: &str, +) -> std::io::Result>> { + let sse_app = sse_router(sessions); + let sse_listener = tokio::net::TcpListener::bind(addr).await?; + info!("SSE server listening on {addr}"); + let sse_handle = tokio::spawn(async move { axum::serve(sse_listener, sse_app).await }); + Ok(sse_handle) } From 22b6dfaa83e07887a3d31d4bbb6fd658f57cb599 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:36:54 -0700 Subject: [PATCH 13/34] simplify ContenderSession constructor --- crates/server/src/sessions.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 315dfeb5..8a183882 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -26,17 +26,18 @@ pub struct NewSessionParams { } impl ContenderSession { - /// Should only be called by ContenderSessionCache when adding a new session, since the session ID is determined by the cache - fn new(sessions: &[ContenderSession], params: NewSessionParams) -> Self { + /// Should only be called by ContenderSessionCache when adding a new session, + /// since the session ID is determined by the cache + fn new(id: usize, params: NewSessionParams) -> Self { let info = ContenderSessionInfo { - id: sessions.len(), + id, name: params.name, rpc_url: params.rpc_url, status: SessionStatus::Initializing, }; let contender = info.create_contender(params.test_config); - let (log_tx, _) = broadcast::channel(256); + let (log_tx, _) = broadcast::channel(4096); Self { info, contender: Some(contender), @@ -92,7 +93,7 @@ impl ContenderSessionCache { /// Returns a mutable reference to the newly added session, /// which can be used to call initialize on it before it's returned by the RPC provider. pub fn add_session(&mut self, params: NewSessionParams) -> &mut ContenderSession { - let session = ContenderSession::new(&self.sessions, params); + let session = ContenderSession::new(self.next_session_id(), params); let info = session.info.clone(); let log_tx = session.log_tx.clone(); From 6cc791964756bc8ef118429da180e025e1fe85b1 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:07:07 -0700 Subject: [PATCH 14/34] (wip) rename confusing var, add dummy spam method, make rpc_server mod --- crates/server/src/error.rs | 21 +++++++++++++++ crates/server/src/rpc.rs | 36 +++++++++++++++++++++++++- crates/server/src/rpc_server/mod.rs | 0 crates/server/src/rpc_server/server.rs | 0 crates/server/src/rpc_server/types.rs | 0 crates/server/src/sessions.rs | 22 +++++++++++----- crates/server/src/sse.rs | 2 +- 7 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 crates/server/src/rpc_server/mod.rs create mode 100644 crates/server/src/rpc_server/server.rs create mode 100644 crates/server/src/rpc_server/types.rs diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 2b26aa0a..2ab06b9b 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -2,11 +2,19 @@ use base64::DecodeError; use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; use thiserror::Error; +use crate::sessions::ContenderSessionInfo; + #[derive(Debug, Error)] pub enum ContenderRpcError { #[error("Failed to initialize contender session: {0}")] SessionInitializationFailed(contender_core::Error), + #[error("Session not found: {0}")] + SessionNotFound(usize), + + #[error("Session {} is not initialized", _0.id)] + SessionNotInitialized(ContenderSessionInfo), + #[error("Invalid test config: {0}")] InvalidTestConfig(#[from] contender_testfile::Error), @@ -50,6 +58,19 @@ impl From for ErrorObjectOwned { Some(e.to_string()), ), + ContenderRpcError::SessionNotFound(id) => { + ErrorObject::owned(5, format!("Session {id} not found"), Option::::None) + } + + ContenderRpcError::SessionNotInitialized(info) => ErrorObject::owned( + 6, + format!( + "Session {} not ready (status: {}); must be initialized before spamming", + info.id, info.status + ), + Option::::None, + ), + ContenderRpcError::InvalidArguments(msg) => { ErrorObject::owned(400, "Invalid arguments".to_string(), Some(msg)) } diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs index 03e444d5..1301cd48 100644 --- a/crates/server/src/rpc.rs +++ b/crates/server/src/rpc.rs @@ -22,6 +22,8 @@ use crate::{ #[rpc(server)] pub trait ContenderRpc { + // ================ RPC Methods ================ + #[method(name = "status")] async fn status(&self) -> jsonrpsee::core::RpcResult; @@ -40,6 +42,11 @@ pub trait ContenderRpc { #[method(name = "remove_session")] async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; + #[method(name = "spam")] + async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult; + + // ================ WS Methods ================ + #[subscription(name = "subscribe_logs" => "session_log", item = String)] async fn subscribe_logs(&self, session_id: usize) -> jsonrpsee::core::SubscriptionResult; } @@ -238,7 +245,7 @@ impl ContenderRpcServer for ContenderServer { .await; return Ok(()); }; - let mut rx = session.log_tx.subscribe(); + let mut rx = session.log_channel.subscribe(); drop(sessions); let sink = pending.accept().await?; @@ -255,6 +262,33 @@ impl ContenderRpcServer for ContenderServer { Ok(()) } + + async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult { + println!("Spamming session {session_id}..."); + + let sessions = self.sessions.read().await; + let Some(session) = sessions.get_session(session_id) else { + return Err(ContenderRpcError::SessionNotFound(session_id).into()); + }; + println!("Got session {}: {}", session_id, session.info.name); + + if session.info.status != SessionStatus::Ready { + return Err(ContenderRpcError::SessionNotInitialized(session.info.clone()).into()); + } + drop(sessions); + + let span = tracing::info_span!("session_spam", id = session_id); + tokio::spawn( + contender_core::CURRENT_SESSION_ID + .scope(session_id, async move { + println!("spawned task for spamming session {session_id}"); + // TODO: spam with contender here + }) + .instrument(span), + ); + + Ok(format!("Spamming session {session_id}")) + } } #[cfg(test)] diff --git a/crates/server/src/rpc_server/mod.rs b/crates/server/src/rpc_server/mod.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/server/src/rpc_server/types.rs b/crates/server/src/rpc_server/types.rs new file mode 100644 index 00000000..e69de29b diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 8a183882..0a481667 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -6,17 +6,27 @@ use tokio::sync::broadcast; use crate::log_layer::SessionLogSinks; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub enum SessionStatus { Initializing, Ready, Failed(String), } +impl std::fmt::Display for SessionStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionStatus::Initializing => write!(f, "Initializing"), + SessionStatus::Ready => write!(f, "Ready"), + SessionStatus::Failed(err) => write!(f, "Failed: {err}"), + } + } +} + pub struct ContenderSession { pub info: ContenderSessionInfo, pub contender: Option>, - pub log_tx: broadcast::Sender, + pub log_channel: broadcast::Sender, } pub struct NewSessionParams { @@ -37,11 +47,11 @@ impl ContenderSession { }; let contender = info.create_contender(params.test_config); - let (log_tx, _) = broadcast::channel(4096); + let (log_channel, _) = broadcast::channel(4096); Self { info, contender: Some(contender), - log_tx, + log_channel, } } } @@ -95,11 +105,11 @@ impl ContenderSessionCache { pub fn add_session(&mut self, params: NewSessionParams) -> &mut ContenderSession { let session = ContenderSession::new(self.next_session_id(), params); let info = session.info.clone(); - let log_tx = session.log_tx.clone(); + let log_channel = session.log_channel.clone(); // Register the broadcast sender in the log sinks so the tracing layer can route to it. if let Ok(mut sinks) = self.log_sinks.try_write() { - sinks.insert(info.id, log_tx); + sinks.insert(info.id, log_channel); } self.sessions.push(session); diff --git a/crates/server/src/sse.rs b/crates/server/src/sse.rs index 68f9af65..e7e80c4a 100644 --- a/crates/server/src/sse.rs +++ b/crates/server/src/sse.rs @@ -37,7 +37,7 @@ async fn logs_handler( format!("Session {session_id} not found"), ) })?; - let rx = session.log_tx.subscribe(); + let rx = session.log_channel.subscribe(); drop(sessions); let stream = BroadcastStream::new(rx).filter_map(|res| match res { From c472ccf6fc7cd2a9b8f9a0aed44e057ebb971571 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:19:52 -0700 Subject: [PATCH 15/34] break up contents of rpc mod into sub-modules --- crates/server/src/lib.rs | 2 +- crates/server/src/main.rs | 2 +- crates/server/src/rpc.rs | 342 ------------------------- crates/server/src/rpc_server/mod.rs | 5 + crates/server/src/rpc_server/server.rs | 198 ++++++++++++++ crates/server/src/rpc_server/types.rs | 146 +++++++++++ 6 files changed, 351 insertions(+), 344 deletions(-) delete mode 100644 crates/server/src/rpc.rs diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 81bdc6b6..f6d4fca9 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,6 +1,6 @@ pub mod config; pub mod error; pub mod log_layer; -pub mod rpc; +pub mod rpc_server; pub mod sessions; pub mod sse; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 4de3ef1c..771822d3 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use contender_server::config::{init_tracing, load_server_config}; -use contender_server::rpc::{ContenderRpcServer as _, ContenderServer}; +use contender_server::rpc_server::{ContenderRpcServer as _, ContenderServer}; use contender_server::sessions::ContenderSessionCache; use contender_server::sse::sse_router; use jsonrpsee::server::{Server, ServerHandle}; diff --git a/crates/server/src/rpc.rs b/crates/server/src/rpc.rs deleted file mode 100644 index 1301cd48..00000000 --- a/crates/server/src/rpc.rs +++ /dev/null @@ -1,342 +0,0 @@ -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use contender_cli::default_scenarios::{BuiltinOptions, BuiltinScenarioCli}; -use contender_core::{ - alloy::{ - network::AnyNetwork, - providers::{DynProvider, ProviderBuilder}, - }, - generator::RandSeed, - test_scenario::Url, -}; -use contender_testfile::TestConfig; -use jsonrpsee::{proc_macros::rpc, PendingSubscriptionSink, SubscriptionMessage}; -use serde::{Deserialize, Serialize}; -use std::{str::FromStr, sync::Arc}; -use tokio::sync::RwLock; -use tracing::{debug, info, Instrument}; - -use crate::{ - error::ContenderRpcError, - sessions::{ContenderSessionCache, ContenderSessionInfo, NewSessionParams, SessionStatus}, -}; - -#[rpc(server)] -pub trait ContenderRpc { - // ================ RPC Methods ================ - - #[method(name = "status")] - async fn status(&self) -> jsonrpsee::core::RpcResult; - - #[method(name = "add_session")] - async fn add_session( - &self, - name: AddSessionParams, - ) -> jsonrpsee::core::RpcResult; - - #[method(name = "get_session")] - async fn get_session( - &self, - id: usize, - ) -> jsonrpsee::core::RpcResult>; - - #[method(name = "remove_session")] - async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; - - #[method(name = "spam")] - async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult; - - // ================ WS Methods ================ - - #[subscription(name = "subscribe_logs" => "session_log", item = String)] - async fn subscribe_logs(&self, session_id: usize) -> jsonrpsee::core::SubscriptionResult; -} - -pub struct ContenderServer { - pub sessions: Arc>, -} - -impl ContenderServer { - pub fn new(sessions: Arc>) -> Self { - Self { sessions } - } -} - -/// RPC parameters for adding a new contender session. -#[derive(Clone, Debug, Deserialize)] -pub struct AddSessionParams { - pub name: String, - pub rpc_url: Url, - pub test_config: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum TestConfigSource { - TomlBase64(String), - Json(TestConfig), - Builtin(BuiltinScenarioCli), -} - -impl TestConfigSource { - pub async fn to_testconfig( - self, - builtin_options: Option, - provider: &DynProvider, - ) -> Result { - match self { - TestConfigSource::TomlBase64(b64) => { - let bytes = BASE64.decode(b64)?; - debug!( - "Decoded test config from base64, length {} bytes", - bytes.len() - ); - let config_str = - String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)?; - TestConfig::from_str(&config_str).map_err(ContenderRpcError::InvalidTestConfig) - } - - TestConfigSource::Json(config) => Ok(config), - - TestConfigSource::Builtin(builtin) => { - let scenario = builtin - .to_builtin_scenario(provider, builtin_options.unwrap_or_default()) - .await - .unwrap() - .into(); - Ok(scenario) - } - } - } -} - -impl AddSessionParams { - pub async fn to_new_session_params( - self, - seed: RandSeed, - ) -> Result { - let test_config = if let Some(config) = self.test_config { - let provider = DynProvider::new( - ProviderBuilder::new() - .network::() - .connect_http(self.rpc_url.clone()), - ); - config - .to_testconfig( - Some(BuiltinOptions { - accounts_per_agent: None, - seed, - spam_rate: None, - }), - &provider, - ) - .await? - } else { - TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")) - .expect("default config should be valid") - }; - - Ok(NewSessionParams { - name: self.name.clone(), - rpc_url: self.rpc_url.clone(), - test_config, - }) - } -} - -#[async_trait::async_trait] -impl ContenderRpcServer for ContenderServer { - async fn status(&self) -> jsonrpsee::core::RpcResult { - let sessions = self.sessions.read().await; - Ok(format!("{} session(s) active", sessions.num_sessions())) - } - - async fn add_session( - &self, - params: AddSessionParams, - ) -> jsonrpsee::core::RpcResult { - let session_seed; - let info; - { - let mut sessions = self.sessions.write().await; - session_seed = RandSeed::seed_from_bytes(&sessions.num_sessions().to_be_bytes()); - let session = sessions.add_session(params.to_new_session_params(session_seed).await?); - info = session.info.clone(); - } - - let session_id = info.id; - let sessions = Arc::clone(&self.sessions); - - info!( - "Spawning initialization for session {} with RPC URL {}", - info.name, info.rpc_url - ); - - let span = tracing::info_span!("session_init", id = session_id); - tokio::spawn( - contender_core::CURRENT_SESSION_ID.scope( - session_id, - async move { - // Take the contender out so we can initialize without holding the lock. - let contender = { - let mut lock = sessions.write().await; - lock.take_contender(session_id) - }; - - let Some(mut contender) = contender else { - return; - }; - - let result = contender.initialize().await; - - // Put the contender back and update status. - let mut lock = sessions.write().await; - lock.put_contender(session_id, contender); - if let Some(session) = lock.get_session_mut(session_id) { - match result { - Ok(()) => { - session.info.status = SessionStatus::Ready; - info!("Session {} initialized successfully", session_id); - } - Err(e) => { - let msg = e.to_string(); - session.info.status = SessionStatus::Failed(msg.clone()); - tracing::error!( - "Session {} initialization failed: {}", - session_id, - msg - ); - } - } - } - } - .instrument(span), - ), - ); - - Ok(info) - } - - async fn get_session( - &self, - id: usize, - ) -> jsonrpsee::core::RpcResult> { - let sessions = self.sessions.read().await; - Ok(sessions.get_session(id).map(|s| s.info.clone())) - } - - async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()> { - let mut sessions = self.sessions.write().await; - sessions.remove_session(id); - Ok(()) - } - - async fn subscribe_logs( - &self, - pending: PendingSubscriptionSink, - session_id: usize, - ) -> jsonrpsee::core::SubscriptionResult { - let sessions = self.sessions.read().await; // TODO: replace self.sessions calls with wrappers to avoid accidental improper locking patterns - let Some(session) = sessions.get_session(session_id) else { - pending - .reject(jsonrpsee::types::ErrorObject::owned( - 5, - format!("Session {session_id} not found"), - None::<()>, - )) - .await; - return Ok(()); - }; - let mut rx = session.log_channel.subscribe(); - drop(sessions); - - let sink = pending.accept().await?; - - tokio::spawn(async move { - while let Ok(msg) = rx.recv().await { - let sub_msg = - SubscriptionMessage::from_json(&msg).expect("failed to serialize log message"); - if sink.send(sub_msg).await.is_err() { - break; - } - } - }); - - Ok(()) - } - - async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult { - println!("Spamming session {session_id}..."); - - let sessions = self.sessions.read().await; - let Some(session) = sessions.get_session(session_id) else { - return Err(ContenderRpcError::SessionNotFound(session_id).into()); - }; - println!("Got session {}: {}", session_id, session.info.name); - - if session.info.status != SessionStatus::Ready { - return Err(ContenderRpcError::SessionNotInitialized(session.info.clone()).into()); - } - drop(sessions); - - let span = tracing::info_span!("session_spam", id = session_id); - tokio::spawn( - contender_core::CURRENT_SESSION_ID - .scope(session_id, async move { - println!("spawned task for spamming session {session_id}"); - // TODO: spam with contender here - }) - .instrument(span), - ); - - Ok(format!("Spamming session {session_id}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD as BASE64; - use contender_cli::default_scenarios::transfers::TransferStressCliArgs; - use contender_core::alloy::{ - consensus::constants::ETH_TO_WEI, - primitives::{Address, U256}, - }; - - #[test] - fn test_toml_base64_variant() { - let toml_content = include_str!("../../../scenarios/uniV2.toml"); - let b64 = BASE64.encode(toml_content); - let json = serde_json::json!({ "TomlBase64": b64 }); - - // println!( - // "TomlBase64:\n{}\n", - // serde_json::to_string_pretty(&json).unwrap() - // ); - - let source: TestConfigSource = serde_json::from_value(json).unwrap(); - assert!(matches!(source, TestConfigSource::TomlBase64(_))); - } - - #[test] - fn test_json_variant() { - let config = TestConfig::from_str(include_str!("../../../scenarios/uniV2.toml")).unwrap(); - let json = serde_json::json!({ "Json": config }); - // println!("Json:\n{}\n", serde_json::to_string_pretty(&json).unwrap()); - - let source: TestConfigSource = serde_json::from_value(json).unwrap(); - assert!(matches!(source, TestConfigSource::Json(_))); - } - - #[tokio::test] - async fn test_builtin_variant() { - let builtin = - TestConfigSource::Builtin(BuiltinScenarioCli::Transfers(TransferStressCliArgs { - amount: U256::from(ETH_TO_WEI), - recipient: Some(Address::ZERO), - })); - let json = serde_json::json!(builtin); - println!("{}", serde_json::to_string_pretty(&json).unwrap()); - - let source: TestConfigSource = serde_json::from_value(json).unwrap(); - assert!(matches!(source, TestConfigSource::Builtin(_))); - } -} diff --git a/crates/server/src/rpc_server/mod.rs b/crates/server/src/rpc_server/mod.rs index e69de29b..5d93d65a 100644 --- a/crates/server/src/rpc_server/mod.rs +++ b/crates/server/src/rpc_server/mod.rs @@ -0,0 +1,5 @@ +mod server; +mod types; + +pub use server::{ContenderRpcServer, ContenderServer}; +pub use types::*; diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index e69de29b..38f22090 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -0,0 +1,198 @@ +use contender_core::generator::RandSeed; +use jsonrpsee::{proc_macros::rpc, PendingSubscriptionSink, SubscriptionMessage}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, Instrument}; + +use crate::{ + error::ContenderRpcError, + rpc_server::types::AddSessionParams, + sessions::{ContenderSessionCache, ContenderSessionInfo, SessionStatus}, +}; + +#[rpc(server)] +pub trait ContenderRpc { + // ================ RPC Methods ================ + + #[method(name = "status")] + async fn status(&self) -> jsonrpsee::core::RpcResult; + + #[method(name = "add_session")] + async fn add_session( + &self, + name: AddSessionParams, + ) -> jsonrpsee::core::RpcResult; + + #[method(name = "get_session")] + async fn get_session( + &self, + id: usize, + ) -> jsonrpsee::core::RpcResult>; + + #[method(name = "remove_session")] + async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; + + #[method(name = "spam")] + async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult; + + // ================ WS Methods ================ + + #[subscription(name = "subscribe_logs" => "session_log", item = String)] + async fn subscribe_logs(&self, session_id: usize) -> jsonrpsee::core::SubscriptionResult; +} + +pub struct ContenderServer { + pub sessions: Arc>, +} + +impl ContenderServer { + pub fn new(sessions: Arc>) -> Self { + Self { sessions } + } +} + +#[async_trait::async_trait] +impl ContenderRpcServer for ContenderServer { + async fn status(&self) -> jsonrpsee::core::RpcResult { + let sessions = self.sessions.read().await; + Ok(format!("{} session(s) active", sessions.num_sessions())) + } + + async fn add_session( + &self, + params: AddSessionParams, + ) -> jsonrpsee::core::RpcResult { + let session_seed; + let info; + { + let mut sessions = self.sessions.write().await; + session_seed = RandSeed::seed_from_bytes(&sessions.num_sessions().to_be_bytes()); + let session = sessions.add_session(params.to_new_session_params(session_seed).await?); + info = session.info.clone(); + } + + let session_id = info.id; + let sessions = Arc::clone(&self.sessions); + + info!( + "Spawning initialization for session {} with RPC URL {}", + info.name, info.rpc_url + ); + + let span = tracing::info_span!("session_init", id = session_id); + tokio::spawn( + contender_core::CURRENT_SESSION_ID.scope( + session_id, + async move { + // Take the contender out so we can initialize without holding the lock. + let contender = { + let mut lock = sessions.write().await; + lock.take_contender(session_id) + }; + + let Some(mut contender) = contender else { + return; + }; + + let result = contender.initialize().await; + + // Put the contender back and update status. + let mut lock = sessions.write().await; + lock.put_contender(session_id, contender); + if let Some(session) = lock.get_session_mut(session_id) { + match result { + Ok(()) => { + session.info.status = SessionStatus::Ready; + info!("Session {} initialized successfully", session_id); + } + Err(e) => { + let msg = e.to_string(); + session.info.status = SessionStatus::Failed(msg.clone()); + tracing::error!( + "Session {} initialization failed: {}", + session_id, + msg + ); + } + } + } + } + .instrument(span), + ), + ); + + Ok(info) + } + + async fn get_session( + &self, + id: usize, + ) -> jsonrpsee::core::RpcResult> { + let sessions = self.sessions.read().await; + Ok(sessions.get_session(id).map(|s| s.info.clone())) + } + + async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()> { + let mut sessions = self.sessions.write().await; + sessions.remove_session(id); + Ok(()) + } + + async fn subscribe_logs( + &self, + pending: PendingSubscriptionSink, + session_id: usize, + ) -> jsonrpsee::core::SubscriptionResult { + let sessions = self.sessions.read().await; // TODO: replace self.sessions calls with wrappers to avoid accidental improper locking patterns + let Some(session) = sessions.get_session(session_id) else { + pending + .reject(jsonrpsee::types::ErrorObject::owned( + 5, + format!("Session {session_id} not found"), + None::<()>, + )) + .await; + return Ok(()); + }; + let mut rx = session.log_channel.subscribe(); + drop(sessions); + + let sink = pending.accept().await?; + + tokio::spawn(async move { + while let Ok(msg) = rx.recv().await { + let sub_msg = + SubscriptionMessage::from_json(&msg).expect("failed to serialize log message"); + if sink.send(sub_msg).await.is_err() { + break; + } + } + }); + + Ok(()) + } + + async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult { + let sessions = self.sessions.read().await; + let Some(session) = sessions.get_session(session_id) else { + return Err(ContenderRpcError::SessionNotFound(session_id).into()); + }; + + if session.info.status != SessionStatus::Ready { + return Err(ContenderRpcError::SessionNotInitialized(session.info.clone()).into()); + } + drop(sessions); + + let span = tracing::info_span!("session_spam", id = session_id); + tokio::spawn( + contender_core::CURRENT_SESSION_ID + .scope(session_id, async move { + println!("spawned task for spamming session {session_id}"); + // TODO: spam with contender here + }) + .instrument(span), + ); + + Ok(format!("Spamming session {session_id}")) + } +} diff --git a/crates/server/src/rpc_server/types.rs b/crates/server/src/rpc_server/types.rs index e69de29b..bc28b2e1 100644 --- a/crates/server/src/rpc_server/types.rs +++ b/crates/server/src/rpc_server/types.rs @@ -0,0 +1,146 @@ +use crate::{error::ContenderRpcError, sessions::NewSessionParams}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use contender_cli::default_scenarios::{BuiltinOptions, BuiltinScenarioCli}; +use contender_core::{ + alloy::{ + network::AnyNetwork, + providers::{DynProvider, ProviderBuilder}, + }, + generator::RandSeed, + test_scenario::Url, +}; +use contender_testfile::TestConfig; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use tracing::debug; + +/// RPC parameters for adding a new contender session. +#[derive(Clone, Debug, Deserialize)] +pub struct AddSessionParams { + pub name: String, + pub rpc_url: Url, + pub test_config: Option, +} + +impl AddSessionParams { + pub async fn to_new_session_params( + self, + seed: RandSeed, + ) -> Result { + let test_config = if let Some(config) = self.test_config { + let provider = DynProvider::new( + ProviderBuilder::new() + .network::() + .connect_http(self.rpc_url.clone()), + ); + config + .to_testconfig( + Some(BuiltinOptions { + accounts_per_agent: None, + seed, + spam_rate: None, + }), + &provider, + ) + .await? + } else { + TestConfig::from_str(include_str!("../../../../scenarios/uniV2.toml")) + .expect("default config should be valid") + }; + + Ok(NewSessionParams { + name: self.name.clone(), + rpc_url: self.rpc_url.clone(), + test_config, + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum TestConfigSource { + TomlBase64(String), + Json(TestConfig), + Builtin(BuiltinScenarioCli), +} + +impl TestConfigSource { + pub async fn to_testconfig( + self, + builtin_options: Option, + provider: &DynProvider, + ) -> Result { + match self { + TestConfigSource::TomlBase64(b64) => { + let bytes = BASE64.decode(b64)?; + debug!( + "Decoded test config from base64, length {} bytes", + bytes.len() + ); + let config_str = + String::from_utf8(bytes).map_err(ContenderRpcError::InvalidUtf8)?; + TestConfig::from_str(&config_str).map_err(ContenderRpcError::InvalidTestConfig) + } + + TestConfigSource::Json(config) => Ok(config), + + TestConfigSource::Builtin(builtin) => { + let scenario = builtin + .to_builtin_scenario(provider, builtin_options.unwrap_or_default()) + .await + .unwrap() + .into(); + Ok(scenario) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64; + use contender_cli::default_scenarios::transfers::TransferStressCliArgs; + use contender_core::alloy::{ + consensus::constants::ETH_TO_WEI, + primitives::{Address, U256}, + }; + + #[test] + fn test_toml_base64_variant() { + let toml_content = include_str!("../../../../scenarios/uniV2.toml"); + let b64 = BASE64.encode(toml_content); + let json = serde_json::json!({ "TomlBase64": b64 }); + // println!( + // "TomlBase64:\n{}\n", + // serde_json::to_string_pretty(&json).unwrap() + // ); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::TomlBase64(_))); + } + + #[test] + fn test_json_variant() { + let config = + TestConfig::from_str(include_str!("../../../../scenarios/uniV2.toml")).unwrap(); + let json = serde_json::json!({ "Json": config }); + // println!("Json:\n{}\n", serde_json::to_string_pretty(&json).unwrap()); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::Json(_))); + } + + #[tokio::test] + async fn test_builtin_variant() { + let builtin = + TestConfigSource::Builtin(BuiltinScenarioCli::Transfers(TransferStressCliArgs { + amount: U256::from(ETH_TO_WEI), + recipient: Some(Address::ZERO), + })); + let json = serde_json::json!(builtin); + // println!("{}", serde_json::to_string_pretty(&json).unwrap()); + + let source: TestConfigSource = serde_json::from_value(json).unwrap(); + assert!(matches!(source, TestConfigSource::Builtin(_))); + } +} From 2e1e38bfe0c5a89f270b66dbd31f6203730b6c8c Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:07:24 -0700 Subject: [PATCH 16/34] fix logs, spam for real --- crates/core/src/lib.rs | 18 ++++++- crates/core/src/orchestrator.rs | 27 +++++++++- crates/core/src/spammer/tx_actor.rs | 5 ++ crates/server/src/log_layer.rs | 13 +++-- crates/server/src/rpc_server/server.rs | 70 +++++++++++++++++++++++--- crates/server/src/rpc_server/types.rs | 43 ++++++++++++++++ 6 files changed, 161 insertions(+), 15 deletions(-) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 95ecf249..1bddc103 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -26,14 +26,28 @@ tokio::task_local! { pub static CURRENT_SESSION_ID: usize; } -/// Spawn a future that inherits the current `CURRENT_SESSION_ID` task-local (if set). +/// Spawn a future that inherits the current `CURRENT_SESSION_ID` task-local (if set) +/// and instruments it with a `session` tracing span so the fmt layer shows the session ID. +/// If already inside a `session*` span, the existing span is used via `follows_from`. pub fn spawn_with_session(future: F) -> tokio::task::JoinHandle where F: std::future::Future + Send + 'static, F::Output: Send + 'static, { match CURRENT_SESSION_ID.try_with(|id| *id) { - Ok(id) => tokio::task::spawn(CURRENT_SESSION_ID.scope(id, future)), + Ok(id) => { + let current = tracing::Span::current(); + let has_session_span = current + .metadata() + .is_some_and(|m| m.name().starts_with("session")); + let future = CURRENT_SESSION_ID.scope(id, future); + if has_session_span { + tokio::task::spawn(tracing::Instrument::instrument(future, current)) + } else { + let span = tracing::info_span!("session", id = id); + tokio::task::spawn(tracing::Instrument::instrument(future, span)) + } + } Err(_) => tokio::task::spawn(future), } } diff --git a/crates/core/src/orchestrator.rs b/crates/core/src/orchestrator.rs index 52111300..870b8b2a 100644 --- a/crates/core/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -9,6 +9,7 @@ use crate::{ agent_pools::AgentSpec, seeder::{rand_seed::SeedGenerator, Seeder}, templater::Templater, + types::AnyProvider, PlanConfig, RandSeed, }, spammer::{tx_actor::TxActorHandle, OnBatchSent, OnTxSent, Spammer}, @@ -17,8 +18,13 @@ use crate::{ Result, }; use alloy::{ - consensus::TxType, node_bindings::WEI_IN_ETHER, primitives::U256, - signers::local::PrivateKeySigner, transports::http::reqwest::Url, + consensus::TxType, + network::AnyNetwork, + node_bindings::WEI_IN_ETHER, + primitives::U256, + providers::{DynProvider, Provider}, + signers::local::PrivateKeySigner, + transports::http::reqwest::Url, }; use contender_bundle_provider::bundle::BundleType; use contender_engine_provider::ControlChain; @@ -523,6 +529,14 @@ where ); let run_id = scenario.db.insert_run(&run_req).map_err(|e| e.into())?; + // Initialize TxActor contexts so flush_loop can match receipts. + let current_block = scenario.rpc_client.get_block_number().await?; + let actor_ctx = crate::spammer::tx_actor::ActorContext::new(current_block, run_id) + .with_pending_tx_timeout(Duration::from_secs(self.ctx.pending_tx_timeout_secs)); + for handle in scenario.msg_handles.values() { + handle.init_ctx(actor_ctx.clone()).await?; + } + // send spam spammer .spam_rpc( @@ -539,4 +553,13 @@ where pub async fn build_scenario(&self) -> Result> { self.ctx.build_scenario().await } + + /// Produce a web3 provider connected to the current instance's RPC URL. + pub fn provider(&self) -> AnyProvider { + DynProvider::new( + alloy::providers::ProviderBuilder::new() + .network::() + .connect_http(self.ctx.rpc_url.clone()), + ) + } } diff --git a/crates/core/src/spammer/tx_actor.rs b/crates/core/src/spammer/tx_actor.rs index f785f754..bc054e4c 100644 --- a/crates/core/src/spammer/tx_actor.rs +++ b/crates/core/src/spammer/tx_actor.rs @@ -543,6 +543,11 @@ async fn process_block_receipts( .collect(); if !run_txs.is_empty() { + info!( + "receipts found: {} confirmed txs in block {}", + run_txs.len(), + target_block_num + ); db.insert_run_txs(run_id, &run_txs).map_err(|e| e.into())?; } diff --git a/crates/server/src/log_layer.rs b/crates/server/src/log_layer.rs index f28466bf..ea5515b2 100644 --- a/crates/server/src/log_layer.rs +++ b/crates/server/src/log_layer.rs @@ -55,7 +55,7 @@ where }; // Format the event. - let formatted = format_event(event); + let formatted = format_event(event, session_id); // Try to send non-blocking (don't await the RwLock — use try_read). if let Ok(sinks) = self.sinks.try_read() { @@ -102,7 +102,7 @@ impl Visit for SessionSpanFields { fn record_debug(&mut self, _field: &tracing::field::Field, _value: &dyn fmt::Debug) {} } -fn format_event(event: &Event<'_>) -> String { +fn format_event(event: &Event<'_>, session_id: usize) -> String { let metadata = event.metadata(); let mut visitor = MessageVisitor { message: String::new(), @@ -114,7 +114,14 @@ fn format_event(event: &Event<'_>) -> String { &mut tracing_subscriber::fmt::format::Writer::new(&mut timestamp), ); - format!("{} {}: {}", timestamp, metadata.level(), visitor.message) + format!( + "{} {} session[{}] {}: {}", + timestamp, + metadata.level(), + session_id, + metadata.target(), + visitor.message + ) } struct MessageVisitor { diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index 38f22090..c2290e02 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -1,12 +1,14 @@ use contender_core::generator::RandSeed; +use contender_core::spammer::{BlockwiseSpammer, LogCallback, NilCallback, TimedSpammer}; use jsonrpsee::{proc_macros::rpc, PendingSubscriptionSink, SubscriptionMessage}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; use tracing::{info, Instrument}; use crate::{ error::ContenderRpcError, - rpc_server::types::AddSessionParams, + rpc_server::types::{AddSessionParams, SpamParams, SpammerType}, sessions::{ContenderSessionCache, ContenderSessionInfo, SessionStatus}, }; @@ -33,7 +35,7 @@ pub trait ContenderRpc { async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; #[method(name = "spam")] - async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult; + async fn spam(&self, params: SpamParams) -> jsonrpsee::core::RpcResult; // ================ WS Methods ================ @@ -172,7 +174,8 @@ impl ContenderRpcServer for ContenderServer { Ok(()) } - async fn spam(&self, session_id: usize) -> jsonrpsee::core::RpcResult { + async fn spam(&self, params: SpamParams) -> jsonrpsee::core::RpcResult { + let session_id = params.session_id; let sessions = self.sessions.read().await; let Some(session) = sessions.get_session(session_id) else { return Err(ContenderRpcError::SessionNotFound(session_id).into()); @@ -181,16 +184,67 @@ impl ContenderRpcServer for ContenderServer { if session.info.status != SessionStatus::Ready { return Err(ContenderRpcError::SessionNotInitialized(session.info.clone()).into()); } + let save_receipts = params.save_receipts.unwrap_or(false); + println!("{}saving receipts", if save_receipts { "" } else { "not " }); + drop(sessions); + // Take the contender out so we can spam without holding the lock. + let contender = { + let mut lock = self.sessions.write().await; + lock.take_contender(session_id) + }; + + let Some(contender) = contender else { + return Err(ContenderRpcError::SessionNotFound(session_id).into()); + }; + + let sessions = Arc::clone(&self.sessions); let span = tracing::info_span!("session_spam", id = session_id); tokio::spawn( - contender_core::CURRENT_SESSION_ID - .scope(session_id, async move { - println!("spawned task for spamming session {session_id}"); - // TODO: spam with contender here - }) + contender_core::CURRENT_SESSION_ID.scope( + session_id, + async move { + let mut contender = contender; + let opts = params.to_run_opts(); + let spammer_type = params.spammer.unwrap_or_default(); + + macro_rules! run_spam { + ($callback:expr) => { + match spammer_type { + SpammerType::Timed => { + let spammer = TimedSpammer::new(Duration::from_secs(1)); + contender.spam(spammer, Arc::new($callback), opts).await + } + SpammerType::Blockwise => { + let spammer = BlockwiseSpammer::new(); + contender.spam(spammer, Arc::new($callback), opts).await + } + } + }; + } + + let result = if save_receipts { + let provider = contender.provider(); + run_spam!(LogCallback::new(Arc::new(provider))) + } else { + run_spam!(NilCallback) + }; + + // Put the contender back and log outcome. + let mut lock = sessions.write().await; + lock.put_contender(session_id, contender); + match result { + Ok(()) => { + info!("Session {} spam completed successfully", session_id); + } + Err(e) => { + tracing::error!("Session {} spam failed: {}", session_id, e); + } + } + } .instrument(span), + ), ); Ok(format!("Spamming session {session_id}")) diff --git a/crates/server/src/rpc_server/types.rs b/crates/server/src/rpc_server/types.rs index bc28b2e1..d5aab592 100644 --- a/crates/server/src/rpc_server/types.rs +++ b/crates/server/src/rpc_server/types.rs @@ -8,6 +8,7 @@ use contender_core::{ }, generator::RandSeed, test_scenario::Url, + RunOpts, }; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; @@ -95,6 +96,48 @@ impl TestConfigSource { } } +/// RPC parameters for the `spam` method. +#[derive(Clone, Debug, Deserialize)] +pub struct SpamParams { + pub session_id: usize, + /// Number of transactions per period. Defaults to 10. + pub txs_per_period: Option, + /// Number of periods (seconds or blocks). Defaults to 10. + pub duration: Option, + /// Which spammer to use. Defaults to `Timed`. + pub spammer: Option, + /// Human-readable name for this spam run. + pub name: Option, + /// Whether to look for receipts while spamming; enables onchain metrics collection. + pub save_receipts: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SpammerType { + /// Send a batch of txs at a fixed time interval (1 second). + #[default] + Timed, + /// Send a batch of txs every new block. + Blockwise, +} + +impl SpamParams { + pub fn to_run_opts(&self) -> RunOpts { + let mut opts = RunOpts::new(); + if let Some(n) = self.txs_per_period { + opts = opts.txs_per_period(n); + } + if let Some(n) = self.duration { + opts = opts.periods(n); + } + if let Some(name) = &self.name { + opts = opts.name(name); + } + opts + } +} + #[cfg(test)] mod tests { use super::*; From ba0f781b2082ca484e8fb5c20920ebf75e829760 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:28:54 -0700 Subject: [PATCH 17/34] (bugfix: orchestrator) shutdown scenario after spamming - cli just drops the ref, which shuts it down naturally - orchestrator (when run in server) doesn't, so we need to invoke the CancellationToken's cancel() function manually --- crates/core/src/orchestrator.rs | 10 ++++++-- crates/core/src/spammer/tx_actor.rs | 36 ++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/core/src/orchestrator.rs b/crates/core/src/orchestrator.rs index 870b8b2a..0405388e 100644 --- a/crates/core/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -538,7 +538,7 @@ where } // send spam - spammer + let result = spammer .spam_rpc( &mut scenario, opts.txs_per_period, @@ -546,7 +546,13 @@ where Some(run_id), callback, ) - .await + .await; + + // Signal the flush loop that sending is done so it can shut down + // once all receipts are processed (or after the stale block timeout). + scenario.ctx.cancel_token.cancel(); + + result } /// Materialize a fresh `TestScenario` using the context which was used to create this `Contender` instance. diff --git a/crates/core/src/spammer/tx_actor.rs b/crates/core/src/spammer/tx_actor.rs index bc054e4c..d1301811 100644 --- a/crates/core/src/spammer/tx_actor.rs +++ b/crates/core/src/spammer/tx_actor.rs @@ -367,8 +367,13 @@ async fn flush_loop( flush_sender: mpsc::Sender, db: Arc, rpc: Arc, + cancel_token: CancellationToken, ) { let mut interval = tokio::time::interval(Duration::from_secs(1)); + /// Number of consecutive blocks with no change in pending count before giving up. + const STALE_BLOCK_LIMIT: u64 = 6; + let mut stale_blocks: u64 = 0; + let mut last_pending_count: usize = usize::MAX; loop { interval.tick().await; @@ -394,6 +399,12 @@ async fn flush_loop( continue; }; + // If cancel_token is set (sending is done) and cache is empty, we're done. + if cancel_token.is_cancelled() && cache_snapshot.is_empty() { + info!("all receipts processed, shutting down flush loop"); + break; + } + // Get current block number let new_block = match rpc.get_block_number().await { Ok(n) => n, @@ -439,12 +450,34 @@ async fn flush_loop( } if cache_snapshot.is_empty() { + if cancel_token.is_cancelled() { + info!("finished processing receipts."); + return; + } break; } } Err(e) => warn!("flush_cache error for block {}: {:?}", bn, e), } } + + // Track stale blocks: if sending is done and pending count hasn't changed, increment. + if cancel_token.is_cancelled() && !cache_snapshot.is_empty() { + let current_count = cache_snapshot.len(); + if current_count == last_pending_count { + stale_blocks += new_block.saturating_sub(ctx.target_block).max(1); + } else { + stale_blocks = 0; + last_pending_count = current_count; + } + if stale_blocks >= STALE_BLOCK_LIMIT { + warn!( + "pending receipt count unchanged ({}) for {} blocks, shutting down flush loop", + current_count, stale_blocks + ); + break; + } + } } } @@ -608,8 +641,9 @@ impl TxActorHandle { }); // Spawn the independent flush task (communicates via channels) + let flush_cancel = cancel_token.clone(); crate::spawn_with_session(async move { - flush_loop(flush_sender, db, rpc).await; + flush_loop(flush_sender, db, rpc, flush_cancel).await; }); // Spawn the flashblocks listener task if URL is provided From 4a8a2320c9396d000b1bec0e7137f4d46f0ff8dd Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:11:36 -0700 Subject: [PATCH 18/34] add "Spamming" status; enforce 1 spam run per session --- crates/core/src/spammer/tx_actor.rs | 6 ++-- crates/server/src/error.rs | 24 ++++++++++++++++ crates/server/src/rpc_server/server.rs | 38 ++++++++++++++++++++++---- crates/server/src/rpc_server/types.rs | 4 +-- crates/server/src/sessions.rs | 19 ++++++++++++- 5 files changed, 79 insertions(+), 12 deletions(-) diff --git a/crates/core/src/spammer/tx_actor.rs b/crates/core/src/spammer/tx_actor.rs index d1301811..43c585de 100644 --- a/crates/core/src/spammer/tx_actor.rs +++ b/crates/core/src/spammer/tx_actor.rs @@ -401,7 +401,7 @@ async fn flush_loop( // If cancel_token is set (sending is done) and cache is empty, we're done. if cancel_token.is_cancelled() && cache_snapshot.is_empty() { - info!("all receipts processed, shutting down flush loop"); + info!("all receipts processed, shutting down receipt collection."); break; } @@ -451,7 +451,7 @@ async fn flush_loop( if cache_snapshot.is_empty() { if cancel_token.is_cancelled() { - info!("finished processing receipts."); + info!("pending tx cache is empty, shutting down receipt collection."); return; } break; @@ -472,7 +472,7 @@ async fn flush_loop( } if stale_blocks >= STALE_BLOCK_LIMIT { warn!( - "pending receipt count unchanged ({}) for {} blocks, shutting down flush loop", + "pending receipt count unchanged ({}) for {} blocks, shutting down receipt collection.", current_count, stale_blocks ); break; diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 2ab06b9b..c6ddd527 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -15,6 +15,15 @@ pub enum ContenderRpcError { #[error("Session {} is not initialized", _0.id)] SessionNotInitialized(ContenderSessionInfo), + #[error("Session {} failed: {error}", info.id)] + SessionFailed { + info: ContenderSessionInfo, + error: String, + }, + + #[error("Session {} is currently spamming with params: {:?}", _0.id, _0.status)] + SessionSpamming(ContenderSessionInfo), + #[error("Invalid test config: {0}")] InvalidTestConfig(#[from] contender_testfile::Error), @@ -71,6 +80,21 @@ impl From for ErrorObjectOwned { Option::::None, ), + ContenderRpcError::SessionSpamming(info) => ErrorObject::owned( + 7, + format!( + "Session {} is currently spamming with params: {:?}", + info.id, info.status + ), + Option::::None, + ), + + ContenderRpcError::SessionFailed { info, error } => ErrorObject::owned( + 8, + format!("Session {} failed with error: {error}", info.id), + Option::::None, + ), + ContenderRpcError::InvalidArguments(msg) => { ErrorObject::owned(400, "Invalid arguments".to_string(), Some(msg)) } diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index c2290e02..f447a2e9 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -180,18 +180,16 @@ impl ContenderRpcServer for ContenderServer { let Some(session) = sessions.get_session(session_id) else { return Err(ContenderRpcError::SessionNotFound(session_id).into()); }; - - if session.info.status != SessionStatus::Ready { - return Err(ContenderRpcError::SessionNotInitialized(session.info.clone()).into()); - } + error_if_session_not_ready(&session.info)?; let save_receipts = params.save_receipts.unwrap_or(false); - println!("{}saving receipts", if save_receipts { "" } else { "not " }); - drop(sessions); // Take the contender out so we can spam without holding the lock. let contender = { let mut lock = self.sessions.write().await; + if let Some(session) = lock.get_session_mut(session_id) { + session.info.status = SessionStatus::Spamming(params.clone()); + } lock.take_contender(session_id) }; @@ -236,9 +234,16 @@ impl ContenderRpcServer for ContenderServer { lock.put_contender(session_id, contender); match result { Ok(()) => { + if let Some(session) = lock.get_session_mut(session_id) { + session.info.status = SessionStatus::Ready; + } info!("Session {} spam completed successfully", session_id); } Err(e) => { + if let Some(session) = lock.get_session_mut(session_id) { + session.info.status = + SessionStatus::Failed(format!("spam failed: {e}")); + } tracing::error!("Session {} spam failed: {}", session_id, e); } } @@ -250,3 +255,24 @@ impl ContenderRpcServer for ContenderServer { Ok(format!("Spamming session {session_id}")) } } + +/// Helper function to check if a session is ready to spam, +/// returning an appropriate RPC error if not. +fn error_if_session_not_ready( + session_info: &ContenderSessionInfo, +) -> jsonrpsee::core::RpcResult<()> { + Ok(match &session_info.status { + SessionStatus::Failed(msg) => { + return Err(ContenderRpcError::SessionFailed { + info: session_info.clone(), + error: msg.to_owned(), + } + .into()) + } + SessionStatus::Spamming(_) => { + return Err(ContenderRpcError::SessionSpamming(session_info.clone()).into()) + } + SessionStatus::Ready => (), + _ => return Err(ContenderRpcError::SessionNotInitialized(session_info.clone()).into()), + }) +} diff --git a/crates/server/src/rpc_server/types.rs b/crates/server/src/rpc_server/types.rs index d5aab592..af1c08c4 100644 --- a/crates/server/src/rpc_server/types.rs +++ b/crates/server/src/rpc_server/types.rs @@ -97,7 +97,7 @@ impl TestConfigSource { } /// RPC parameters for the `spam` method. -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct SpamParams { pub session_id: usize, /// Number of transactions per period. Defaults to 10. @@ -112,7 +112,7 @@ pub struct SpamParams { pub save_receipts: Option, } -#[derive(Clone, Debug, Deserialize, Serialize, Default)] +#[derive(Clone, Debug, Deserialize, Serialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SpammerType { /// Send a batch of txs at a fixed time interval (1 second). diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 0a481667..e6bc50df 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -4,12 +4,16 @@ use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; -use crate::log_layer::SessionLogSinks; +use crate::{ + log_layer::SessionLogSinks, + rpc_server::{SpamParams, SpammerType}, +}; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] pub enum SessionStatus { Initializing, Ready, + Spamming(SpamParams), Failed(String), } @@ -18,6 +22,19 @@ impl std::fmt::Display for SessionStatus { match self { SessionStatus::Initializing => write!(f, "Initializing"), SessionStatus::Ready => write!(f, "Ready"), + SessionStatus::Spamming(params) => { + let res = params.to_run_opts(); + let spammer_type = params.spammer.clone().unwrap_or_default(); + let units = match spammer_type { + SpammerType::Timed => ("tps", "seconds"), + SpammerType::Blockwise => ("tpb", "blocks"), + }; + write!( + f, + "Spamming ({} {} for {} {})", + res.txs_per_period, units.0, res.periods, units.1 + ) + } SessionStatus::Failed(err) => write!(f, "Failed: {err}"), } } From 0d84914bc7d0ecde266a3bbddd77f2d83c4b3ede Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:23:52 -0700 Subject: [PATCH 19/34] session: wait for receipt collection to finish... ... before marking status as Ready --- crates/core/src/orchestrator.rs | 5 +++++ crates/core/src/spammer/tx_actor.rs | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/core/src/orchestrator.rs b/crates/core/src/orchestrator.rs index 0405388e..f6af36d8 100644 --- a/crates/core/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -552,6 +552,11 @@ where // once all receipts are processed (or after the stale block timeout). scenario.ctx.cancel_token.cancel(); + // Wait for all flush loops to finish collecting receipts. + for handle in scenario.msg_handles.values() { + handle.await_flush().await; + } + result } diff --git a/crates/core/src/spammer/tx_actor.rs b/crates/core/src/spammer/tx_actor.rs index 43c585de..c3d08873 100644 --- a/crates/core/src/spammer/tx_actor.rs +++ b/crates/core/src/spammer/tx_actor.rs @@ -576,11 +576,6 @@ async fn process_block_receipts( .collect(); if !run_txs.is_empty() { - info!( - "receipts found: {} confirmed txs in block {}", - run_txs.len(), - target_block_num - ); db.insert_run_txs(run_id, &run_txs).map_err(|e| e.into())?; } @@ -602,6 +597,7 @@ fn get_tx_error( #[derive(Debug)] pub struct TxActorHandle { sender: mpsc::Sender, + flush_complete: CancellationToken, } #[derive(Debug)] @@ -642,8 +638,11 @@ impl TxActorHandle { // Spawn the independent flush task (communicates via channels) let flush_cancel = cancel_token.clone(); + let flush_complete = CancellationToken::new(); + let flush_done = flush_complete.clone(); crate::spawn_with_session(async move { flush_loop(flush_sender, db, rpc, flush_cancel).await; + flush_done.cancel(); }); // Spawn the flashblocks listener task if URL is provided @@ -655,7 +654,15 @@ impl TxActorHandle { }); } - Ok(Self { sender }) + Ok(Self { + sender, + flush_complete, + }) + } + + /// Waits until the flush loop has finished processing all receipts. + pub async fn await_flush(&self) { + self.flush_complete.cancelled().await; } /// Adds a new tx to the cache. From 9d52f02ead0c8c1da83dc04a864afa8d2e703830 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:43:32 -0700 Subject: [PATCH 20/34] cleanup rpc error messages --- crates/server/src/error.rs | 11 ++++------- crates/server/src/rpc_server/server.rs | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index c6ddd527..c1841a0c 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -21,8 +21,8 @@ pub enum ContenderRpcError { error: String, }, - #[error("Session {} is currently spamming with params: {:?}", _0.id, _0.status)] - SessionSpamming(ContenderSessionInfo), + #[error("Session {} is currently busy: {:?}", _0.id, _0.status)] + SessionBusy(ContenderSessionInfo), #[error("Invalid test config: {0}")] InvalidTestConfig(#[from] contender_testfile::Error), @@ -80,12 +80,9 @@ impl From for ErrorObjectOwned { Option::::None, ), - ContenderRpcError::SessionSpamming(info) => ErrorObject::owned( + ContenderRpcError::SessionBusy(info) => ErrorObject::owned( 7, - format!( - "Session {} is currently spamming with params: {:?}", - info.id, info.status - ), + format!("Session {} is currently busy: {}", info.id, info.status), Option::::None, ), diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index f447a2e9..975b1ccb 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -270,7 +270,7 @@ fn error_if_session_not_ready( .into()) } SessionStatus::Spamming(_) => { - return Err(ContenderRpcError::SessionSpamming(session_info.clone()).into()) + return Err(ContenderRpcError::SessionBusy(session_info.clone()).into()) } SessionStatus::Ready => (), _ => return Err(ContenderRpcError::SessionNotInitialized(session_info.clone()).into()), From 92fa00b4e49a499b2bb9c34b32980ee18ad9c24d Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:31:23 -0700 Subject: [PATCH 21/34] shutdown log streams when remove_session is called --- Cargo.lock | 1 + crates/server/Cargo.toml | 1 + crates/server/src/rpc_server/server.rs | 17 +++++++--- crates/server/src/sessions.rs | 8 +++++ crates/server/src/sse.rs | 43 ++++++++++++++++++++------ 5 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9b180fb..845dcab0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2314,6 +2314,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-stream", + "tokio-util", "tracing", "tracing-subscriber 0.3.20", ] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 968b9e97..4b7a0498 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -28,6 +28,7 @@ async-trait = { workspace = true } jsonrpsee = { workspace = true, features = ["server", "macros", "ws-client"] } thiserror.workspace = true tokio = { workspace = true, features = ["full"] } +tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } serde.workspace = true diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index 975b1ccb..80b02036 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -157,16 +157,23 @@ impl ContenderRpcServer for ContenderServer { return Ok(()); }; let mut rx = session.log_channel.subscribe(); + let cancel = session.cancel.clone(); drop(sessions); let sink = pending.accept().await?; tokio::spawn(async move { - while let Ok(msg) = rx.recv().await { - let sub_msg = - SubscriptionMessage::from_json(&msg).expect("failed to serialize log message"); - if sink.send(sub_msg).await.is_err() { - break; + loop { + tokio::select! { + result = rx.recv() => { + let Ok(msg) = result else { break }; + let sub_msg = + SubscriptionMessage::from_json(&msg).expect("failed to serialize log message"); + if sink.send(sub_msg).await.is_err() { + break; + } + } + _ = cancel.cancelled() => break, } } }); diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index e6bc50df..02117002 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -3,6 +3,7 @@ use contender_sqlite::SqliteDb; use contender_testfile::TestConfig; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; use crate::{ log_layer::SessionLogSinks, @@ -44,6 +45,8 @@ pub struct ContenderSession { pub info: ContenderSessionInfo, pub contender: Option>, pub log_channel: broadcast::Sender, + /// Cancelled when the session is removed; subscriber tasks should select on this. + pub cancel: CancellationToken, } pub struct NewSessionParams { @@ -69,6 +72,7 @@ impl ContenderSession { info, contender: Some(contender), log_channel, + cancel: CancellationToken::new(), } } } @@ -161,6 +165,10 @@ impl ContenderSessionCache { } pub fn remove_session(&mut self, id: usize) { + // Cancel subscriber streams before dropping the session. + if let Some(session) = self.get_session(id) { + session.cancel.cancel(); + } // Deregister the log sink. if let Ok(mut sinks) = self.log_sinks.try_write() { sinks.remove(&id); diff --git a/crates/server/src/sse.rs b/crates/server/src/sse.rs index e7e80c4a..7a15b4d9 100644 --- a/crates/server/src/sse.rs +++ b/crates/server/src/sse.rs @@ -7,7 +7,8 @@ use axum::{ Router, }; use tokio::sync::RwLock; -use tokio_stream::{wrappers::BroadcastStream, StreamExt}; +use tokio_stream::{wrappers::ReceiverStream, Stream}; +use tokio_util::sync::CancellationToken; use tracing::warn; use crate::sessions::ContenderSessionCache; @@ -27,7 +28,7 @@ async fn logs_handler( Path(session_id): Path, State(sessions): State, ) -> Result< - Sse>>, + Sse>>, (axum::http::StatusCode, String), > { let sessions = sessions.read().await; @@ -38,15 +39,39 @@ async fn logs_handler( ) })?; let rx = session.log_channel.subscribe(); + let cancel = session.cancel.clone(); drop(sessions); - let stream = BroadcastStream::new(rx).filter_map(|res| match res { - Ok(msg) => Some(Ok(Event::default().data(msg))), - Err(e) => { - warn!("SSE broadcast lag: {e}"); - None - } - }); + let stream = cancel_on_remove(rx, cancel); Ok(Sse::new(stream)) } + +/// Wraps a broadcast receiver into a stream that terminates when the cancel token fires. +fn cancel_on_remove( + mut rx: tokio::sync::broadcast::Receiver, + cancel: CancellationToken, +) -> impl Stream> { + let (tx, mpsc_rx) = tokio::sync::mpsc::channel::>(256); + tokio::spawn(async move { + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(msg) => { + if tx.send(Ok(Event::default().data(msg))).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("SSE broadcast lag: skipped {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + _ = cancel.cancelled() => break, + } + } + }); + ReceiverStream::new(mpsc_rx) +} From 915c4623071f783cab990b601d3b4c619c56c66f Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:03:30 -0700 Subject: [PATCH 22/34] add 'stop' method to terminate a session's spammer --- crates/core/src/orchestrator.rs | 51 ++++++++++++++++++++------ crates/server/src/error.rs | 9 +++++ crates/server/src/rpc_server/server.rs | 31 +++++++++++++++- crates/server/src/sessions.rs | 3 ++ crates/testfile/src/lib.rs | 2 +- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/crates/core/src/orchestrator.rs b/crates/core/src/orchestrator.rs index f6af36d8..0ec17ba9 100644 --- a/crates/core/src/orchestrator.rs +++ b/crates/core/src/orchestrator.rs @@ -29,6 +29,7 @@ use alloy::{ use contender_bundle_provider::bundle::BundleType; use contender_engine_provider::ControlChain; use std::sync::LazyLock; +use tokio_util::sync::CancellationToken; static SMOL_AMOUNT: LazyLock = LazyLock::new(|| WEI_IN_ETHER / U256::from(100)); @@ -504,11 +505,19 @@ where /// let callback = NilCallback; /// // initialize opts; slightly tweaking the defaults /// let opts = RunOpts::new().txs_per_period(50).periods(10); + /// // create a cancellation token that can be used to stop the spam run from outside the `Contender` (optional) + /// let cancel_token = tokio_util::sync::CancellationToken::new(); /// /// // run spammer - /// contender.spam(spammer, callback.into(), opts).await.unwrap(); + /// contender.spam(spammer, callback.into(), opts, Some(cancel_token.clone())).await.unwrap(); /// ``` - pub async fn spam(&mut self, spammer: SP, callback: Arc, opts: RunOpts) -> Result<()> + pub async fn spam( + &mut self, + spammer: SP, + callback: Arc, + opts: RunOpts, + cancel_token: Option, + ) -> Result<()> where F: OnTxSent + OnBatchSent + Send + Sync + 'static, SP: Spammer, @@ -537,16 +546,34 @@ where handle.init_ctx(actor_ctx.clone()).await?; } - // send spam - let result = spammer - .spam_rpc( - &mut scenario, - opts.txs_per_period, - opts.periods, - Some(run_id), - callback, - ) - .await; + // send spam; if an external cancel token was provided, select on it + // so we can abort mid-run (the cursor.next() inside spam_rpc won't + // check cancellation on its own). + let result = if let Some(external) = cancel_token { + tokio::select! { + res = spammer.spam_rpc( + &mut scenario, + opts.txs_per_period, + opts.periods, + Some(run_id), + callback, + ) => res, + _ = external.cancelled() => { + scenario.shutdown().await; + Ok(()) + } + } + } else { + spammer + .spam_rpc( + &mut scenario, + opts.txs_per_period, + opts.periods, + Some(run_id), + callback, + ) + .await + }; // Signal the flush loop that sending is done so it can shut down // once all receipts are processed (or after the stale block timeout). diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index c1841a0c..f6daf4b3 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -24,6 +24,9 @@ pub enum ContenderRpcError { #[error("Session {} is currently busy: {:?}", _0.id, _0.status)] SessionBusy(ContenderSessionInfo), + #[error("Session {0} is not currently spamming")] + SessionNotBusy(usize), + #[error("Invalid test config: {0}")] InvalidTestConfig(#[from] contender_testfile::Error), @@ -86,6 +89,12 @@ impl From for ErrorObjectOwned { Option::::None, ), + ContenderRpcError::SessionNotBusy(id) => ErrorObject::owned( + 9, + format!("Session {id} is not currently spamming"), + Option::::None, + ), + ContenderRpcError::SessionFailed { info, error } => ErrorObject::owned( 8, format!("Session {} failed with error: {error}", info.id), diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index 80b02036..0934909e 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -4,6 +4,7 @@ use jsonrpsee::{proc_macros::rpc, PendingSubscriptionSink, SubscriptionMessage}; use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; use tracing::{info, Instrument}; use crate::{ @@ -37,6 +38,9 @@ pub trait ContenderRpc { #[method(name = "spam")] async fn spam(&self, params: SpamParams) -> jsonrpsee::core::RpcResult; + #[method(name = "stop")] + async fn stop(&self, session_id: usize) -> jsonrpsee::core::RpcResult; + // ================ WS Methods ================ #[subscription(name = "subscribe_logs" => "session_log", item = String)] @@ -192,10 +196,12 @@ impl ContenderRpcServer for ContenderServer { drop(sessions); // Take the contender out so we can spam without holding the lock. + let spam_cancel = CancellationToken::new(); let contender = { let mut lock = self.sessions.write().await; if let Some(session) = lock.get_session_mut(session_id) { session.info.status = SessionStatus::Spamming(params.clone()); + session.spam_cancel = Some(spam_cancel.clone()); } lock.take_contender(session_id) }; @@ -219,11 +225,15 @@ impl ContenderRpcServer for ContenderServer { match spammer_type { SpammerType::Timed => { let spammer = TimedSpammer::new(Duration::from_secs(1)); - contender.spam(spammer, Arc::new($callback), opts).await + contender + .spam(spammer, Arc::new($callback), opts, Some(spam_cancel)) + .await } SpammerType::Blockwise => { let spammer = BlockwiseSpammer::new(); - contender.spam(spammer, Arc::new($callback), opts).await + contender + .spam(spammer, Arc::new($callback), opts, Some(spam_cancel)) + .await } } }; @@ -239,6 +249,9 @@ impl ContenderRpcServer for ContenderServer { // Put the contender back and log outcome. let mut lock = sessions.write().await; lock.put_contender(session_id, contender); + if let Some(session) = lock.get_session_mut(session_id) { + session.spam_cancel = None; + } match result { Ok(()) => { if let Some(session) = lock.get_session_mut(session_id) { @@ -261,6 +274,20 @@ impl ContenderRpcServer for ContenderServer { Ok(format!("Spamming session {session_id}")) } + + async fn stop(&self, session_id: usize) -> jsonrpsee::core::RpcResult { + let sessions = self.sessions.read().await; + let Some(session) = sessions.get_session(session_id) else { + return Err(ContenderRpcError::SessionNotFound(session_id).into()); + }; + let Some(ref token) = session.spam_cancel else { + return Err(ContenderRpcError::SessionNotBusy(session_id).into()); + }; + token.cancel(); + drop(sessions); + info!("Sent stop signal to session {session_id}"); + Ok(format!("Stopping session {session_id}")) + } } /// Helper function to check if a session is ready to spam, diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 02117002..58393d96 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -47,6 +47,8 @@ pub struct ContenderSession { pub log_channel: broadcast::Sender, /// Cancelled when the session is removed; subscriber tasks should select on this. pub cancel: CancellationToken, + /// Cancelled to stop a running spam. Reset each time spam is started. + pub spam_cancel: Option, } pub struct NewSessionParams { @@ -73,6 +75,7 @@ impl ContenderSession { contender: Some(contender), log_channel, cancel: CancellationToken::new(), + spam_cancel: None, } } } diff --git a/crates/testfile/src/lib.rs b/crates/testfile/src/lib.rs index b4c41753..92988e44 100644 --- a/crates/testfile/src/lib.rs +++ b/crates/testfile/src/lib.rs @@ -607,7 +607,7 @@ mod more_tests { let spammer = TimedSpammer::new(Duration::from_secs(1)); let callback = NilCallback; let opts = RunOpts::new().txs_per_period(100).periods(3); - contender.spam(spammer, callback.into(), opts).await?; + contender.spam(spammer, callback.into(), opts, None).await?; Ok(()) } From b391cb17c0e5b7f07b26d801283afb8a5242919d Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:09:49 -0700 Subject: [PATCH 23/34] cancel spammer when calling remove --- crates/server/src/sessions.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index 58393d96..f9f704c7 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -42,12 +42,19 @@ impl std::fmt::Display for SessionStatus { } pub struct ContenderSession { + /// Metadata about this session (id, name, rpc_url, status). pub info: ContenderSessionInfo, + /// The contender instance for this session. `None` while it is taken out for + /// initialization or spamming (to avoid holding the lock during long operations). pub contender: Option>, + /// Broadcast channel for per-session log lines. The tracing layer sends formatted + /// events here; WS and SSE subscribers receive from it. pub log_channel: broadcast::Sender, - /// Cancelled when the session is removed; subscriber tasks should select on this. + /// Session-lifetime token. Cancelled when the session is removed, which terminates + /// all WS/SSE log subscriber tasks. Once cancelled the session cannot be reused. pub cancel: CancellationToken, - /// Cancelled to stop a running spam. Reset each time spam is started. + /// Per-spam-run token. Created fresh each time `spam` is called, cancelled by `stop` + /// (or `remove`). After cancellation the session returns to `Ready` and can spam again. pub spam_cancel: Option, } @@ -168,8 +175,12 @@ impl ContenderSessionCache { } pub fn remove_session(&mut self, id: usize) { - // Cancel subscriber streams before dropping the session. if let Some(session) = self.get_session(id) { + // Stop any running spam before tearing down. + if let Some(ref token) = session.spam_cancel { + token.cancel(); + } + // Cancel subscriber streams before dropping the session. session.cancel.cancel(); } // Deregister the log sink. From d3cabc64100fd4bc989543685a5f93b59b902cef Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:25:32 -0700 Subject: [PATCH 24/34] reduce streamed log verbosity + stream log for 'stop' method --- crates/server/src/log_layer.rs | 3 +-- crates/server/src/rpc_server/server.rs | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/server/src/log_layer.rs b/crates/server/src/log_layer.rs index ea5515b2..d9495dfc 100644 --- a/crates/server/src/log_layer.rs +++ b/crates/server/src/log_layer.rs @@ -115,11 +115,10 @@ fn format_event(event: &Event<'_>, session_id: usize) -> String { ); format!( - "{} {} session[{}] {}: {}", + "{} {} session[{}]: {}", timestamp, metadata.level(), session_id, - metadata.target(), visitor.message ) } diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index 0934909e..b44075ee 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -276,6 +276,7 @@ impl ContenderRpcServer for ContenderServer { } async fn stop(&self, session_id: usize) -> jsonrpsee::core::RpcResult { + let span = tracing::info_span!("session_stop", id = session_id); let sessions = self.sessions.read().await; let Some(session) = sessions.get_session(session_id) else { return Err(ContenderRpcError::SessionNotFound(session_id).into()); @@ -285,7 +286,10 @@ impl ContenderRpcServer for ContenderServer { }; token.cancel(); drop(sessions); - info!("Sent stop signal to session {session_id}"); + { + let _enter = span.enter(); + info!("Sent stop signal to session {session_id}"); + } Ok(format!("Stopping session {session_id}")) } } From ebabd2157d952a5eebfa10faf2a5001e3d15cf28 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:17:06 -0700 Subject: [PATCH 25/34] add basic web interface for contender API --- Cargo.lock | 26 +- Cargo.toml | 1 + crates/server/Cargo.toml | 6 + crates/server/build.rs | 350 ++++++++++++++++ crates/server/src/main.rs | 11 +- crates/server/src/rpc_server/server.rs | 9 +- crates/server/src/rpc_server/types.rs | 6 + crates/server/static/builtin_scenarios.js | 59 +++ crates/server/static/index.html | 475 ++++++++++++++++++++++ 9 files changed, 936 insertions(+), 7 deletions(-) create mode 100644 crates/server/build.rs create mode 100644 crates/server/static/builtin_scenarios.js create mode 100644 crates/server/static/index.html diff --git a/Cargo.lock b/Cargo.lock index 845dcab0..9730c67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2309,12 +2309,16 @@ dependencies = [ "contender_sqlite", "contender_testfile", "jsonrpsee 0.24.10", + "quote", "serde", "serde_json", + "syn 2.0.108", "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", + "tower 0.4.13", + "tower-http 0.5.2", "tracing", "tracing-subscriber 0.3.20", ] @@ -6434,7 +6438,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower 0.5.2", - "tower-http", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", @@ -8477,7 +8481,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.5.2", - "tower-http", + "tower-http 0.6.6", "tracing", ] @@ -8639,7 +8643,7 @@ dependencies = [ "jsonrpsee-http-client", "pin-project", "tower 0.5.2", - "tower-http", + "tower-http 0.6.6", "tracing", ] @@ -10629,6 +10633,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.6" diff --git a/Cargo.toml b/Cargo.toml index 32af9bbb..46aa5a84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,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 } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4b7a0498..b700ede0 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -34,4 +34,10 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } serde.workspace = true serde_json.workspace = true axum = { workspace = true } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors"] } tokio-stream.workspace = true + +[build-dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" diff --git a/crates/server/build.rs b/crates/server/build.rs new file mode 100644 index 00000000..dfa1c99c --- /dev/null +++ b/crates/server/build.rs @@ -0,0 +1,350 @@ +//! Build script that generates `static/builtin_scenarios.js` — a JavaScript +//! constant describing the parameter schema for every builtin scenario. +//! +//! Uses `syn` to parse the actual Rust token stream from the CLI crate, +//! extracting field names, types, defaults, and help text from `#[arg(...)]` +//! attributes and doc comments. + +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use quote::ToTokens; +use syn::{Attribute, Expr, Fields, Item, Lit, Meta, Type}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let cli_dir = manifest_dir + .parent() + .unwrap() + .join("cli/src/default_scenarios"); + + // (UI name, source file relative to cli_dir, struct name) + let scenarios: &[(&str, &str, &str)] = &[ + ("Blobs", "blobs.rs", "BlobsCliArgs"), + ("Contract", "custom_contract.rs", "CustomContractCliArgs"), + ( + "EthFunctions", + "eth_functions/command.rs", + "EthFunctionsCliArgs", + ), + ("Erc20", "erc20.rs", "Erc20CliArgs"), + ("FillBlock", "fill_block.rs", "FillBlockCliArgs"), + ("Revert", "revert.rs", "RevertCliArgs"), + ("SetCode", "setcode/base.rs", "SetCodeCliArgs"), + ("Storage", "storage.rs", "StorageStressCliArgs"), + ("Stress", "stress.rs", "StressCliArgs"), + ("Transfers", "transfers.rs", "TransferStressCliArgs"), + ("UniV2", "uni_v2.rs", "UniV2CliArgs"), + ]; + + let enum_files: &[(&str, &str)] = &[ + ("EthereumOpcode", "eth_functions/opcodes.rs"), + ("EthereumPrecompile", "eth_functions/precompiles.rs"), + ]; + + let enum_variants = parse_enum_files(&cli_dir, enum_files); + + let mut js = + String::from("// AUTO-GENERATED by build.rs — do not edit\nconst BUILTIN_SCENARIOS = {\n"); + + for (name, file, struct_name) in scenarios { + let path = cli_dir.join(file); + println!("cargo:rerun-if-changed={}", path.display()); + + let source = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!("Failed to read {}: {e}", path.display()); + }); + let ast = syn::parse_file(&source).unwrap_or_else(|e| { + panic!("Failed to parse {}: {e}", path.display()); + }); + + let fields = extract_fields(&ast, struct_name, &enum_variants); + + let key = to_kebab_case(name); + js.push_str(&format!(" \"{key}\": [\n")); + for f in &fields { + js.push_str(&format!( + " {{ name: {}, type: {}, default: {}, help: {}, optional: {} }},\n", + js_str(&f.name), + js_str(&f.field_type), + js_opt(&f.default), + js_opt(&f.help), + if f.optional { "true" } else { "false" }, + )); + } + js.push_str(" ],\n"); + } + js.push_str("};\n"); + + let static_dir = manifest_dir.join("static"); + fs::create_dir_all(&static_dir).unwrap(); + fs::write(static_dir.join("builtin_scenarios.js"), &js).unwrap(); + + println!("cargo:rerun-if-changed=build.rs"); +} + +// ── types ──────────────────────────────────────────────────────────── + +#[derive(Debug)] +struct FieldInfo { + name: String, + field_type: String, + default: Option, + help: Option, + optional: bool, +} + +// ── JS helpers ─────────────────────────────────────────────────────── + +/// Convert PascalCase to kebab-case to match `#[serde(rename_all = "kebab-case")]`. +fn to_kebab_case(s: &str) -> String { + let mut result = String::new(); + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c.is_uppercase() && i > 0 { + let prev = chars[i - 1]; + if prev.is_lowercase() || prev.is_ascii_digit() { + result.push('-'); + } else if i + 1 < chars.len() && chars[i + 1].is_lowercase() { + result.push('-'); + } + } + result.push(c.to_ascii_lowercase()); + } + result +} + +fn js_str(s: &str) -> String { + format!( + "\"{}\"", + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + ) +} + +fn js_opt(v: &Option) -> String { + match v { + Some(s) => js_str(s), + None => "null".into(), + } +} + +// ── enum parsing ───────────────────────────────────────────────────── + +fn parse_enum_files(cli_dir: &Path, enums: &[(&str, &str)]) -> HashMap> { + let mut map = HashMap::new(); + for (enum_name, file) in enums { + let path = cli_dir.join(file); + println!("cargo:rerun-if-changed={}", path.display()); + let source = fs::read_to_string(&path).unwrap_or_default(); + let ast = syn::parse_file(&source).unwrap(); + for item in &ast.items { + if let Item::Enum(e) = item { + if e.ident == enum_name { + let variants: Vec = + e.variants.iter().map(|v| v.ident.to_string()).collect(); + map.insert(enum_name.to_string(), variants); + } + } + } + } + map +} + +// ── struct field extraction ────────────────────────────────────────── + +fn extract_fields( + ast: &syn::File, + struct_name: &str, + enum_variants: &HashMap>, +) -> Vec { + for item in &ast.items { + if let Item::Struct(s) = item { + if s.ident == struct_name { + return fields_from_struct(s, enum_variants); + } + } + } + vec![] +} + +fn fields_from_struct( + s: &syn::ItemStruct, + enum_variants: &HashMap>, +) -> Vec { + let Fields::Named(ref named) = s.fields else { + return vec![]; + }; + + let mut out = Vec::new(); + + for field in &named.named { + // Skip #[command(flatten)] and #[command(subcommand)] fields. + if has_command_attr(field, "flatten") || has_command_attr(field, "subcommand") { + continue; + } + + let name = field + .ident + .as_ref() + .map(|i| i.to_string()) + .unwrap_or_default(); + + let raw_ty = type_to_string(&field.ty); + let optional = raw_ty.starts_with("Option <") || raw_ty.starts_with("Option<"); + let field_type = rust_type_to_js(&raw_ty, enum_variants); + + let arg_attrs = collect_arg_kv(&field.attrs); + let default = arg_attrs + .get("default_value") + .cloned() + .or_else(|| arg_attrs.get("default_value_t").cloned()); + let help = arg_attrs + .get("long_help") + .cloned() + .or_else(|| arg_attrs.get("help").cloned()) + .or_else(|| doc_comment(&field.attrs)); + + out.push(FieldInfo { + name, + field_type, + default, + help, + optional, + }); + } + out +} + +/// Check if a field has `#[command(subcommand)]` or `#[command(flatten)]`. +fn has_command_attr(field: &syn::Field, keyword: &str) -> bool { + field.attrs.iter().any(|attr| { + if !attr.path().is_ident("command") { + return false; + } + let tokens = attr.meta.to_token_stream().to_string(); + tokens.contains(keyword) + }) +} + +/// Collect key-value pairs from `#[arg(...)]` attributes. +fn collect_arg_kv(attrs: &[Attribute]) -> HashMap { + let mut map = HashMap::new(); + for attr in attrs { + if !attr.path().is_ident("arg") { + continue; + } + if let Meta::List(list) = &attr.meta { + let tokens = list.tokens.to_string(); + for segment in split_top_level(&tokens) { + let segment = segment.trim(); + if let Some((k, v)) = segment.split_once('=') { + let k = k.trim(); + let v = v.trim(); + // Parse quoted values through syn::LitStr to properly unescape. + let value = if v.starts_with('"') { + syn::parse_str::(v) + .map(|lit| lit.value()) + .unwrap_or_else(|_| v.to_string()) + } else { + v.to_string() + }; + map.insert(k.to_string(), value); + } + } + } + } + map +} + +/// Split a token stream string on top-level commas (not inside parens/brackets/quotes). +fn split_top_level(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0u32; + let mut in_string = false; + let mut prev = '\0'; + for ch in s.chars() { + if ch == '"' && prev != '\\' { + in_string = !in_string; + } + if !in_string { + match ch { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(std::mem::take(&mut current)); + prev = ch; + continue; + } + _ => {} + } + } + current.push(ch); + prev = ch; + } + if !current.trim().is_empty() { + parts.push(current); + } + parts +} + +/// Extract `/// doc comments` concatenated into a single string. +fn doc_comment(attrs: &[Attribute]) -> Option { + let docs: Vec = attrs + .iter() + .filter_map(|attr| { + if !attr.path().is_ident("doc") { + return None; + } + if let Meta::NameValue(nv) = &attr.meta { + if let Expr::Lit(lit) = &nv.value { + if let Lit::Str(s) = &lit.lit { + return Some(s.value().trim().to_string()); + } + } + } + None + }) + .filter(|s| !s.is_empty()) + .collect(); + if docs.is_empty() { + None + } else { + Some(docs.join(" ")) + } +} + +fn type_to_string(ty: &Type) -> String { + ty.to_token_stream().to_string() +} + +fn rust_type_to_js(raw: &str, enum_variants: &HashMap>) -> String { + let inner = raw + .replace("Option <", "Option<") + .replace("Vec <", "Vec<") + .replace("Option<", "") + .replace("Vec<", "") + .replace('>', "") + .replace(' ', ""); + + for (enum_name, variants) in enum_variants { + if inner.contains(enum_name.as_str()) { + let opts = variants.join(","); + if raw.contains("Vec") { + return format!("multi-select:{opts}"); + } + return format!("select:{opts}"); + } + } + + match inner.as_str() { + "bool" => "bool".into(), + "u32" | "u64" | "i64" | "usize" => "number".into(), + _ => "text".into(), + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 771822d3..81072e6c 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -7,6 +7,7 @@ use contender_server::sse::sse_router; use jsonrpsee::server::{Server, ServerHandle}; use tokio::sync::RwLock; use tokio::task::JoinHandle; +use tower_http::cors::{Any, CorsLayer}; use tracing::info; #[tokio::main] @@ -49,7 +50,15 @@ async fn start_rpc_server( sessions: Arc>, addr: &str, ) -> std::io::Result { - let server = Server::builder().build(addr).await?; + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let server = Server::builder() + .set_http_middleware(tower::ServiceBuilder::new().layer(cors)) + .build(addr) + .await?; let module = ContenderServer::new(sessions).into_rpc(); let handle = server.start(module); diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index b44075ee..e6fcbe6d 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -7,6 +7,7 @@ use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use tracing::{info, Instrument}; +use crate::rpc_server::ServerStatus; use crate::{ error::ContenderRpcError, rpc_server::types::{AddSessionParams, SpamParams, SpammerType}, @@ -18,7 +19,7 @@ pub trait ContenderRpc { // ================ RPC Methods ================ #[method(name = "status")] - async fn status(&self) -> jsonrpsee::core::RpcResult; + async fn status(&self) -> jsonrpsee::core::RpcResult; #[method(name = "add_session")] async fn add_session( @@ -59,9 +60,11 @@ impl ContenderServer { #[async_trait::async_trait] impl ContenderRpcServer for ContenderServer { - async fn status(&self) -> jsonrpsee::core::RpcResult { + async fn status(&self) -> jsonrpsee::core::RpcResult { let sessions = self.sessions.read().await; - Ok(format!("{} session(s) active", sessions.num_sessions())) + Ok(ServerStatus { + num_sessions: sessions.num_sessions(), + }) } async fn add_session( diff --git a/crates/server/src/rpc_server/types.rs b/crates/server/src/rpc_server/types.rs index af1c08c4..1fd71589 100644 --- a/crates/server/src/rpc_server/types.rs +++ b/crates/server/src/rpc_server/types.rs @@ -15,6 +15,12 @@ use serde::{Deserialize, Serialize}; use std::str::FromStr; use tracing::debug; +/// Data returned from the `status` endpoint, containing general info about the server. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ServerStatus { + pub num_sessions: usize, +} + /// RPC parameters for adding a new contender session. #[derive(Clone, Debug, Deserialize)] pub struct AddSessionParams { diff --git a/crates/server/static/builtin_scenarios.js b/crates/server/static/builtin_scenarios.js new file mode 100644 index 00000000..ea666746 --- /dev/null +++ b/crates/server/static/builtin_scenarios.js @@ -0,0 +1,59 @@ +// AUTO-GENERATED by build.rs — do not edit +const BUILTIN_SCENARIOS = { + "blobs": [ + { name: "blob_data", type: "text", default: "0xdeadbeef", help: "Blob data. Values can be hexidecimal or UTF-8 strings.", optional: false }, + { name: "recipient", type: "text", default: null, help: "The recipient of the blob transactions. Defaults to sender's address. May be a contract placeholder from a previous contender setup.", optional: true }, + ], + "contract": [ + { name: "contract_path", type: "text", default: null, help: "Path to smart contract source. Format: :", optional: false }, + { name: "constructor_args", type: "text", default: null, help: "Comma-separated constructor arguments. Format: \"arg1, arg2, ...\" ", optional: true }, + { name: "setup_calls", type: "text", default: null, help: "Setup function calls that run once before spamming. May be specified multiple times. Example: `--spam \"setNumber(123456)\"`", optional: false }, + { name: "spam_calls", type: "text", default: null, help: "Spam function calls. May be specified multiple times. Example: `--spam \"setNumber(123456)\"`", optional: false }, + ], + "eth-functions": [ + { name: "opcodes", type: "multi-select:Stop,Add,Mul,Sub,Div,Sdiv,Mod,Smod,Addmod,Mulmod,Exp,Signextend,Lt,Gt,Slt,Sgt,Eq,Iszero,And,Or,Xor,Not,Byte,Shl,Shr,Sar,Sha3,Keccak256,Address,Balance,Origin,Caller,Callvalue,Calldataload,Calldatasize,Calldatacopy,Codesize,Codecopy,Gasprice,Extcodesize,Extcodecopy,Returndatasize,Returndatacopy,Extcodehash,Blockhash,Coinbase,Timestamp,Number,Prevrandao,Gaslimit,Chainid,Selfbalance,Basefee,Pop,Mload,Mstore,Mstore8,Sload,Sstore,Msize,Gas,Log0,Log1,Log2,Log3,Log4,Create,Call,Callcode,Return,Delegatecall,Create2,Staticcall,Revert,Invalid,Selfdestruct", default: null, help: "Comma-separated list of opcodes to call in spam transactions.", optional: false }, + { name: "precompiles", type: "multi-select:HashSha256,HashRipemd160,Identity,ModExp,EcAdd,EcMul,EcPairing,Blake2f", default: null, help: "Comma-separated list of precompiles to call in spam transactions.", optional: false }, + { name: "num_iterations", type: "number", default: "10", help: "Number of times to call an opcode/precompile in a single transaction.", optional: false }, + ], + "erc20": [ + { name: "send_amount", type: "text", default: "DEFAULT_TOKENS_SENT", help: "The amount to send in each spam tx.", optional: false }, + { name: "fund_amount", type: "text", default: "DEFAULT_TOKENS_FUNDED", help: "The amount of tokens to give each spammer account before spamming starts.", optional: false }, + { name: "token_recipient", type: "text", default: null, help: "The address to receive tokens sent by spam txs. By default, address(0) receives the tokens.", optional: true }, + ], + "fill-block": [ + { name: "max_gas_per_block", type: "number", default: null, help: "Override gas used per block. By default, the block limit is used.", optional: true }, + ], + "revert": [ + { name: "gas_use", type: "number", default: "30_000", help: "Amount of gas to use before reverting. This number + 35k gas is added to each tx's gas limit.", optional: false }, + ], + "set-code": [ + { name: "contract_address", type: "text", default: null, help: "The contract address containing the bytecode to copy into the sender's EOA. May be a placeholder. If not set, a test contract will be deployed.", optional: true }, + { name: "signature", type: "text", default: null, help: "The solidity signature of the function to call after setCode changes the account's bytecode.\nExample (smart wallet):\n--sig \"execute((address to, uint256 value, bytes data)[])\"", optional: true }, + { name: "args", type: "text", default: null, help: "Comma-separated arguments to the function being called on the EOA after the setCode transaction executes.\nExample (smart wallet):\n--args \"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266,0,0xd09de08a\"", optional: true }, + ], + "storage": [ + { name: "num_slots", type: "number", default: "500", help: "Number of storage slots to fill with random data.", optional: false }, + { name: "num_iterations", type: "number", default: "1", help: "Number of times to write over each storage slot.", optional: false }, + ], + "stress": [ + { name: "disable_storage", type: "bool", default: "false", help: "Remove storage stress txs from the scenario.", optional: false }, + { name: "disable_transfers", type: "bool", default: "false", help: "Remove transfer stress txs from the scenario.", optional: false }, + { name: "disable_opcodes", type: "multi-select:Stop,Add,Mul,Sub,Div,Sdiv,Mod,Smod,Addmod,Mulmod,Exp,Signextend,Lt,Gt,Slt,Sgt,Eq,Iszero,And,Or,Xor,Not,Byte,Shl,Shr,Sar,Sha3,Keccak256,Address,Balance,Origin,Caller,Callvalue,Calldataload,Calldatasize,Calldatacopy,Codesize,Codecopy,Gasprice,Extcodesize,Extcodecopy,Returndatasize,Returndatacopy,Extcodehash,Blockhash,Coinbase,Timestamp,Number,Prevrandao,Gaslimit,Chainid,Selfbalance,Basefee,Pop,Mload,Mstore,Mstore8,Sload,Sstore,Msize,Gas,Log0,Log1,Log2,Log3,Log4,Create,Call,Callcode,Return,Delegatecall,Create2,Staticcall,Revert,Invalid,Selfdestruct", default: null, help: "Comma-separated list of opcodes to be ignored in the scenario.", optional: true }, + { name: "disable_precompiles", type: "multi-select:HashSha256,HashRipemd160,Identity,ModExp,EcAdd,EcMul,EcPairing,Blake2f", default: null, help: "Comma-separated list of precompiles to be ignored in the scenario.", optional: true }, + { name: "disable_all_precompiles", type: "bool", default: "false", help: "Disable all precompiles in the scenario.", optional: false }, + { name: "disable_all_opcodes", type: "bool", default: "false", help: "Disable all opcodes in the scenario.", optional: false }, + { name: "opcode_iterations", type: "number", default: "10", help: "Number of times to call an opcode in a single tx.", optional: false }, + { name: "with_fails", type: "bool", default: "false", help: "Enables all precompiles & opcodes. By default, the ones that typically fail are disabled.", optional: false }, + ], + "transfers": [ + { name: "amount", type: "text", default: "0.001 eth", help: "Amount of tokens to transfer in each transaction.", optional: false }, + { name: "recipient", type: "text", default: null, help: "Address to receive ether sent from spammers.", optional: true }, + ], + "uni-v2": [ + { name: "num_tokens", type: "number", default: "2", help: "The number of tokens to create in the scenario. Each token will be paired with WETH and each other token.", optional: false }, + { name: "weth_per_token", type: "text", default: "1 eth", help: "The amount of ETH to deposit into each TOKEN pool. One additional multiple of this is also minted for trading.", optional: false }, + { name: "initial_token_supply", type: "text", default: "5000000 eth", help: "The initial amount minted for each token. 50% of this will be deposited among trading pools. Units must be provided, e.g. '1 eth' to mint 1 token with 1e18 decimal precision.", optional: false }, + { name: "weth_trade_amount", type: "text", default: null, help: "The amount of WETH to trade in the scenario. If not provided, 0.01% of the pool's initial WETH will be traded for each token. Units must be provided, e.g. '0.1 eth'.", optional: true }, + { name: "token_trade_amount", type: "text", default: null, help: "The amount of tokens to trade in the scenario. If not provided, 0.01% of the initial supply will be traded for each token.", optional: true }, + ], +}; diff --git a/crates/server/static/index.html b/crates/server/static/index.html new file mode 100644 index 00000000..30cb0c22 --- /dev/null +++ b/crates/server/static/index.html @@ -0,0 +1,475 @@ + + + + + +Contender Server + + + + +

Contender Server

+
+ + + + disconnected +
+ +
+ + + + + From 447203543c7dcf47a39966a62fcae74ec6bf4425 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:35:59 -0700 Subject: [PATCH 26/34] (web) 2-column ui, auto log subscription & aggregate status polling --- crates/server/src/rpc_server/server.rs | 8 + crates/server/src/sessions.rs | 4 + crates/server/static/index.html | 385 +++++++++++++++++-------- 3 files changed, 276 insertions(+), 121 deletions(-) diff --git a/crates/server/src/rpc_server/server.rs b/crates/server/src/rpc_server/server.rs index e6fcbe6d..b666036b 100644 --- a/crates/server/src/rpc_server/server.rs +++ b/crates/server/src/rpc_server/server.rs @@ -33,6 +33,9 @@ pub trait ContenderRpc { id: usize, ) -> jsonrpsee::core::RpcResult>; + #[method(name = "get_all_sessions")] + async fn get_all_sessions(&self) -> jsonrpsee::core::RpcResult>; + #[method(name = "remove_session")] async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()>; @@ -141,6 +144,11 @@ impl ContenderRpcServer for ContenderServer { Ok(sessions.get_session(id).map(|s| s.info.clone())) } + async fn get_all_sessions(&self) -> jsonrpsee::core::RpcResult> { + let sessions = self.sessions.read().await; + Ok(sessions.all_sessions()) + } + async fn remove_session(&self, id: usize) -> jsonrpsee::core::RpcResult<()> { let mut sessions = self.sessions.write().await; sessions.remove_session(id); diff --git a/crates/server/src/sessions.rs b/crates/server/src/sessions.rs index f9f704c7..10885c71 100644 --- a/crates/server/src/sessions.rs +++ b/crates/server/src/sessions.rs @@ -190,6 +190,10 @@ impl ContenderSessionCache { self.sessions.retain(|s| s.info.id != id); } + pub fn all_sessions(&self) -> Vec { + self.sessions.iter().map(|s| s.info.clone()).collect() + } + pub fn num_sessions(&self) -> usize { self.sessions.len() } diff --git a/crates/server/static/index.html b/crates/server/static/index.html index 30cb0c22..a19f8d42 100644 --- a/crates/server/static/index.html +++ b/crates/server/static/index.html @@ -15,6 +15,7 @@ --input-bg: #0f3460; --success: #4ecca3; --error: #e94560; + --warning: #f0a500; } * { box-sizing: border-box; margin: 0; padding: 0; } body { @@ -22,10 +23,10 @@ background: var(--bg); color: var(--text); padding: 24px; - max-width: 860px; margin: 0 auto; } h1 { font-size: 1.4rem; margin-bottom: 8px; } + h2 { font-size: 1rem; margin-bottom: 10px; color: var(--muted); } .server-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 20px; } @@ -33,6 +34,13 @@ flex: 1; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text); font-family: inherit; font-size: .85rem; } + .columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; + } + .column { min-width: 0; } .cabinet { border: 1px solid var(--border); border-radius: 6px; @@ -50,6 +58,7 @@ } .cabinet-header:hover { background: var(--border); } .cabinet-header .name { font-weight: bold; font-size: .95rem; } + .cabinet-header .meta { font-size: .78rem; color: var(--muted); display: flex; align-items: center; gap: 6px; } .cabinet-header .arrow { transition: transform .2s; } .cabinet.open .cabinet-header .arrow { transform: rotate(90deg); } .cabinet-body { @@ -83,12 +92,12 @@ .result.ok { background: rgba(78,204,163,.12); border: 1px solid var(--success); } .result.err { background: rgba(233,69,96,.12); border: 1px solid var(--error); } .log-box { - margin-top: 10px; background: #111; border: 1px solid var(--border); border-radius: 4px; + background: #111; border: 1px solid var(--border); border-radius: 4px; padding: 8px 10px; font-size: .78rem; max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; color: var(--muted); } .log-box .line { color: var(--text); } - .log-controls { display: flex; gap: 8px; align-items: center; margin-top: 6px; } + .log-controls { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; } .log-controls button { background: var(--surface); color: var(--text); border: 1px solid var(--border); padding: 4px 12px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: .8rem; @@ -99,6 +108,14 @@ } .status-dot.connected { background: var(--success); } .status-dot.disconnected { background: var(--error); } + .status-badge { + font-size: .72rem; padding: 2px 8px; border-radius: 3px; font-weight: bold; + } + .status-badge.initializing { background: var(--warning); color: #000; } + .status-badge.ready { background: var(--success); color: #000; } + .status-badge.spamming { background: var(--accent); color: #fff; } + .status-badge.failed { background: var(--error); color: #fff; } + .no-sessions { font-size: .85rem; color: var(--muted); padding: 20px 0; text-align: center; } @@ -111,7 +128,15 @@

Contender Server

disconnected -
+
+
+

Controls

+
+
+

Sessions

+
No sessions
+
+
From 46c23f318938417000f499989d99112fbe45f0a8 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:30:18 -0700 Subject: [PATCH 27/34] move stop button to session cabinet --- crates/server/static/index.html | 36 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/server/static/index.html b/crates/server/static/index.html index a19f8d42..b95a85eb 100644 --- a/crates/server/static/index.html +++ b/crates/server/static/index.html @@ -406,6 +406,10 @@

Sessions

const arrow = state.cab._metaRight.querySelector('.arrow'); state.cab._metaRight.innerHTML = badgeHtml + ' '; state.cab._metaRight.appendChild(arrow); + // Update stopButton visibility for live status + if (state.stopButton) { + state.stopButton.hidden = !(s.status && typeof s.status === 'object' && Object.keys(s.status).includes('Spamming')); + } // Re-attach if cabinet was detached from the DOM if (!state.cab.parentNode) list.appendChild(state.cab); } else { @@ -420,17 +424,36 @@

Sessions

const cab = makeCabinet(`#${s.id} ${s.name}`, body => { const controls = document.createElement('div'); controls.className = 'log-controls'; + const clearBtn = document.createElement('button'); clearBtn.textContent = 'Clear logs'; clearBtn.onclick = (e) => { e.stopPropagation(); logBox.textContent = ''; clearLogs(s.id); }; + + const stopButton = sendBtn('Stop spammer'); + stopButton.onclick = async (e) => { + e.stopPropagation(); + try { + await rpc("stop", [s.id]); + } catch (e) { + console.error("failed to stop session", e) + } + }; + stopButton.hidden = !(s.status && typeof s.status === 'object' && Object.keys(s.status).includes('Spamming')); + controls.appendChild(clearBtn); + controls.appendChild(stopButton); + body.appendChild(controls); body.appendChild(logBox); + + // Store stopButton reference for live updates + if (!sessionState[s.id]) sessionState[s.id] = {}; + sessionState[s.id].stopButton = stopButton; }, { metaHtml: badgeHtml + ' ', }); - sessionState[s.id] = { ws: null, logBox, cab }; + sessionState[s.id] = { ...sessionState[s.id], ws: null, logBox, cab }; list.appendChild(cab); // Subscribe immediately so logs accumulate in the background @@ -602,17 +625,6 @@

Sessions

}; })); -// ========== stop ========== -controls.appendChild(makeCabinet('stop', body => { - const idF = field('session_id', 'number', { value: '0', min: '0' }); - body.appendChild(idF.container); - const btn = sendBtn('Stop'); - body.appendChild(btn); - btn.onclick = async () => { - try { showResult(body, true, await rpc('stop', [parseInt(idF.input.value)])); } - catch (e) { showResult(body, false, e); } - }; -})); From 71b58c11aa2b72960ff69bb41c4c4b0feeee44d6 Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:41:19 -0700 Subject: [PATCH 28/34] cleanup spam status readout --- crates/server/static/index.html | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/server/static/index.html b/crates/server/static/index.html index b95a85eb..e25f3f3a 100644 --- a/crates/server/static/index.html +++ b/crates/server/static/index.html @@ -580,8 +580,8 @@

Sessions

body.appendChild(idF.container); const btn = sendBtn('Remove'); body.appendChild(btn); + const id = parseInt(idF.input.value); btn.onclick = async () => { - const id = parseInt(idF.input.value); try { showResult(body, true, await rpc('remove_session', [id])); clearLogs(id); @@ -599,6 +599,7 @@

Sessions

const spammerF = field('spammer', 'select', { options: ['timed', 'blockwise'] }); const nameF = field('name (optional)', 'text', { placeholder: 'my-run' }); const receiptsF = field('save_receipts', 'select', { options: ['false', 'true'] }); + const sessionId = parseInt(idF.input.value); body.appendChild(idF.container); const row = document.createElement('div'); row.className = 'row'; @@ -609,19 +610,37 @@

Sessions

body.appendChild(nameF.container); body.appendChild(receiptsF.container); + // Message element for feedback + const msg = document.createElement('div'); + msg.style.margin = '10px 0'; + msg.style.fontSize = '.9rem'; + msg.style.color = 'var(--success)'; + msg.style.display = 'none'; + body.appendChild(msg); + const btn = sendBtn('Spam'); body.appendChild(btn); btn.onclick = async () => { const params = { - session_id: parseInt(idF.input.value), + session_id: sessionId, txs_per_period: parseInt(tppF.input.value), duration: parseInt(durF.input.value), spammer: spammerF.input.value, save_receipts: receiptsF.input.value === 'true', }; if (nameF.input.value) params.name = nameF.input.value; - try { showResult(body, true, await rpc('spam', [params])); } - catch (e) { showResult(body, false, e); } + try { + await rpc('spam', [params]); + msg.textContent = `Spamming session ${sessionId}...`; + msg.style.display = ''; + setTimeout(() => { msg.style.display = 'none'; }, 3000); + } + catch (e) { + msg.textContent = 'Error: ' + (e && e.message ? e.message : e); + msg.style.color = 'var(--error)'; + msg.style.display = ''; + setTimeout(() => { msg.style.display = 'none'; msg.style.color = 'var(--success)'; }, 4000); + } }; })); From d0c8e42d820b68190d20dfbd603a69d14fb7fe5e Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:42:11 -0700 Subject: [PATCH 29/34] disable spam button when target session is already spamming --- crates/server/static/index.html | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/server/static/index.html b/crates/server/static/index.html index e25f3f3a..7c57a3f2 100644 --- a/crates/server/static/index.html +++ b/crates/server/static/index.html @@ -599,7 +599,6 @@

Sessions

const spammerF = field('spammer', 'select', { options: ['timed', 'blockwise'] }); const nameF = field('name (optional)', 'text', { placeholder: 'my-run' }); const receiptsF = field('save_receipts', 'select', { options: ['false', 'true'] }); - const sessionId = parseInt(idF.input.value); body.appendChild(idF.container); const row = document.createElement('div'); row.className = 'row'; @@ -620,7 +619,33 @@

Sessions

const btn = sendBtn('Spam'); body.appendChild(btn); + + // --- Spam button enable/disable logic --- + let lastSessions = []; + function updateSpamButton() { + const sessionId = parseInt(idF.input.value); + const session = lastSessions.find(s => s.id === sessionId); + if (session && session.status && typeof session.status === 'object' && Object.keys(session.status).includes('Spamming')) { + btn.disabled = true; + btn.title = 'This session is currently spamming.'; + } else { + btn.disabled = false; + btn.title = ''; + } + } + idF.input.addEventListener('input', updateSpamButton); + + // Patch renderSessions to call updateSpamButton with latest sessions + const origRenderSessions = window.renderSessions || renderSessions; + function patchedRenderSessions(sessions) { + lastSessions = sessions; + updateSpamButton(); + return origRenderSessions.apply(this, arguments); + } + window.renderSessions = patchedRenderSessions; + btn.onclick = async () => { + const sessionId = parseInt(idF.input.value); const params = { session_id: sessionId, txs_per_period: parseInt(tppF.input.value), @@ -634,6 +659,8 @@

Sessions

msg.textContent = `Spamming session ${sessionId}...`; msg.style.display = ''; setTimeout(() => { msg.style.display = 'none'; }, 3000); + btn.disabled = true; + btn.title = 'This session is currently spamming.'; } catch (e) { msg.textContent = 'Error: ' + (e && e.message ? e.message : e); From a3629b6e775c9676097a66f0ce65fee3df8f5dfc Mon Sep 17 00:00:00 2001 From: zeroXbrock <2791467+zeroXbrock@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:49:00 -0700 Subject: [PATCH 30/34] confirm before removing session --- crates/server/static/index.html | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/server/static/index.html b/crates/server/static/index.html index 7c57a3f2..2a9d52f8 100644 --- a/crates/server/static/index.html +++ b/crates/server/static/index.html @@ -138,6 +138,15 @@

Sessions

+