Hardhat Mainnet Forking: Deterministic Local Testing Against Live State
Hardhat Mainnet Forking lets you test against live Ethereum state at a pinned block. Configure forking in hardhat.config.js, impersonate whales for write access, and avoid RPC rate limits with caching and archive endpoints.
09 Jan 2026, 09:09 UTC

The Problem: Testing Against Real Protocols Without Real Risk
When you need to verify that your contract interacts correctly with a live protocol — Uniswap V3, Aave, or a complex DAO — unit tests against mocks often miss edge cases in actual state. Deploying to a testnet is slow and still doesn't replicate mainnet's exact conditions at a specific block. Hardhat Network's Mainnet Forking solves this by spinning up a local Ethereum node that mirrors mainnet state from a chosen block, letting you run deterministic, fast tests against real contracts without spending ETH.
How Forking Works
Hardhat connects to an RPC provider (Alchemy, Infura, QuickNode) and fetches state on demand. The first time a test accesses a contract's storage or calls a view function, Hardhat retrieves the relevant data from the remote node and caches it locally. Subsequent reads hit the cache, making test suites significantly faster than repeated RPC calls. Writes — transactions sent from your tests or impersonated accounts — apply only to the local fork; the live network is never touched.
Configuration: Pinning to a Specific Block
Determinism comes from the blockNumber parameter. Without it, the fork tracks the chain head, so a test passing today might fail tomorrow when a protocol upgrades or a whale moves liquidity. Pinning ensures every run starts from identical state.
// hardhat.config.js
module.exports = {
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL, // e.g., https://eth-mainnet.alchemyapi.io/v2/xxx
blockNumber: 18500000 // Freeze state at this block
}
}
}
};
Set MAINNET_RPC_URL in your shell or a .env file (never commit keys). The blockNumber should be recent enough to include the contracts you're testing but old enough to be finalized — typically a few thousand blocks behind head.
Worked Example: Testing a Uniswap V3 Swap
Suppose you're building a router that splits a trade across multiple pools. You want to verify the output amount matches on-chain quotes at block 18500000.
// test/router.test.js
const { ethers } = require('hardhat');
const { expect } = require('chai');
const USDC = '0xA0b86a33E6441b8C4C8C8C8C8C8C8C8C8C8C8C8C8'; // placeholder
const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
const POOL_3000 = '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8'; // USDC/WETH 0.3%
const UNISWAP_V3_POOL_ABI = [
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, ...)',
'function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96, bytes calldata data) returns (int256 amount0, int256 amount1)'
];
describe('Router split swap', function () {
let pool, router, signer;
before(async () => {
// Impersonate a whale holding USDC
const whale = '0xF977814e90dA44bFA03b6295A0616a897441aceC'; // Binance hot wallet
await ethers.provider.send('hardhat_impersonateAccount', [whale]);
signer = await ethers.getSigner(whale);
pool = new ethers.Contract(POOL_3000, UNISWAP_V3_POOL_ABI, signer);
// Deploy your router contract here
const Router = await ethers.getContractFactory('MyRouter');
router = await Router.deploy();
});
it('matches on-chain quote for 10,000 USDC -> WETH', async () => {
const amountIn = ethers.utils.parseUnits('10000', 6); // 10k USDC
// Read pool state at forked block
const { sqrtPriceX96 } = await pool.slot0();
// Call your router's quote function
const expectedOut = await router.quoteExactInputSingle(USDC, WETH, 3000, amountIn, 0);
// Execute actual swap on fork
const tx = await router.exactInputSingle(USDC, WETH, 3000, amountIn, 0, { gasLimit: 500000 });
await tx.wait();
// Verify received amount matches quote within slippage tolerance
const balanceAfter = await ethers.provider.getBalance(signer.address);
// ... assertions
});
});
Run with npx hardhat test. The test executes against the exact pool state at block 18500000. No mainnet ETH spent; the whale's USDC balance is simulated locally.
Impersonating Accounts for Write Access
The hardhat_impersonateAccount RPC method lets any test sign transactions as an existing address — a whale, a multisig, a protocol admin. This is essential for:
- Setting up complex preconditions (approving tokens, depositing collateral)
- Testing admin-only functions (pausing, upgrading, parameter changes)
- Simulating governance proposals by impersonating the timelock
After impersonation, ethers.getSigner(address) returns a Signer that sends transactions from that account on the fork. No private key needed.
Common Pitfalls and Limits
RPC Rate Limits
Heavy test suites — especially those deploying many contracts or reading large state trees — can exhaust provider quotas. Symptoms: tests hang, throw 429 Too Many Requests, or return stale data. Mitigations:
- Use a dedicated archive node endpoint (Alchemy/Infura archive plans)
- Reduce
blockNumberdistance from head to minimize initial state fetch - Run tests with
--no-cacheonly when necessary; Hardhat's built-in cache persists fork data across runs
Memory Growth
Each unique block or contract accessed expands the local cache. A suite that iterates over 10,000 blocks or touches hundreds of contracts can consume several GB of RAM. If Node crashes with JavaScript heap out of memory, increase the heap: NODE_OPTIONS='--max-old-space-size=4096' npx hardhat test.
No Auto-Sync
The fork is a snapshot. If a protocol upgrades at block 18500100, your fork at 18500000 won't see it. To test against the new version, update blockNumber and restart the node (npx hardhat node or rerun tests).
Verification Checklist
- Start the forked node:
npx hardhat node(runs on localhost:8545 by default). - In a separate terminal, attach a console:
npx hardhat console --network localhost. - Call a known view function:
await ethers.provider.getCode('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')should return WETH bytecode, not0x. - Send a transaction via impersonated account:
await signer.sendTransaction({to: '0x...', value: 1})— check it mines locally (block number increments).
When Not to Fork
- Pure unit tests for isolated logic — use Hardhat's default in-memory network (no RPC, instant).
- Testing cross-chain or L2 interactions — fork only mirrors one chain.
- Load testing gas costs under variable base fees — fork uses the pinned block's fee market.
Summary
Hardhat Mainnet Forking gives you a deterministic, writable replica of Ethereum at a specific block. Configure forking.url and forking.blockNumber in hardhat.config.js, impersonate accounts with hardhat_impersonateAccount, and run tests that execute real transactions against live protocol state. Watch RPC limits and memory; restart the node when you need a fresh snapshot.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.