迁移到Hardhat 3 - 第一部分:初始配置与测试迁移
本文是1inch团队从Hardhat v2迁移到Hardhat 3的系列第一部分,详细介绍了初始配置和测试迁移的实践。
1. 引言
1.1 我们的历程:Hardhat 2 → Forge → Hardhat 3
在 1inch,我们在以太坊开发工具方面经历了不少变化。
大约两年前,我们从 Hardhat v2 迁移到了 Forge (Foundry),因为它支持用 Solidity 编写测试。能够用与合约相同的语言编写测试,这对于测试复杂的 DeFi 协议来说似乎是一个显著的优势。
然而,Forge 在实践中给我们带来了相当大的痛苦。虽然它在某些方面很强大,但整体体验并不适合我们的工作流程。
当 Hardhat 3 发布时,我们看到了回归的机会。Hardhat 3 现在支持:
- 原生 Solidity 测试 —— 最初吸引我们使用 Forge 的功能
- 模糊测试 —— 基于随机输入的自动化属性测试
- 不变性测试 —— 系统不变量的持续验证
这些新增功能,加上我们对 Hardhat 工具链的熟悉,以及我们已经围绕它构建了 TypeScript 基础设施的事实,使回归成为一个切实可行的选择。
本系列文章记录了我们的迁移经验,分享了我们遇到的挑战和开发的解决方案,以便其他开发人员能够避免同样的陷阱。
1.2 被迁移的项目
对于这次迁移,我们选择了 Merkle Distribution 项目。它是一个用于生成和管理代币空投的开源工具。我们选择这个项目是专门因为它是一个独立的项目,不依赖于其他协议(如 1inch Aggregator 或 Limit Orders)。这种独立性使其成为测试迁移过程的安全候选,而不会破坏我们整个代码库的依赖关系。
技术栈: 该项目使用 Solidity 编写智能合约,使用 TypeScript 编写工具和测试,使用 Hardhat v2 作为开发框架,外加 ESLint 用于 TypeScript 代码检查,Solhint 用于 Solidity 代码检查。
该项目将 Hardhat 用于三个主要目的:
测试: 我们对 merkle drop 合约进行智能合约测试,并对 TypeScript 服务进行单元测试和集成测试。测试套件包括共享行为模块——这是一种将通用测试逻辑提取为可重用函数的模式。
部署: 合约可以使用 hardhat-deploy 部署到多个网络上。每个部署都有版本号,并且我们积累了需要保留的部署产物。
通过任务管理合约: 我们构建了自定义的 Hardhat 任务来管理完整的空投生命周期:生成领取链接、部署合约、在区块浏览器上验证部署、检查领取统计数据以及回收未领取的代币。
这次迁移的目标是以最小的努力将所有功能迁移到 Hardhat 3。
1.3 系列文章概览
本迁移指南分为三个部分:
在第一部分中,我们将介绍配置文件更改和测试迁移,包括我们在过程中遇到的一些不明显的问题。
2. 配置更改
2.1 迁移到 ES Modules
Hardhat 3 基于 ES modules(请参阅 Hardhat 文档),因此第一步是将项目配置为 ESM。
package.json:
{
+ "type": "module",
...
}
即使在 TypeScript 项目中,也需要此设置。当你通过 ts-node 运行代码或 TypeScript 编译为 JS 时,Node.js 使用此标志来确定如何处理模块的导入/导出。如果没有此标志,ts-node 将默认采用 CommonJS 行为。
tsconfig.json:
{
"compilerOptions": {
- "module": "nodenext",
- "moduleResolution": "nodenext",
+ "module": "esnext",
+ "moduleResolution": "bundler",
},
+ "ts-node": {
+ "esm": true,
+ "experimentalSpecifierResolution": "node",
+ "transpileOnly": true
+ }
}
module 和 moduleResolution 的更改:
module: "esnext"- 输出标准的 ES 模块。nodenext与 Node.js 特定的 ESM 特性绑定;esnext与 Hardhat 3 的模块系统更兼容。moduleResolution: "bundler"- 这种较新的解析策略支持现代 package.json 的exports字段,Hardhat 3 的包使用了这些字段。旧的nodenext解析方式有更严格的要求,与 Hardhat 3 导出模块的方式配合不佳。
ts-node 部分允许直接以 ESM 方式运行 TypeScript 文件:
esm: true- 启用 ES 模块模式experimentalSpecifierResolution: "node"- 允许 Node.js 风格的导入(例如导入包含 index 文件的目录)transpileOnly: true- 通过跳过类型检查来加快编译速度
额外的 tsconfig.json 更改:
你可能还需要更新 types 和 exclude 部分:
{
"compilerOptions": {
- "types": ["node"],
+ "types": ["node", "chai", "hardhat", "ethers"],
},
"exclude": [
"node_modules",
"dist",
- "test",
- "**/*.test.ts"
],
}
- types: 在 Hardhat 2 with CommonJS 中,类型声明通过模块解析自动发现——当你
import 'hardhat'时。添加显式的"hardhat"和"ethers"条目可确保 TypeScript 能够找到类型声明。 - exclude: 如果你之前排除了测试文件,请将其从
exclude中移除。否则 VS Code 将无法识别测试中的 Hardhat 对象,并显示编译错误——尽管测试实际上通过 ts-node 正确运行。
2.2 依赖更新
Hardhat 3 生态系统使用了一套新的包。在 package.json 中,你需要移除旧的 Hardhat 2 开发依赖并添加新的依赖:
"devDependencies": {
- "hardhat": "2.26.1",
+ "hardhat": "3.0.15",
- "@nomicfoundation/hardhat-chai-matchers": "2.1.0",
- "@nomicfoundation/hardhat-ethers": "3.1.0",
- "@nomicfoundation/hardhat-verify": "2.1.0",
- "hardhat-dependency-compiler": "1.2.1",
- "hardhat-gas-reporter": "2.3.0",
- "solidity-coverage": "0.8.16",
+ "@nomicfoundation/hardhat-ethers": "^4.0.3",
+ "@nomicfoundation/hardhat-ethers-chai-matchers": "^3.0.2",
+ "@nomicfoundation/hardhat-ignition": "^3.0.5",
+ "@nomicfoundation/hardhat-ignition-ethers": "^3.0.5",
+ "@nomicfoundation/hardhat-keystore": "^3.0.3",
+ "@nomicfoundation/hardhat-mocha": "^3.0.7",
+ "@nomicfoundation/hardhat-network-helpers": "^3.0.3",
+ "@nomicfoundation/hardhat-toolbox-mocha-ethers": "3.0.1",
+ "@nomicfoundation/hardhat-typechain": "^3.0.1",
+ "@nomicfoundation/hardhat-verify": "^3.0.7",
+ "@nomicfoundation/ignition-core": "^3.0.5",
- "chai": "4.5.0",
+ "chai": "5.1.2",
// ... other dependencies
}
请注意,一些包如 hardhat-deploy、hardhat-dependency-compiler 等不再与 Hardhat 3 兼容。以下是我们在迁移中使用的 Hardhat 3 替代品列表。
| HH2 插件 | HH3 状态 / 替代品 |
|---|---|
| hardhat-deploy | 使用内置部署引擎 Hardhat Ignition |
| hardhat-dependency-compiler | 使用内置配置参数 npmFilesToBuild |
| hardhat-gas-reporter | 使用 HH3 内置的 Gas 统计功能 |
| solidity-coverage | 没有官方 HH3 兼容版本;迁移时移除 |
| hardhat-tracer | 不支持 HH3,迁移时移除 |
关于 yarn 的说明: Yarn 默认不会自动安装 peer 依赖。你可能需要手动添加其他包作为 peer 要求的一些依赖——请检查安装警告并显式添加它们。
3. Hardhat 配置迁移
Hardhat 3 引入了新的配置格式(请参阅 配置参考)。主要变化有:
defineConfig()函数取代了纯对象导出- 插件现在作为 ES modules 导入,并在
plugins数组中显式注册 - 副作用导入(
import 'plugin-name')被替换为命名导入
3.1 配置代码更改
在 Hardhat 3 中,插件必须在 plugins 数组中显式注册。如果某个插件未在其中列出,则尝试从 hardhat 运行时环境访问它提供的对象和函数时,它们将为 undefined。这是迁移过程中常见的错误来源——如果之前能用的东西现在变成了 undefined,请检查其插件是否已注册。
之前的配置(Hardhat 2):
import '@nomicfoundation/hardhat-verify';
import '@nomicfoundation/hardhat-ethers';
import '@nomicfoundation/hardhat-chai-matchers';
import 'hardhat-dependency-compiler';
import { HardhatUserConfig } from 'hardhat/config';
const config: HardhatUserConfig = {
solidity: { ... },
networks: { ... },
};
export default config;
之后的配置(Hardhat 3):
import { defineConfig } from 'hardhat/config';
import type { HardhatPlugin } from 'hardhat/types/plugins';
import hardhatEthers from '@nomicfoundation/hardhat-ethers';
import hardhatToolboxMochaEthers from '@nomicfoundation/hardhat-toolbox-mocha-ethers';
import hardhatEthersChaiMatchers from '@nomicfoundation/hardhat-ethers-chai-matchers';
import hardhatNetworkHelpers from '@nomicfoundation/hardhat-network-helpers';
const plugins: HardhatPlugin[] = [
hardhatNetworkHelpers,
hardhatEthers,
hardhatToolboxMochaEthers,
hardhatEthersChaiMatchers,
];
export default defineConfig({
plugins,
solidity: { ... },
});
3.2 使用外部库合约
随着 hardhat-dependency-compiler 的消失,Hardhat 3 提供了一种原生方式来通过 npmFilesToBuild 配置选项编译来自 npm 包的合约(请参阅 NPM 产物文档):
export default defineConfig({
plugins,
solidity: {
version: '0.8.23',
settings: { ... },
+ npmFilesToBuild: ["@1inch/solidity-utils/contracts/mocks/TokenMock.sol"],
},
- dependencyCompiler: {
- paths: ['@1inch/solidity-utils/contracts/mocks/TokenMock.sol'],
- },
});
如果你没有将外部合约添加到 npmFilesToBuild,它们将不会被编译,也不会在测试中可用。
4. 测试迁移
4.1 导入更改
在 Hardhat 3 中,你不再需要从 hardhat 或插件包直接导入 ethers、loadFixture 等。相反,你调用 network.connect(),它会创建一个到本地区块链模拟的连接并返回关联的对象(请参阅 Hardhat 测试文档)。
你可以在模块级别(文件内所有测试共享)或在每个 describe 块内部(每个 describe 拥有单独连接)调用 network.connect()。如果在每个 describe 中调用,每个 describe 都会获得一个具有独立状态的区块链模拟。
- import '@nomicfoundation/hardhat-chai-matchers';
- import { loadFixture } from '@nomicfoundation/hardhat-network-helpers';
- import { Contract, Signer } from 'ethers';
-
- const hre = require('hardhat');
- const { ethers } = hre;
+ import { expect } from 'chai';
+ import type { Contract, Signer } from 'ethers';
+ import { network } from 'hardhat';
+
+ const { ethers, networkHelpers } = await network.connect();
+ const { loadFixture } = networkHelpers;
关键更改:
- 不再有副作用插件导入(
import '@nomicfoundation/...') ethers来自network.connect(),而不是来自require('hardhat')loadFixture来自network.connect()返回的networkHelperschai可以使用命名导出直接导入
关于仅类型导入的说明: 当你只将导入用于类型注解(例如使用
Contract对变量进行类型标注)时,请使用import type而不是import。这告诉 TypeScript 不要在 JavaScript 输出中发出该导入,从而避免在 ESM 模式下当模块的导出与 Node.js 期望不符时出现潜在运行时错误。
4.2 loadFixture 与模块化测试(注意!)
这是一个微妙的问题,花费了我们大量的调试时间。当将测试组织到单独的行为模块中时,你可能会遇到 loadFixture 无法在测试之间正确重置的问题。
症状: 测试在单个文件中定义时可以正常工作,但当行为函数被提取到单独的模块中时失败。fixture 似乎只运行一次,而不是为每个测试重置。
根本原因: 在 ESM 中,每个模块在顶层调用 await network.connect() 都会获得自己独立的区块链模拟实例。如果行为模块有自己的 network.connect() 调用,它将使用与测试文件不同的 loadFixture 实例——而这些实例不会协调它们的快照/恢复机制。
失败的情况(分离的模块):
// behavior.ts
import { network } from 'hardhat';
const { networkHelpers } = await network.connect(); // ⚠️ 行为模块的网络实例
const { loadFixture } = networkHelpers;
export function shouldBehaveLikeDrop() {
// 使用来自此模块网络的 loadFixture - 错误的上下文
}
// test.ts
import { shouldBehaveLikeDrop } from './behavior';
import { network } from 'hardhat';
const { networkHelpers } = await network.connect(); // ⚠️ 测试网络实例
// 测试失败 - fixture 没有正确重置
shouldBehaveLikeDrop();
解决方案: 从测试文件将 loadFixture 传递给行为模块,确保单一数据源:
// behavior.ts
export function shouldBehaveLikeDrop({ loadFixture }) {
// 使用从调用者传递的 loadFixture
}
// test.ts
import { shouldBehaveLikeDrop } from './behavior';
const { networkHelpers } = await network.connect();
const { loadFixture } = networkHelpers;
shouldBehaveLikeDrop({ loadFixture }); // 传递 loadFixture
4.3 Chai Matchers 现在需要 ethers 参数
在 Hardhat 3 中,一些 chai matchers 的签名已更新。由于 Hardhat 3 支持多个连接(每个连接有自己的 ethers 实例),你现在必须将 ethers 作为第一个参数传递给与余额相关的 matchers(受影响 matchers 的完整列表):
// changeEtherBalance
- await expect(tx).to.changeEtherBalance(sender, -1000);
+ await expect(tx).to.changeEtherBalance(ethers, sender, -1000);
// changeTokenBalance
- await expect(tx).to.changeTokenBalance(token, sender, -1000);
+ await expect(tx).to.changeTokenBalance(ethers, token, sender, -1000);
请确保传递正确的 ethers 实例——即与你的测试使用的同一个 network.connect() 调用返回的实例。如果你传递来自不同连接(例如从具有自己 network.connect() 的行为模块中导入的)的 ethers,matcher 将查询不同的区块链状态并产生错误的结果或令人费解的错误。
4.4 遇到 HHE100 错误?检查你的 await 语句
在迁移过程中,我们遇到了一个神秘的错误,该错误在测试已经通过后才出现:
Unhandled promise rejection:
HardhatError: HHE100: An internal invariant was violated: The block doesn't exist
错误消息并没有指向实际的问题。经过调查,我们发现这是由于现有测试中缺少 await 导致的:
- expect(txn).to.changeEtherBalance(ethers, alice, 10);
+ await expect(txn).to.changeEtherBalance(ethers, alice, 10);
这个测试在 Hardhat 2 中(巧合地)可以工作,但在 Hardhat 3 中会失败。如果你看到 HHE100,请检查是否有缺少的 await 语句——尤其是在 chai matchers 如 .to.changeEtherBalance、.to.emit 和 .to.be.reverted 上。
4.5 合约部署更改
在 Hardhat 2 中,deployContract 是 ethers.js 中 ContractFactory 的一个辅助函数。在 Hardhat 3 中,它由 hardhat-ethers 插件提供,位于从 network.connect() 返回的 ethers 对象上(请参阅 部署文档):
const { ethers } = await network.connect();
const contract = await ethers.deployContract("MyContract", [constructorArg]);
5. ESLint 配置
随着 package.json 中设置了 "type": "module",Node.js 默认将 .js 文件视为 ES modules。然而,ESLint 的 flat config 和许多 ESLint 插件仍然依赖于 CommonJS(require()、module.exports)。
最简单的解决方案是将你的 ESLint 配置文件重命名为使用 .cjs 扩展名:
- eslint.config.js
+ eslint.config.cjs
.cjs 扩展名强制 Node.js 将该文件视为 CommonJS,无论包的 type 设置如何。你的配置可以继续使用 require() 和 module.exports。
不要忘记更新 eslint.config.cjs 配置中的全局忽略项以匹配新的文件名:
{
ignores: [
'node_modules/**',
- 'eslint.config.js'
+ 'eslint.config.cjs'
]
}
6. 下一步
在本第一部分中,我们介绍了迁移到 Hardhat 3 的基础:
- ES 模块配置(package.json、tsconfig.json)
- 依赖更新和插件更改
- Hardhat 配置文件迁移
- 测试文件导入更改和常见陷阱
- ESLint 配置以适应 ESM
在 第二部分:部署迁移 中,我们将介绍如何替换 hardhat-deploy(与 Hardhat 3 不兼容)并迁移到 Hardhat Ignition。
在 第三部分:任务迁移 中,我们将介绍如何迁移现有的自定义 Hardhat 任务。
- 原文链接: github.com/1inch/merkle-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~