Hardhat
Hardhat is the dominant Solidity development framework. It provides compilation, testing, deployment, and debugging for EVM smart contracts. The core value is Hardhat Network — a local EVM that supports console.log, stack traces, and mainnet forking. All configuration lives in hardhat.config.ts.
What You Probably Got Wrong
LLMs frequently generate Hardhat code mixing v1 patterns, ethers v5 syntax, and deprecated plugins. These corrections are non-negotiable.
- ethers v6, not v5 — the API changed fundamentally — Hardhat toolbox now bundles ethers v6.
ethers.getContractFactory()returns a different type.parseEther()is a standalone import, notethers.utils.parseEther().BigNumberis gone — ethers v6 uses nativebigint. If you seeBigNumber.from()orethers.utils.*, you are writing v5 code. Stop. hardhat-deployis NOT the official deployment tool — The official deployment system is Hardhat Ignition (@nomicfoundation/hardhat-ignition).hardhat-deployby wighawag is a community plugin that is not maintained by the Hardhat team. Use Ignition for new projects.@nomicfoundation/hardhat-toolboxreplaces individual plugins — Do not install@nomiclabs/hardhat-ethers,@nomiclabs/hardhat-waffle, orhardhat-gas-reporterindividually. Thehardhat-toolboxmeta-plugin includes ethers, chai matchers, coverage, gas reporter, and typechain. The old@nomiclabs/scoped packages are deprecated.npx hardhat compiledoes NOT generate TypeScript types by default — You must have@nomicfoundation/hardhat-toolbox(or@typechain/hardhat) installed AND runnpx hardhat compileto generate types intypechain-types/. The types are not checked into source control.hardhat.config.tsis NOT optional for TypeScript — If you use.tsconfig, you must havets-nodeandtypescriptinstalled. Hardhat uses ts-node to transpile the config at runtime. Without it, Hardhat silently falls back to looking for.js.ethers.getSigners()returnsHardhatEthersSigner, not raw ethers Signer — The signer type isHardhatEthersSignerwhich extends ethersAbstractSigner. It has additional properties like.addressas a direct property (not a method). Type your test variables accordingly.- Hardhat Network resets between test files, not between
it()blocks — State persists acrossit()blocks within the samedescribe(). UseloadFixture()from@nomicfoundation/hardhat-toolbox/network-helpersto get clean state per test. Do not rely onbeforeEachdeploying fresh contracts — it is slower and error-prone. - Forking requires an archive node RPC —
hardhat_resetandforking.blockNumberrequire archive data. Standard RPC endpoints only serve recent state. Use Alchemy or Infura archive tier, or a local archive node. console.login Solidity only works on Hardhat Network —import "hardhat/console.sol"is a Hardhat-specific feature. It does nothing on mainnet, testnets, or other local nodes. Do not leaveconsole.login production contracts — it wastes gas on the import.- Constructor arguments for verification must match exactly —
npx hardhat verifyfails silently when constructor args do not match the deployed bytecode. Use the--constructor-argsflag pointing to a JS file that exports the exact arguments used during deployment.
Quick Start
New Project
mkdir my-project && cd my-project
npx hardhat init
Select "Create a TypeScript project". This generates:
my-project/
contracts/ # Solidity source files
ignition/modules/ # Hardhat Ignition deployment modules
test/ # Mocha test files
hardhat.config.ts # Central configuration
package.json
tsconfig.json
Install Dependencies
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
hardhat-toolbox includes:
@nomicfoundation/hardhat-ethers— ethers.js integration@nomicfoundation/hardhat-chai-matchers— Chai matchers for reverts, events, balance changes@nomicfoundation/hardhat-network-helpers—loadFixture,time,mine@typechain/hardhat— TypeScript type generationhardhat-gas-reporter— gas usage per functionsolidity-coverage— code coverage
Minimal Config
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const config: HardhatUserConfig = {
solidity: "0.8.27",
};
export default config;
First Compile + Test
npx hardhat compile
npx hardhat test
Configuration
Multi-Network Setup
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
const config: HardhatUserConfig = {
solidity: {
version: "0.8.27",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
viaIR: false,
},
},
networks: {
hardhat: {
chainId: 31337,
},
sepolia: {
url: process.env.SEPOLIA_RPC_URL ?? "",
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
},
mainnet: {
url: process.env.MAINNET_RPC_URL ?? "",
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
},
arbitrum: {
url: process.env.ARBITRUM_RPC_URL ?? "",
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
chainId: 42161,
},
base: {
url: process.env.BASE_RPC_URL ?? "",
accounts: process.env.DEPLOYER_PRIVATE_KEY
? [process.env.DEPLOYER_PRIVATE_KEY]
: [],
chainId: 8453,
},
},
etherscan: {
apiKey: {
mainnet: process.env.ETHERSCAN_API_KEY ?? "",
sepolia: process.env.ETHERSCAN_API_KEY ?? "",
arbitrumOne: process.env.ARBISCAN_API_KEY ?? "",
base: process.env.BASESCAN_API_KEY ?? "",
},
},
gasReporter: {
enabled: process.env.REPORT_GAS === "true",
currency: "USD",
coinmarketcap: process.env.COINMARKETCAP_API_KEY,
},
};
export default config;
Compiler Settings
solidity: {
compilers: [
{
version: "0.8.27",
settings: {
optimizer: { enabled: true, runs: 200 },
evmVersion: "cancun",
},
},
{
version: "0.8.20",
settings: {
optimizer: { enabled: true, runs: 1000 },
},
},
],
overrides: {
"contracts/Legacy.sol": {
version: "0.8.17",
},
},
},
Multiple compiler versions let you compile contracts with different Solidity requirements in the same project.
Forking Configuration
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL ?? "",
blockNumber: 19_000_000,
enabled: true,
},
},
},
Pin blockNumber for deterministic tests. Without it, tests depend on live chain state and break when state changes.
Testing
Test Structure with Fixtures
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers";
describe("Token", function () {
async function deployTokenFixture() {
const [owner, alice, bob] = await ethers.getSigners();
const initialSupply = ethers.parseEther("1000000");
const Token = await ethers.getContractFactory("Token");
const token = await Token.deploy(initialSupply);
return { token, owner, alice, bob, initialSupply };
}
describe("Deployment", function () {
it("should set the correct total supply", async function () {
const { token, initialSupply } = await loadFixture(deployTokenFixture);
expect(await token.totalSupply()).to.equal(initialSupply);
});
it("should assign total supply to owner", async function () {
const { token, owner, initialSupply } = await loadFixture(deployTokenFixture);
expect(await token.balanceOf(owner.address)).to.equal(initialSupply);
});
});
describe("Transfers", function () {
it("should transfer tokens between accounts", async function () {
const { token, owner, alice } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("100");
await expect(token.transfer(alice.address, amount))
.to.changeTokenBalances(token, [owner, alice], [-amount, amount]);
});
it("should revert on insufficient balance", async function () {
const { token, alice, bob } = await loadFixture(deployTokenFixture);
const amount = ethers.parseEther("1");
await expect(token.connect(alice).transfer(bob.address, amount))
.to.be.revertedWithCustomError(token, "ERC20InsufficientBalance");
});
});
});
Key Testing Patterns
Event Assertions
await expect(token.transfer(alice.address, amount))
.to.emit(token, "Transfer")
.withArgs(owner.address, alice.address, amount);
Custom Error Assertions
await expect(vault.withdraw(amount))
.to.be.revertedWithCustomError(vault, "InsufficientBalance")
.withArgs(currentBalance, amount);
Ether Balance Changes
await expect(payable.withdraw())
.to.changeEtherBalances([payable, owner], [-amount, amount]);
Time Manipulation
import { time } from "@nomicfoundation/hardhat-toolbox/network-helpers";
await time.increase(3600);
await time.increaseTo(targetTimestamp);
await time.latestBlock();
const latest = await time.latest();
Impersonating Accounts
import { impersonateAccount, setBalance } from "@nomicfoundation/hardhat-toolbox/network-helpers";
const whaleAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
await impersonateAccount(whaleAddress);
await setBalance(whaleAddress, ethers.parseEther("100"));
const whale = await ethers.getSigner(whaleAddress);
await token.connect(whale).transfer(recipient, amount);
Mining Control
import { mine, mineUpTo } from "@nomicfoundation/hardhat-toolbox/network-helpers";
await mine(10);
await mineUpTo(20_000_000);
Gas Reporter
Enable gas reporting in tests:
REPORT_GAS=true npx hardhat test
Output shows gas per function call and deployment cost.
Coverage
npx hardhat coverage
Generates coverage/ directory with HTML report. Coverage runs on a modified EVM — some tests may behave differently under coverage (gas-dependent assertions will fail).
Deployment with Hardhat Ignition
Hardhat Ignition is the declarative deployment system. Define what to deploy, Ignition handles execution order, retries, and state tracking.
Basic Module
// ignition/modules/Token.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { parseEther } from "ethers";
const TokenModule = buildModule("TokenModule", (m) => {
const initialSupply = m.getParameter("initialSupply", parseEther("1000000"));
const token = m.contract("Token", [initialSupply]);
return { token };
});
export default TokenModule;
Deploy Command
npx hardhat ignition deploy ignition/modules/Token.ts --network sepolia
Module with Dependencies
// ignition/modules/Protocol.ts
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
const ProtocolModule = buildModule("ProtocolModule", (m) => {
const registry = m.contract("Registry");
const vault = m.contract("Vault", [registry]);
m.call(registry, "setVault", [vault]);
const controller = m.contract("Controller", [registry, vault]);
m.call(registry, "setController", [controller]);
return { registry, vault, controller };
});
export default ProtocolModule;
Ignition resolves the dependency graph automatically. vault deploys after registry because it takes registry as a constructor arg. m.call() executes after the contract it references is deployed.
Parameters
const owner = m.getParameter("owner");
const fee = m.getParameter("fee", 300n);
Pass parameters via JSON file or CLI:
npx hardhat ignition deploy ignition/modules/Token.ts \
--network sepolia \
--parameters ignition/parameters.json
{
"TokenModule": {
"initialSupply": "1000000000000000000000000"
}
}
Deployment State
Ignition stores deployment state in ignition/deployments/<deployment-id>/. This directory tracks which contracts deployed, their addresses, and which calls executed. Do not delete it — Ignition uses it for resumption and verification.
Verify After Deploy
npx hardhat ignition verify <deployment-id>
This reads constructor args from the deployment state. No need to specify them manually.
Contract Verification
Automatic (After Ignition Deploy)
npx hardhat ignition verify sepolia-deployment
Manual
npx hardhat verify --network sepolia \
0xYOUR_CONTRACT_ADDRESS \
"constructor_arg_1" \
"constructor_arg_2"
Complex Constructor Args
Create a file for arguments:
// arguments.ts
export default [
"0xTokenAddress",
1000n,
"My Token",
];
npx hardhat verify --network sepolia \
--constructor-args arguments.ts \
0xYOUR_CONTRACT_ADDRESS
Multi-Chain Verification
The etherscan.apiKey config accepts a map:
etherscan: {
apiKey: {
mainnet: process.env.ETHERSCAN_API_KEY ?? "",
arbitrumOne: process.env.ARBISCAN_API_KEY ?? "",
optimisticEthereum: process.env.OPTIMISTIC_ETHERSCAN_API_KEY ?? "",
base: process.env.BASESCAN_API_KEY ?? "",
polygon: process.env.POLYGONSCAN_API_KEY ?? "",
},
},
Custom Chain Verification
For chains not natively supported by hardhat-verify:
etherscan: {
apiKey: {
monad: process.env.MONAD_EXPLORER_API_KEY ?? "",
},
customChains: [
{
network: "monad",
chainId: 10143,
urls: {
apiURL: "https://explorer.monad.xyz/api",
browserURL: "https://explorer.monad.xyz",
},
},
],
},
Hardhat Network
Console.log in Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "hardhat/console.sol";
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
console.log("Deposit from %s: %s wei", msg.sender, msg.value);
balances[msg.sender] += msg.value;
}
}
Remove console.log before deploying to production. It compiles to no-ops on non-Hardhat networks but the import still costs deployment gas.
Stack Traces
Hardhat Network provides Solidity stack traces on reverts. No configuration needed — revert reasons and the exact line number appear in test output automatically.
JSON-RPC Methods
Hardhat Network exposes additional JSON-RPC methods:
// Snapshot and revert (alternative to fixtures for special cases)
const snapshotId = await ethers.provider.send("evm_snapshot", []);
await ethers.provider.send("evm_revert", [snapshotId]);
// Set block timestamp
await ethers.provider.send("evm_setNextBlockTimestamp", [1700000000]);
await ethers.provider.send("evm_mine", []);
// Set account balance
await ethers.provider.send("hardhat_setBalance", [
"0xAddress",
"0xDE0B6B3A7640000", // 1 ETH in hex
]);
// Set storage slot directly
await ethers.provider.send("hardhat_setStorageAt", [
contractAddress,
"0x0", // slot 0
"0x0000000000000000000000000000000000000000000000000000000000000001",
]);
// Reset fork
await ethers.provider.send("hardhat_reset", [
{
forking: {
jsonRpcUrl: process.env.MAINNET_RPC_URL,
blockNumber: 19_000_000,
},
},
]);
Mainnet Forking
Fork mainnet to test against real protocol state:
// hardhat.config.ts
networks: {
hardhat: {
forking: {
url: process.env.MAINNET_RPC_URL ?? "",
blockNumber: 19_500_000,
},
},
},
// test/ForkTest.ts
import { ethers } from "hardhat";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; // Binance hot wallet
describe("Fork Tests", function () {
it("should read USDC balance on fork", async function () {
const usdc = await ethers.getContractAt("IERC20", USDC);
const balance = await usdc.balanceOf(WHALE);
expect(balance).to.be.greaterThan(0n);
});
});
Custom Tasks
Basic Task
// hardhat.config.ts (or tasks/accounts.ts if you split tasks into files)
import { task } from "hardhat/config";
task("accounts", "Prints the list of accounts", async (_taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
npx hardhat accounts
Task with Arguments
import { task } from "hardhat/config";
task("balance", "Prints an account's balance")
.addParam("account", "The account's address")
.setAction(async (taskArgs, hre) => {
const balance = await hre.ethers.provider.getBalance(taskArgs.account);
console.log(hre.ethers.formatEther(balance), "ETH");
});
npx hardhat balance --account 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Task Calling Other Tasks
task("deploy-and-verify", "Deploys and verifies a contract")
.addParam("name", "Contract name")
.setAction(async (taskArgs, hre) => {
await hre.run("compile");
const Contract = await hre.ethers.getContractFactory(taskArgs.name);
const contract = await Contract.deploy();
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log(`${taskArgs.name} deployed to: ${address}`);
if (hre.network.name !== "hardhat" && hre.network.name !== "localhost") {
console.log("Waiting for block confirmations...");
await contract.deploymentTransaction()?.wait(5);
await hre.run("verify:verify", {
address,
constructorArguments: [],
});
}
});
TypeScript Configuration
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"declaration": true
},
"include": [
"./scripts",
"./test",
"./typechain-types"
],
"files": [
"./hardhat.config.ts"
]
}
Type-Safe Contract Interaction
After npx hardhat compile, TypeChain generates types in typechain-types/:
import { Token } from "../typechain-types";
describe("Token", function () {
let token: Token;
async function deployFixture() {
const Token = await ethers.getContractFactory("Token");
token = await Token.deploy(ethers.parseEther("1000000"));
return { token };
}
});
The Token type provides autocomplete for all contract methods with proper argument and return types.
Importing Artifacts
import TokenArtifact from "../artifacts/contracts/Token.sol/Token.json";
const token = new ethers.Contract(address, TokenArtifact.abi, signer);
Plugin Ecosystem
Essential Plugins
| Plugin | Purpose |
|---|---|
@nomicfoundation/hardhat-toolbox |
Meta-plugin: ethers, chai, coverage, gas, typechain |
@openzeppelin/hardhat-upgrades |
Proxy deployment (UUPS, Transparent) |
@nomicfoundation/hardhat-verify |
Etherscan/Blockscout verification |
@nomicfoundation/hardhat-ignition |
Declarative deployments |
hardhat-contract-sizer |
Report contract bytecode sizes |
hardhat-abi-exporter |
Export ABIs to separate files |
Installing a Plugin
npm install --save-dev @openzeppelin/hardhat-upgrades @openzeppelin/contracts
// hardhat.config.ts
import "@openzeppelin/hardhat-upgrades";
All plugins are activated by importing them in hardhat.config.ts. No other registration needed.
Common Patterns
Environment Variable Handling
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";
dotenv.config();
function getRequiredEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
const config: HardhatUserConfig = {
solidity: "0.8.27",
networks: {
mainnet: {
url: getRequiredEnv("MAINNET_RPC_URL"),
accounts: [getRequiredEnv("DEPLOYER_PRIVATE_KEY")],
},
},
};
export default config;
Contract Size Check
npm install --save-dev hardhat-contract-sizer
// hardhat.config.ts
import "hardhat-contract-sizer";
const config: HardhatUserConfig = {
contractSizer: {
alphaSort: true,
runOnCompile: true,
disambiguatePaths: false,
strict: true, // fail if any contract exceeds 24KB
},
};
Parallel Test Execution
npx hardhat test --parallel
Tests must be isolated (use loadFixture) for parallel execution. Shared mutable state between tests will cause flaky failures.
Hardhat vs Foundry
| Feature | Hardhat | Foundry |
|---|---|---|
| Language | TypeScript/JavaScript | Solidity |
| Speed | Slower compilation | Faster compilation |
| Testing | Mocha/Chai (JS) | Solidity tests |
| Debugging | console.log, stack traces | Traces, cheatcodes |
| Deployment | Hardhat Ignition | forge script |
| Plugins | NPM ecosystem | Less extensible |
| Forking | Built-in | Built-in |
Use Hardhat when: TypeScript integration matters, complex deployment orchestration, large plugin ecosystem needed, team knows JS/TS better than Solidity.
Use Foundry when: Speed matters, fuzz testing needed, team prefers Solidity-native tooling.
Both can coexist in the same project with hardhat-foundry plugin.
Reference
- Official Docs: https://hardhat.org/docs
- Hardhat Ignition: https://hardhat.org/ignition
- GitHub: https://github.com/NomicFoundation/hardhat
- Plugin Directory: https://hardhat.org/hardhat-runner/plugins
- Hardhat Network Reference: https://hardhat.org/hardhat-network/docs/reference
- ethers v6 Docs: https://docs.ethers.org/v6/