Skip to content

Commit 6ad1403

Browse files
feat(fuzz): add max_deal config to fund senders for payable functions
Adds max_deal invariant config option that generates random deal amounts (0 to max_deal) to increase sender balance before each tx for payable functions. Behavior: - If max_deal is NOT configured: value is generated for payable functions but falls back to 0 if sender has insufficient balance - If max_deal IS configured: random deal amount is applied before the call, enabling payable calls with msg.value > 0 to succeed The deal is only applied when the function is payable (has value > 0). If balance is still insufficient after deal, value falls back to 0. Counterexamples display deal as: - Regular: deal=X - Solidity: vm.deal(sender, sender.balance + X); Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019bf992-367c-721c-a497-58a1a11ee2be
1 parent 40fd045 commit 6ad1403

10 files changed

Lines changed: 188 additions & 11 deletions

File tree

crates/config/src/invariant.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ pub struct InvariantConfig {
4141
pub max_time_delay: Option<u32>,
4242
/// Maximum number of blocks elapsed between generated txs.
4343
pub max_block_delay: Option<u32>,
44+
/// Maximum amount (in wei) to deal to sender before each tx for payable functions.
45+
pub max_deal: Option<u64>,
4446
/// Number of calls to execute between invariant assertions.
4547
///
4648
/// - `0`: Only assert on the last call of each run (fastest, but may miss exact breaking call)
@@ -69,6 +71,7 @@ impl Default for InvariantConfig {
6971
show_solidity: false,
7072
max_time_delay: None,
7173
max_block_delay: None,
74+
max_deal: None,
7275
check_interval: 1,
7376
}
7477
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,7 @@ mod tests {
11451145
BasicTxDetails {
11461146
warp: None,
11471147
roll: None,
1148+
deal: None,
11481149
sender: Address::ZERO,
11491150
call_details: foundry_evm_fuzz::CallDetails {
11501151
target: Address::ZERO,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ impl FuzzedExecutor {
250250
&[BasicTxDetails {
251251
warp: None,
252252
roll: None,
253+
deal: None,
253254
sender: self.sender,
254255
call_details: CallDetails {
255256
target: address,
@@ -417,6 +418,7 @@ impl FuzzedExecutor {
417418
.prop_map(move |calldata| BasicTxDetails {
418419
warp: None,
419420
roll: None,
421+
deal: None,
420422
sender: Default::default(),
421423
call_details: CallDetails { target: Default::default(), calldata, value: None },
422424
});

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,7 +1105,8 @@ pub(crate) fn call_invariant_function(
11051105
}
11061106

11071107
/// Executes a fuzz call and returns the result.
1108-
/// Applies any block timestamp (warp) and block number (roll) adjustments before the call.
1108+
/// Applies any block timestamp (warp), block number (roll), and balance (deal) adjustments before
1109+
/// the call.
11091110
pub(crate) fn execute_tx(executor: &mut Executor, tx: &BasicTxDetails) -> Result<RawCallResult> {
11101111
let warp = tx.warp.unwrap_or_default();
11111112
let roll = tx.roll.unwrap_or_default();
@@ -1129,9 +1130,19 @@ pub(crate) fn execute_tx(executor: &mut Executor, tx: &BasicTxDetails) -> Result
11291130
}
11301131
}
11311132

1132-
// Perform the raw call.
1133-
// Only use value if sender has sufficient balance, otherwise fall back to 0.
11341133
let requested_value = tx.call_details.value.unwrap_or(U256::ZERO);
1134+
1135+
// Apply deal (increase sender balance) if specified and function is payable (has value).
1136+
// Value is only generated for payable functions, so we check if requested_value > 0.
1137+
if let Some(deal) = tx.deal
1138+
&& requested_value > U256::ZERO
1139+
{
1140+
let current_balance = executor.get_balance(tx.sender)?;
1141+
executor.set_balance(tx.sender, current_balance + deal)?;
1142+
}
1143+
1144+
// Perform the raw call.
1145+
// Only use value if sender has sufficient balance (after deal), otherwise fall back to 0.
11351146
let sender_balance = executor.get_balance(tx.sender)?;
11361147
let value = if sender_balance >= requested_value { requested_value } else { U256::ZERO };
11371148
executor

crates/evm/fuzz/src/invariant/call_override.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,13 @@ impl RandomCallGenerator {
9191

9292
// `original_caller` has a 80% chance of being the `new_target`.
9393
let choice = self.strategy.new_tree(&mut self.runner.lock()).unwrap().current().map(
94-
|call_details| BasicTxDetails { warp: None, roll: None, sender, call_details },
94+
|call_details| BasicTxDetails {
95+
warp: None,
96+
roll: None,
97+
deal: None,
98+
sender,
99+
call_details,
100+
},
95101
);
96102

97103
self.last_sequence.write().push(choice.clone());

crates/evm/fuzz/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub struct BasicTxDetails {
4242
/// Number to increase block number before executing the tx.
4343
#[serde(default, skip_serializing_if = "Option::is_none")]
4444
pub roll: Option<U256>,
45+
/// Amount to deal (add) to sender's balance before executing the tx.
46+
#[serde(default, skip_serializing_if = "Option::is_none")]
47+
pub deal: Option<U256>,
4548
/// Transaction sender address.
4649
pub sender: Address,
4750
/// Transaction call details.
@@ -84,6 +87,8 @@ pub struct BaseCounterExample {
8487
pub warp: Option<U256>,
8588
// Amount to increase block number.
8689
pub roll: Option<U256>,
90+
// Amount to deal (add) to sender's balance.
91+
pub deal: Option<U256>,
8792
/// Address which makes the call.
8893
pub sender: Option<Address>,
8994
/// Address to which to call to.
@@ -125,6 +130,7 @@ impl BaseCounterExample {
125130
let value = tx.call_details.value;
126131
let warp = tx.warp;
127132
let roll = tx.roll;
133+
let deal = tx.deal;
128134
if let Some((name, abi)) = &contracts.get(&target)
129135
&& let Some(func) = abi.functions().find(|f| f.selector() == bytes[..4])
130136
{
@@ -133,6 +139,7 @@ impl BaseCounterExample {
133139
return Self {
134140
warp,
135141
roll,
142+
deal,
136143
sender: Some(sender),
137144
addr: Some(target),
138145
calldata: bytes.clone(),
@@ -153,6 +160,7 @@ impl BaseCounterExample {
153160
Self {
154161
warp,
155162
roll,
163+
deal,
156164
sender: Some(sender),
157165
addr: Some(target),
158166
calldata: bytes.clone(),
@@ -176,6 +184,7 @@ impl BaseCounterExample {
176184
Self {
177185
warp: None,
178186
roll: None,
187+
deal: None,
179188
sender: None,
180189
addr: None,
181190
calldata: bytes,
@@ -204,6 +213,9 @@ impl fmt::Display for BaseCounterExample {
204213
if let Some(roll) = &self.roll {
205214
writeln!(f, "\t\tvm.roll(block.number + {roll});")?;
206215
}
216+
if let Some(deal) = &self.deal {
217+
writeln!(f, "\t\tvm.deal({sender}, {sender}.balance + {deal});")?;
218+
}
207219
writeln!(f, "\t\tvm.prank({sender});")?;
208220
// Use value syntax for payable calls.
209221
if let Some(value) = &self.value
@@ -250,6 +262,9 @@ impl fmt::Display for BaseCounterExample {
250262
if let Some(roll) = &self.roll {
251263
write!(f, "roll={roll} ")?;
252264
}
265+
if let Some(deal) = &self.deal {
266+
write!(f, "deal={deal} ")?;
267+
}
253268

254269
// Display value if non-zero (for payable calls).
255270
if let Some(value) = &self.value

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ pub fn invariant_strat(
7777
let senders = Rc::new(senders);
7878
let dictionary_weight = config.dictionary.dictionary_weight;
7979

80-
// Strategy to generate values for tx warp and roll.
81-
let warp_roll_strat = |cond: bool| {
80+
// Strategy to generate optional U256 values for tx warp, roll, and deal.
81+
let optional_u256_strat = |cond: bool| {
8282
if cond { any::<U256>().prop_map(Some).boxed() } else { Just(None).boxed() }
8383
};
8484

@@ -97,17 +97,19 @@ pub fn invariant_strat(
9797
target_function.clone(),
9898
);
9999

100-
let warp = warp_roll_strat(config.max_time_delay.is_some());
101-
let roll = warp_roll_strat(config.max_block_delay.is_some());
100+
let warp = optional_u256_strat(config.max_time_delay.is_some());
101+
let roll = optional_u256_strat(config.max_block_delay.is_some());
102+
let deal = optional_u256_strat(config.max_deal.is_some());
102103

103-
(warp, roll, sender, call_details)
104+
(warp, roll, deal, sender, call_details)
104105
})
105-
.prop_map(move |(warp, roll, sender, call_details)| {
106+
.prop_map(move |(warp, roll, deal, sender, call_details)| {
106107
let warp =
107108
warp.map(|time| time % U256::from(config.max_time_delay.unwrap_or_default()));
108109
let roll =
109110
roll.map(|block| block % U256::from(config.max_block_delay.unwrap_or_default()));
110-
BasicTxDetails { warp, roll, sender, call_details }
111+
let deal = deal.map(|amount| amount % U256::from(config.max_deal.unwrap_or_default()));
112+
BasicTxDetails { warp, roll, deal, sender, call_details }
111113
})
112114
}
113115

crates/forge/src/runner.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ impl<'a> FunctionRunner<'a> {
778778
BasicTxDetails {
779779
warp: seq.warp,
780780
roll: seq.roll,
781+
deal: seq.deal,
781782
sender: seq.sender.unwrap_or_default(),
782783
call_details: CallDetails {
783784
target: seq.addr.unwrap_or_default(),

crates/forge/tests/cli/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,7 @@ forgetest_init!(test_default_config, |prj, cmd| {
12881288
"show_solidity": false,
12891289
"max_time_delay": null,
12901290
"max_block_delay": null,
1291+
"max_deal": null,
12911292
"check_interval": 1
12921293
},
12931294
"ffi": false,

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

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2055,3 +2055,138 @@ contract InvariantOptimizeWarpTest is Test {
20552055
...
20562056
"#]]);
20572057
});
2058+
2059+
// Test that without max_deal, payable calls with value fail when sender has no balance.
2060+
// The fuzzer should fall back to value=0, so the invariant should pass.
2061+
forgetest_init!(invariant_no_max_deal_fallback, |prj, cmd| {
2062+
prj.update_config(|config| {
2063+
config.fuzz.seed = Some(U256::from(42u32));
2064+
config.invariant.runs = 50;
2065+
config.invariant.depth = 10;
2066+
// No max_deal configured - sender has no balance so value falls back to 0
2067+
});
2068+
2069+
prj.add_test(
2070+
"InvariantNoMaxDeal.t.sol",
2071+
r#"
2072+
import "forge-std/Test.sol";
2073+
2074+
contract ValueTarget {
2075+
bool public valueReceived;
2076+
2077+
function deposit() external payable {
2078+
if (msg.value > 0) {
2079+
valueReceived = true;
2080+
}
2081+
}
2082+
}
2083+
2084+
contract InvariantNoMaxDeal is Test {
2085+
ValueTarget target;
2086+
2087+
function setUp() public {
2088+
target = new ValueTarget();
2089+
// Target only the ValueTarget contract
2090+
targetContract(address(target));
2091+
// Use a custom sender with no initial balance
2092+
targetSender(makeAddr("sender1"));
2093+
// NOTE: No vm.deal() for sender1 - it has 0 balance
2094+
// Without max_deal config, value should fall back to 0
2095+
}
2096+
2097+
// This invariant should PASS because without max_deal, value falls back to 0
2098+
function invariant_value_never_received() public view {
2099+
require(!target.valueReceived(), "Value was received");
2100+
}
2101+
}
2102+
"#,
2103+
);
2104+
2105+
// The test should pass because without max_deal and no sender balance,
2106+
// value falls back to 0
2107+
cmd.args(["test", "--mt", "invariant_value_never_received"]).assert_success().stdout_eq(str![
2108+
[r#"
2109+
...
2110+
[PASS] invariant_value_never_received() (runs: 50, calls: 500, reverts: 0)
2111+
...
2112+
"#]
2113+
]);
2114+
});
2115+
2116+
// Test that with max_deal configured, the fuzzer deals balance to sender before tx,
2117+
// enabling payable calls with msg.value > 0 to succeed.
2118+
forgetest_init!(invariant_with_max_deal, |prj, cmd| {
2119+
prj.update_config(|config| {
2120+
config.fuzz.seed = Some(U256::from(42u32));
2121+
config.invariant.runs = 100;
2122+
config.invariant.depth = 20;
2123+
// Configure max_deal to fund senders before each tx
2124+
config.invariant.max_deal = Some(1_000_000_000_000_000_000); // 1 ETH in wei
2125+
});
2126+
2127+
prj.add_test(
2128+
"InvariantWithMaxDeal.t.sol",
2129+
r#"
2130+
import "forge-std/Test.sol";
2131+
2132+
contract ValueTarget {
2133+
bool public valueReceived;
2134+
2135+
function deposit() external payable {
2136+
if (msg.value > 0) {
2137+
valueReceived = true;
2138+
}
2139+
}
2140+
}
2141+
2142+
contract InvariantWithMaxDeal is Test {
2143+
ValueTarget target;
2144+
2145+
function setUp() public {
2146+
target = new ValueTarget();
2147+
// Target only the ValueTarget contract
2148+
targetContract(address(target));
2149+
// Use a custom sender with no initial balance
2150+
targetSender(makeAddr("sender1"));
2151+
// NOTE: No vm.deal() in setUp - max_deal config will fund sender1
2152+
}
2153+
2154+
// This invariant should FAIL because max_deal funds senders,
2155+
// allowing payable calls with msg.value > 0
2156+
function invariant_value_never_received() public view {
2157+
require(!target.valueReceived(), "Value was received");
2158+
}
2159+
}
2160+
"#,
2161+
);
2162+
2163+
// The test should fail because max_deal funds senders before each tx,
2164+
// enabling value > 0 to be sent
2165+
cmd.args(["test", "--mt", "invariant_value_never_received"])
2166+
.assert_failure()
2167+
.stdout_eq(str![[r#"
2168+
...
2169+
[FAIL: Value was received]
2170+
[Sequence] (original: [..], shrunk: 1)
2171+
sender=[..] addr=[test/InvariantWithMaxDeal.t.sol:ValueTarget][..] deal=[..] value=[..] calldata=deposit() args=[]
2172+
...
2173+
"#]]);
2174+
2175+
// Check solidity output format shows vm.deal()
2176+
cmd.forge_fuse().arg("clean").assert_success();
2177+
prj.update_config(|config| {
2178+
config.invariant.show_solidity = true;
2179+
});
2180+
cmd.forge_fuse()
2181+
.args(["test", "--mt", "invariant_value_never_received"])
2182+
.assert_failure()
2183+
.stdout_eq(str![[r#"
2184+
...
2185+
[FAIL: Value was received]
2186+
[Sequence] (original: [..], shrunk: 1)
2187+
vm.deal([..], [..].balance + [..]);
2188+
vm.prank([..]);
2189+
ValueTarget([..]).deposit{value: [..]}();
2190+
...
2191+
"#]]);
2192+
});

0 commit comments

Comments
 (0)