🌿 Intermediate ⏱️ 40 min

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.

📖 Fork Testing vs Unit Testing

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 Fork Configuration
# 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_KEY

createFork and selectFork Cheatcodes

🍴 vm.createFork and vm.selectFork
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:

💰 Acquiring Tokens in Fork Tests
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

📊 Interacting with Live Protocols on 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

💥 Flash Loan Attack PoC on Forked Mainnet
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"); } }
📖 RPC Caching Makes Fork Tests Fast After the First Run

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 TypeSpeedRealismWhen to Use
Unit test (no fork)Fastest (~1ms)Low — uses mocksCore logic, happy paths, error conditions
Fork test (latest block)Slow (network calls)High — real stateIntegration with DeFi protocols
Fork test (pinned block)Slow then cachedHighest — reproducibleExploit PoC, protocol audits
Fuzz testMediumMediumFinding edge cases in logic
Invariant testSlowLow-mediumProtocol-level invariant verification
⚠️ Archival Nodes Required for Historical Blocks

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.

💡 Pin Block Numbers for Reproducibility

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.

✏️ Try It — Run a Fork Test from the CLI
# 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_URL

Key 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, use vm.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.