From ce921221eb941ef0cc061a13a66a95bfbc5e0c2b Mon Sep 17 00:00:00 2001 From: op-will <232669456+op-will@users.noreply.github.com> Date: Sat, 9 May 2026 08:34:58 -0500 Subject: [PATCH] feat(generator): support fuzzing max_priority_fee_per_gas Adds a per-tx EIP-1559 priority fee that can be set statically or fuzzed via the existing fuzz mechanism: - new field FunctionCallDefinition::max_priority_fee_per_gas (Option, may be a {placeholder}); plumbed through FunctionCallDefinitionStrict and the templater - new FuzzParam::max_priority_fee_per_gas (Option), mirroring the existing FuzzParam::value flag; mutually exclusive with `param` and `value`, with the conflict caught at scenario load time in parse_map_key - complete_tx_request now raises max_fee_per_gas to match the priority fee when the priority fee exceeds the sampled cap, preserving the EIP-1559 invariant - docs/creating_scenarios.md documents both the static and fuzzed forms - crates/core/CHANGELOG.md updated under [0.11.0] --- crates/cli/src/default_scenarios/erc20.rs | 1 + crates/cli/src/server/static/openrpc.json | 8 +- crates/core/CHANGELOG.md | 6 ++ crates/core/src/generator/constants.rs | 1 + crates/core/src/generator/function_def.rs | 62 +++++++++++ crates/core/src/generator/templater.rs | 6 ++ crates/core/src/generator/trait.rs | 123 ++++++++++++++++++++-- crates/core/src/generator/util.rs | 55 +++++++++- crates/core/src/test_scenario.rs | 9 +- crates/testfile/src/lib.rs | 1 + docs/creating_scenarios.md | 31 ++++++ 11 files changed, 287 insertions(+), 16 deletions(-) diff --git a/crates/cli/src/default_scenarios/erc20.rs b/crates/cli/src/default_scenarios/erc20.rs index 630cfca0..337b4b8c 100644 --- a/crates/cli/src/default_scenarios/erc20.rs +++ b/crates/cli/src/default_scenarios/erc20.rs @@ -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(), diff --git a/crates/cli/src/server/static/openrpc.json b/crates/cli/src/server/static/openrpc.json index cbb7ef58..ecaf69c0 100644 --- a/crates/cli/src/server/static/openrpc.json +++ b/crates/cli/src/server/static/openrpc.json @@ -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}, @@ -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}, @@ -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"}}}}, diff --git a/crates/core/CHANGELOG.md b/crates/core/CHANGELOG.md index ff71b949..4a540a2c 100644 --- a/crates/core/CHANGELOG.md +++ b/crates/core/CHANGELOG.md @@ -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)) diff --git a/crates/core/src/generator/constants.rs b/crates/core/src/generator/constants.rs index 4c3ca867..ad4aab8f 100644 --- a/crates/core/src/generator/constants.rs +++ b/crates/core/src/generator/constants.rs @@ -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"; diff --git a/crates/core/src/generator/function_def.rs b/crates/core/src/generator/function_def.rs index 3cc307cd..b6e4bba2 100644 --- a/crates/core/src/generator/function_def.rs +++ b/crates/core/src/generator/function_def.rs @@ -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, } /// User-facing definition of a function call to be executed. @@ -70,6 +75,7 @@ impl FunctionCallDefinition { blob_data: None, authorization_address: None, for_all_accounts: false, + max_priority_fee_per_gas: None, } } @@ -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) -> Self { + self.max_priority_fee_per_gas = Some(fee_wei.as_ref().to_owned()); + self + } pub fn sidecar_data(&self) -> Result, GeneratorError> { let sidecar_data = if let Some(data) = self.blob_data.as_ref() { @@ -154,6 +164,7 @@ pub struct FunctionCallDefinitionStrict { pub fuzz: Vec, pub kind: Option, pub gas_limit: Option, + pub max_priority_fee_per_gas: Option, // may be a placeholder, so we can't use u128 pub sidecar: Option, pub authorization: Option>, } @@ -164,6 +175,8 @@ pub struct FuzzParam { pub param: Option, /// Fuzz the `value` field of the tx (ETH sent with the tx). pub value: Option, + /// Fuzz the `max_priority_fee_per_gas` field of the tx (EIP-1559 priority fee, in wei). + pub max_priority_fee_per_gas: Option, /// Minimum value fuzzer will use. pub min: Option, /// Maximum value fuzzer will use. @@ -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)); + } } diff --git a/crates/core/src/generator/templater.rs b/crates/core/src/generator/templater.rs index 8c58b80e..9739e3c2 100644 --- a/crates/core/src/generator/templater.rs +++ b/crates/core/src/generator/templater.rs @@ -225,6 +225,11 @@ where .as_ref() .map(|x| self.replace_placeholders(x, placeholder_map)) .and_then(|s| s.parse::().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::().ok()); Ok(TransactionRequest { to: Some(TxKind::Call(to)), @@ -232,6 +237,7 @@ where 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() diff --git a/crates/core/src/generator/trait.rs b/crates/core/src/generator/trait.rs index 0992ae95..683b872b 100644 --- a/crates/core/src/generator/trait.rs +++ b/crates/core/src/generator/trait.rs @@ -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]), }) @@ -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 @@ -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( @@ -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>, + fuzz_idx: usize, +) -> Result> { + 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 { - 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 { @@ -735,3 +772,69 @@ pub fn sign_auth(signer: &PrivateKeySigner, auth: Authorization) -> Result 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) + )); + } +} diff --git a/crates/core/src/generator/util.rs b/crates/core/src/generator/util.rs index f69ac1d5..54b2f17a 100644 --- a/crates/core/src/generator/util.rs +++ b/crates/core/src/generator/util.rs @@ -118,7 +118,11 @@ pub fn complete_tx_request( tx_req.gas_price = Some(gas_price); } TxType::Eip1559 => { - tx_req.max_fee_per_gas = Some(gas_price); + // EIP-1559 requires max_fee_per_gas >= max_priority_fee_per_gas. + // If a fuzzed/explicit priority fee is higher than the sampled + // base+tip cap, raise the cap to keep the tx well-formed. + let max_fee = gas_price.max(priority_fee); + tx_req.max_fee_per_gas = Some(max_fee); tx_req.max_priority_fee_per_gas = Some(priority_fee); tx_req.chain_id = Some(chain_id); } @@ -248,3 +252,52 @@ pub mod test { Anvil::new().block_time_f64(0.25).try_spawn().unwrap() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_tx_req() -> TransactionRequest { + TransactionRequest::default() + } + + #[test] + fn complete_tx_request_eip1559_uses_gas_price_when_priority_fee_lower() { + let mut tx = empty_tx_req(); + complete_tx_request(&mut tx, TxType::Eip1559, 1_000, 100, 21_000, 1, 0); + assert_eq!(tx.max_fee_per_gas, Some(1_000)); + assert_eq!(tx.max_priority_fee_per_gas, Some(100)); + assert_eq!(tx.chain_id, Some(1)); + assert_eq!(tx.gas, Some(21_000)); + } + + #[test] + fn complete_tx_request_eip1559_raises_max_fee_when_priority_fee_higher() { + // EIP-1559 invariant: max_fee_per_gas >= max_priority_fee_per_gas. + // When a fuzzed/explicit priority fee exceeds the sampled gas_price + // cap, the cap must be raised so the tx remains well-formed. + let mut tx = empty_tx_req(); + complete_tx_request(&mut tx, TxType::Eip1559, 1_000, 5_000, 21_000, 1, 0); + assert_eq!(tx.max_fee_per_gas, Some(5_000)); + assert_eq!(tx.max_priority_fee_per_gas, Some(5_000)); + } + + #[test] + fn complete_tx_request_eip1559_handles_equal_fees() { + let mut tx = empty_tx_req(); + complete_tx_request(&mut tx, TxType::Eip1559, 2_000, 2_000, 21_000, 1, 0); + assert_eq!(tx.max_fee_per_gas, Some(2_000)); + assert_eq!(tx.max_priority_fee_per_gas, Some(2_000)); + } + + #[test] + fn complete_tx_request_eip4844_inherits_eip1559_priority_fee_cap() { + // Eip4844 recurses into Eip1559 for gas params; the same invariant + // (max_fee_per_gas >= max_priority_fee_per_gas) must hold. + let mut tx = empty_tx_req(); + complete_tx_request(&mut tx, TxType::Eip4844, 1_000, 5_000, 21_000, 1, 7); + assert_eq!(tx.max_fee_per_gas, Some(5_000)); + assert_eq!(tx.max_priority_fee_per_gas, Some(5_000)); + assert_eq!(tx.max_fee_per_blob_gas, Some(7)); + } +} diff --git a/crates/core/src/test_scenario.rs b/crates/core/src/test_scenario.rs index 42219a98..f7d3d8ca 100644 --- a/crates/core/src/test_scenario.rs +++ b/crates/core/src/test_scenario.rs @@ -1020,11 +1020,15 @@ where .to_owned(); let mut full_tx = tx_req.to_owned().with_nonce(nonce); + // honor a per-tx priority fee if the scenario set one (e.g., via + // FuzzParam::max_priority_fee_per_gas = true); fall back to the + // gas_price/10 default otherwise. + let priority_fee = tx_req.max_priority_fee_per_gas.unwrap_or(gas_price / 10); complete_tx_request( &mut full_tx, self.tx_type, gas_price, - gas_price / 10, + priority_fee, gas_limit, self.chain_id, blob_gas_price, @@ -2321,6 +2325,7 @@ pub mod tests { .with_fuzz(&[FuzzParam { param: Some("x".to_string()), value: None, + max_priority_fee_per_gas: None, min: None, max: None, }]) @@ -2340,6 +2345,7 @@ pub mod tests { .with_fuzz(&[FuzzParam { param: Some("x".to_string()), value: None, + max_priority_fee_per_gas: None, min: None, max: None, }]) @@ -2359,6 +2365,7 @@ pub mod tests { .with_fuzz(&[FuzzParam { param: Some("x".to_string()), value: None, + max_priority_fee_per_gas: None, min: None, max: None, }]) diff --git a/crates/testfile/src/lib.rs b/crates/testfile/src/lib.rs index 197c7a8a..b39dbb20 100644 --- a/crates/testfile/src/lib.rs +++ b/crates/testfile/src/lib.rs @@ -91,6 +91,7 @@ pub mod tests { .with_fuzz(&[FuzzParam { param: Some("x".to_string()), value: None, + max_priority_fee_per_gas: None, min: None, max: None, }]) diff --git a/docs/creating_scenarios.md b/docs/creating_scenarios.md index 6721d11b..2e7e7967 100644 --- a/docs/creating_scenarios.md +++ b/docs/creating_scenarios.md @@ -254,6 +254,37 @@ The `param` field picks out the argument to inject by the name given in `signatu > 💡 Note that we require `args` to be specified, even when `fuzz` will replace its value. This may change. +#### fuzzing transaction-level fields + +In addition to function arguments and the tx `value`, you can fuzz `max_priority_fee_per_gas` (EIP-1559 priority fee, in wei) by setting the `max_priority_fee_per_gas` flag on a `fuzz` entry: + +```toml +[spam.tx] +to = "{SpamMe2}" +from_pool = "redpool" +signature = "consumeGas(uint256 gasAmount)" +args = ["3000000"] +fuzz = [ + { param = "gasAmount", min = "1000000", max = "3000000" }, + { max_priority_fee_per_gas = true, min = "0x2540be400", max = "0x4a817c800" }, +] +``` + +Exactly one of `param`, `value`, or `max_priority_fee_per_gas` must be set per `fuzz` entry; combining them is rejected at scenario-load time. + +#### setting a static priority fee + +You can also pin a per-tx priority fee without fuzzing by setting `max_priority_fee_per_gas` directly on the spam step. The value is in wei and may use a `{placeholder}`. If unset, contender falls back to its default (`gas_price / 10`). + +```toml +[spam.tx] +to = "{SpamMe2}" +from_pool = "redpool" +signature = "consumeGas(uint256 gasAmount)" +args = ["3000000"] +max_priority_fee_per_gas = "10000000000" # 10 gwei +``` + ## run it Once your scenario config is complete, pass it to contender: