Skip to content

Commit 1465ae0

Browse files
grandizzyQiuhaoLi
andcommitted
feat(fuzz): enhance corpus mutation with all-call strategy and msg.value support
When using the ABI mutation type in coverage-guided fuzzing: - 30% chance to mutate ALL calls in the sequence rather than just one - Mutate sender (15%) using addresses from dictionary - Mutate msg.value (15%) for payable functions Also adds automatic msg.value generation for payable functions during initial call generation, with value shown in sequence output. Value generation is biased towards smaller values to avoid balance issues: - 85% no value, 10% small (0-1000 wei), 4% medium (0.001 ETH), 1% large (1 ETH) Based on #8644 Co-authored-by: QiuhaoLi <qiuhaoli@outlook.com>
1 parent 49be83d commit 1465ae0

9 files changed

Lines changed: 225 additions & 19 deletions

File tree

crates/evm/evm/src/executors/corpus.rs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ use foundry_config::FuzzCorpusConfig;
4343
use foundry_evm_fuzz::{
4444
BasicTxDetails,
4545
invariant::FuzzRunIdentifiedContracts,
46-
strategies::{EvmFuzzState, mutate_param_value},
46+
strategies::{EvmFuzzState, generate_msg_value, mutate_param_value},
4747
};
4848
use proptest::{
4949
prelude::{Just, Rng, Strategy},
@@ -560,12 +560,23 @@ impl WorkerCorpus {
560560

561561
new_seq = corpus.tx_seq.clone();
562562

563-
let idx = rng.random_range(0..new_seq.len());
564-
let tx = new_seq.get_mut(idx).unwrap();
565-
if let (_, Some(function)) = targets.fuzzed_artifacts(tx) {
566-
// TODO: add call_value to call details and mutate it as well as sender some
567-
// of the time.
568-
if !function.inputs.is_empty() {
563+
// 30% chance to mutate ALL calls in the sequence.
564+
// This helps break multi-constraint bugs where any call could hit the target.
565+
if rng.random_range(0..10) < 3 {
566+
for tx in &mut new_seq {
567+
if let (_, Some(function)) = targets.fuzzed_artifacts(tx)
568+
&& !function.inputs.is_empty()
569+
{
570+
self.abi_mutate(tx, function, test_runner, fuzz_state)?;
571+
}
572+
}
573+
} else {
574+
// Standard: mutate a single random call.
575+
let idx = rng.random_range(0..new_seq.len());
576+
let tx = new_seq.get_mut(idx).unwrap();
577+
if let (_, Some(function)) = targets.fuzzed_artifacts(tx)
578+
&& !function.inputs.is_empty()
579+
{
569580
self.abi_mutate(tx, function, test_runner, fuzz_state)?;
570581
}
571582
}
@@ -687,7 +698,26 @@ impl WorkerCorpus {
687698
test_runner: &mut TestRunner,
688699
fuzz_state: &EvmFuzzState,
689700
) -> Result<()> {
690-
// let rng = test_runner.rng();
701+
// Mutate sender with 15% probability using addresses from dictionary.
702+
if test_runner.rng().random_ratio(15, 100) {
703+
let dict = fuzz_state.dictionary_read();
704+
let addresses = dict.addresses();
705+
if !addresses.is_empty() {
706+
let idx = test_runner.rng().random_range(0..addresses.len());
707+
if let Some(&addr) = addresses.get_index(idx) {
708+
tx.sender = addr;
709+
}
710+
}
711+
}
712+
713+
// Mutate value with 15% probability for payable functions.
714+
if function.state_mutability == alloy_json_abi::StateMutability::Payable
715+
&& test_runner.rng().random_ratio(15, 100)
716+
{
717+
tx.call_details.value = Some(generate_msg_value(test_runner));
718+
}
719+
720+
// Mutate calldata.
691721
let mut arg_mutation_rounds =
692722
test_runner.rng().random_range(0..=function.inputs.len()).max(1);
693723
let round_arg_idx: Vec<usize> = if function.inputs.len() <= 1 {
@@ -1104,6 +1134,7 @@ mod tests {
11041134
call_details: foundry_evm_fuzz::CallDetails {
11051135
target: Address::ZERO,
11061136
calldata: Bytes::new(),
1137+
value: None,
11071138
},
11081139
}
11091140
}

crates/evm/evm/src/executors/fuzz/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,11 @@ impl FuzzedExecutor {
251251
warp: None,
252252
roll: None,
253253
sender: self.sender,
254-
call_details: CallDetails { target: address, calldata: calldata.clone() },
254+
call_details: CallDetails {
255+
target: address,
256+
calldata: calldata.clone(),
257+
value: None,
258+
},
255259
}],
256260
new_coverage,
257261
);
@@ -414,7 +418,7 @@ impl FuzzedExecutor {
414418
warp: None,
415419
roll: None,
416420
sender: Default::default(),
417-
call_details: CallDetails { target: Default::default(), calldata },
421+
call_details: CallDetails { target: Default::default(), calldata, value: None },
418422
});
419423

420424
let mut corpus = WorkerCorpus::new(

crates/evm/evm/src/executors/invariant/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1090,8 +1090,12 @@ pub(crate) fn execute_tx(executor: &mut Executor, tx: &BasicTxDetails) -> Result
10901090
}
10911091

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

10971101
// Propagate block adjustments to call result which will be committed.

crates/evm/fuzz/src/lib.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub struct CallDetails {
5656
pub target: Address,
5757
/// The data of the transaction.
5858
pub calldata: Bytes,
59+
/// Ether value to send with the transaction.
60+
/// Uses `#[serde(default)]` for backwards compatibility with existing corpus files.
61+
#[serde(default, skip_serializing_if = "Option::is_none")]
62+
pub value: Option<U256>,
5963
}
6064

6165
impl BasicTxDetails {
@@ -86,6 +90,9 @@ pub struct BaseCounterExample {
8690
pub addr: Option<Address>,
8791
/// The data to provide.
8892
pub calldata: Bytes,
93+
/// Ether value sent with the call.
94+
#[serde(default, skip_serializing_if = "Option::is_none")]
95+
pub value: Option<U256>,
8996
/// Contract name if it exists.
9097
pub contract_name: Option<String>,
9198
/// Function name if it exists.
@@ -115,6 +122,7 @@ impl BaseCounterExample {
115122
let sender = tx.sender;
116123
let target = tx.call_details.target;
117124
let bytes = &tx.call_details.calldata;
125+
let value = tx.call_details.value;
118126
let warp = tx.warp;
119127
let roll = tx.roll;
120128
if let Some((name, abi)) = &contracts.get(&target)
@@ -128,6 +136,7 @@ impl BaseCounterExample {
128136
sender: Some(sender),
129137
addr: Some(target),
130138
calldata: bytes.clone(),
139+
value,
131140
contract_name: Some(name.clone()),
132141
func_name: Some(func.name.clone()),
133142
signature: Some(func.signature()),
@@ -147,6 +156,7 @@ impl BaseCounterExample {
147156
sender: Some(sender),
148157
addr: Some(target),
149158
calldata: bytes.clone(),
159+
value,
150160
contract_name: None,
151161
func_name: None,
152162
signature: None,
@@ -169,6 +179,7 @@ impl BaseCounterExample {
169179
sender: None,
170180
addr: None,
171181
calldata: bytes,
182+
value: None,
172183
contract_name: None,
173184
func_name: None,
174185
signature: None,
@@ -194,6 +205,20 @@ impl fmt::Display for BaseCounterExample {
194205
writeln!(f, "\t\tvm.roll(block.number + {roll});")?;
195206
}
196207
writeln!(f, "\t\tvm.prank({sender});")?;
208+
// Use value syntax for payable calls.
209+
if let Some(value) = &self.value
210+
&& !value.is_zero()
211+
{
212+
write!(
213+
f,
214+
"\t\t{}({}).{}{{value: {value}}}({});",
215+
contract.split_once(':').map_or(contract.as_str(), |(_, contract)| contract),
216+
address,
217+
func_name,
218+
args
219+
)?;
220+
return Ok(());
221+
}
197222
write!(
198223
f,
199224
"\t\t{}({}).{}({});",
@@ -226,6 +251,13 @@ impl fmt::Display for BaseCounterExample {
226251
write!(f, "roll={roll} ")?;
227252
}
228253

254+
// Display value if non-zero (for payable calls).
255+
if let Some(value) = &self.value
256+
&& !value.is_zero()
257+
{
258+
write!(f, "value={value} ")?;
259+
}
260+
229261
if let Some(sig) = &self.signature {
230262
write!(f, "calldata={sig}")?
231263
} else {

crates/evm/fuzz/src/strategies/invariants.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{fuzz_calldata, fuzz_param_from_state};
1+
use super::{fuzz_calldata, fuzz_msg_value, fuzz_param_from_state};
22
use crate::{
33
BasicTxDetails, CallDetails, FuzzFixtures,
44
invariant::{FuzzRunIdentifiedContracts, SenderFilters},
@@ -153,15 +153,21 @@ pub fn fuzz_contract_with_calldata(
153153
target: Address,
154154
func: Function,
155155
) -> impl Strategy<Value = CallDetails> + use<> {
156+
let is_payable = func.state_mutability == alloy_json_abi::StateMutability::Payable;
157+
156158
// We need to compose all the strategies generated for each parameter in all possible
157159
// combinations.
158160
// `prop_oneof!` / `TupleUnion` `Arc`s for cheap cloning.
159-
prop_oneof![
161+
let calldata_strategy = prop_oneof![
160162
60 => fuzz_calldata(func.clone(), fuzz_fixtures),
161163
40 => fuzz_calldata_from_state(func, fuzz_state),
162-
]
163-
.prop_map(move |calldata| {
164-
trace!(input=?calldata);
165-
CallDetails { target, calldata }
164+
];
165+
166+
// For payable functions, generate random value using shared strategy.
167+
let value_strategy = if is_payable { fuzz_msg_value().boxed() } else { Just(None).boxed() };
168+
169+
(calldata_strategy, value_strategy).prop_map(move |(calldata, value)| {
170+
trace!(input=?calldata, ?value);
171+
CallDetails { target, calldata, value }
166172
})
167173
}

crates/evm/fuzz/src/strategies/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ pub use uint::UintStrategy;
66

77
mod param;
88
pub use param::{
9-
fuzz_param, fuzz_param_from_state, fuzz_param_with_fixtures, mutate_param_value,
10-
mutate_param_value_with_senders,
9+
fuzz_msg_value, fuzz_param, fuzz_param_from_state, fuzz_param_with_fixtures,
10+
generate_msg_value, mutate_param_value, mutate_param_value_with_senders,
1111
};
1212

1313
mod calldata;

crates/evm/fuzz/src/strategies/param.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,53 @@ fn mutate_random_array_value(
512512
*elem = new_val;
513513
}
514514

515+
/// 0.001 ETH in wei.
516+
const MILLI_ETH: u64 = 1_000_000_000_000_000;
517+
/// 1 ETH in wei.
518+
const ONE_ETH: u64 = 1_000_000_000_000_000_000;
519+
520+
/// Returns a proptest strategy for generating random msg.value for payable functions.
521+
/// Biased towards smaller values to avoid balance issues.
522+
///
523+
/// Distribution:
524+
/// - 85% chance: no value (None)
525+
/// - 10% chance: small values (0-1000 wei)
526+
/// - 4% chance: medium values (up to 0.001 ETH)
527+
/// - 1% chance: larger values (up to 1 ETH)
528+
pub fn fuzz_msg_value() -> impl Strategy<Value = Option<U256>> {
529+
proptest::prop_oneof![
530+
// 85% chance: no value
531+
85 => proptest::strategy::Just(None),
532+
// 10% chance: small values (0-1000 wei)
533+
10 => (0u64..=1000).prop_map(|v| Some(U256::from(v))),
534+
// 4% chance: medium values (up to 0.001 ETH)
535+
4 => (0u64..=MILLI_ETH).prop_map(|v| Some(U256::from(v))),
536+
// 1% chance: larger values (up to 1 ETH)
537+
1 => (0u64..=ONE_ETH).prop_map(|v| Some(U256::from(v))),
538+
]
539+
}
540+
541+
/// Generates a random msg.value for payable functions using TestRunner's RNG.
542+
/// Biased towards smaller values to avoid balance issues.
543+
///
544+
/// Distribution:
545+
/// - 60% chance: small values (0-1000 wei)
546+
/// - 30% chance: medium values (up to 0.001 ETH)
547+
/// - 9% chance: larger values (up to 1 ETH)
548+
/// - 1% chance: max value (edge case)
549+
pub fn generate_msg_value(test_runner: &mut TestRunner) -> U256 {
550+
match test_runner.rng().random_range(0..=10) {
551+
// Small values (0-1000 wei) - 60% chance.
552+
0..=5 => U256::from(test_runner.rng().random_range(0u64..=1000)),
553+
// Medium values (up to 0.001 ETH) - 30% chance.
554+
6..=8 => U256::from(test_runner.rng().random_range(0u64..=MILLI_ETH)),
555+
// Larger values (up to 1 ETH) - 9% chance.
556+
9 => U256::from(test_runner.rng().random_range(0u64..=ONE_ETH)),
557+
// Edge case (max) - 1% chance.
558+
_ => U256::MAX,
559+
}
560+
}
561+
515562
#[cfg(test)]
516563
mod tests {
517564
use crate::{

crates/forge/src/runner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,7 @@ impl<'a> FunctionRunner<'a> {
786786
call_details: CallDetails {
787787
target: seq.addr.unwrap_or_default(),
788788
calldata: seq.calldata.clone(),
789+
value: None,
789790
},
790791
}
791792
})

crates/forge/tests/cli/test_cmd/invariant/common.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,3 +1782,84 @@ Logs:
17821782
...
17831783
"#]]);
17841784
});
1785+
1786+
// Test that invariant fuzzer generates random msg.value for payable functions.
1787+
// Based on the example from https://github.com/foundry-rs/foundry/pull/8644
1788+
forgetest_init!(invariant_msg_value, |prj, cmd| {
1789+
prj.update_config(|config| {
1790+
config.fuzz.seed = Some(U256::from(42u32));
1791+
config.invariant.runs = 200;
1792+
config.invariant.depth = 20;
1793+
});
1794+
1795+
prj.add_test(
1796+
"InvariantMsgValue.t.sol",
1797+
r#"
1798+
import "forge-std/Test.sol";
1799+
1800+
contract ValueTarget {
1801+
bool public valueReceived;
1802+
1803+
// Payable function that tracks if any value was received
1804+
function deposit() external payable {
1805+
if (msg.value > 0) {
1806+
valueReceived = true;
1807+
}
1808+
}
1809+
}
1810+
1811+
contract InvariantMsgValue is Test {
1812+
ValueTarget target;
1813+
address sender1;
1814+
address sender2;
1815+
1816+
function setUp() public {
1817+
target = new ValueTarget();
1818+
// Create and fund specific senders
1819+
sender1 = makeAddr("sender1");
1820+
sender2 = makeAddr("sender2");
1821+
vm.deal(sender1, 1000 ether);
1822+
vm.deal(sender2, 1000 ether);
1823+
// Target only these funded senders
1824+
targetSender(sender1);
1825+
targetSender(sender2);
1826+
// Target only the ValueTarget contract
1827+
targetContract(address(target));
1828+
}
1829+
1830+
function invariant_value_never_received() public view {
1831+
require(!target.valueReceived(), "Value was received");
1832+
}
1833+
}
1834+
"#,
1835+
);
1836+
1837+
// The test should fail because the fuzzer generates msg.value > 0 for payable functions
1838+
// First check regular output format shows value=X
1839+
cmd.args(["test", "--mt", "invariant_value_never_received"])
1840+
.assert_failure()
1841+
.stdout_eq(str![[r#"
1842+
...
1843+
[FAIL: Value was received]
1844+
[Sequence] (original: [..], shrunk: 1)
1845+
sender=[..] addr=[test/InvariantMsgValue.t.sol:ValueTarget][..] value=[..] calldata=deposit() args=[]
1846+
...
1847+
"#]]);
1848+
1849+
// Now check solidity output format shows proper {value: X} syntax
1850+
cmd.forge_fuse().arg("clean").assert_success();
1851+
prj.update_config(|config| {
1852+
config.invariant.show_solidity = true;
1853+
});
1854+
cmd.forge_fuse()
1855+
.args(["test", "--mt", "invariant_value_never_received"])
1856+
.assert_failure()
1857+
.stdout_eq(str![[r#"
1858+
...
1859+
[FAIL: Value was received]
1860+
[Sequence] (original: [..], shrunk: 1)
1861+
vm.prank([..]);
1862+
ValueTarget([..]).deposit{value: [..]}();
1863+
...
1864+
"#]]);
1865+
});

0 commit comments

Comments
 (0)