☰
从零搭建Hardhat 2:以太坊智能合约开发环境与部署实战
2026/10/7 3:05:32 网站建设 项目流程

我最早跑合约开发环境,用的还是 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 URLChain ID
Hardhat Networkhttp://127.0.0.1:854531337
Ganache CLI 默认http://127.0.0.1:85451337
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'

遇到这类问题,我的排查顺序是:

  1. 先看当前目录是不是项目根目录,有没有hardhat.config.js。
  2. 检查node_modules/hardhat是否存在。
  3. 删除node_modules后重新执行npm ci,不要用npm install。
  4. 检查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上做一次从部署到交互的手工冒烟,确认无误后才会上测试网。这套路径看起来多花几分钟,却帮我拦住了至少十次到测试网才发现的低级错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询