Tutoriel·8 min de lecture·Par Solingo

Mainnet Fork Testing with Foundry

Test your contracts against real protocol state without leaving your terminal. A practical guide to fork cheatcodes, account impersonation, and CI setup in Foundry.

# Mainnet Fork Testing with Foundry

Unit tests prove your logic works in isolation. Fork tests prove it works against the messy, real state of a live chain: actual Uniswap pools, real Aave liquidity, the exact storage of a token you do not control. Foundry makes this almost free. You point a test at an RPC URL, and the EVM reads live state on demand while your transactions stay local. This guide walks through the fork cheatcodes, impersonating whales, time travel with block numbers, RPC configuration, and a clean CI setup.

Why fork tests matter

Mocking external protocols is fast but lies to you. A mock Uniswap router never reverts on slippage the way the real one does, and a mock ERC20 may not replicate fee-on-transfer behavior or a frozen blacklist. Forking removes the guesswork:

  • You integrate against the real bytecode of deployed protocols.
  • You read live storage: balances, reserves, oracle prices, governance state.
  • You catch integration bugs that mocks structurally cannot reproduce.

The cost is a network round trip per cold storage slot. Foundry caches those reads, so the second run is fast.

RPC configuration

Never hardcode an API key in a test. Foundry reads named endpoints from foundry.toml and resolves environment variables at runtime.

[rpc_endpoints]

mainnet = "${MAINNET_RPC_URL}"

arbitrum = "${ARBITRUM_RPC_URL}"

[etherscan]

mainnet = { key = "${ETHERSCAN_API_KEY}" }

Put the secrets in a .env file (and add it to .gitignore):

MAINNET_RPC_URL=https://eth-mainnet.example.com/v3/your-key

ARBITRUM_RPC_URL=https://arb-mainnet.example.com/v3/your-key

Now vm.createFork("mainnet") resolves the alias to the URL. You can also pass a raw URL string, but the alias keeps secrets out of your source tree.

createFork vs createSelectFork

Foundry treats forks as objects you can hold by id and switch between.

  • vm.createFork(alias) creates a fork and returns its uint256 id without activating it.
  • vm.createSelectFork(alias) creates a fork and immediately makes it the active EVM.
  • vm.selectFork(id) switches the active fork to a previously created one.

The two-step pattern shines when a single test touches more than one chain, for example a cross-chain bridge:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";

interface IERC20 {

function balanceOf(address) external view returns (uint256);

function transfer(address to, uint256 amount) external returns (bool);

}

contract MultiForkTest is Test {

uint256 internal mainnetFork;

uint256 internal arbitrumFork;

address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

function setUp() public {

mainnetFork = vm.createFork("mainnet");

arbitrumFork = vm.createFork("arbitrum");

}

function test_activeForkSwitches() public {

vm.selectFork(mainnetFork);

assertEq(vm.activeFork(), mainnetFork);

// Live mainnet USDC supply is non-zero.

assertGt(IERC20(USDC).balanceOf(USDC), 0);

vm.selectFork(arbitrumFork);

assertEq(vm.activeFork(), arbitrumFork);

}

}

Each fork keeps its own state, so switching back and forth does not leak balances or storage between chains.

Pinning a block with rollFork

A fork pinned to a moving "latest" block makes tests flaky: liquidity, prices, and balances drift between runs. Pin to a specific block number so every run sees identical state.

You can pin at creation time, vm.createSelectFork("mainnet", 20_000_000), or move an existing fork with vm.rollFork:

function test_pinAndRoll() public {

uint256 fork = vm.createSelectFork("mainnet", 20_000_000);

assertEq(block.number, 20_000_000);

// Advance the fork to a later block to observe state changes.

vm.rollFork(20_100_000);

assertEq(block.number, 20_100_000);

}

There is also vm.rollFork(forkId, blockNumber) to roll a non-active fork, and a variant that rolls to the block of a specific transaction hash, useful for reproducing an incident exactly as it happened on chain.

Pinning is also a performance win. A pinned block is cached on disk after the first run, so subsequent runs skip the network entirely.

Impersonating accounts

To test a deposit, you need tokens. On a fork you do not have the private keys of a whale, but you do not need them. vm.prank and vm.startPrank make the next call (or every call until stopPrank) appear to come from any address.

function test_impersonateWhale() public {

vm.selectFork(mainnetFork);

address whale = 0x55FE002aefF02F77364de339a1292923A15844B8; // a USDC holder

address recipient = address(0xBEEF);

uint256 amount = 1_000e6; // USDC has 6 decimals

uint256 before = IERC20(USDC).balanceOf(recipient);

vm.startPrank(whale);

IERC20(USDC).transfer(recipient, amount);

vm.stopPrank();

assertEq(IERC20(USDC).balanceOf(recipient), before + amount);

}

If no convenient holder exists, vm.deal overwrites a balance directly. For native ETH, vm.deal(addr, 100 ether). For ERC20s, the three-argument form computes and writes the storage slot for you:

// Give recipient 10,000 USDC by writing the balance slot directly.

deal(USDC, recipient, 10_000e6);

Note that deal for tokens works by guessing the balances mapping slot. It handles standard layouts, but exotic tokens (proxies with unusual storage, rebasing tokens) may need a manual vm.store.

Persistent contracts across forks

When you selectFork, contracts you deployed in the test are wiped because each fork has its own state. If you need a helper contract to survive a fork switch, mark it persistent:

function test_persistentHelper() public {

MyHelper helper = new MyHelper();

vm.makePersistent(address(helper));

vm.selectFork(mainnetFork);

// helper still has code here.

vm.selectFork(arbitrumFork);

// helper still has code here too.

}

The test contract itself and the cheatcode address are persistent by default, which is why your assertions keep working after a switch.

A realistic integration test

Putting it together, here is the shape of a test that swaps real USDC into a vault deposit against live state:

function test_depositRealUsdc() public {

vm.createSelectFork("mainnet", 20_000_000);

address user = makeAddr("user");

deal(USDC, user, 5_000e6);

Vault vault = new Vault(USDC);

vm.startPrank(user);

IERC20(USDC).approve(address(vault), 5_000e6);

uint256 shares = vault.deposit(5_000e6);

vm.stopPrank();

assertGt(shares, 0);

assertEq(IERC20(USDC).balanceOf(address(vault)), 5_000e6);

}

This hits the real USDC contract, real decimals, real transfer semantics. If USDC ever blacklists user or changes behavior, this test would catch it.

Running and CI

Run the suite with a verbosity that surfaces traces on failure:

forge test --match-contract MultiForkTest -vvv

In CI, set the RPC URL as a secret and let the fork cache speed up repeat runs. A minimal GitHub Actions step:

# In the job environment

export MAINNET_RPC_URL="${{ secrets.MAINNET_RPC_URL }}"

forge test --fork-block-number 20000000

Two practical tips for CI:

  • Always pin a block. Unpinned fork tests are non-deterministic and will fail intermittently.
  • Cache ~/.foundry/cache. Foundry stores fetched RPC state there, cutting both runtime and provider usage on later runs.
  • A dedicated fork-test job that runs on a schedule (rather than on every push) keeps your fast unit suite green while still exercising live integrations.

    Practice this hands-on

    Reading about fork tests is one thing; writing one that catches a real integration bug is another. On app.solingo-blockchain.xyz you can run Foundry-style exercises in the browser, impersonate accounts, and verify your assertions step by step. Open the testing track, fork against live protocol state, and ship contracts you actually trust.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement