Skip to content
Open
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
1 change: 1 addition & 0 deletions crates/cli/src/default_scenarios/erc20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ impl ToTestConfig for Erc20Args {
func_def = func_def.with_fuzz(&[FuzzParam {
param: Some("guy".to_string()),
value: None,
max_priority_fee_per_gas: None,
min: Some(U256::from(1)),
max: Some(
U256::from_str("0x0000000000ffffffffffffffffffffffffffffffff").unwrap(),
Expand Down
8 changes: 4 additions & 4 deletions crates/cli/src/server/static/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"BlobsCliArgs": {"file":"crates/cli/src/default_scenarios/blobs.rs","line":10},
"BuilderParams": {"file":"crates/cli/src/server/rpc_server/types.rs","line":227},
"BuiltinScenarioCli": {"file":"crates/cli/src/default_scenarios/builtin.rs","line":33},
"BundleCallDefinition": {"file":"crates/core/src/generator/function_def.rs","line":53},
"BundleCallDefinition": {"file":"crates/core/src/generator/function_def.rs","line":58},
"BundleTypeCli": {"file":"crates/cli/src/commands/common.rs","line":438},
"CompiledContract": {"file":"crates/core/src/generator/create_def.rs","line":8},
"ContenderSessionInfo": {"file":"crates/cli/src/server/sessions.rs","line":128},
Expand All @@ -29,7 +29,7 @@
"FillBlockCliArgs": {"file":"crates/cli/src/default_scenarios/fill_block.rs","line":17},
"FunctionCallDefinition": {"file":"crates/core/src/generator/function_def.rs","line":12},
"FundAccountsParams": {"file":"crates/cli/src/server/rpc_server/types.rs","line":273},
"FuzzParam": {"file":"crates/core/src/generator/function_def.rs","line":162},
"FuzzParam": {"file":"crates/core/src/generator/function_def.rs","line":173},
"RevertCliArgs": {"file":"crates/cli/src/default_scenarios/revert.rs","line":8},
"ServerStatus": {"file":"crates/cli/src/server/rpc_server/types.rs","line":33},
"SessionOptions": {"file":"crates/cli/src/server/rpc_server/types.rs","line":234},
Expand Down Expand Up @@ -188,9 +188,9 @@
"EthereumOpcode": {"type":"string","enum":["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"]},
"EthereumPrecompile": {"type":"string","enum":["HashSha256","HashRipemd160","Identity","ModExp","EcAdd","EcMul","EcPairing","Blake2f"]},
"FillBlockCliArgs": {"description":"Taken from the CLI, this is used to fill a block with transactions.","type":"object","properties":{"max_gas_per_block":{"type":"integer"}}},
"FunctionCallDefinition": {"description":"User-facing definition of a function call to be executed.","type":"object","properties":{"to":{"description":"Address of the contract to call.","type":"string"},"from":{"description":"Address of the tx sender.","type":"string"},"from_pool":{"description":"Get a `from` address from the pool of signers specified here.","type":"string"},"signature":{"description":"Name of the function to call.","type":"string"},"args":{"description":"Parameters to pass to the function.","type":"array","items":{"type":"string"}},"value":{"description":"Value in wei to send with the tx.","type":"string"},"fuzz":{"description":"Parameters to fuzz during the test.","type":"array","items":{"$ref":"#/components/schemas/FuzzParam"}},"kind":{"description":"Optional type of the spam transaction for categorization.","type":"string"},"gas_limit":{"description":"Optional gas limit, which will skip gas estimation. This allows reverting txs to be sent.","type":"integer"},"blob_data":{"description":"Optional blob data; tx type must be set to EIP4844 by spammer","type":"string"},"authorization_address":{"description":"Optional setCode data; tx type must be set to EIP7702 by spammer","type":"string"},"for_all_accounts":{"description":"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).","type":"boolean"}},"required":["to","for_all_accounts"]},
"FunctionCallDefinition": {"description":"User-facing definition of a function call to be executed.","type":"object","properties":{"to":{"description":"Address of the contract to call.","type":"string"},"from":{"description":"Address of the tx sender.","type":"string"},"from_pool":{"description":"Get a `from` address from the pool of signers specified here.","type":"string"},"signature":{"description":"Name of the function to call.","type":"string"},"args":{"description":"Parameters to pass to the function.","type":"array","items":{"type":"string"}},"value":{"description":"Value in wei to send with the tx.","type":"string"},"fuzz":{"description":"Parameters to fuzz during the test.","type":"array","items":{"$ref":"#/components/schemas/FuzzParam"}},"kind":{"description":"Optional type of the spam transaction for categorization.","type":"string"},"gas_limit":{"description":"Optional gas limit, which will skip gas estimation. This allows reverting txs to be sent.","type":"integer"},"blob_data":{"description":"Optional blob data; tx type must be set to EIP4844 by spammer","type":"string"},"authorization_address":{"description":"Optional setCode data; tx type must be set to EIP7702 by spammer","type":"string"},"for_all_accounts":{"description":"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).","type":"boolean"},"max_priority_fee_per_gas":{"description":"Optional EIP-1559 priority fee (wei) for this tx. May be a `{placeholder}`. If unset, the spammer falls back to its default (`gas_price / 10`). This field is also fuzzable via `FuzzParam::max_priority_fee_per_gas = true`.","type":"string"}},"required":["to","for_all_accounts"]},
"FundAccountsParams": {"type":"object","properties":{"sessionId":{"type":"integer"},"agentClass":{"$ref":"#/components/schemas/AgentClass"},"amount":{"type":"string"}},"required":["sessionId","amount"]},
"FuzzParam": {"type":"object","properties":{"param":{"description":"Name of the parameter to fuzz.","type":"string"},"value":{"description":"Fuzz the `value` field of the tx (ETH sent with the tx).","type":"boolean"},"min":{"description":"Minimum value fuzzer will use.","type":"string"},"max":{"description":"Maximum value fuzzer will use.","type":"string"}}},
"FuzzParam": {"type":"object","properties":{"param":{"description":"Name of the parameter to fuzz.","type":"string"},"value":{"description":"Fuzz the `value` field of the tx (ETH sent with the tx).","type":"boolean"},"max_priority_fee_per_gas":{"description":"Fuzz the `max_priority_fee_per_gas` field of the tx (EIP-1559 priority fee, in wei).","type":"boolean"},"min":{"description":"Minimum value fuzzer will use.","type":"string"},"max":{"description":"Maximum value fuzzer will use.","type":"string"}}},
"RevertCliArgs": {"type":"object","properties":{"gas_use":{"description":"Amount of gas to use before reverting.","type":"integer"}},"required":["gas_use"]},
"ServerStatus": {"description":"Data returned from the `status` endpoint, containing general info about the server.","type":"object","properties":{"numSessions":{"type":"integer"}},"required":["numSessions"]},
"SessionOptions": {"type":"object","properties":{"auth":{"$ref":"#/components/schemas/AuthParams"},"builder":{"$ref":"#/components/schemas/BuilderParams"},"minBalance":{"type":"string"},"timeoutSecs":{"type":"object","properties":{"secs":{"type":"integer"},"nanos":{"type":"integer"}}},"txType":{"$ref":"#/components/schemas/TxTypeCli"},"privateKeys":{"type":"array","items":{"type":"string"}},"agents":{"$ref":"#/components/schemas/AgentParams"},"env":{"type":"object","additionalProperties":{"type":"string"}}}},
Expand Down
6 changes: 6 additions & 0 deletions crates/core/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).

## [0.11.0]

- added `FunctionCallDefinition::max_priority_fee_per_gas` for setting a static (or `{placeholder}`-driven) per-tx EIP-1559 priority fee
- added `FuzzParam::max_priority_fee_per_gas` (a bool flag mirroring `FuzzParam::value`) so the priority fee can be fuzzed per-tx alongside function args and `value`
- `complete_tx_request` now raises `max_fee_per_gas` to match `max_priority_fee_per_gas` when the priority fee exceeds the sampled cap, preserving the EIP-1559 invariant

## [0.10.1](https://github.com/flashbots/contender/releases/tag/v0.7.0) - 2026-05-05

- update alloy dependencies ([#561](https://github.com/flashbots/contender/pull/561))
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/generator/constants.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub const VALUE_KEY: &str = "__tx_value_contender__";
pub const MAX_PRIORITY_FEE_KEY: &str = "__tx_max_priority_fee_contender__";
pub const SENDER_KEY: &str = "_sender";
pub const SETCODE_KEY: &str = "_setCodeSender";

Expand Down
62 changes: 62 additions & 0 deletions crates/core/src/generator/function_def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ pub struct FunctionCallDefinition {
/// Defaults to false (only runs for the first account).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub for_all_accounts: bool,
/// Optional EIP-1559 priority fee (wei) for this tx. May be a `{placeholder}`.
/// If unset, the spammer falls back to its default (`gas_price / 10`).
/// This field is also fuzzable via `FuzzParam::max_priority_fee_per_gas = true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_priority_fee_per_gas: Option<String>,
}

/// User-facing definition of a function call to be executed.
Expand All @@ -70,6 +75,7 @@ impl FunctionCallDefinition {
blob_data: None,
authorization_address: None,
for_all_accounts: false,
max_priority_fee_per_gas: None,
}
}

Expand Down Expand Up @@ -122,6 +128,10 @@ impl FunctionCallDefinition {
self.for_all_accounts = for_all_accounts;
self
}
pub fn with_max_priority_fee_per_gas(mut self, fee_wei: impl AsRef<str>) -> Self {
self.max_priority_fee_per_gas = Some(fee_wei.as_ref().to_owned());
self
}

pub fn sidecar_data(&self) -> Result<Option<BlobTransactionSidecar>, GeneratorError> {
let sidecar_data = if let Some(data) = self.blob_data.as_ref() {
Expand Down Expand Up @@ -154,6 +164,7 @@ pub struct FunctionCallDefinitionStrict {
pub fuzz: Vec<FuzzParam>,
pub kind: Option<String>,
pub gas_limit: Option<u64>,
pub max_priority_fee_per_gas: Option<String>, // may be a placeholder, so we can't use u128
pub sidecar: Option<BlobTransactionSidecar>,
pub authorization: Option<Vec<SignedAuthorization>>,
}
Expand All @@ -164,6 +175,8 @@ pub struct FuzzParam {
pub param: Option<String>,
/// Fuzz the `value` field of the tx (ETH sent with the tx).
pub value: Option<bool>,
/// Fuzz the `max_priority_fee_per_gas` field of the tx (EIP-1559 priority fee, in wei).
pub max_priority_fee_per_gas: Option<bool>,
/// Minimum value fuzzer will use.
pub min: Option<U256>,
/// Maximum value fuzzer will use.
Expand Down Expand Up @@ -221,4 +234,53 @@ mod tests {
.with_for_all_accounts(false);
assert!(!def.for_all_accounts);
}

#[test]
fn parses_max_priority_fee_per_gas_field() {
let toml = r#"
to = "0x1234567890123456789012345678901234567890"
from_pool = "p"
signature = "burn(uint256)"
max_priority_fee_per_gas = "10000000000"
"#;
let def: FunctionCallDefinition = toml::from_str(toml).unwrap();
assert_eq!(def.max_priority_fee_per_gas.as_deref(), Some("10000000000"));
}

#[test]
fn parses_fuzz_with_max_priority_fee_per_gas_flag() {
let toml = r#"
to = "0x1234567890123456789012345678901234567890"
from_pool = "p"
signature = "burn(uint256)"
fuzz = [{ max_priority_fee_per_gas = true, min = "0x2540be400", max = "0x4a817c800" }]
"#;
let def: FunctionCallDefinition = toml::from_str(toml).unwrap();
let fuzz = def.fuzz.expect("fuzz must parse");
assert_eq!(fuzz.len(), 1);
assert_eq!(fuzz[0].max_priority_fee_per_gas, Some(true));
assert!(fuzz[0].param.is_none());
assert!(fuzz[0].value.is_none());
}

#[test]
fn fuzz_with_priority_fee_and_param_parses_for_runtime_rejection() {
// Deserialization itself succeeds — the conflict between `param` and
// `max_priority_fee_per_gas` is caught later by `parse_map_key` when
// the fuzz map is built (see
// `parse_map_key_rejects_param_and_priority_fee` in trait.rs). This
// test only confirms the parser tolerates the combination so the
// runtime check has a chance to run with a useful error.
let toml = r#"
to = "0x1234567890123456789012345678901234567890"
from_pool = "p"
signature = "burn(uint256 n)"
fuzz = [{ param = "n", max_priority_fee_per_gas = true, min = "0x1", max = "0x2" }]
"#;
let def: FunctionCallDefinition =
toml::from_str(toml).expect("toml parses; conflict caught at runtime");
let fuzz = def.fuzz.expect("fuzz must parse");
assert!(fuzz[0].param.is_some());
assert_eq!(fuzz[0].max_priority_fee_per_gas, Some(true));
}
}
6 changes: 6 additions & 0 deletions crates/core/src/generator/templater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,19 @@ where
.as_ref()
.map(|x| self.replace_placeholders(x, placeholder_map))
.and_then(|s| s.parse::<U256>().ok());
let max_priority_fee_per_gas = funcdef
.max_priority_fee_per_gas
.as_ref()
.map(|x| self.replace_placeholders(x, placeholder_map))
.and_then(|s| s.parse::<u128>().ok());

Ok(TransactionRequest {
to: Some(TxKind::Call(to)),
input: alloy::rpc::types::TransactionInput::both(input.into()),
from: Some(funcdef.from),
value,
gas: funcdef.gas_limit,
max_priority_fee_per_gas,
sidecar: funcdef.sidecar.as_ref().map(|sc| sc.to_owned().into()),
authorization_list: funcdef.authorization.to_owned(),
..Default::default()
Expand Down
123 changes: 113 additions & 10 deletions crates/core/src/generator/trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ where
fuzz: funcdef.fuzz.to_owned().unwrap_or_default(),
kind: funcdef.kind.to_owned(),
gas_limit: funcdef.gas_limit.to_owned(),
max_priority_fee_per_gas: funcdef.max_priority_fee_per_gas.to_owned(),
sidecar: funcdef.sidecar_data()?,
authorization: signed_auth.map(|a| vec![a]),
})
Expand Down Expand Up @@ -563,6 +564,8 @@ where
let prepare_tx = |req| {
let mut args = get_fuzzed_args(req, &canonical_fuzz_map, i)?;
let fuzz_tx_value = get_fuzzed_tx_value(req, &canonical_fuzz_map, i)?;
let fuzz_priority_fee =
get_fuzzed_max_priority_fee_per_gas(req, &canonical_fuzz_map, i)?;
// Special handling for WorldID proof generation
if req
.kind
Expand Down Expand Up @@ -593,6 +596,9 @@ where
if fuzz_tx_value.is_some() {
req.value = fuzz_tx_value;
}
if fuzz_priority_fee.is_some() {
req.max_priority_fee_per_gas = fuzz_priority_fee;
}

let tx = NamedTxRequest::new(
templater.template_function_call(
Expand Down Expand Up @@ -711,22 +717,53 @@ fn get_fuzzed_tx_value(
Ok(None)
}

/// If a `FuzzParam` with `max_priority_fee_per_gas = true` is present,
/// return the fuzzed wei value (as a decimal string) for `fuzz_idx`.
fn get_fuzzed_max_priority_fee_per_gas(
tx: &FunctionCallDefinition,
fuzz_map: &HashMap<String, Vec<U256>>,
fuzz_idx: usize,
) -> Result<Option<String>> {
if let Some(fuzz) = &tx.fuzz {
for fuzz_param in fuzz {
if fuzz_param.max_priority_fee_per_gas == Some(true) {
let values = fuzz_map
.get(MAX_PRIORITY_FEE_KEY)
.ok_or(GeneratorError::ValueFuzzerNotInitialized)?;
return Ok(Some(values[fuzz_idx].to_string()));
}
}
}
Ok(None)
}

fn parse_map_key(fuzz: FuzzParam) -> Result<String> {
if fuzz.param.is_none() && fuzz.value.is_none() {
// exactly one of `param`, `value`, or `max_priority_fee_per_gas` must be set
let set_count = [
fuzz.param.is_some(),
fuzz.value.is_some(),
fuzz.max_priority_fee_per_gas.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
if set_count == 0 {
return Err(GeneratorError::FuzzMissingParams);
}
if fuzz.param.is_some() && fuzz.value.is_some() {
if set_count > 1 {
return Err(GeneratorError::FuzzConflictingParams);
}

let key = match (fuzz.param.as_ref(), fuzz.value) {
(Some(param), _) => param.to_owned(),
(None, Some(true)) => VALUE_KEY.to_owned(),
(None, Some(false)) => return Err(GeneratorError::FuzzValueNeedsParam),
_ => return Err(GeneratorError::FuzzInvalid),
};

Ok(key)
if let Some(param) = fuzz.param {
return Ok(param);
}
match (fuzz.value, fuzz.max_priority_fee_per_gas) {
(Some(true), _) => Ok(VALUE_KEY.to_owned()),
(Some(false), _) => Err(GeneratorError::FuzzValueNeedsParam),
(_, Some(true)) => Ok(MAX_PRIORITY_FEE_KEY.to_owned()),
(_, Some(false)) => Err(GeneratorError::FuzzValueNeedsParam),
_ => Err(GeneratorError::FuzzInvalid),
}
}

pub fn sign_auth(signer: &PrivateKeySigner, auth: Authorization) -> Result<SignedAuthorization> {
Expand All @@ -735,3 +772,69 @@ pub fn sign_auth(signer: &PrivateKeySigner, auth: Authorization) -> Result<Signe
.map_err(UtilError::Signer)?;
Ok(auth.into_signed(auth_sig))
}

#[cfg(test)]
mod tests {
use super::*;

fn fuzz_param() -> FuzzParam {
FuzzParam {
param: None,
value: None,
max_priority_fee_per_gas: None,
min: None,
max: None,
}
}

#[test]
fn parse_map_key_routes_param_by_name() {
let mut p = fuzz_param();
p.param = Some("guy".to_string());
assert_eq!(parse_map_key(p).unwrap(), "guy");
}

#[test]
fn parse_map_key_routes_value_to_value_key() {
let mut p = fuzz_param();
p.value = Some(true);
assert_eq!(parse_map_key(p).unwrap(), VALUE_KEY);
}

#[test]
fn parse_map_key_routes_priority_fee_flag_to_priority_fee_key() {
let mut p = fuzz_param();
p.max_priority_fee_per_gas = Some(true);
assert_eq!(parse_map_key(p).unwrap(), MAX_PRIORITY_FEE_KEY);
}

#[test]
fn parse_map_key_rejects_param_and_priority_fee() {
let mut p = fuzz_param();
p.param = Some("x".to_string());
p.max_priority_fee_per_gas = Some(true);
assert!(matches!(
parse_map_key(p),
Err(GeneratorError::FuzzConflictingParams)
));
}

#[test]
fn parse_map_key_rejects_value_and_priority_fee() {
let mut p = fuzz_param();
p.value = Some(true);
p.max_priority_fee_per_gas = Some(true);
assert!(matches!(
parse_map_key(p),
Err(GeneratorError::FuzzConflictingParams)
));
}

#[test]
fn parse_map_key_rejects_empty() {
assert!(matches!(
parse_map_key(fuzz_param()),
Err(GeneratorError::FuzzMissingParams)
));
}
}
Loading