Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

> Note: this file did not exist until after `v0.5.6`.

## Unreleased

- track nonces internally for create & setup transactions ([#438](https://github.com/flashbots/contender/pull/438))
- bugfix: tolerate failure of `get_block_receipts` ([#438](https://github.com/flashbots/contender/pull/438))

### Breaking changes

- removed `--redeploy`, no longer skips contract deployments if previously deployed ([#438](https://github.com/flashbots/contender/pull/438))

## [0.7.4](https://github.com/flashbots/contender/releases/tag/v0.7.4) - 2026-01-27

- revised campaign spammer ([#427](https://github.com/flashbots/contender/pull/427))
Expand Down
6 changes: 6 additions & 0 deletions crates/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Breaking changes

- removed `--redeploy`, no longer skips contract deployments if previously deployed ([#438](https://github.com/flashbots/contender/pull/438))

## [0.7.4](https://github.com/flashbots/contender/releases/tag/v0.7.4) - 2026-01-27

- revised campaign spammer ([#427](https://github.com/flashbots/contender/pull/427/files))
Expand Down
38 changes: 7 additions & 31 deletions crates/cli/src/commands/campaign.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
use super::{setup::SetupCommandArgs, spam::SpamCommandArgs, SpamScenario};
use crate::commands::spam::SpamCampaignContext;
use crate::commands::{
self,
common::{ScenarioSendTxsCliArgs, SendTxsCliArgsInner},
SpamCliArgs,
};
use crate::error::CliError;
use crate::util::load_testconfig;
use crate::util::{data_dir, load_seedfile, parse_duration};
use crate::BuiltinScenarioCli;
use crate::{
commands::{
self,
common::{ScenarioSendTxsCliArgs, SendTxsCliArgsInner},
SpamCliArgs,
},
util::bold,
};
use alloy::primitives::{keccak256, U256};
use clap::Args;
use contender_core::db::DbOps;
Expand Down Expand Up @@ -85,14 +82,6 @@ pub struct CampaignCliArgs {
)]
pub gen_report: bool,

/// Re-deploy contracts in builtin scenarios.
#[arg(
long,
global = true,
long_help = "If set, re-deploy contracts that have already been deployed. Only builtin scenarios are affected."
)]
pub redeploy: bool,

/// Skip setup steps when running builtin scenarios.
#[arg(
long,
Expand Down Expand Up @@ -134,15 +123,6 @@ pub async fn run_campaign(
validate_stage_rates(&stages, &args).await?;
let campaign_id = Uuid::new_v4().to_string();

if args.redeploy && args.skip_setup {
return Err(RuntimeParamErrorKind::InvalidArgs(format!(
"{} and {} cannot be passed together",
bold("--redeploy"),
bold("--skip-setup")
))
.into());
}

let base_seed = args
.eth_json_rpc_args
.seed
Expand Down Expand Up @@ -314,7 +294,6 @@ fn create_spam_cli_args(
spam_rate: u64,
spam_duration: u64,
skip_setup: bool,
redeploy: bool,
) -> SpamCliArgs {
SpamCliArgs {
eth_json_rpc_args: ScenarioSendTxsCliArgs {
Expand All @@ -340,7 +319,6 @@ fn create_spam_cli_args(
ignore_receipts: args.ignore_receipts,
optimistic_nonces: args.optimistic_nonces,
gen_report: false,
redeploy,
skip_setup,
rpc_batch_size: args.rpc_batch_size,
spam_timeout: args.spam_timeout,
Expand Down Expand Up @@ -423,12 +401,11 @@ async fn prepare_scenario(
args.eth_json_rpc_args.seed = Some(scenario_seed.clone());
debug!("mix {mix_idx} seed: {}", scenario_seed);

// Check if this is a builtin scenario to determine skip_setup/redeploy behavior:
// Check if this is a builtin scenario to determine skip_setup behavior:
// - Builtins: respect campaign's flags (they do their own setup during spam)
// - Toml scenarios: always skip setup (ran in Phase 1), redeploy not applicable
// - Toml scenarios: always skip setup (ran in Phase 1)
let is_builtin = parse_builtin_reference(&mix.scenario).is_some();
let skip_setup = if is_builtin { args.skip_setup } else { true };
let redeploy = if is_builtin { args.redeploy } else { false };

let spam_cli_args = create_spam_cli_args(
Some(mix.scenario.clone()),
Expand All @@ -437,7 +414,6 @@ async fn prepare_scenario(
mix.rate,
stage.duration,
skip_setup,
redeploy,
);

let spam_scenario = if let Some(builtin_cli) = parse_builtin_reference(&mix.scenario) {
Expand Down
1 change: 0 additions & 1 deletion crates/cli/src/commands/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ pub async fn setup(
bundle_type: bundle_type.into(),
pending_tx_timeout_secs: 12,
extra_msg_handles: None,
redeploy: true,
sync_nonces_after_batch: true,
rpc_batch_size: 0,
gas_price: None,
Expand Down
31 changes: 0 additions & 31 deletions crates/cli/src/commands/spam.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,6 @@ pub struct SpamCliArgs {
)]
pub gen_report: bool,

/// Re-deploy contracts in builtin scenarios.
#[arg(
long,
global = true,
long_help = "If set, re-deploy contracts that have already been deployed. Only builtin scenarios are affected."
)]
pub redeploy: bool,

/// Skip setup steps when running builtin scenarios.
#[arg(
long,
Expand Down Expand Up @@ -256,15 +248,6 @@ impl SpamCommandArgs {
}
}

if self.spam_args.redeploy && self.spam_args.skip_setup {
return Err(RuntimeParamErrorKind::InvalidArgs(format!(
"{} and {} cannot be passed together",
bold("--redeploy"),
bold("--skip-setup")
))
.into());
}

// check if txs_per_duration is enough to cover the spam requests
if (txs_per_duration * duration) < spam_len as u64 {
return Err(ArgsError::TransactionsPerDurationInsufficient {
Expand Down Expand Up @@ -428,7 +411,6 @@ impl SpamCommandArgs {
bundle_type: bundle_type.into(),
pending_tx_timeout_secs: pending_timeout * block_time,
extra_msg_handles: None,
redeploy: self.spam_args.redeploy,
sync_nonces_after_batch: !self.spam_args.optimistic_nonces,
rpc_batch_size,
gas_price: self.spam_args.eth_json_rpc_args.rpc_args.gas_price,
Expand All @@ -443,18 +425,6 @@ impl SpamCommandArgs {
)
.await?;

// Builtin/default behavior: best-effort (skip redeploy if code exists); allow CLI override
tracing::trace!(
"spam mode: redeploy={} ({} ) [--redeploy flag set? {}]",
self.spam_args.redeploy,
if self.spam_args.redeploy {
"will redeploy and run all setup"
} else {
"will skip redeploy when possible"
},
self.spam_args.redeploy
);

// run deployments & setup for builtin scenarios
if self.scenario.is_builtin() && !self.spam_args.skip_setup {
let test_scenario = &mut test_scenario;
Expand Down Expand Up @@ -925,7 +895,6 @@ mod tests {
ignore_receipts: false,
optimistic_nonces: true,
gen_report: false,
redeploy: true,
skip_setup: false,
rpc_batch_size: 0,
spam_timeout: Duration::from_secs(5),
Expand Down
7 changes: 7 additions & 0 deletions crates/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased

- added timeout for send_transaction calls ([#430](https://github.com/flashbots/contender/pull/430/files))
- track nonces internally for create & setup transactions ([#438](https://github.com/flashbots/contender/pull/438/changes))
- removed contract deployment detection from builtin scenario spam setup
- also removed `redeploy`

### Breaking changes

- `TestScenario::load_txs` return type changed to support nonce tracking ([#438](https://github.com/flashbots/contender/pull/438/changes))

## [0.7.3](https://github.com/flashbots/contender/releases/tag/v0.7.3) - 2026-01-20

Expand Down
29 changes: 24 additions & 5 deletions crates/core/src/generator/trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,14 @@ where
})
}

/// Loads transactions from the plan configuration and returns execution requests.
/// Loads transactions from the plan configuration and returns execution requests
/// along with the updated nonce map. The caller should use the returned nonce map
/// to update their internal state, especially when the RPC provider is slow to
/// update nonces after transactions are mined.
async fn load_txs<F: Send + Sync + Fn(NamedTxRequest) -> AsyncCallbackResult>(
&self,
plan_type: PlanType<F>,
) -> std::result::Result<Vec<ExecutionRequest>, crate::Error> {
) -> std::result::Result<(Vec<ExecutionRequest>, HashMap<Address, u64>), crate::Error> {
let conf = self.get_plan_conf();
let env = conf.get_env().unwrap_or_default();
let db = self.get_db();
Expand All @@ -345,6 +348,11 @@ where

let mut txs: Vec<ExecutionRequest> = vec![];

// Track nonces locally to avoid relying on the RPC provider's nonce state,
// which may be slow to update after a transaction is mined.
// Initialize from existing tracked nonces to preserve state across calls.
let mut next_nonce: HashMap<Address, u64> = self.get_nonce_map().clone();

match plan_type {
PlanType::Create(on_create_step) => {
let create_steps = conf.get_create_steps()?;
Expand All @@ -363,12 +371,24 @@ where
let step = self.make_strict_create(step, 0)?;

// create tx with template values
let tx = NamedTxRequestBuilder::new(
let mut tx = NamedTxRequestBuilder::new(
templater.template_contract_deploy(&step, &placeholder_map)?,
)
.with_name(&step.name)
.build();

// assign a unique nonce to each tx (tracker per sender)
// - fetch once from RPC then track locally to avoid "replacement transaction underpriced"
// errors when the RPC provider is slow to update the nonce after a tx is mined
let from = tx.tx.from.expect("from address");
if let std::collections::hash_map::Entry::Vacant(e) = next_nonce.entry(from) {
let nonce = self.get_rpc_provider().get_transaction_count(from).await?;
e.insert(nonce);
}
let nonce = next_nonce.get_mut(&from).expect("nonce");
tx.tx.nonce = Some(*nonce);
*nonce += 1;

let handle = on_create_step(tx.to_owned())?;
if let Some(handle) = handle {
handle.await.map_err(CallbackError::Join)??;
Expand All @@ -381,7 +401,6 @@ where
let rpc_url = self.get_rpc_url();

let mut handles = Vec::new();
let mut next_nonce: HashMap<Address, u64> = HashMap::new();

for step in setup_steps.iter() {
// lookup placeholders in DB & update map before templating
Expand Down Expand Up @@ -583,7 +602,7 @@ where
}
}

Ok(txs)
Ok((txs, next_nonce))
}
}

Expand Down
11 changes: 0 additions & 11 deletions crates/core/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,6 @@ where
pub prometheus: PrometheusCollector,
/// The amount of ether each agent account gets.
pub funding: U256,
/// Redeploys contracts that have already been deployed.
pub redeploy: bool,
pub sync_nonces_after_batch: bool,
pub rpc_batch_size: u64,
}
Expand Down Expand Up @@ -119,7 +117,6 @@ where
auth_provider: None,
prometheus: PrometheusCollector::default(),
funding: *SMOL_AMOUNT,
redeploy: false,
sync_nonces_after_batch: true,
rpc_batch_size: 0,
}
Expand Down Expand Up @@ -194,7 +191,6 @@ where
auth_provider: None,
prometheus: PrometheusCollector::default(),
funding: *SMOL_AMOUNT,
redeploy: false,
sync_nonces_after_batch: true,
rpc_batch_size: 0,
}
Expand All @@ -211,7 +207,6 @@ where
pending_tx_timeout_secs: self.pending_tx_timeout_secs,
bundle_type: self.bundle_type,
extra_msg_handles: self.extra_msg_handles.clone(),
redeploy: self.redeploy,
sync_nonces_after_batch: self.sync_nonces_after_batch,
rpc_batch_size: self.rpc_batch_size,
gas_price: None,
Expand Down Expand Up @@ -251,7 +246,6 @@ where
auth_provider: Option<Arc<dyn ControlChain + Send + Sync + 'static>>,
prometheus: PrometheusCollector,
funding: U256,
redeploy: bool,
sync_nonces_after_batch: bool,
rpc_batch_size: u64,
}
Expand Down Expand Up @@ -306,10 +300,6 @@ where
self.seeder = s;
self
}
pub fn redeploy(mut self, r: bool) -> Self {
self.redeploy = r;
self
}

pub fn build(self) -> ContenderCtx<D, S, P> {
// always try to create tables before building, so user doesn't have to think about it later.
Expand All @@ -331,7 +321,6 @@ where
auth_provider: self.auth_provider,
prometheus: self.prometheus,
funding: self.funding,
redeploy: self.redeploy,
sync_nonces_after_batch: self.sync_nonces_after_batch,
rpc_batch_size: self.rpc_batch_size,
}
Expand Down
1 change: 0 additions & 1 deletion crates/core/src/spammer/blockwise.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ mod tests {
bundle_type: BundleType::default(),
pending_tx_timeout_secs: 12,
extra_msg_handles: None,
redeploy: false,
sync_nonces_after_batch: true,
rpc_batch_size: 0,
gas_price: None,
Expand Down
20 changes: 16 additions & 4 deletions crates/core/src/spammer/tx_actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,22 @@ async fn process_block_receipts<D: DbOps + Send + Sync + 'static>(
};

// Get receipts
let receipts = rpc
.get_block_receipts(target_block_num.into())
.await?
.unwrap_or_default();
let receipts = match rpc.get_block_receipts(target_block_num.into()).await {
Ok(Some(r)) => r,
Ok(None) | Err(_) => {
// Fallback: fetch receipts individually in parallel
let receipt_futures: Vec<_> = target_block
.transactions
.hashes()
.map(|tx_hash| rpc.get_transaction_receipt(tx_hash))
.collect();
let results = futures::future::join_all(receipt_futures).await;
results
.into_iter()
.filter_map(|r| r.ok().flatten())
.collect()
}
};
info!(
"found {} receipts for block {}",
receipts.len(),
Expand Down
Loading