Skip to content
47 changes: 39 additions & 8 deletions crates/evm/evm/src/executors/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ use foundry_config::FuzzCorpusConfig;
use foundry_evm_fuzz::{
BasicTxDetails,
invariant::FuzzRunIdentifiedContracts,
strategies::{EvmFuzzState, mutate_param_value},
strategies::{EvmFuzzState, generate_msg_value, mutate_param_value},
};
use proptest::{
prelude::{Just, Rng, Strategy},
Expand Down Expand Up @@ -560,12 +560,23 @@ impl WorkerCorpus {

new_seq = corpus.tx_seq.clone();

let idx = rng.random_range(0..new_seq.len());
let tx = new_seq.get_mut(idx).unwrap();
if let (_, Some(function)) = targets.fuzzed_artifacts(tx) {
// TODO: add call_value to call details and mutate it as well as sender some
// of the time.
if !function.inputs.is_empty() {
// 30% chance to mutate ALL calls in the sequence.
// This helps break multi-constraint bugs where any call could hit the target.
if rng.random_range(0..10) < 3 {
for tx in &mut new_seq {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Prefix mutation is essentially this but a subset and generation instead of mutation. Maybe we should have GenPrefix and GenMutate and mutate up to every element, but not always every element

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, will merge with prefix mutations

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed 40fd045

Copy link
Copy Markdown
Contributor

@0xalpharush 0xalpharush Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the name GenMutate was a mistake on my part and the old name Abi was better. The implementation is fine though.

What I was suggesting was to add two new mutators, MutatePrefix and MutateSuffix, where gen means call new_tx and mutate means use a tx in the seq and mutate it with abi_mutate (optionally you can have even an identity one which clones existing txs) . Here is a patch to reduce confusion. It also fixes the repeat mutation to insert instead of splice (which overwrites) as well as adds a swap and delete mutation.

if let (_, Some(function)) = targets.fuzzed_artifacts(tx)
&& !function.inputs.is_empty()
{
self.abi_mutate(tx, function, test_runner, fuzz_state)?;
}
}
} else {
// Standard: mutate a single random call.
let idx = rng.random_range(0..new_seq.len());
let tx = new_seq.get_mut(idx).unwrap();
if let (_, Some(function)) = targets.fuzzed_artifacts(tx)
&& !function.inputs.is_empty()
{
self.abi_mutate(tx, function, test_runner, fuzz_state)?;
}
}
Expand Down Expand Up @@ -687,7 +698,26 @@ impl WorkerCorpus {
test_runner: &mut TestRunner,
fuzz_state: &EvmFuzzState,
) -> Result<()> {
// let rng = test_runner.rng();
// Mutate sender with 15% probability using addresses from dictionary.
if test_runner.rng().random_ratio(15, 100) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also check targeted/excluded senders as was done here #13090?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added in d452197

let dict = fuzz_state.dictionary_read();
let addresses = dict.addresses();
if !addresses.is_empty() {
let idx = test_runner.rng().random_range(0..addresses.len());
if let Some(&addr) = addresses.get_index(idx) {
tx.sender = addr;
}
}
}

// Mutate value with 15% probability for payable functions.
if function.state_mutability == alloy_json_abi::StateMutability::Payable
&& test_runner.rng().random_ratio(15, 100)
{
tx.call_details.value = Some(generate_msg_value(test_runner));
}

// Mutate calldata.
let mut arg_mutation_rounds =
test_runner.rng().random_range(0..=function.inputs.len()).max(1);
let round_arg_idx: Vec<usize> = if function.inputs.len() <= 1 {
Expand Down Expand Up @@ -1104,6 +1134,7 @@ mod tests {
call_details: foundry_evm_fuzz::CallDetails {
target: Address::ZERO,
calldata: Bytes::new(),
value: None,
},
}
}
Expand Down
8 changes: 6 additions & 2 deletions crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,11 @@ impl FuzzedExecutor {
warp: None,
roll: None,
sender: self.sender,
call_details: CallDetails { target: address, calldata: calldata.clone() },
call_details: CallDetails {
target: address,
calldata: calldata.clone(),
value: None,
},
}],
new_coverage,
);
Expand Down Expand Up @@ -414,7 +418,7 @@ impl FuzzedExecutor {
warp: None,
roll: None,
sender: Default::default(),
call_details: CallDetails { target: Default::default(), calldata },
call_details: CallDetails { target: Default::default(), calldata, value: None },
});

let mut corpus = WorkerCorpus::new(
Expand Down
6 changes: 5 additions & 1 deletion crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,8 +1090,12 @@ pub(crate) fn execute_tx(executor: &mut Executor, tx: &BasicTxDetails) -> Result
}

// Perform the raw call.
// Only use value if sender has sufficient balance, otherwise fall back to 0.
let requested_value = tx.call_details.value.unwrap_or(U256::ZERO);
let sender_balance = executor.get_balance(tx.sender)?;
let value = if sender_balance >= requested_value { requested_value } else { U256::ZERO };
let mut call_result = executor
.call_raw(tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), U256::ZERO)
.call_raw(tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), value)
.map_err(|e| eyre!(format!("Could not make raw evm call: {e}")))?;

// Propagate block adjustments to call result which will be committed.
Expand Down
32 changes: 32 additions & 0 deletions crates/evm/fuzz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub struct CallDetails {
pub target: Address,
/// The data of the transaction.
pub calldata: Bytes,
/// Ether value to send with the transaction.
/// Uses `#[serde(default)]` for backwards compatibility with existing corpus files.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<U256>,
}

impl BasicTxDetails {
Expand Down Expand Up @@ -86,6 +90,9 @@ pub struct BaseCounterExample {
pub addr: Option<Address>,
/// The data to provide.
pub calldata: Bytes,
/// Ether value sent with the call.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<U256>,
/// Contract name if it exists.
pub contract_name: Option<String>,
/// Function name if it exists.
Expand Down Expand Up @@ -115,6 +122,7 @@ impl BaseCounterExample {
let sender = tx.sender;
let target = tx.call_details.target;
let bytes = &tx.call_details.calldata;
let value = tx.call_details.value;
let warp = tx.warp;
let roll = tx.roll;
if let Some((name, abi)) = &contracts.get(&target)
Expand All @@ -128,6 +136,7 @@ impl BaseCounterExample {
sender: Some(sender),
addr: Some(target),
calldata: bytes.clone(),
value,
contract_name: Some(name.clone()),
func_name: Some(func.name.clone()),
signature: Some(func.signature()),
Expand All @@ -147,6 +156,7 @@ impl BaseCounterExample {
sender: Some(sender),
addr: Some(target),
calldata: bytes.clone(),
value,
contract_name: None,
func_name: None,
signature: None,
Expand All @@ -169,6 +179,7 @@ impl BaseCounterExample {
sender: None,
addr: None,
calldata: bytes,
value: None,
contract_name: None,
func_name: None,
signature: None,
Expand All @@ -194,6 +205,20 @@ impl fmt::Display for BaseCounterExample {
writeln!(f, "\t\tvm.roll(block.number + {roll});")?;
}
writeln!(f, "\t\tvm.prank({sender});")?;
// Use value syntax for payable calls.
if let Some(value) = &self.value
&& !value.is_zero()
{
write!(
f,
"\t\t{}({}).{}{{value: {value}}}({});",
contract.split_once(':').map_or(contract.as_str(), |(_, contract)| contract),
address,
func_name,
args
)?;
return Ok(());
}
write!(
f,
"\t\t{}({}).{}({});",
Expand Down Expand Up @@ -226,6 +251,13 @@ impl fmt::Display for BaseCounterExample {
write!(f, "roll={roll} ")?;
}

// Display value if non-zero (for payable calls).
if let Some(value) = &self.value
&& !value.is_zero()
{
write!(f, "value={value} ")?;
}

if let Some(sig) = &self.signature {
write!(f, "calldata={sig}")?
} else {
Expand Down
18 changes: 12 additions & 6 deletions crates/evm/fuzz/src/strategies/invariants.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::{fuzz_calldata, fuzz_param_from_state};
use super::{fuzz_calldata, fuzz_msg_value, fuzz_param_from_state};
use crate::{
BasicTxDetails, CallDetails, FuzzFixtures,
invariant::{FuzzRunIdentifiedContracts, SenderFilters},
Expand Down Expand Up @@ -153,15 +153,21 @@ pub fn fuzz_contract_with_calldata(
target: Address,
func: Function,
) -> impl Strategy<Value = CallDetails> + use<> {
let is_payable = func.state_mutability == alloy_json_abi::StateMutability::Payable;

// We need to compose all the strategies generated for each parameter in all possible
// combinations.
// `prop_oneof!` / `TupleUnion` `Arc`s for cheap cloning.
prop_oneof![
let calldata_strategy = prop_oneof![
60 => fuzz_calldata(func.clone(), fuzz_fixtures),
40 => fuzz_calldata_from_state(func, fuzz_state),
]
.prop_map(move |calldata| {
trace!(input=?calldata);
CallDetails { target, calldata }
];

// For payable functions, generate random value using shared strategy.
let value_strategy = if is_payable { fuzz_msg_value().boxed() } else { Just(None).boxed() };

(calldata_strategy, value_strategy).prop_map(move |(calldata, value)| {
trace!(input=?calldata, ?value);
CallDetails { target, calldata, value }
})
}
4 changes: 2 additions & 2 deletions crates/evm/fuzz/src/strategies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ pub use uint::UintStrategy;

mod param;
pub use param::{
fuzz_param, fuzz_param_from_state, fuzz_param_with_fixtures, mutate_param_value,
mutate_param_value_with_senders,
fuzz_msg_value, fuzz_param, fuzz_param_from_state, fuzz_param_with_fixtures,
generate_msg_value, mutate_param_value, mutate_param_value_with_senders,
};

mod calldata;
Expand Down
47 changes: 47 additions & 0 deletions crates/evm/fuzz/src/strategies/param.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,53 @@ fn mutate_random_array_value(
*elem = new_val;
}

/// 0.001 ETH in wei.
const MILLI_ETH: u64 = 1_000_000_000_000_000;
/// 1 ETH in wei.
const ONE_ETH: u64 = 1_000_000_000_000_000_000;

/// Returns a proptest strategy for generating random msg.value for payable functions.
/// Biased towards smaller values to avoid balance issues.
///
/// Distribution:
/// - 85% chance: no value (None)
/// - 10% chance: small values (0-1000 wei)
/// - 4% chance: medium values (up to 0.001 ETH)
/// - 1% chance: larger values (up to 1 ETH)
pub fn fuzz_msg_value() -> impl Strategy<Value = Option<U256>> {
proptest::prop_oneof![
// 85% chance: no value
85 => proptest::strategy::Just(None),
// 10% chance: small values (0-1000 wei)
10 => (0u64..=1000).prop_map(|v| Some(U256::from(v))),
// 4% chance: medium values (up to 0.001 ETH)
4 => (0u64..=MILLI_ETH).prop_map(|v| Some(U256::from(v))),
// 1% chance: larger values (up to 1 ETH)
1 => (0u64..=ONE_ETH).prop_map(|v| Some(U256::from(v))),
]
}

/// Generates a random msg.value for payable functions using TestRunner's RNG.
/// Biased towards smaller values to avoid balance issues.
///
/// Distribution:
/// - 60% chance: small values (0-1000 wei)
/// - 30% chance: medium values (up to 0.001 ETH)
/// - 9% chance: larger values (up to 1 ETH)
/// - 1% chance: max value (edge case)
pub fn generate_msg_value(test_runner: &mut TestRunner) -> U256 {
match test_runner.rng().random_range(0..=10) {
// Small values (0-1000 wei) - 60% chance.
0..=5 => U256::from(test_runner.rng().random_range(0u64..=1000)),
// Medium values (up to 0.001 ETH) - 30% chance.
6..=8 => U256::from(test_runner.rng().random_range(0u64..=MILLI_ETH)),
// Larger values (up to 1 ETH) - 9% chance.
9 => U256::from(test_runner.rng().random_range(0u64..=ONE_ETH)),
// Edge case (max) - 1% chance.
_ => U256::MAX,
}
}

#[cfg(test)]
mod tests {
use crate::{
Expand Down
1 change: 1 addition & 0 deletions crates/forge/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,7 @@ impl<'a> FunctionRunner<'a> {
call_details: CallDetails {
target: seq.addr.unwrap_or_default(),
calldata: seq.calldata.clone(),
value: None,
},
}
})
Expand Down
81 changes: 81 additions & 0 deletions crates/forge/tests/cli/test_cmd/invariant/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1782,3 +1782,84 @@ Logs:
...
"#]]);
});

// Test that invariant fuzzer generates random msg.value for payable functions.
// Based on the example from https://github.com/foundry-rs/foundry/pull/8644
forgetest_init!(invariant_msg_value, |prj, cmd| {
prj.update_config(|config| {
config.fuzz.seed = Some(U256::from(42u32));
config.invariant.runs = 200;
config.invariant.depth = 20;
});

prj.add_test(
"InvariantMsgValue.t.sol",
r#"
import "forge-std/Test.sol";

contract ValueTarget {
bool public valueReceived;

// Payable function that tracks if any value was received
function deposit() external payable {
if (msg.value > 0) {
valueReceived = true;
}
}
}

contract InvariantMsgValue is Test {
ValueTarget target;
address sender1;
address sender2;

function setUp() public {
target = new ValueTarget();
// Create and fund specific senders
sender1 = makeAddr("sender1");
sender2 = makeAddr("sender2");
vm.deal(sender1, 1000 ether);
vm.deal(sender2, 1000 ether);
// Target only these funded senders
targetSender(sender1);
targetSender(sender2);
// Target only the ValueTarget contract
targetContract(address(target));
}

function invariant_value_never_received() public view {
require(!target.valueReceived(), "Value was received");
}
}
"#,
);

// The test should fail because the fuzzer generates msg.value > 0 for payable functions
// First check regular output format shows value=X
cmd.args(["test", "--mt", "invariant_value_never_received"])
.assert_failure()
.stdout_eq(str![[r#"
...
[FAIL: Value was received]
[Sequence] (original: [..], shrunk: 1)
sender=[..] addr=[test/InvariantMsgValue.t.sol:ValueTarget][..] value=[..] calldata=deposit() args=[]
...
"#]]);

// Now check solidity output format shows proper {value: X} syntax
cmd.forge_fuse().arg("clean").assert_success();
prj.update_config(|config| {
config.invariant.show_solidity = true;
});
cmd.forge_fuse()
.args(["test", "--mt", "invariant_value_never_received"])
.assert_failure()
.stdout_eq(str![[r#"
...
[FAIL: Value was received]
[Sequence] (original: [..], shrunk: 1)
vm.prank([..]);
ValueTarget([..]).deposit{value: [..]}();
...
"#]]);
});
Loading