如果你最近准备上手 Solidity 智能合约开发,大概会反复听到 Hardhat 2 这个名字。很多以太坊项目的 README 里,部署和测试脚本都默认用 Hardhat 来执行。我想用一篇偏实操的文章,把 Hardhat 2 的部署安装、项目初始化、合约编译部署、常见报错都讲透,内容尽量照顾从没接触过 Node.js 的同学,也保留我自己的踩坑记录。这篇不聊玄乎的架构,只讲怎么把环境搭起来、把合约跑起来,并解释每一步为什么要这么做。
1. 先弄明白 Hardhat 2 解决什么问题
1.1 它到底是个什么工具
Hardhat 2 是基于 Node.js 的以太坊智能合约开发框架。看到“框架”两个字,你可能马上想到 Spring、Django,其实定位差不多:它把合约开发过程中零散的工具统一成一套工作流。合约源码写好后要编译成 ABI 和字节码,需要调用不同版本的 Solidity 编译器;测试时需要一条本地链环境;部署时要签交易、估算 gas;验证源码时要对接区块浏览器。这些事如果全靠手动,很容易因为工具版本不一致而浪费大量时间。Hardhat 2 的做法是,把所有能力收敛成一个个 npm 包和一组 CLI 命令,并在一个叫 Hardhat Runtime Environment(HRE)的上下文里共享配置和网络信息。你写编译、测试、部署脚本时,拿到的是同一个hre对象,内部自动帮你管理网络连接和账户。
Hardhat Network 是它内置的本地以太坊节点。这个节点不是玩具,它支持快照、自动挖矿、配置矿工间隔、甚至可以把主网状态 fork 到本地。很多团队在审计前先用 fork 模式,把目标协议的所有合约拉到本地,再模拟攻击场景,这比直接在主网测试便宜得多。这一点是很多教程不强调、但实际价值非常高的能力。对刚入门的人来说,你不需要一开始就理解 HRE 的所有细节,只需要知道:它让你不需要手动启动 geth,不需要自己管理 solc 版本,不需要为了“跑一个部署脚本”而写一堆胶水代码。
1.2 和 Truffle、Foundry 比,为什么我推 Hardhat 2
用 Truffle 的人会觉着它的脚手架很全,但更新节奏慢,而且项目结构偏重。Foundry 的特点是快,编译和测试都基于 Rust,跑测试时的速度确实让人上瘾,但它的合约部署、脚本管理、插件生态相对年轻,如果你要在一个项目里同时管理多个合约的部署顺序,还需要额外设计。Hardhat 2 的插件体系是我最看重的地方:官方有 hardhat-toolbox 聚合包,社区有 hardhat-deploy 管理部署记录,有 solidity-coverage 统计测试覆盖率,还有 hardhat-upgrades 配合代理合约。这意味着大部分工程化诉求都能找到现成方案,而不是自己造轮子。
当然不是说 Hardhat 2 是唯一正确答案。我个人的建议是:如果你只是临时跑一两个合约,用 Foundry 会很爽;如果做项目交付、要维护部署记录、要让团队里不熟悉底层的人也能通过命令跑通,Hardhat 2 的生态会让你省心很多。这篇文章里我不会把话说死,但后面所有操作都按 Hardhat 2 来演示。
2. 部署安装 Hardhat 2 之前,先把环境准备好
2.1 版本选择:Node.js 到底装哪个
Hardhat 2 是 npm 包,所以你得先有 Node.js。这里不是随便装一个能跑就行,版本很关键。Hardhat 2 当前要求 Node.js 18 及以上,我平时固定用 20 LTS,原因是 LTS 版本的生态兼容性最好,各种原生模块都有预编译二进制,npm 安装时不容易触发 node-gyp 现场编译。不建议用奇数版本如 Node 21、23,短命版本往往伴随原生依赖跟不上;更建议用 nvm 来管理 Node。nvm 的全称是 Node Version Manager,macOS 和 Linux 下安装后可以nvm install 20、nvm use 20;Windows 上则用 nvm-windows,命令类似。
用 nvm 还有一个额外好处:切换 Node 版本时不会像系统级安装那样留下全局路径残留,那些残留经常导致hardhat: command not found。检查环境时先跑两个命令:node -v看 Node 版本,npm -v看 npm 版本。如果 npm 版本太旧,执行npm install -g npm@latest升级。我不推荐用sudo全局装 npm 包,权限问题在 Linux/macOS 上很常见,用 nvm 管理用户级环境就没有这层烦恼。
2.2 npm 依赖加速与网络受限的处理
安装 Hardhat 2 需要下载一堆 npm 包,网络不好时会很慢,甚至装到一半卡住。常规做法是把 registry 切到 npmmirror,这是目前比较常用且口碑稳定的镜像站,设置命令是:
npm config set registry https://registry.npmmirror.com设置完可以npm config get registry确认。如果你只想让某个项目使用镜像,不污染全局配置,可以在项目根目录新建.npmrc,内容写registry=https://registry.npmmirror.com。注意不要随意使用不明来源的 registry,安全风险比速度问题更严重。安装完成、要发布自己的项目时,可以改回官方源:npm config set registry https://registry.npmjs.org。
网络没问题的开发者也别急着跳过这一步。我的经验是,即使网络正常,使用镜像也能显著减少npm install的失败概率,尤其是在 CI 环境或公司内网里,先配置好 registry 能省去很多玄学问题。npm 还支持缓存,加--prefer-offline参数在某些场景也能加速,但治标不治本。
2.3 除了 Node.js,还要不要装别的
严格来说,装好 Node.js 和 npm 就够安装 Hardhat 2 了。但实际开发中,我建议顺手装好 Git。原因有两个:一是很多小型依赖直接通过 Git 仓库安装,不装 Git 会报错;二是 Hardhat 初始化向导会帮你创建 Git 仓库,如果你本来就要做版本管理,早晚要用到。
Windows 用户还要注意 Visual Studio Build Tools。如果你只部署合约、不碰原生模块,可能一直没事;但一旦某个依赖需要从源码编译,就会看到 node-gyp 报错。提前在 Visual Studio Installer 中安装“使用 C++ 的桌面开发”工作负载,后面会少很多麻烦。macOS 用户则建议先执行xcode-select --install装好 Command Line Tools。这些内容看起来和 Hardhat 没有直接关系,但我在多个群里见过新人卡在环境编译上,几乎都是这些根因。
3. 开始安装 Hardhat 2 并初始化项目
3.1 用 npm 把 Hardhat 2 装进项目
新建目录后,在终端里进入目录,执行npm init -y生成一个默认的 package.json。然后安装 Hardhat 2 本体:
npm install --save-dev hardhat@2这里我特意加了--save-dev,因为 Hardhat 只服务于开发流程,不应该出现在生产依赖里。加了@2是避免不小心装到 v3 预览版;如果你的环境已经存在旧版本,还可以在安装后执行npx hardhat --version确认版本。注意,第一次运行 npx 时,如果 Hardhat 刚装完,npx 会提示是否安装 hardhat 包,输入 y 即可。也可以直接node_modules/.bin/hardhat --version这种路径方式运行,避免 npx 下载误判。
装完后项目里会出现 node_modules 目录,package.json 中 devDependencies 会增加"hardhat": "^2.x.x"。看到这条记录,就说明安装成功。
3.2 使用初始化向导创建项目骨架
执行npx hardhat init,较新的 Hardhat 2 版本会弹出项目类型选择:JavaScript、TypeScript 还是创建空配置。如果你是完全新手,选 JavaScript,先跑通流程再说;如果想长期维护复杂项目,选 TypeScript 会更稳,但你要额外处理 ts-node 和类型定义。确定类型后,向导还会问是否安装@nomicfoundation/hardhat-toolbox等依赖,通常选 Yes;再问是否初始化 Git 仓库,可以根据自己需要来。
如果你执行npx hardhat init没有出现向导,而是打印了任务列表,可能是命令被 npx 解析到了别的版本,或者终端不支持交互式输入。解决办法是显式指定本地命令:npx hardhat init,或者node_modules/.bin/hardhat init。初始化完成后,目录下会有 contracts、scripts、test、hardhat.config.js(或 .ts)等文件。默认还会生成一个 Lock.sol 示例合约,以及配套的测试和部署脚本,这个模板非常适合入门,建议先别删,就拿它验证环境。
3.3 安装插件并理解 hardhat.config.js 的作用
Hardhat 2 的插件是按需加载的。最省事的方式是安装@nomicfoundation/hardhat-toolbox:
npm install --save-dev @nomicfoundation/hardhat-toolbox它把 ethers、chai matchers、network helpers、verify 等常用能力打包在一起,避免我们自己处理版本配对。安装后必须在hardhat.config.js顶部加一行require("@nomicfoundation/hardhat-toolbox");,不然运行任何命令都会报模块不存在。config 文件里最常见的配置项是solidity和networks。比如:
require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.24", networks: { hardhat: { chainId: 1337, }, }, };solidity指定编译器版本,既可以是字符串,也可以是对象,用来配置多个版本、开启优化器。networks用于注册不同的链,默认内置hardhat网络。理解 config 文件的意义在于,Hardhat 的所有命令最终都会读取这份配置,脚本里用到hre时,网络、账户、编译器信息都来自这里。
4. 写合约、编译、部署的完整实操
4.1 编写自己的第一个示例合约
为了不依赖模板示例,我习惯写一个最简单的 Storage 合约。新建contracts/Storage.sol:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract Storage { uint256 data; function store(uint256 _data) external { data = _data; } function retrieve() external view returns (uint256) { return data; } }这里有三个细节。第一行 SPDX 许可声明一定要写,否则编译会警告。pragma solidity ^0.8.24要和 config 里的版本兼容,^表示允许高于 0.8.24、但低于 0.9.0 的版本。写完后运行:
npx hardhat compile第一次编译会比较慢,因为要下载对应版本的 solc 编译器;之后增量编译会快很多。编译完成后,项目里会多出artifacts和cache目录。cache 是 Hardhat 自己的缓存,可以随时删除;artifacts 里每个合约的 JSON 文件包含 ABI 和字节码,前端集成或者部署脚本都可能要读它。
4.2 部署脚本的正确打开方式
在scripts/deploy.js里写部署逻辑。Hardhat 2 官方推荐使用ethers来操作合约,脚本内容如下:
const { ethers } = require("hardhat"); async function main() { const Storage = await ethers.getContractFactory("Storage"); const storage = await Storage.deploy(); await storage.waitForDeployment(); console.log("Storage deployed to:", storage.target); } main().catch((error) => { console.error(error); process.exitCode = 1; });使用main()包裹是官方惯例,process.exitCode = 1确保异步出错时进程返回非零状态,CI 里会用到。这里要特别提醒:在 ethers 6 版本里,部署后的合约地址是storage.target,不是旧版本的storage.address。如果你在网上找到的教程用的是await storage.deployed()或storage.address,先确认它对应的 ethers 版本。Hardhat 2 搭配的默认 ethers 是 v6,照 v5 教程写大概率报错。
执行部署:
npx hardhat run scripts/deploy.js这条命令默认跑在hardhat网络上,也就是临时内存链。它每次执行都从一个空链开始,交易立刻被打包,非常适合快速验证。脚本输出的地址是随机生成的,因为每次都是全新链。
4.3 启动本地节点并手动验证部署
前面那种跑完就消失的方式不利于手工交互。真实工作里,我经常先用npx hardhat node启动一个常驻的本地节点。这个节点默认端口是 8545,启动后会打印 20 个测试账户地址和对应私钥。注意,此时终端会被节点进程占用,需要再开一个新终端执行后续命令。
部署到这个常驻本地节点,命令要加网络参数:
npx hardhat run scripts/deploy.js --network localhost部署完成后,在新终端里打开一个 Node REPL 或者写一个简单的交互脚本,通过合约地址调用store(42)和retrieve()。本地节点的日志会显示交易哈希、gas 消耗以及调用详情。这一步虽然繁琐,但能帮助你直观理解部署交易和调用交易的区别:部署交易创建了新合约,调用交易改变了合约状态。实测下来,用 MetaMask 连接http://127.0.0.1:8545,再配合 Hardhat 的 console 合约,可以在前端里实现一整套本地 DApp 交互。
5. 部署到测试网与真实链前的关键细节
5.1 自定义网络配置怎么做
本地节点跑通后,下一步是部署到公共测试网。不同链的网络参数都不相同,但只要走 JSON-RPC,配置方式都类似。在hardhat.config.js里加一个网络:
require("dotenv").config(); require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.24", networks: { sepolia: { url: process.env.SEPOLIA_RPC_URL, accounts: [process.env.PRIVATE_KEY], chainId: 11155111, }, }, };url指向 Sepolia 测试网的 RPC 地址,可以来自 Infura、Alchemy,也可以使用公共 RPC。accounts是部署时用来签名的私钥数组,流程上通常只用一个账户。配置完成后,执行:
npx hardhat run scripts/deploy.js --network sepolia部署前必须确保账户里有测试币,否则会报余额不足。Sepolia 测试币需要通过水龙头领取,有的水龙头会要求你提供测试网地址和社交认证。注意每个水龙头有每日额度,领取间隔可能很长,所以不要反复刷。
5.2 gas 费用和部署验证的实操心得
部署测试网和本地最大的区别是,每笔交易都要付 gas。如果交易一直 pending,常见原因是 gas 设置过低或 RPC 节点延迟。Hardhat 2 在处理网络配置时,默认会使用节点建议的 gas 价格;如果仍想手动干预,可以在部署脚本里对合约工厂交易手动传 gas,或者在全网络配置里设置gasPrice/gas。不过我不建议把 gas 写死,因为网络拥堵时写死 gas 很容易导致交易卡住。
部署成功后,建议做源码验证。传统方式是使用@nomiclabs/hardhat-etherscan,新版本推荐@nomicfoundation/hardhat-verify(Toolbox 里已经包含)。执行:
npx hardhat verify --network sepolia <合约地址>验证前需要在 config 里配置区块浏览器的 API Key,环境变量名通常是ETHERSCAN_API_KEY。源码验证的意义在于,合约代码公开、和链上字节码匹配,用户才敢相信它没有隐性后门。这一点在测试网阶段就养成习惯,等部署主网时能省掉很多信任成本。
5.3 私钥安全:必须养成的习惯
部署到真实链前,私钥安全是我最想强调的一点。很多新手图方便,把私钥直接写在hardhat.config.js里,然后项目推到 GitHub,整个仓库瞬间变成提款机。正确做法是安装dotenv:
npm install dotenv在 config 顶部引入后,从.env文件读取私钥。同时把.env写进.gitignore。仓库里可以保留一个.env.example,里面只放占位符,说明需要哪些字段。还有一个建议:使用单独生成的测试网私钥,不要拿主网有钱的账户私钥去测试网测试,更不要复制到本地环境。如果私钥泄露,第一时间把账户资金转走,那比补救任何配置都要紧急。
6. 常见问题与排查技巧实录
6.1 npm 安装阶段的典型坑
前面提过 node-gyp 编译问题,这里再展开说。报错信息通常是gyp ERR! stack Error: EPERM: operation not permitted或node-gyp rebuild failed。Windows 上一般是 Build Tools 缺失;macOS 上是 Command Line Tools 未装。解决后,把node_modules和package-lock.json删除,重新安装。npm 缓存也可能导致问题,可以先npm cache clean --force,但如果只是版本冲突,清理缓存不一定有效。
网络导致的安装失败,特征是下载速度极慢,或者卡在reify阶段。这时优先检查 registry 是否设成了镜像,然后用npm install --loglevel verbose看具体卡在哪个包。如果某个包反复下载失败,也可以单独确认它是否有镜像缓存。整体来说,保持 Node 版本在 20 LTS、registry 指向 npmmirror、避免在目录名称里带空格和中文,能规避大部分环境问题。
6.2 运行 Hardhat 命令时的报错对照
| 报错关键词 | 可能原因 | 解决思路 |
|---|---|---|
| HH1: Cannot find module | plugin 未安装或未 require | 安装对应插件,在 config 顶部 require |
TypeError: storage.deployed is not a function | ethers v6 旧 API 残留 | 使用 ethers v6 的waitForDeployment()target |
ProviderError: transaction underpriced | gas 低于网络最低值 | 提高 gas 或让节点自动估算 |
Nonce too low | 本地签名 nonce 和链上不同步 | 检查 RPC 是否稳定,或清理缓存重试 |
Failed to connect to localhost:8545 | 使用--network localhost但未启动节点 | 先运行npx hardhat node |
ReferenceError: __dirname is not defined | TypeScript 配置或构建问题 | 在 TS 项目中使用 ESM 规范或改用path处理 |
这张表是我平时排查问题的速查,不一定覆盖所有冷门情况,但对照着能很快定位方向。如果遇到看不明白的报错,最有效的办法是加参数重跑:npx hardhat --verbose,或者查看cache和artifacts有没有残留的旧编译结果。
6.3 我踩过的最值钱的三个坑
第一个坑是 ethers 版本切换。我从 ethers 5 项目迁移到 Hardhat 2 时,部署脚本里沿用getSigners()的返回值类型,结果无法直接当作provider使用。后来才意识到,Hardhat 2 的 Toolbox 默认注入的是 ethers 6,两个版本里Contract对象、事件监听接口都有差异。建议新工程一律按 ethers 6 写,旧工程迁移时集中改个遍,不要混用。
第二个坑是多账户部署顺序。有一次部署一组交互合约,需要账户 A 先部署 Factory,再由账户 B 调用 Factory 创建实例。我在测试脚本里直接用hardhat网络的默认第一个账户签名,结果因为hre.network.provider.send方法使用不当,签名账户和预期不一致,导致权限校验失败。后来统一通过ethers.getSigners()获取账户,并在部署参数中显式传递signer,问题才解决。
第三个坑是 fork 主网状态时 RPC 限流。Hardhat Network 可以把主网状态 fork 到本地,但如果 RPC 服务商限流很严格,npx hardhat node --fork https://...会频繁断连。后来我在 config 里给 hardhat 网络设置forking: { url: ..., blockNumber: ... },锁定了历史区块高度,尽量只读取状态,减少了大量重复请求。如果你遇到 fork 后状态查询时好时坏,优先检查 RPC 限流和区块高度是否被锁定。
7. Hardhat 2 的日常进阶:测试、多网络与个人心得
7.1 测试和覆盖率要接着部署一起搭
很多新手部署完合约就以为大功告成,但我强烈建议在项目早期就把测试框架搭好。Hardhat 2 配合 Toolbox,测试代码可以直接使用chai的断言和loadFixture。在test/下新建测试文件:
const { expect } = require("chai"); describe("Storage", function () { it("should store and retrieve", async function () { const Storage = await ethers.getContractFactory("Storage"); const storage = await Storage.deploy(); await storage.waitForDeployment(); await storage.store(42); expect(await storage.retrieve()).to.equal(42); }); });运行npx hardhat test,Hardhat 会自动为每个测试用例创建独立的内存链,测试之间不会互相污染。覆盖率可以用solidity-coverage插件,跑完会生成一份报告,告诉你哪些分支还没测到。测试不是为了应付指标,而是让合约在部署前能承受各种边界输入,比上线后补 bug 省力太多。
7.2 多网络部署用脚本参数和任务来组织
当一个项目要部署到本地、测试网、主网多个环境时,硬编码地址会乱套。Hardhat 2 的run命令天然支持--network参数,部署脚本里可以读取hre.network.name来判断当前网络。更工程化的做法是引入 hardhat-deploy 插件,它把部署脚本组织成带依赖关系、标签和部署历史的流程,每次部署后会把地址写进 deployments 目录,方便回放和对比。不过要用它之前,先确认和当前 ethers 版本的兼容性,避免又掉进插件矩阵冲突的坑。
我之前在项目里维护了两个部署脚本:deploy_local.js和deploy_sepolia.js,后来改成同一个脚本按network.name分发参数,逻辑更干净。部署参数比如合约构造函数参数、初始 owner 地址,统一从环境变量读取,避免在代码里出现“测试网地址和主网地址混淆”这种低级事故。
7.3 我的最后一个建议
说了这么多,其实我最想传达的是:Hardhat 2 只是一个工具,价值在于它帮你把“能跑通”变成“可维护、可复现、可审计”。不要只停留在npx hardhat run跑完就结束,要主动去读artifacts里的 ABI、去看 node 日志、去理解部署脚本里每一步在签什么类型的交易。等到某一天你遇到一个新网络,能够不看教程也能自己写出对应的 networks 配置时,就说明你真的会用这个框架了。如果你刚开始学,建议从默认模板开始,先跑通一遍编译、测试、部署、验证;遇到报错不要慌,对照上面这些问题排查思路逐步定位。踩坑本身就是学习过程,我的经验只能帮你绕开其中一部分,剩下的坑还得自己踩过才算长记性。