Hardhat Forking Diagnostics: RPC Gaps, Gas Limits & State Drift
When Hardhat fork tests fail with 'block not found', gas reverts, or stale state, this guide walks a repeatable diagnostic flow—from RPC archive checks to gas-limit alignment and block pinning.
11 Dec 2025, 13:28 UTC

The Problem
\nHardhat's forking feature lets you run tests against a live network snapshot, but three recurring failures derail CI pipelines and local debugging: block not found errors from pruned RPC endpoints, gas-limit mismatches causing spurious out of gas reverts, and stale state where balances or contract code don't match the forked block. The root cause is almost always a mismatch between what your RPC endpoint serves and what Hardhat assumes. This guide gives a repeatable diagnostic flow to pinpoint the mismatch and apply the minimal fix.
Symptom-to-Cause Diagnostic Table
\n| Observed Symptom | Likely Cause | Quick Verification |
|---|---|---|
block not found or header not found during test run | RPC endpoint prunes history (non-archive tier) or requested block tag (latest/pending) no longer available | curl -X POST -H 'Content-Type: application/json' --data '{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBlockByNumber\",\"params\":[\"0x1\",false],\"id\":1}' $RPC_URL returns block 1 |
Contract deploy succeeds locally but reverts with out of gas on fork | Hardhat default gasLimit: 30000000 exceeds forked network's actual block gas limit (~15M on Ethereum mainnet) | In Hardhat console: await ethers.provider.getBlock('latest') and compare gasLimit field with config |
| Forked state shows old balances or outdated contract code | forking.blockNumber pinned to a block the RPC no longer serves, or RPC lacks eth_getProof for state verification | Compare eth_blockNumber from RPC against the block number in fork config |
hardhat_impersonateAccount fails with account not found | Account (EOA or contract) was created after the forked block number | curl --data '{\"jsonrpc\":\"2.0\",\"method\":\"eth_getCode\",\"params\":[\"0xADDRESS\",\"0xFORK_BLOCK\"]}' $RPC_URL returns 0x |
Ordered Diagnostic Checks
\n- \n
- Verify RPC archive capability. Run the curl command from the diagnostic table against your RPC URL. If it returns an error or
null, your endpoint lacks archive data. Switch to an archive-tier plan (Alchemy, QuickNode, Infura) or use a public archive endpoint likehttps://eth.llamarpc.comfor mainnet. \n - Pin the fork block explicitly. In
hardhat.config.js, setforking: { url: process.env.RPC_URL, blockNumber: 19482345 }instead of'latest'. A fixed block ensures reproducibility and avoids the RPC's movinglatestpointer. \n - Align gas limit with the forked network. Add
gasLimit: 'auto'to thehardhatnetwork config (Hardhat ≥ 2.19.0), or set a numeric value matching the forked chain's block gas limit (e.g.,15000000for Ethereum mainnet). Hardhat's default 30M will cause transactions to be rejected by the forked EVM. \n - Start the fork node and inspect startup logs. Run:
Look fornpx hardhat node --fork $RPC_URL --fork-block-number 19482345Forked from block 19482345in the output. If you seeFailed to fetch block, the RPC cannot serve that block — go back to step 1. \n - Validate runtime fork parameters in tests. Add a temporary test that logs the fork's view of the chain:
Run withit('prints fork diagnostics', async () => { const block = await ethers.provider.getBlock('latest'); console.log('fork block:', block.number, 'gasLimit:', block.gasLimit); console.log('gasPrice:', (await ethers.provider.getGasPrice()).toString()); });npx hardhat test --network hardhat. Confirm the printed block number matches your pinnedforking.blockNumberand the gas limit matches the live network. \n
Fixes Tied to Findings
\n\nRPC Pruning / Non‑Archive Node
\nUpgrade to an archive-tier RPC plan or switch providers. Free tiers from major providers typically prune blocks older than 128–256 blocks. If budget is constrained, fork from a recent block (within the provider's retention window) rather than a historical one.
\nConfig change: Update hardhat.config.js:
module.exports = { networks: { hardhat: { forking: { url: process.env.ARCHIVE_RPC_URL, blockNumber: 19482345 // must be within RPC retention }, gasLimit: 'auto' } } }; Risk: Archive tiers cost $49–$199/month. Verify the new endpoint serves block 1 before committing.\n\nGas Limit Mismatch
\nSet gasLimit: 'auto' (Hardhat ≥ 2.19.0) or explicitly configure the numeric limit. 'auto' queries the forked provider's latest block gas limit at startup.
Config change: Same as above — the gasLimit: 'auto' line handles it. For older Hardhat versions, hard-code the value:
gasLimit: 15000000 // Ethereum mainnet block gas limit as of 2024\n\nVerification: Re-run the diagnostic test from step 5. The printed gasLimit should now match the live network's limit.
Stale Fork State
\nEnsure forking.blockNumber points to a block the RPC serves and that the RPC supports eth_getProof (required for Hardhat's state verification). Most archive endpoints do; some lightweight providers do not.
Config change: Pin a block you confirmed in step 1. If you must use a non-archive RPC, choose a block within its retention window (usually the last 128 blocks).
forking: { url: process.env.RPC_URL, blockNumber: 19482345 // verified via curl in step 1 }\n\nImpersonation Failure
\nOnly impersonate accounts that exist at the forked block. For contracts deployed after the fork block, you cannot impersonate them — instead, deploy a fresh copy in your test setup. For EOAs, ensure the address has code/storage at the fork block (it will, unless it's a future contract).
\nWorkaround in test:
const IMPERSONATED = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; // USDC whale at block 19482345 const code = await ethers.provider.getCode(IMPERSONATED, forkBlockNumber); if (code === '0x') throw new Error('Account not present at fork block'); await network.provider.request({ method: 'hardhat_impersonateAccount', params: [IMPERSONATED] }); const signer = await ethers.getSigner(IMPERSONATED);\n\nConcrete Example: End-to-End Fork Setup for a DeFi Test
\nScenario: You need to test a Uniswap V3 swap against mainnet state at block 19482345 (a known stable block after the Dencun upgrade).
- \n
- Confirm RPC serves the block:
Run from your terminal (no special permissions). Expect a JSON response withcurl -X POST -H 'Content-Type: application/json' \ --data '{\"jsonrpc\":\"2.0\",\"method\":\"eth_getBlockByNumber\",\"params\":[\"0x1299f39\",false],\"id\":1}' \ https://eth-mainnet.g.alchemy.com/v2/YOUR_KEYresult.number=0x1299f39. Ifresultisnull, the RPC lacks this block. \n - Configure Hardhat: In
hardhat.config.js:require('@nomicfoundation/hardhat-toolbox'); module.exports = { solidity: '0.8.24', networks: { hardhat: { forking: { url: process.env.ALCHEMY_MAINNET_URL, blockNumber: 19482345 }, gasLimit: 'auto', allowUnlimitedContractSize: true // useful for large forked contracts } } };\n - Run the fork node:
npx hardhat node --fork $ALCHEMY_MAINNET_URL --fork-block-number 19482345. VerifyForked from block 19482345appears. \n - Write the test:
test/fork-swap.test.jsconst { ethers, network } = require('hardhat'); it('swaps USDC for WETH on forked mainnet', async () => { const forkBlock = 19482345; const USDC_WHALE = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb'; // Verify whale exists at fork block const code = await ethers.provider.getCode(USDC_WHALE, forkBlock); if (code === '0x') throw new Error('Whale not at fork block'); await network.provider.request({ method: 'hardhat_impersonateAccount', params: [USDC_WHALE] }); const whale = await ethers.getSigner(USDC_WHALE); const usdc = await ethers.getContractAt('IERC20', '0xA0b86a33E6441b8C4C8C8C8C8C8C8C8C8C8C8C8C8'); const bal = await usdc.balanceOf(USDC_WHALE); console.log('USDC balance:', bal.toString()); // ... swap logic here });\n - Run test:
npx hardhat test test/fork-swap.test.js --network hardhat. Expect the balance log to match the on-chain balance at block 19482345 (check on Etherscan). \n
Limitations and Verification
\n- \n
- Hardhat version: Requires ≥ 2.19.0 for
gasLimit: 'auto'and stable forking. Runnpx hardhat --versionto confirm. \n - Archive RPC cost: No free archive endpoints exist for Ethereum mainnet. Testnets (Sepolia, Arbitrum Sepolia) often have free archive access via Alchemy/QuickNode free tiers. \n
- Impersonation limits: Impersonated accounts cannot sign typed data (
eth_signTypedData_v4) on the forked provider; onlyeth_sendTransactionworks. \n - State drift over time: A running fork node does not auto-advance. If you need a later block, restart with a new
--fork-block-number. \n
Practical verification checklist after any config change:
\n- \n
npx hardhat node --fork $RPC_URL --fork-block-number $BLOCKstarts withoutFailed to fetch block. \nnpx hardhat test --network hardhatpasses the diagnostic test (prints matching block number and gas limit). \n- A real test (like the Uniswap swap) executes without
out of gasorblock not found. \n
Rollback
\nFork configuration changes are stateless — they only affect the next node startup. No rollback procedure is needed; revert hardhat.config.js and restart hardhat node.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.