Testing Against Mainnet State Locally with Hardhat Forking
Hardhat's mainnet forking lets you test against live protocol state locally. This post covers lazy-loading state, account impersonation, a worked Uniswap V3 example, and memory trade-offs.
23 Dec 2025, 05:17 UTC

The Problem: Mainnet Contracts, Testnet Friction
You're building a protocol that integrates with Uniswap V3, Aave, or a niche ERC-20 token that only exists on Ethereum Mainnet. Your options have traditionally been painful: deploy to a testnet and hope the contracts you need are there (they're often not), spin up a local node and manually deploy mocks (tedious and error-prone), or test against a forked mainnet RPC via external services (works, but adds latency and cost).
Hardhat's forking feature changes this calculus. It spins up a local Hardhat Network instance that pulls state from a live network at a specific block, giving you a private, mutable copy of mainnet that executes transactions instantly and for free.
How Forking Works Under the Hood
When you start Hardhat with --fork <RPC_URL>, the local node doesn't replay blocks—it fetches state on demand. The first time your test touches a storage slot, contract code, or account balance at the forked block, Hardhat requests that data from the remote RPC provider and caches it locally. Subsequent reads hit the local cache. Writes go only to the local state; the remote provider never sees them.
This lazy-loading approach means startup is near-instant regardless of how far back you fork. You can fork block 19,000,000 or "latest" and start writing tests in seconds. The trade-off is that the first run of a test suite touching many contracts will be slower as the cache populates.
Impersonation: The Killer Feature for Integration Testing
Forking alone lets you read mainnet state. Impersonation lets you write as any address. Hardhat exposes the hardhat_impersonateAccount JSON-RPC method, which unlocks an account on the local node so you can send transactions from it without a private key.
This is essential for testing admin-only functions, governance proposals, or whale interactions. Want to verify your liquidation logic works when a large USDC holder gets liquidated? Impersonate the whale, approve your contract, trigger the liquidation, and assert the outcome—all locally, no mainnet gas spent.
Worked Example: Testing a Uniswap V3 Swap Callback
Suppose you're writing a contract that executes a flash swap on Uniswap V3 and needs to repay the pool in the uniswapV3SwapCallback. You want to test the full flow with real pool state.
// test/FlashSwap.test.js
const { ethers } = require("hardhat");
async function main() {
// Fork mainnet at a recent block
await hre.network.provider.request({
method: "hardhat_reset",
params: [{
forking: {
jsonRpcUrl: process.env.MAINNET_RPC_URL,
blockNumber: 19500000 // pin to a known-good block
}
}]
});
// Impersonate a whale with WETH
const whale = "0xF977814e90dA44bFA03b6295A0616a897441aceC"; // Example address
await hre.network.provider.request({
method: "hardhat_impersonateAccount",
params: [whale]
});
const whaleSigner = await ethers.getSigner(whale);
// Get the USDC/WETH 0.05% pool
const poolAddress = "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640";
const pool = await ethers.getContractAt("IUniswapV3Pool", poolAddress, whaleSigner);
// Deploy your flash swap contract
const FlashSwap = await ethers.getContractFactory("FlashSwap");
const flashSwap = await FlashSwap.deploy(poolAddress);
await flashSwap.deployed();
// Fund the contract with callback fees
await whaleSigner.sendTransaction({
to: flashSwap.address,
value: ethers.utils.parseEther("0.1")
});
// Execute a 10 WETH flash swap
const amount = ethers.utils.parseEther("10");
const tx = await flashSwap.connect(whaleSigner).executeFlashSwap(amount);
const receipt = await tx.wait();
console.log("Gas used:", receipt.gasUsed.toString());
}
main().catch(console.error);
Run this with npx hardhat run test/FlashSwap.test.js --network hardhat. The test executes against real Uniswap V3 pool state at block 19,500,000, using the impersonated account's balance, and costs zero gas.
Built-in Solidity Console Logging
Hardhat injects hardhat/console.sol during compilation, so console.log("value:", x) works directly in your contracts when running on Hardhat Network. This is a quality-of-life improvement over emitting events or using external tracers. Note: the injected library is stripped for non-Hardhat networks, so it is safe to leave in production code.
Trade-offs and Limitations
- RPC dependency: Forking requires a reliable archive node RPC URL. Rate limits or latency will slow test runs. Pin a
blockNumberin config to avoid "block not found" errors if the chain reorgs. - Memory growth: The local state cache lives in Node's heap. Forking from an old block and interacting with many large contracts can push memory past 2–4 GB. Run
node --max-old-space-size=4096if you hit OOM errors. - No persistence: Local state resets when the node restarts. For CI, either re-fork each run or use
hardhat_snapshotandhardhat_revertto manage state. - Not a full client: Hardhat Network may not implement all EVM opcodes or precompiles identically to Geth. Validate critical paths on a real client before mainnet deployment.
Actionable Next Step
Add a forking block to your hardhat.config.js and run one integration test against a mainnet protocol you depend on:
// hardhat.config.js
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL,
blockNumber: 19500000
}
}
}
};
Then write a single test that impersonates an account, calls a real contract, and asserts an outcome. You'll have a reproducible, zero-cost test that catches integration bugs before they hit production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.