Pin a Fork Block in Hardhat to Make Mainnet Tests Reproducible
Fork mainnet at a fixed block and cache state locally to get deterministic integration tests against real deployed contracts without spending gas.
06 Jul 2026, 18:21 UTC

Testing an integration against real mainnet contracts is useful, but running those tests against a moving chain makes them flaky. The useful takeaway is to fork mainnet at a fixed block and let Hardhat cache that state locally. You get real deployed code and real balances for free, with deterministic results across runs.
The problem with testing against live mainnet
When you deploy to Ethereum, your contract will interact with contracts that you do not control: tokens, routers, oracles. Pure unit tests with mocks hide integration bugs like wrong decimals, non-standard ERC-20 behavior, or reentrancy assumptions.
Running against a live RPC solves realism but introduces non-determinism. Balances change, prices move, and a test that passes today can fail tomorrow. That is the engineering tension Hardhat forking is designed for.
How Hardhat forking works and why pinning matters
Hardhat Network can start as a copy of another chain. It asks an archive node for historical state via JSON-RPC and replays it locally. An archive node is a node that retains full historical state, not just recent blocks, which is required for forking.
Forking is configured in hardhat.config.js. The following shape applies to Hardhat v2.x. Hardhat 3 introduced architectural changes and config differences, so confirm the installed major version before using this snippet.
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL,
blockNumber: 20_000_000
}
}
}
};Run this config from the project root with Node permissions. Never commit API keys; use environment variables for the RPC URL.
Pinning blockNumber is the key engineering decision. It makes the fork deterministic. Hardhat can also cache fetched state to disk under cache/. On the first run the node fetches state from the RPC provider; subsequent runs read from the cache, which reduces RPC calls and speeds up tests.
Fork-aware helpers work only on the local Hardhat Network, not on live chains:
hardhat_resetto reload the fork, optionally with a new block number.hardhat_impersonateAccountto act as an existing address.hardhat_setBalanceto give an impersonated account ETH for gas.
Worked example: reading real token state with a pinned fork
The classic pattern is to impersonate a whale, then call a real mainnet contract to validate an integration before deploying.
First, create a test file that uses ethers and the Hardhat Runtime Environment. The example below reads a token totalSupply at the pinned block. Replace the address and ABI with the contract you need.
// test/fork-read.test.js
const { ethers } = require("hardhat");
const { expect } = require("chai");
describe("Forked mainnet read", function () {
it("should read totalSupply from a pinned fork", async function () {
const tokenAddress = "0x..."; // placeholder
const abi = ["function totalSupply() view returns (uint256)", "function decimals() view returns (uint8)"];
const token = await ethers.getContractAt(abi, tokenAddress);
const supply = await token.totalSupply();
expect(supply).to.be.gt(0);
});
});Run the test from the project root with npx hardhat test. Expected checks: the call should succeed without broadcasting a transaction, and the returned value should be consistent across runs because the block is pinned.
For state-changing flows you can impersonate:
await hre.network.provider.request({ method: "hardhat_impersonateAccount", params: ["0xWHALE"] });
const whale = await ethers.getSigner("0xWHALE");
await hre.network.provider.request({ method: "hardhat_setBalance", params: ["0xWHALE", "0xDE0B6B3A7640000"] });Risks: impersonation and balance setting only work locally. Do not assume the same account has funds on live mainnet. Also, archive RPC access is often metered and more expensive than regular RPC.
Trade-offs and when not to rely on a fork
Forked tests are slower than pure local tests on first run and depend on RPC provider availability and rate limits. Caching mitigates but does not eliminate this.
A fork is a snapshot. It does not reflect live mempool conditions, MEV, or state changes after the pinned block. It also cannot reproduce timing-dependent behavior that relies on new blocks.
Use a fork to validate integration logic against real bytecode and real historical balances. Use testnets or staging deployments for pre-release validation against current state and network conditions.
Check it yourself
Create a scratch Hardhat project, configure forking with a pinned block, and run a test that reads a well-known mainnet contract state such as totalSupply. Run the same test twice and compare durations to observe the cache effect.
Inspect the fork cache directory and call hardhat_reset in a test to confirm the block-pinning behavior matches expectations. Confirm the installed Hardhat major version and that the config snippet compiles under that exact version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.