🌿 Intermediate ⏱️ 45 min

Setting Up Foundry

Foundry is the industry-standard toolkit for smart contract development and security testing. Written in Rust, it is 10-100x faster than JavaScript-based alternatives. More importantly for security work: you write exploits as Solidity test cases, enabling you to test vulnerabilities with the full expressiveness of Solidity itself. Think of Foundry as your Burp Suite for smart contracts — precise, scriptable, and professional.

Why Foundry Over Hardhat?

FeatureFoundryHardhat
Test languageSolidityJavaScript/TypeScript
Compilation speedSeconds (Rust)Minutes (JS)
Fuzz testingBuilt-in, 1000s of runs/secPlugin required, slower
CheatcodesRich set (prank, warp, deal, etc.)Limited
Mainnet forkingNative, fastSupported but slower
Stack tracesExcellent (Solidity-level)Good
Gas reportsBuilt-in, detailedPlugin required
Script deploymentforge script (Solidity)deploy.js (JS)
Learning curveLower for Solidity devsLower for JS devs
Industry adoptionNow dominant for auditorsCommon in older projects

Installation

💻 Install Foundry (macOS/Linux)
# Install foundryup (the version manager) curl -L https://foundry.paradigm.xyz | bash # Reload your shell (or open a new terminal) source ~/.bashrc # or ~/.zshrc on macOS # Install the latest Foundry tools foundryup # Verify installation forge --version # e.g., forge 0.2.0 (abc1234 2024-01-01) cast --version # cast 0.2.0 ... anvil --version # anvil 0.2.0 ... # Update to latest anytime: foundryup
💡 Windows Users

Foundry works best on Linux/macOS. On Windows, use WSL2 (Windows Subsystem for Linux) with Ubuntu. Install WSL2 first via wsl --install in PowerShell, then run the Foundry installation inside the WSL terminal.

Project Structure

📁 Standard Foundry Project Layout
# Create a new Foundry project: forge init my-audit-project cd my-audit-project # Project structure: my-audit-project/ ├── src/ # Your Solidity source files │ └── Counter.sol # Example contract ├── test/ # Test files (must end in .t.sol) │ └── Counter.t.sol # Example test ├── script/ # Deployment scripts (.s.sol) │ └── Counter.s.sol ├── lib/ # Dependencies (git submodules) │ └── forge-std/ # Foundry standard library ├── out/ # Compiled artifacts (auto-generated) ├── cache/ # Build cache (auto-generated) └── foundry.toml # Configuration file # Install OpenZeppelin as a dependency: forge install OpenZeppelin/openzeppelin-contracts # Remap it in foundry.toml or remappings.txt: # @openzeppelin/=lib/openzeppelin-contracts/

foundry.toml — Configuration Reference

⚙️ foundry.toml — All Key Options
[profile.default] src = "src" # Source directory out = "out" # Compiled artifacts output libs = ["lib"] # Library directories test = "test" # Test directory # Solidity version: solc_version = "0.8.20" # Pin exact compiler version # Optimizer settings (must match production!): optimizer = true # Enable optimizer optimizer_runs = 200 # Higher = more gas optimized, bigger bytecode # Fuzz testing settings: [fuzz] runs = 1000 # Number of fuzz inputs to generate per test max_test_rejects = 65536 # Max rejected inputs before giving up seed = "0xdeadbeef" # Reproducible fuzz runs # Mainnet fork settings: [rpc_endpoints] mainnet = "${MAINNET_RPC_URL}" # From .env file sepolia = "${SEPOLIA_RPC_URL}" # Testnet # Etherscan for contract verification: [etherscan] mainnet = { key = "${ETHERSCAN_KEY}" } # Invariant testing settings: [invariant] runs = 256 depth = 128 # Max calls per run

Your First Test — Structure and Conventions

🧪 Writing Your First Foundry Test
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; // Foundry test base import "../src/SimpleBank.sol"; // Contract under test // Test contract MUST inherit from Test contract SimpleBankTest is Test { SimpleBank bank; address alice = makeAddr("alice"); // Creates deterministic test address address bob = makeAddr("bob"); // setUp() runs BEFORE each test function function setUp() public { bank = new SimpleBank(); vm.deal(alice, 10 ether); // Give Alice 10 ETH vm.deal(bob, 5 ether); // Give Bob 5 ETH } // Test functions MUST start with "test" function test_Deposit() public { vm.prank(alice); // Next call will be from alice bank.deposit{value: 1 ether}(); // Assertion using forge-std's assertEq assertEq(bank.getBalance(alice), 1 ether); } function test_Withdraw() public { // Deposit first vm.prank(alice); bank.deposit{value: 2 ether}(); // Then withdraw uint256 balanceBefore = alice.balance; vm.prank(alice); bank.withdraw(1 ether); assertEq(alice.balance, balanceBefore + 1 ether); assertEq(bank.getBalance(alice), 1 ether); } // Test that a revert happens (for negative testing) function test_RevertOnInsufficientBalance() public { vm.expectRevert("Insufficient balance"); // Expect this revert vm.prank(alice); bank.withdraw(100 ether); // Alice has 0, this should revert } }

Cheatcodes Deep Dive

Cheatcodes are the superpower of Foundry. They let your tests manipulate the EVM state in ways that would be impossible in production:

🎰 Essential Cheatcodes for Security Testing
// ────────────────────────────────────────────── // IDENTITY / CALLER MANIPULATION // ────────────────────────────────────────────── vm.prank(address who); // Next call ONLY will be from 'who' vm.prank(alice); target.adminFunction(); // Executes as alice vm.startPrank(address who); vm.stopPrank(); // ALL calls between start and stop are from 'who' vm.startPrank(alice); target.stepOne(); target.stepTwo(); vm.stopPrank(); // ────────────────────────────────────────────── // ETH / TOKEN BALANCE MANIPULATION // ────────────────────────────────────────────── vm.deal(address who, uint256 amount); // Set ETH balance of any address (including whales!) vm.deal(address(this), 1000 ether); // ────────────────────────────────────────────── // TIME AND BLOCK MANIPULATION // ────────────────────────────────────────────── vm.warp(uint256 timestamp); // Set block.timestamp to any value vm.warp(block.timestamp + 30 days); // Fast-forward 30 days vm.roll(uint256 blockNumber); // Set block.number vm.roll(block.number + 100); // Advance 100 blocks // ────────────────────────────────────────────── // REVERT TESTING // ────────────────────────────────────────────── vm.expectRevert(); // Expect any revert vm.expectRevert("Error message"); // Expect specific string vm.expectRevert(MyError.selector); // Expect custom error // ────────────────────────────────────────────── // STATE MANIPULATION // ────────────────────────────────────────────── vm.store(address target, bytes32 slot, bytes32 value); // Directly write to any storage slot — bypass all checks! // Useful for setting "private" variables in tests bytes32 slot0 = bytes32(uint256(0)); // First storage slot vm.store(address(myContract), slot0, bytes32(uint256(999))); vm.load(address target, bytes32 slot); // Read any storage slot directly // ────────────────────────────────────────────── // DEBUGGING AND LABELING // ────────────────────────────────────────────── vm.label(address(myContract), "MyContract"); // Give an address a human-readable label in stack traces console.log("Value:", someUint); // Print to terminal during tests (forge-std/console.sol)

Fuzz Testing — Automated Vulnerability Discovery

Fuzz testing automatically generates thousands of random inputs to find edge cases your manual tests miss. In security auditing, this is invaluable for finding integer overflow, precision loss, and invariant violations.

🎯 Writing Fuzz Tests
contract FuzzTest is Test { SimpleBank bank; function setUp() public { bank = new SimpleBank(); } // Fuzz test: Foundry generates random 'amount' values // Try 1000 different amounts (configurable in foundry.toml) function testFuzz_DepositWithdraw(uint256 amount) public { // Constrain the input to reasonable bounds amount = bound(amount, 1, 100 ether); address user = makeAddr("user"); vm.deal(user, amount); vm.prank(user); bank.deposit{value: amount}(); assertEq(bank.getBalance(user), amount, "Balance after deposit wrong"); vm.prank(user); bank.withdraw(amount); assertEq(bank.getBalance(user), 0, "Balance after withdraw wrong"); assertEq(user.balance, amount, "ETH not returned"); } // Invariant: Contract balance should always equal sum of all user balances // Foundry's invariant testing runs random call sequences and checks this function invariant_SolvencyCheck() public view { // This should NEVER be violated // assertGe(address(bank).balance, totalDeposited); } }

Fork Testing — Test Against Real Deployed Contracts

🍴 Mainnet Fork — Test With Real DeFi State
// .env file (never commit this!) // MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY contract ForkTest is Test { // Real Uniswap V2 router on mainnet address constant UNISWAP_V2 = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; function setUp() public { // Fork mainnet at the latest block vm.createSelectFork(vm.envString("MAINNET_RPC_URL")); // Or fork at a specific block (for reproducibility): vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19000000); } function test_SwapOnUniswap() public { // Impersonate a whale with lots of USDC address whale = 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341; vm.startPrank(whale); // Now interact with real Uniswap with real USDC // This is incredibly powerful for testing DeFi exploits! IERC20(USDC).approve(UNISWAP_V2, type(uint256).max); vm.stopPrank(); } } # Run fork test from command line: forge test --fork-url $MAINNET_RPC_URL --match-test test_SwapOnUniswap -vvvv

The cast CLI — Interact With Blockchain

🔧 cast — Essential Commands
# Read contract state cast call 0xContractAddr "balanceOf(address)(uint256)" 0xUserAddr # Get ETH balance cast balance 0xAddress # Get contract bytecode cast code 0xContractAddr # Decode calldata cast 4byte-decode 0xa9059cbb000000000000000000000000... # Compute function selector cast sig "transfer(address,uint256)" # → 0xa9059cbb # Convert units cast to-wei 1.5 ether # → 1500000000000000000 cast from-wei 1500000000000000000 # → 1.500000000000000000 # Compute keccak256 hash cast keccak "MINTER_ROLE" # Read storage slot directly cast storage 0xContractAddr 0 # Read slot 0 # Get transaction details cast tx 0xTxHash # Get block details cast block latest

Anvil — Local Testnet

⛏️ anvil — Local Ethereum Node
# Start local node with default settings (10 pre-funded accounts, 10000 ETH each) anvil # Output includes: # Private Keys: # (0) 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 # Addresses: # (0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 # Fork mainnet locally (work with real contract state, but run locally): anvil --fork-url $MAINNET_RPC_URL # Fork at specific block for reproducible tests: anvil --fork-url $MAINNET_RPC_URL --fork-block-number 19000000 # Custom settings: anvil --accounts 20 --balance 100000 --block-time 2 # Connect MetaMask to local anvil: # Network: localhost:8545, Chain ID: 31337

Complete Security Testing Example

Here is the full workflow for finding and proving a vulnerability: write the vulnerable contract, write the exploit test, then write the fixed contract and confirm the fix works:

🛡️ Full Security Test: Reentrancy Exploit + Fix
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; // ── Vulnerable contract ── contract VulnerableBank { mapping(address => uint256) public balances; function deposit() external payable { balances[msg.sender] += msg.value; } function withdraw() external { uint256 bal = balances[msg.sender]; require(bal > 0); (bool ok,) = msg.sender.call{value: bal}(""); // VULNERABLE: state not updated yet require(ok); balances[msg.sender] = 0; // Too late! Reentered already } } // ── Attacker contract ── contract Attacker { VulnerableBank target; uint256 public stolenAmount; constructor(address _target) { target = VulnerableBank(_target); } function attack() external payable { target.deposit{value: msg.value}(); target.withdraw(); stolenAmount = address(this).balance; } receive() external payable { if (address(target).balance >= 1 ether) { target.withdraw(); // Reenter! } } } // ── Test ── contract ReentrancyTest is Test { function test_ReentrancyExploit() public { VulnerableBank bank = new VulnerableBank(); // Victims deposit 10 ETH total address victim = makeAddr("victim"); vm.deal(victim, 10 ether); vm.prank(victim); bank.deposit{value: 10 ether}(); // Attacker has only 1 ETH address attackerEOA = makeAddr("attacker"); vm.deal(attackerEOA, 1 ether); vm.prank(attackerEOA); Attacker atk = new Attacker(address(bank)); vm.prank(attackerEOA); atk.attack{value: 1 ether}(); // Attacker stole MORE than they put in! assertGt(atk.stolenAmount(), 1 ether, "Exploit should steal ETH"); console.log("Stolen:", atk.stolenAmount() / 1 ether, "ETH"); } } # Run with full trace: # forge test --match-test test_ReentrancyExploit -vvvv

Running Tests — Command Reference

✏️ Try It — Essential forge Commands
# Build all contracts: forge build # Run all tests: forge test # Run tests with gas report: forge test --gas-report # Run specific test file: forge test --match-path test/SimpleBank.t.sol # Run specific test function: forge test --match-test test_ReentrancyExploit # Verbose output (shows logs and traces on failure): forge test -v # Failed tests only forge test -vv # + logs forge test -vvv # + stack traces forge test -vvvv # + calldata (maximum verbosity) # Fork test: forge test --fork-url $MAINNET_RPC_URL # Fuzz with more runs: forge test --fuzz-runs 10000

Common Mistakes Section

⚠️ Mistake #1: Not Using vm.startPrank / vm.stopPrank

vm.prank() only affects the NEXT call. If your setup requires multiple calls as the same user, use vm.startPrank(alice) ... vm.stopPrank() to wrap a block. Forgetting this is the most common beginner mistake in Foundry tests — the second call in a sequence unexpectedly comes from the test contract's own address.

⚠️ Mistake #2: Not Pinning Fork Block Number

When fork testing, always pin to a specific block number: vm.createSelectFork(rpcUrl, blockNumber). Without this, your test runs against the current live blockchain state, which changes over time. Tests will become non-reproducible and fail unexpectedly when state changes.

⚠️ Mistake #3: Storing RPC URLs in foundry.toml Directly

Never hardcode API keys or RPC URLs in foundry.toml. Store them in a .env file (which should be in .gitignore) and reference them with environment variable syntax: vm.envString("MAINNET_RPC_URL"). Leaking API keys on GitHub is an immediately exploitable mistake.

Summary / Key Takeaways

ToolPurposeKey Command
forge buildCompile contractsforge build
forge testRun all testsforge test -vvvv
forge fuzzAuto-generate inputsNamed testFuzz_*
forge scriptDeploy/interactforge script script/Deploy.s.sol
cast callRead contract statecast call addr "fn()(type)"
cast sendWrite transactioncast send addr "fn()" --value
anvilLocal testnetanvil --fork-url $RPC
vm.prankImpersonate addressOne call only
vm.warpManipulate timeSets block.timestamp
vm.dealSet ETH balanceAny amount, any address