Hardhat Network Forking: A Practical Guide to Testing Against Live Chain State
Forking lets you run integration tests against a pinned copy of mainnet state. This guide covers configuration, impersonation, archive requirements, and how to avoid flaky tests.
22 Aug 2025, 21:15 UTC

Testing a contract that calls a live DEX or reads a token balance usually forces a choice between slow mainnet forks, risky testnet deployments, and brittle mocks. Hardhat Network forking removes that trade-off: it serves a local copy of real chain state at a block you choose, so integration tests run in milliseconds without spending real funds. The practical takeaway is that you should pin a block, use an archive-capable RPC endpoint, and treat forked suites as integration tests rather than unit tests.
What forking actually does
When you enable forking, Hardhat starts an in-memory Ethereum Virtual Machine (EVM) whose initial state is copied from an upstream JSON-RPC provider at a specific block. Your contracts and tests then execute against that snapshot. Balances, storage slots, and deployed bytecode match the upstream chain at the pinned height, but new transactions stay local. This means you can interact with real mainnet tokens, oracles, or lending protocols as if you were on-chain, while the surrounding network remains disposable.
Two terms matter here. An archive node retains historical state for every block, not just recent ones; without it, requests for old storage or past balances fail. Impersonation means Hardhat lets you send transactions from an address you do not control, which is essential for simulating swaps or governance calls from whale accounts.
Minimal configuration
In Hardhat 2.x, forking is configured under the hardhat network. The examples below assume you are using @nomicfoundation/hardhat-toolbox, which bundles ethers, chai matchers, and network helpers. Because Hardhat's config schema has changed across major versions, verify the exact keys against your installed release.
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
networks: {
hardhat: {
forking: {
url: process.env.FORK_RPC_URL,
blockNumber: 20000000
}
}
}
};
Run any script or test with npx hardhat run scripts/example.js --network hardhat or npx hardhat test. The FORK_RPC_URL environment variable must point to an endpoint that supports archive calls for the chain you target. Never commit the key; load it from your shell or a secrets manager. If blockNumber is omitted, Hardhat forks from the latest block at startup, which makes test results drift as the chain advances.
A worked integration check
Suppose you want to verify that a swap helper correctly pulls a mainnet ERC-20 token from a large holder. A typical test shape uses loadFixture to set up the fork, then impersonateAccount and setBalance to act as that holder.
// test/swap.integration.js
const { expect } = require("chai");
const { loadFixture, impersonateAccount, setBalance } = require("@nomicfoundation/hardhat-network-helpers");
describe("Swap integration on a fork", function () {
it("lets an impersonated whale transfer tokens", async function () {
// Replace with a real mainnet holder and token for your target chain
const whale = "0x0000000000000000000000000000000000000000";
const token = await ethers.getContractAt("IERC20", "0x0000000000000000000000000000000000000000");
await impersonateAccount(whale);
await setBalance(whale, "0x1000000000000000000"); // 1 ETH in wei
const signer = await ethers.getSigner(whale);
const recipient = "0x0000000000000000000000000000000000000001";
const tx = await token.connect(signer).transfer(recipient, 1000n);
await tx.wait();
expect(await token.balanceOf(recipient)).to.equal(1000n);
});
});
Run this with npx hardhat test test/swap.integration.js. The placeholders must be replaced with real addresses for the chain you are forking. The setBalance call gives the impersonated account enough ETH to pay gas locally; it does not create real funds. If the upstream endpoint is not archive-capable or rate-limited, the test may fail with a provider error rather than a Hardhat bug.
Choosing an upstream endpoint
| Endpoint type | Historical state | Typical limits | Use case |
|---|---|---|---|
| Public RPC | Recent blocks only | Aggressive rate limits | Quick smoke checks near chain tip |
| Free-tier provider | Often restricted | Archive calls may error | Small, pinned test suites |
| Dedicated archive node | Full history | Cost, but stable | CI pipelines and deep state reads |
When a request for old state fails, the error usually originates from the provider, not Hardhat. To confirm, point forking.url at a known non-archive endpoint and request a very old block; the failure mode helps you distinguish configuration mistakes from upstream limits.
Limits and common mistakes
Archive access and provider errors
Forking needs an upstream endpoint with sufficient history. Free-tier RPCs often restrict archive calls and will error on older blocks or heavy traces. These errors can look like Hardhat bugs, but they are provider limits. Pin a recent block or upgrade your endpoint if historical reads are required.
State drift and pinning
Without blockNumber, the fork starts at the chain tip when Hardhat boots. Two runs minutes apart can see different oracle prices, pool reserves, or governance states. Pinning a block improves reproducibility but can make tests stale relative to live deployments. Choose a pin cadence—daily, per release, or per CI run—and document when forks must be refreshed.
Impersonation is local only
impersonateAccount and setBalance affect only the running Hardhat Network instance. They disappear on restart and never touch mainnet. Do not encode them as assumptions about production behavior; they exist purely to exercise code paths that would otherwise require real keys and funds.
Chain ID and block environment
The forked chain keeps the upstream chainId unless you override it. This can surprise signature verification, replay protection, and deployment-address assumptions. Gas price, base fee, and block timestamps also come from the fork rather than your local defaults, so tests that assert exact gas values may break when the pinned block changes.
Persistence and CI
Mutations made during a forked run do not persist across separate node instances. If you start npx hardhat node in one terminal and target localhost from another process, the state resets when the node restarts. In CI, treat forked suites as integration tests: they validate wiring and external-call behavior, not the internal logic of your own contracts. Keep fast deterministic unit tests on the plain in-memory Hardhat Network and reserve forks for paths that touch deployed protocols.
Verifying the fork works
A practical check is to write a throwaway script that reads a known mainnet contract's name() or symbol() and a token balance, then run it against the hardhat network. If the returned values match the pinned block, forking is active. Next, run the same script with and without blockNumber to observe upstream drift. Finally, start a standalone hardhat node, send a transaction, restart the node, and confirm the state reverted—proving that forked state is per-instance and disposable.
Forking is not a replacement for unit tests or mocks. It is a targeted tool for the narrow class of bugs that appear only when your code meets real deployed contracts. Pin the block, respect provider limits, and keep the fork's assumptions out of your production logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.