Fork Testing & Mainnet Simulation
Fork testing lets you run your Foundry test suite against a live copy of the Ethereum mainnet — complete with real token balances, real Uniswap liquidity pools, real Chainlink price feeds, and real protocol deployments. This is the difference between testing with mocks that might not reflect reality and testing against the actual environment where an exploit would occur. For security research, fork tests are how you prove a vulnerability is real and exploitable in production conditions.
Unit tests deploy fresh contracts in a blank EVM state — fast, fully controlled, no external dependencies. Fork tests clone the state of mainnet at a specific block — slower (network calls required), but they test your code against the actual environment it will run in. Many vulnerabilities only manifest when interacting with specific protocol states (e.g., specific price ratios, liquidity levels, governance states) that are impossible to reproduce with mocks.
Setting Up Fork Testing in Foundry
# foundry.toml — project configuration
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
# Named RPC endpoints — use in tests as "mainnet", "arbitrum", etc.
[rpc_endpoints]
mainnet = "${MAINNET_RPC_URL}"
arbitrum = "${ARBITRUM_RPC_URL}"
polygon = "${POLYGON_RPC_URL}"
optimism = "${OPTIMISM_RPC_URL}"
# Cache RPC responses — critical for test speed
[rpc_storage_caching]
chains = ["mainnet", "arbitrum"]
endpoints = "all"
# .env file (never commit this!):
# MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
# ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/YOUR_KEYcreateFork and selectFork Cheatcodes
contract ForkTest is Test {
uint256 mainnetFork;
uint256 arbitrumFork;
// Addresses on mainnet
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address constant UNISWAP = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
function setUp() public {
// Create a fork at the latest block
mainnetFork = vm.createFork("mainnet");
// Create a fork at a SPECIFIC block (reproducible)
mainnetFork = vm.createFork("mainnet", 19_500_000);
// Create and immediately activate a fork:
mainnetFork = vm.createSelectFork("mainnet", 19_500_000);
// Create multiple forks
arbitrumFork = vm.createFork("arbitrum");
}
function test_MultiFork() public {
// Switch between forks in a single test
vm.selectFork(mainnetFork);
uint256 mainnetBalance = IERC20(USDC).balanceOf(whale);
vm.selectFork(arbitrumFork);
uint256 arbBalance = IERC20(USDC).balanceOf(whale);
// Each fork has independent state
}
}Getting Tokens on a Fork
The vm.deal() cheatcode works for ETH on any fork. For ERC20 tokens, you need different strategies depending on the token's storage layout:
contract TokenAcquisitionTest is Test {
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
function setUp() public {
vm.createSelectFork("mainnet", 19_500_000);
}
function test_AcquireTokens() public {
// Method 1: deal() for most ERC20 tokens
deal(USDC, address(this), 1_000_000e6); // 1M USDC
deal(DAI, address(this), 1_000_000e18); // 1M DAI
assertEq(IERC20(USDC).balanceOf(address(this)), 1_000_000e6);
// Method 2: vm.store() for tokens with non-standard balance slots
// Find the balance slot:
// forge inspect IERC20 storage-layout
bytes32 slot = keccak256(abi.encode(address(this), uint256(9)));
vm.store(USDC, slot, bytes32(uint256(1_000_000e6)));
// Method 3: impersonate a known whale and transfer
address whale = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
vm.startPrank(whale);
IERC20(USDC).transfer(address(this), 1_000_000e6);
vm.stopPrank();
}
}Reading Live Data on a Fork
interface AggregatorV3Interface {
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt,
uint256 updatedAt, uint80 answeredInRound
);
}
contract LiveDataTest is Test {
address constant ETH_USD_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419;
address constant UNISWAP_V3_ROUTER = 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45;
function test_ReadChainlinkPrice() public {
vm.createSelectFork("mainnet", 19_500_000);
// Read real Chainlink price at block 19,500,000
(, int256 price,,,) = AggregatorV3Interface(ETH_USD_FEED).latestRoundData();
console.log("ETH/USD price at block 19.5M:", uint256(price));
// Price is 8 decimals: price / 1e8 = $USD
// Now you can test your price oracle logic against this real data
assertGt(uint256(price), 1000e8); // ETH > $1000 at that block
}
function test_UniswapSwap() public {
vm.createSelectFork("mainnet");
deal(address(this), 10 ether);
// Real Uniswap swap using actual mainnet liquidity
ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({
tokenIn: WETH, tokenOut: USDC, fee: 3000,
recipient: address(this), amountIn: 1 ether,
amountOutMinimum: 0, sqrtPriceLimitX96: 0
});
uint256 out = ISwapRouter(UNISWAP_V3_ROUTER).exactInputSingle{value: 1 ether}(params);
assertGt(out, 0);
}
}Writing a Flash Loan Exploit Test
interface IFlashLender {
function flashLoan(address receiver, address token,
uint256 amount, bytes calldata data) external returns (bool);
}
contract FlashLoanAttack {
address immutable AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
VulnerableTarget immutable target;
constructor(VulnerableTarget _target) { target = _target; }
function attack() external {
// Borrow 10M USDC with zero cost (only premium to repay)
IFlashLender(AAVE_POOL).flashLoan(
address(this), USDC, 10_000_000e6, ""
);
}
// Aave calls this during flash loan
function executeOperation(address asset, uint256 amount,
uint256 premium, address, bytes calldata) external returns (bool) {
// 1. Use 10M USDC to manipulate price in vulnerable protocol
target.manipulatePrice(amount);
// 2. Exploit the mispriced collateral
target.drainCollateral();
// 3. Repay flash loan + premium
IERC20(asset).approve(AAVE_POOL, amount + premium);
return true;
}
}
contract FlashLoanExploitTest is Test {
function test_FlashLoanExploit() public {
vm.createSelectFork("mainnet"); // real Aave on mainnet
FlashLoanAttack attacker = new FlashLoanAttack(target);
uint256 before = IERC20(USDC).balanceOf(address(attacker));
attacker.attack();
uint256 after = IERC20(USDC).balanceOf(address(attacker));
assertGt(after, before, "exploit did not profit");
}
}The first time you run a fork test against a given block, Foundry fetches all required state from the RPC endpoint — slow, with many network requests. Foundry caches these responses in ~/.foundry/cache/rpc/. On subsequent runs with the same pinned block, the cache is used and tests run nearly as fast as unit tests. This is another strong reason to always pin to a specific block: the cache can only work when the block number is constant.
Performance and When to Use Fork Tests
| Test Type | Speed | Realism | When to Use |
|---|---|---|---|
| Unit test (no fork) | Fastest (~1ms) | Low — uses mocks | Core logic, happy paths, error conditions |
| Fork test (latest block) | Slow (network calls) | High — real state | Integration with DeFi protocols |
| Fork test (pinned block) | Slow then cached | Highest — reproducible | Exploit PoC, protocol audits |
| Fuzz test | Medium | Medium | Finding edge cases in logic |
| Invariant test | Slow | Low-medium | Protocol-level invariant verification |
Standard (non-archive) Ethereum nodes only store recent state — typically the last 128 blocks. To fork at historical blocks (e.g., block 15,000,000 from 2022), you need an archival node. Alchemy and Infura provide archive access on paid plans. Ankr offers free archive access with rate limits. If you try to fork an old block with a non-archive endpoint, Foundry will return "missing trie node" errors.
Always pin fork tests to a specific block number using vm.createSelectFork("mainnet", BLOCK_NUMBER). Tests that fork at "latest" are non-deterministic — they will behave differently tomorrow than they do today because the blockchain state changes with every block. A PoC that worked yesterday might fail today if prices or liquidity moved. Pinned forks are reproducible across machines and CI runs.
# Run all fork tests
forge test --fork-url $MAINNET_RPC_URL
# Run a specific test with fork and verbose traces
forge test --match-test test_FlashLoanExploit --fork-url $MAINNET_RPC_URL -vvvv
# Fork at specific block
forge test --fork-url $MAINNET_RPC_URL --fork-block-number 19500000
# Run in CI without exposing keys — use .env or GH Actions secrets
# MAINNET_RPC_URL is read from environment automatically if set
# Cache is stored at ~/.foundry/cache/rpc/
# Clear cache if you get stale data:
forge clean
# Get current block number for a network:
cast block-number --rpc-url $MAINNET_RPC_URLKey Takeaways
- Fork tests clone real blockchain state into the Foundry EVM — enabling tests against live protocol contracts, real token balances, and real price feeds without spending actual ETH.
- Always pin fork tests to a specific block number for reproducibility — tests forking "latest" produce non-deterministic results that break CI and make PoCs unreliable.
- Use
deal(tokenAddress, user, amount)to mint test tokens to any address. For tokens with unusual storage patterns, usevm.store()to write directly to the balance slot. - Archive nodes are required for forking historical blocks — standard nodes only store the last ~128 blocks. Budget for an Alchemy or Infura archive plan for serious security research.
- Fork tests are slower than unit tests but catch entire classes of integration bugs that mocked environments miss — reserve them for protocol integration points and exploit PoC validation.
- Flash loan exploit tests on forked mainnet prove a vulnerability is practical and quantify the potential profit, which is essential for a convincing audit report or bug bounty submission.