Stop Mocking: Testing Smart Contracts Against Real Mainnet State with Hardhat Forking
Hardhat mainnet forking lets you run integration tests against real deployed contracts and balances locally. Learn the setup, a worked impersonation example, and the RPC trade-offs.
23 Sept 2025, 19:26 UTC

Your integration test passes locally, but the same transaction reverts on mainnet. The usual culprit is a mock: a simplified stand-in for a live contract that slowly drifts from reality as fees, access controls, or storage layouts change. Hardhat's mainnet forking attacks this problem directly by running a local Ethereum node that mirrors the state of a live network at a chosen block height. Instead of guessing how a deployed protocol behaves, your tests execute against the actual bytecode, balances, and storage that exist on-chain.
The practical takeaway is that you can validate complex interactions—token swaps, liquidations, or governance calls—without spending real ETH or maintaining brittle mock libraries, as long as you accept a dependency on an external RPC provider.
Why a local fork beats a hand-written mock
When you deploy a mock ERC-20 in a standard Hardhat test, you control every line of its logic. That is fine for unit testing your own contract in isolation, but it falls apart the moment your code touches an existing protocol. The real token might have a transfer fee, a pause switch, or a decimals value you forgot to replicate. A fork removes that guesswork.
Hardhat Network does not simulate the EVM from scratch when forking; it proxies state reads to a live RPC endpoint and caches what it fetches. That means a call to balanceOf on a real token contract returns the true mainnet balance for the block you pinned. It also means you can use hardhat_impersonateAccount (exposed in Ethers.js as ethers.getImpersonatedSigner) to send transactions from any address—say, a large token holder—without needing its private key. The local node signs on behalf of that account, which is useful for testing permissioned functions or whale-sized liquidity flows.
Configuring the fork and impersonating an account
Forking is configured in the hardhat network section of your hardhat.config.js. You need a valid RPC URL from a provider such as Alchemy or Infura. The example below uses a placeholder; replace it with your own endpoint.
// hardhat.config.js
module.exports = {
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY",
// Optional: pin to a specific block for reproducible tests
// blockNumber: 19000000
}
}
}
};
Save the file, then start a local node from your project root:
npx hardhat node
Expected check: the terminal output should indicate that the node is running and forking is enabled. If the RPC URL is invalid or rate-limited, the node will typically fail to start or will log fetch errors.
Next, create a script that impersonates a known mainnet address and reads from a deployed contract. Run it against the local node:
// scripts/check-fork.js
const { ethers } = require("hardhat");
async function main() {
// Replace with a real mainnet address that holds ETH
const whale = "0x0000000000000000000000000000000000000000";
const signer = await ethers.getImpersonatedSigner(whale);
// Minimal ABI for a read-only call on a live contract
const abi = ["function decimals() view returns (uint8)"];
const token = new ethers.Contract("0xTOKEN_ADDRESS", abi, signer);
const decimals = await token.decimals();
console.log(`Decimals read from forked state: ${decimals}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Execute it with:
npx hardhat run scripts/check-fork.js --network localhost
Expected check: the value printed for decimals should match what you see for that token on a block explorer at the forked block. If it does not, confirm that 0xTOKEN_ADDRESS is correct and that your RPC endpoint is serving mainnet data. Note that impersonation works only on your local forked node; it has no effect on live networks.
The hidden costs: RPC quotas and volatile state
Forking trades isolation for fidelity, and that trade has a price. Every state read that misses the local cache becomes an HTTP request to your RPC provider. A test suite that touches many contracts or loops through large storage arrays can generate thousands of calls quickly, tripping rate limits or adding seconds of latency per transaction.
Local state changes are also volatile. If you deploy a contract, modify a balance, or advance blocks during a test, those changes disappear when you stop the Hardhat node. There is no automatic persistence across restarts unless you explicitly configure a forked state snapshot, and even then the snapshot is tied to the block height you captured.
- External dependency: your tests are only as available as your RPC provider.
- Rate limits: heavy integration suites can exhaust free-tier quotas.
- Reproducibility: without a pinned
blockNumber, a fork started today may see different state than one started tomorrow.
A practical decision framework
Reach for mainnet forking when your contract interacts with existing on-chain assets or accounts: reading a Uniswap pool price, simulating a liquidation against Aave, or testing a function restricted to a specific governance address. In those cases, a fork is often faster to write and more trustworthy than a mock.
Stick with pure local deployment when you are testing new business logic in isolation. There is no reason to pay RPC latency for a unit test that only touches contracts you just deployed.
Before wiring forking into CI, run one read-only script against a contract you can independently verify on a block explorer. If the numbers match, you have a reliable integration environment. If they do not, check your RPC URL and confirm the node actually started in forking mode. Because Hardhat's configuration surface evolves between major versions, verify the exact forking keys against your installed release before relying on them in an automated pipeline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.