Integrating Hardhat Forking into a CI Pipeline: Minimal Design & Operational Checks
Learn how to add Hardhat’s forking to continuous‑integration workflows with a lean config, trust boundaries, sanity checks, and how to detect stale forks that could break your tests.
29 Jan 2026, 22:09 UTC

Problem Statement
When testing smart contracts that depend on live chain data, developers often use Hardhat Network’s forking feature to spin up a local blockchain that mirrors a public network. In a CI environment, the challenge is to keep the fork fresh, trustworthy, and deterministic without adding unnecessary complexity.
Minimal Design Overview
The simplest architecture requires only a single hardhat.config.js file and a dedicated environment variable for the RPC endpoint. All tests run against the hardhat network, which automatically forks the specified chain. After each test run, the network is torn down by Hardhat, so no cleanup task is necessary.
module.exports = {
solidity: "0.8.20",
networks: {
hardhat: {
forking: {
url: process.env.FORK_RPC_URL,
// Optional: pin to a specific block for deterministic runs
blockNumber: process.env.FORK_BLOCK_NUMBER
? parseInt(process.env.FORK_BLOCK_NUMBER)
: undefined
}
}
}
};
In CI, you set FORK_RPC_URL to a provider such as Alchemy or Infura, and optionally FORK_BLOCK_NUMBER to the latest block at the time of the last successful build. The hardhat network is read‑only; any state changes remain local and are discarded after the job finishes.
Trust and Data Boundaries
Read‑only state guarantees that the fork cannot affect the source chain. However, the fork inherits all public data, including contract code, account balances, and historical events. Sensitive private keys must never be injected into the forked environment; keep them in separate secrets or use a dedicated test account with minimal balance. The only trust boundary is the RPC endpoint: you must trust that the provider delivers accurate block data. If the provider is compromised or misconfigured, the fork will reflect that corruption.
Operational Sanity Checks
Before running the full test suite, perform lightweight checks that the fork is in sync and gas estimates are realistic. These checks are inexpensive and can be run as a pre‑step in CI.
1. Block Number Drift
npx hardhat run scripts/checkBlock.js --network hardhat
scripts/checkBlock.js:
async function main() {
const { ethers } = require('hardhat');
const forkBlock = await ethers.provider.getBlockNumber();
const liveBlock = parseInt(process.env.FORK_BLOCK_NUMBER || forkBlock);
const drift = Math.abs(forkBlock - liveBlock);
if (drift > 200) {
console.error(`Fork drift is ${drift} blocks; aborting tests.`);
process.exit(1);
}
console.log(`Fork block ${forkBlock} is within acceptable drift.`);
}
main();
Adjust the drift threshold based on how fresh you need the fork. A drift of 200 blocks (~3‑5 minutes on Ethereum) is often acceptable for most test scenarios.
2. Gas Estimate Consistency
npx hardhat run scripts/checkGas.js --network hardhat
scripts/checkGas.js (example for an ERC‑20 transfer):
async function main() {
const { ethers } = require('hardhat');
const [deployer, recipient] = await ethers.getSigners();
const token = await ethers.getContractAt('IERC20', '0xTokenAddress');
const gasEstimate = await token.estimateGas.transfer(recipient.address, 1000);
if (gasEstimate.gt(1000000)) {
console.error(`Unexpectedly high gas estimate: ${gasEstimate}`);
process.exit(1);
}
console.log(`Gas estimate is ${gasEstimate} – within expected range.`);
}
main();
Comparing gas usage on the fork to a known live estimate helps detect anomalies caused by stale or corrupted state.
Failure Modes and Redesign Triggers
- Stale Fork: If the fork lags significantly behind the live chain, tests that rely on recent state (e.g., new token listings) may pass incorrectly. Trigger a redesign by pinning to the latest block on each CI run or by adding a pre‑step that fetches the current block number.
- Dynamic Data (Oracles): Forked networks cannot simulate non‑deterministic oracle feeds unless you stub them. If tests depend on real oracle values, consider using a mock oracle contract or a snapshot of the chain that includes the oracle state.
- Provider Rate Limits: Excessive RPC calls during fork initialization can hit provider limits, causing intermittent failures. Mitigate by caching the RPC URL and reusing the same fork across multiple test jobs when possible.
- Privileged Key Leakage: Accidentally injecting a private key into the forked environment can lead to unintended on‑chain transactions. Enforce a policy that disallows any
PRIVATE_KEYvariable in the CI environment for the forked network.
Verification Checklist
After implementing the minimal design, run the following verification steps locally and in CI to ensure reliability:
- Deploy a simple contract on the fork and verify that the address matches the address obtained from a fresh Hardhat network without forking.
- Query a known on‑chain value (e.g., the balance of a well‑known token holder) and confirm it matches the value reported by the live network at the same block number.
- Execute a transaction that consumes a known gas amount on both the forked and live networks; the gas used should be within 5% of each other.
These checks validate that the fork accurately reflects the source chain and that the environment behaves as expected.
Practical Example
Assume you want to test a DEX router that interacts with an existing liquidity pool on Ethereum Mainnet. Your CI pipeline could look like this:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run sanity checks
env:
FORK_RPC_URL: ${{ secrets.ALCHEMY_MAINNET }}
run: |
npx hardhat run scripts/checkBlock.js --network hardhat
npx hardhat run scripts/checkGas.js --network hardhat
- name: Run tests
env:
FORK_RPC_URL: ${{ secrets.ALCHEMY_MAINNET }}
FORK_BLOCK_NUMBER: ${{ steps.get_block_number.outputs.block }}
run: npx hardhat test --network hardhat
In the above workflow, get_block_number is a separate step that queries the latest block number from the RPC provider and stores it in an output variable. This ensures every test run starts from the freshest possible state.
Conclusion
By keeping the Hardhat fork configuration minimal, respecting read‑only boundaries, and adding lightweight sanity checks, you can reliably integrate forking into a CI pipeline. The design scales with project complexity: if you later need dynamic oracle data or tighter synchronization, you can extend the pipeline with additional stubs or snapshot mechanisms without breaking the core architecture.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.