我最早跑合约开发环境,用的还是 Ganache + Truffle 那套组合。后来换到 Hardhat 2,起因是一次紧急开发:本地的 Ganache 自动挖矿节奏频繁打乱我对交易顺序的判断,而 Hardhat Network 的即时交易反馈和扎实的报错堆栈,让我很快定位了一个重入攻击的测试用例。从那时起,Hardhat 2 就成了我主力使用的以太坊合约开发框架,集编译、测试、部署、调试于一体,特别适合“本地模拟”和“测试网真实部署”两条线并行的团队。
这篇文章不是官方文档的复读。我会按自己实际搭环境的流程写一遍:环境准备、本地网络、编译测试、部署脚本、合约验证,以及那些我反复踩过的坑。无论你是刚接触 Solidity 的新手,还是正准备从其他框架迁移过来的老手,照着下面的路径走,基本都能顺利跑通。
1. 从 Truffle 时代到 Hardhat 2:我换掉全家桶的真实原因
1.1 Ganache 给我的三个痛点
最早我其实很依赖 Ganache 的图形界面,觉得点点鼠标能看到区块高度、账户余额,比纯命令行更踏实。但用久了以后,问题逐渐暴露:
- 自动挖矿节奏不可控。Ganache 默认每个交易到达后立刻挖一个块,某些场景下我想要连续发起多笔交易再整体检查状态,结果交易被逐个打包,断言顺序总是不对。
- 主网 fork 不稳定。我用 Ganache CLI 做过几次 fork,连接经常超时,区块高度一旦设得比较旧,RPC 响应能慢到让我怀疑人生。
- 工程组织松散。Truffle 的 migrations 目录在项目变大后很不好维护,部署脚本之间经常出现顺序依赖,还要额外管理 migrations 的编号,非常原始。
这些痛点不算致命,但每一项都在拖慢开发速度。真正让我下决心换掉它的,是 Hardhat 2 里那种“为合约开发者而设计”的细节。
1.2 Hardhat 2 解决的不只是部署问题
Hardhat 2 的生态很清晰:它把编译器、本地网络、测试框架、调试工具、部署脚本和验证功能,统一收敛到一个命令行工具里。最吸引我的是下面几个能力:
- 内置 Hardhat Network。默认情况下,每次执行
npx hardhat run或npx hardhat test都会启动一个内存里的临时链,跑完自动销毁,不需要额外开进程。 - Solidity console.log。在合约里通过
import "hardhat/console.sol"打印变量值,本地网络和 fork 模式下都能直接看到输出,这对排查复杂逻辑的帮助是巨大的。 - 完善的错误堆栈。交易失败时,Hardhat 会尽量告诉你失败发生在哪个合约、哪个函数、哪个文件行数,不再像以前那样只有一串难懂的 revert data。
- 插件体系。hardhat-ethers、hardhat-verify、hardhat-gas-reporter、hardhat-deploy 这些插件,让“部署”“验证”“Gas 统计”都有现成的落地方案,不需要自己折腾一堆脚本。
1.3 2.x 和 1.x 的版本差异
这里要特别说下“Hardhat 2”和旧 1.x 的差异,因为网上很多教程还停留在 1.x 时代的包名和命令。
- 插件包名换了。旧教程里常见的
@nomiclabs/hardhat-ethers、@nomiclabs/hardhat-etherscan,在 2.x 官方推荐里已经逐步被@nomicfoundation/hardhat-ethers、@nomicfoundation/hardhat-toolbox、@nomicfoundation/hardhat-verify替代。 - 验证命令变了。新版本里用
npx hardhat verify,而老版本经常是npx hardhat etherscan-verify,照抄老命令会直接报 “Unknown task”。 - 脚手架更强。
npx hardhat init可以生成 JavaScript 或 TypeScript 项目,并自带示例合约、测试文件和部署脚本,开箱即用。
所以,如果你看到一篇教 Hardhat 的文章还在让你安装@nomiclabs/hardhat-etherscan,建议先确认文章的发布时间。Hardhat 2 的迭代非常快,依赖锁定和插件版本一致性,比想象中重要得多。
2. 环境准备里最容易翻车的三件事:Node 版本、目录布局、依赖锁定
2.1 Node 版本别选极端版本
Hardhat 2 官方要求 Node.js 长期支持版本,我个人的建议是直接用 Node 20 LTS,或至少是 Node 18 LTS。太老的 Node 16 在一些新插件上会碰到 ESM 依赖解析问题,太新的非 LTS 版本偶尔也会出现某些原生模块还没适配的情况。
如果你本机装了多个 Node 版本,推荐用 nvm 管理:
nvm install 20 nvm use 20 node --version这个步骤看起来简单,但我见过不少同事卡在“明明安装了 Hardhat,却一直报语法错误”的问题上,最后发现是nvm use切回了旧版本,全局环境里的 Node 和项目里的 Node 不是同一个。
2.2 目录布局与 .gitignore
一个干净的 Hardhat 2 项目目录通常是这样的:
my-contract-project/ ├── contracts/ # Solidity 源文件 ├── scripts/ # 部署和交互脚本 ├── test/ # 测试文件 ├── hardhat.config.js # 核心配置 ├── package.json ├── package-lock.json └── .gitignore运行编译和部署后,还会自动生成artifacts/和cache/。这两个目录以及node_modules/、.env,都应该写进.gitignore,不要提交到仓库。否则别人 clone 项目后,拉下来一堆无关的编译产物,还容易造成“本地缓存和新代码不一致”的诡异问题。
2.3 用 npx hardhat init 初始化
我推荐用官方脚手架初始化项目,而不是完全手写配置。进入空目录后执行:
npm init -y npx hardhat init交互界面会让你选择创建 JavaScript 项目、TypeScript 项目,或者创建一个空项目。选 JavaScript 项目,它会生成一个包含Lock.sol示例合约、scripts/deploy.js部署脚本、test/Lock.js测试文件的模板项目。
模板里的Lock.sol可以留着做验证,也可以删掉。我的习惯是先跑通一遍官方的测试,确认环境没问题,再一步步替换成自己的合约代码。
2.4 安装效率最高的包组合
如果从零开始,我通常会装下面这一组:
npm install --save-dev hardhat npm install --save-dev @nomicfoundation/hardhat-toolbox npm install --save-dev dotenv@nomicfoundation/hardhat-toolbox会把 ethers、chai matchers、gas reporter、network helpers 等常用工具打包到一起。在hardhat.config.js里只要写一句:
require("@nomicfoundation/hardhat-toolbox");后续在测试和脚本里就能直接使用ethers、expect这些工具,非常省事。
如果你用的是 Hardhat 2 比较新的小版本,建议先看一下npm install时输出的依赖树。比如hardhat和@nomicfoundation/hardhat-toolbox是否都指向同一个主版本,避免出现“Ethers v5 和 v6 混用”的情况。
2.5 依赖锁定:小版本升级也能坑人
Hardhat 生态里的插件依赖非常敏感。同一个项目里,hardhat从 2.18 升到 2.22,某些插件的 API 行为都可能变,更不用说跨大版本了。
所以,项目一创建就要提交package-lock.json,在 CI 或别的机器上统一用:
npm ci而不是npm install。npm ci会严格按 lockfile 安装,避免因为^符号带来了意外的小版本升级。
3. 先跑通本地网络:Hardhat Network 的配置与行为
3.1 默认网络 vs 本地持久节点
Hardhat 2 默认的网络就是hardhat网络。执行:
npx hardhat run scripts/deploy.js时,它会启动一个临时链,跑完脚本后这个链就销毁了。所以通过这种默认方式部署的合约地址,只存在于本次运行的内存里,随后你再想用 MetaMask 连上去看,是看不到的。
如果你需要一个持续运行的、可以被外部钱包访问的本地链,就要执行:
npx hardhat node这个命令会启动一个监听http://127.0.0.1:8545的节点,并打印 20 个测试账户和对应的私钥,每个账户默认有 10000 ETH。此时要在另一个终端里运行部署脚本,必须显式指定--network localhost:
npx hardhat run scripts/deploy.js --network localhost我第一次用的时候就在这栽过跟头:开着hardhat node,却忘了加--network localhost,脚本又跑到了临时网络上,结果 Transaction 在链上根本查不到。
3.2 配置 networks 的三种场景
hardhat.config.js里的网络配置,基本可以覆盖本地、测试网、主网三种场景:
require("@nomicfoundation/hardhat-toolbox"); require("dotenv").config(); module.exports = { solidity: "0.8.24", networks: { localhost: { url: "http://127.0.0.1:8545", }, sepolia: { url: process.env.SEPOLIA_RPC_URL || "", accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], }, }, };这里有一个安全习惯:私钥不要硬编码进配置文件,也不要提交到仓库。从.env读取后,再构造accounts数组。注意私钥字符串要带0x前缀,否则 Hardhat 会报 invalid address 相关的错误。
3.3 Hardhat Network 和 MetaMask 的 chainId 陷阱
经常有人问:为什么本地 Hardhat 节点在 MetaMask 里添加了,但浏览器插件还是连不上?最常见的坑是 chainId 填错。
Hardhat Network 默认的 chainId 是31337,RPC URL 是http://127.0.0.1:8545。而早期 Ganache 的默认 chainId 是1337。如果你在 MetaMask 里沿用 Ganache 的习惯填了1337,交易大概率会失败。这里用一张表做个对照:
| 节点 | RPC URL | Chain ID |
|---|---|---|
| Hardhat Network | http://127.0.0.1:8545 | 31337 |
| Ganache CLI 默认 | http://127.0.0.1:8545 | 1337 |
| Sepolia 测试网 | 由 RPC 服务商提供 | 11155111 |
3.4 用 fork 主网模拟复杂场景
Hardhat 2 一个非常强的功能是主网 fork。比如你在开发一个需要和 Uniswap 交互的合约,直接在配置里指定:
networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL || "", blockNumber: 19000000, }, }, }这样npx hardhat test和本地npx hardhat node都会基于指定区块高度 fork 主网状态。指定blockNumber的好处是让测试结果可复现,不会因为主网状态不断变化而抖动。
不过 fork 只是开发辅助,不能当作真实安全的保证。测试环境和主网环境的差异、流动性深度、区块状态变化,都需要在测试网和主网上分别验证。
4. 从编译到测试:把 Hardhat 2 当调试器,而不是只当编译机
4.1 先写一个最小可跑的合约
我不建议一上来就搬大型合约框架,先用一个最简单的存储合约把链路打通,再逐步替换。这里用StorageBox做例子:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract StorageBox { uint256 private value; event ValueStored(address indexed caller, uint256 newValue); function store(uint256 newValue) external { value = newValue; emit ValueStored(msg.sender, newValue); } function retrieve() external view returns (uint256) { return value; } }把文件放在contracts/StorageBox.sol。这个合约足够简单,但足以验证编译、部署、调用、测试全链路。
4.2 hardhat compile 到底生成了什么
执行:
npx hardhat compile在artifacts/contracts/StorageBox.sol/下会生成StorageBox.json。这个 JSON 文件里最重要的是abi、bytecode和deployedBytecode:
abi:部署后和前端交互需要的接口描述。bytecode:用于部署新合约的完整创建代码。deployedBytecode:部署后链上存储的运行时字节码。
另外,项目根目录会出现cache/,用于记录编译缓存,加速后续编译。如果合约文件改名或很复杂时遇到“读取到旧 ABI”的诡异问题,可以先执行:
npx hardhat clean清空产物后重新编译。
4.3 solidity 编译器版本与优化器设置
在hardhat.config.js里可以配置多个 Solidity 版本和优化器参数:
module.exports = { solidity: { version: "0.8.24", settings: { optimizer: { enabled: true, runs: 200, }, }, }, };runs: 200是比较常规的优化目标,意思是希望合约在多次调用场景下有更优的链上执行成本。如果你不确定优化器对你的合约是否有影响,可以先关闭优化器跑一遍测试,再打开优化器跑一遍,对比结果。
不要盲目加大优化器迭代次数,也不要为了“省 Gas”开 10000 runs 这种极端值。尤其是有复杂继承或代理模式的合约,优化器在某些组合下可能改变字节码顺序,测试覆盖不足会让你在测试网上线后才发现问题。
4.4 用 hardhat test 做快速回归
Hardhat 2 推荐测试文件放在test/目录。一个最基本的测试:
const { expect } = require("chai"); describe("StorageBox", function () { it("should store value and retrieve it", async function () { const StorageBox = await ethers.getContractFactory("StorageBox"); const box = await StorageBox.deploy(); await box.waitForDeployment(); await box.store(42); expect(await box.retrieve()).to.equal(42); }); });运行:
npx hardhat test注意,测试文件里可以直接使用全局注入的ethers,但如果你在普通脚本文件里这样写,运行时会报ethers is not defined。脚本里需要显式引入 Hardhat Runtime Environment,例如:
const hre = require("hardhat");这是新手最容易搞混的地方。
4.5 console.log 调试技巧
Hardhat 2 的console.log是我调试合约的头号工具。在合约里:
import "hardhat/console.sol"; function store(uint256 newValue) external { console.log("newValue:", newValue); value = newValue; }运行测试或在本地的 Hardhat Network 上执行交易时,终端会直接打印newValue: 42。它不会真正写入链上,也不会产生额外的合约存储消耗,非常适合开发阶段。
但要记住,console.log在正式测试网的链上合约里虽然不会报错,却会在字节码里引入额外的调试符号和分支依赖,增加合约复杂度和审计成本。我的习惯是:发布前把所有console.logimport 和调用从 Solidity 源文件里移除,再重新编译部署。
5. 把合约部署到不同目标:脚本写法、网络选择、源码验证
5.1 单文件部署脚本就够了
Hardhat 2 的部署本质上是“写脚本 + 指定网络”。一个能用的scripts/deploy.js:
const hre = require("hardhat"); async function main() { const [deployer] = await hre.ethers.getSigners(); console.log("Deploying from:", deployer.address); const StorageBox = await hre.ethers.getContractFactory("StorageBox"); const box = await StorageBox.deploy(); await box.waitForDeployment(); console.log("StorageBox deployed to:", box.target); } main().catch((error) => { console.error(error); process.exitCode = 1; });这里有两个细节:
box.target是 ethers v6 里获取合约地址的方式,如果你还在用 ethers v5,通常是box.address。box.waitForDeployment()等价于旧版本的box.deployed(),用于等待部署交易确认。
5.2 部署到 Sepolia 测试网的正确姿势
先在.env里准备好下面三个变量:
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/你的KEY PRIVATE_KEY=0x你的私钥 ETHERSCAN_API_KEY=你的区块浏览器Key然后执行:
npx hardhat run scripts/deploy.js --network sepolia执行前确认两件事:
- 部署账户里有没有 Sepolia 测试币,没有的话先去 faucet 领。
.env文件有没有被误提交,私钥一旦进了公共仓库,等于直接送钱。
5.3 验证:让区块浏览器信任你的合约
部署完成后,可以在区块浏览器里查看合约代码,但默认是未验证状态。用 Hardhat 2 内置的 verify 功能验证:
npx hardhat verify --network sepolia <合约地址>如果构造函数有参数,就把参数值跟在地址后面:
npx hardhat verify --network sepolia <合约地址> "参数1" "参数2"验证失败最常见的两个原因,一是合约编译时使用了特定的优化器配置,但 verify 时没有保持一致;二是 Solidity 版本和配置文件里的版本声明不一致。所以我的原则是:部署和验证之间不要重新编译,更不要清了缓存又改版本。
5.4 什么时候需要 hardhat-deploy 插件
如果你的项目只有一两个合约,手写脚本完全够用。但项目开始涉及多合约依赖部署、升级、按顺序执行多个初始化步骤时,hardhat-deploy插件会方便很多。
它会约定一个deploy/目录,每个部署脚本导出一个deploy函数,并且自动保存每次部署的记录到deployments/目录。由于插件不是官方包,版本和 Hardhat 2 的兼容性需要额外关注。我个人的建议是:项目早期先用手动脚本把流程跑熟,再决定要不要上 hardhat-deploy,不要一上来就依赖它。
6. 我在 Hardhat 2 上踩过的坑和排查思路
6.1 从“找不到模块”到能跑通的完整排查链路
我刚从 1.x 迁移到 Hardhat 2 时,最常见的是这个错误:
Error: Cannot find module 'hardhat'遇到这类问题,我的排查顺序是:
- 先看当前目录是不是项目根目录,有没有
hardhat.config.js。 - 检查
node_modules/hardhat是否存在。 - 删除
node_modules后重新执行npm ci,不要用npm install。 - 检查
package.json里的hardhat是否还在 devDependencies。
大部分“找不到模块”都出在依赖没有真正安装成功,或者安装时用了全局环境,而不是项目本地环境。Hardhat 2 要求所有插件都以项目本地依赖存在,不要全局安装。
6.2 私钥、chainId、地址对不上的社死现场
有一次我部署脚本一直报:
Error: invalid argument 0: hex string without 0x prefix排查半天,发现.env里的PRIVATE_KEY没有加0x。Hardhat 的 accounts 配置要求私钥带0x前缀,否则会被当作普通 hex 字符串解析失败。
另一个常见问题是 MetaMask 里的 chainId。很多老教程默认写 1337,但 Hardhat Network 是 31337。如果前端连接本地节点却无法切换网络,记得检查 chainId 是否为 31337。
6.3 编译缓存与陈旧 artifacts
合约代码改了几行,重新编译后返回的字节码还是老版本?这种问题一般出现在多编译任务并行,或者文件从StorageBox.sol重命名为StorageBoxV2.sol后,旧 artifact 还残留在artifacts/目录里。
Hardhat 2 不会自动清理旧产物。如果你发现部署的合约行为和新源码不一致,直接:
npx hardhat clean然后重新编译和测试。宁可多花几秒全量编译,也不要带着半新半旧的缓存上线。
6.4 TypeScript 和 ESM 项目的兼容问题
现在很多新项目用 TypeScript 模板,hardhat.config.ts本身没有问题,但如果你在package.json里手动加了"type": "module",可能会导致 CommonJS 风格的配置文件加载报错。
我的建议是:如果不需要 ESM,就不要在package.json里声明"type": "module"。Hardhat 2 的默认项目结构更偏向 CommonJS,强行混合 ESM 只会增加解析复杂度。
6.5 千万不要在日志里打印私钥
我看到过一些示例代码喜欢把process.env.PRIVATE_KEY打印到终端,方便调试。这个习惯非常危险。日志文件一旦被 CI 系统缓存、或者不小心粘贴到公开工单里,私钥就等于公开了。
另外,不要在主网和测试网共用同一个私钥钱包。测试网被攻击、被 drain 的成本不高,但同一个私钥一旦在主网有资产,两边被混用后风险会被无限放大。建议测试环境永远用独立钱包,并单独给测试地址领水。
7. 如果让我重新搭一套 Hardhat 2 开发环境,我会这样做
7.1 新项目默认配置清单
经过大量项目折腾,我现在的新项目起步基本固定成这样:
- Node 20 LTS。
npm init -y后安装hardhat、@nomicfoundation/hardhat-toolbox、dotenv。- 用
npx hardhat init初始化 TypeScript 项目,保留测试目录结构。 .gitignore里必须有node_modules、artifacts、cache、.env、deployments、typechain-types。hardhat.config.js统一从.env读取 RPC URL 和私钥。- 测试跑通前,不部署任何测试网合约。
这套流程真的帮我挡掉了很多低级错误。尤其是一次合约里用了旧版console.log,在本地测试时一切正常,但在测试网部署后我忘记移除,后来被审计同事专门提了一次。现在这个清单会在每次新项目开始时提醒我:不要把调试负担带到生产环境。
7.2 只用几个命令,不要背诵插件名
Hardhat 2 的常用命令并不多,真正需要记住的是:
npx hardhat compile npx hardhat test npx hardhat run scripts/deploy.js --network sepolia npx hardhat node npx hardhat verify --network sepolia <合约地址> npx hardhat clean把这几条命令理解透了,你其实已经能完成 90% 的日常开发工作。剩下的那些插件细节,用到的时候再查文档,完全来得及。相比背各种插件名,我更建议你花时间把测试写好,把部署脚本的可重复性做好。因为工具会迭代,但“能复现、可验证、有测试”的开发习惯不会过时。
我现在的做法是:任何合约改动,先跑一遍完整的npx hardhat test,再在npx hardhat node上做一次从部署到交互的手工冒烟,确认无误后才会上测试网。这套路径看起来多花几分钟,却帮我拦住了至少十次到测试网才发现的低级错误。