本文是Hardhat 2迁移到Hardhat 3系列第三部分,详细介绍了自定义任务的迁移。内容包括任务定义语法的变化(使用builder模式、显式注册)、参数定义(从addParam到addOption,支持类型和短名)、CLI参数映射、内联与懒加载两种动作模式、网络访问方式变化、结构化返回值的使用,以及如何手动读取Ignition部署工件(如deployed_addresses.json和journal.jsonl)。文章通过对比代码示例,展示了迁移前后的差异,并指出了新框架的改进和痛点。
在 第一部分 中,我们介绍了迁移到 Hardhat 3 的基础知识:基本配置变更和测试迁移。在 第二部分 中,我们用 Hardhat Ignition 替换了 hardhat-deploy。
在最后一部分,我们将介绍自定义 Hardhat 任务的迁移——这是迁移至 Hardhat 3 的最后一块拼图。我们的项目中有几个自定义任务负责管理完整的空投生命周期:生成申领链接、部署合约、收集链上统计数据,以及回收未申领的代币。
我们将介绍:
在 Hardhat 3 中,任务的定义方式发生了重大变化。任务使用构建器构建,并且必须在配置中显式注册。参数是带有类型定义的结构化对象。动作可以从单独的模块中惰性加载。
以下是我们的空投任务在迁移前后的对比。
之前(Hardhat 2):
import { task } from 'hardhat/config';
import { dropTask } from './src/tasks/hardhat-drop-task';
task('drop', '生成默克尔空投链接、部署合约并验证所有生成的申领链接')
.addParam('v', '部署版本')
.addParam('a', '要生成的金额')
.addParam('n', '要生成的代码数')
.addFlag('debug', '调试模式')
.setAction(async (taskArgs, hre) => {
await dropTask(hre, taskArgs);
});
export default { solidity: {...}, networks: {...} };
之后(Hardhat 3):
import { defineConfig, task } from 'hardhat/config';
import { ArgumentType } from 'hardhat/types/arguments';
const drop = task('drop', '生成默克尔空投链接、部署合约并验证所有生成的申领链接')
.addOption({
name: 'ver',
shortName: 'v',
description: '部署版本(默认为 .latest + 1)',
defaultValue: 0,
type: ArgumentType.INT,
})
.addOption({
name: 'amounts',
shortName: 'a',
description: '要生成的空投金额',
defaultValue: 'not set',
type: ArgumentType.STRING,
})
.addOption({
name: 'numbers',
shortName: 'n',
description: '要生成的代码数量',
defaultValue: 'not set',
type: ArgumentType.STRING,
})
.addFlag({
name: 'debug',
description: '调试模式',
})
.setAction(() => import('./src/tasks/drop'))
.build();
export default defineConfig({
// ...
tasks: [drop],
// ...
});
关键区别:
.build() 的构建器模式 – task() 返回一个构建器。你链式调用方法,并以 .build() 结束,这会返回一个任务对象。如果忘了 .build(),任务根本不会被注册。defineConfig({ tasks: [...] })。不再有副作用注册。.addParam('v', 'desc') 变成了 .addOption({ name, shortName, description, defaultValue, type })。参数现在通过 ArgumentType 枚举进行类型化,并且原生支持短名称(详见第 4 节)。.setAction() 接受一个模块导入,而不是内联函数(下一节将详细介绍)。Hardhat 3 支持两种定义任务动作的模式(详见 文档和对比)。
内联动作 – 直接定义函数:
const myTask = task('my-task', '做某事')
.setInlineAction(async (args, hre) => {
const conn = await hre.network.connect();
console.log('已连接到', conn.networkName);
return successfulResult(true);
})
.build();
这适用于简单任务,但会将逻辑放在 hardhat.config.ts 中。
模块动作(惰性加载) – 指向一个模块:
const drop = task('drop', '生成默克尔空投链接...')
.setAction(() => import('./src/tasks/drop'))
.build();
该模块必须导出一个默认函数,其签名为 (args, hre) => Promise<TaskResult>:
// src/tasks/drop.ts
import { HardhatRuntimeEnvironment } from 'hardhat/types/hre';
import { successfulResult, errorResult } from 'hardhat/utils/result';
interface DropTaskArguments {
ver: number;
amounts: string;
numbers: string;
debug: boolean;
}
export default async function (
args: DropTaskArguments,
hre: HardhatRuntimeEnvironment,
) {
if (args.amounts === 'not set' || args.numbers === 'not set') {
return errorResult(new Error('缺少必需参数'));
}
const conn = await hre.network.connect();
const chainId = conn.networkConfig.chainId ?? 31337;
// ... 任务逻辑 ...
return successfulResult<boolean>(true);
}
我们为所有任务选用了模块模式,因为:
hardhat.config.ts 只包含任务定义和参数模式,不包含实现细节。src/tasks/lib/ 中的共享工具。注意参数接口(
DropTaskArguments):属性名称必须与任务定义中.addOption()和.addFlag()的name值匹配。Hardhat 3 不会为你生成这些类型——你需要自己定义并确保匹配。
参数映射是我们迁移过程中最令人困惑的部分之一。这个概念被重新组织,官方文档对此介绍很少——我们最终阅读了 Hardhat 3 的源代码,才理解旧参数类型如何映射到新参数类型。为了节省你的时间,这是我们整理的参考表:

注意,addOption() 总是需要 defaultValue——在 Hardhat 3 中没有办法定义一个必需的命名选项。如果你在 Hardhat 2 中有一个必需参数,你需要在任务动作中自行验证。例如,我们的 --ver 选项默认值为 0,我们在每个需要它的任务开始时进行检查:
// 任务定义:ver 默认为 0
const verifyDeployment = task('verify-deployment', '...')
.addOption({ name: 'ver', shortName: 'v', defaultValue: 0, type: ArgumentType.INT })
.setAction(() => import('./src/tasks/verify-deployment'))
.build();
// 任务动作:验证 ver 是否确实提供了
export default async function (args: VerifyDeploymentTaskArguments, hre: HardhatRuntimeEnvironment) {
const version = args.ver;
if (version < 1) {
console.error('错误:必须使用 --v 参数指定版本');
return errorResult(new Error('缺少必需的版本参数'));
}
// ... 其余任务逻辑
}
我们欣赏的一些特性:
ArgumentType 枚举取代了旧的未类型化字符串参数。可用类型:STRING、INT、BOOLEAN、BIGINT、FILE。在 Hardhat 2 中,参数本质上是未类型化的字符串——你需要自己解析和验证。在 Hardhat 3 中,类型由框架强制执行,因此 ArgumentType.INT 会在任务运行之前拒绝非数字输入。shortName 是一个新属性,原生支持短 CLI 标志。在 Hardhat 2 中,如果你想要 --v,就得把参数命名为 v。在 Hardhat 3 中,你给它一个描述性名称(如 ver)和一个 shortName(如 v),这样 --ver 53 和 -v 53 都能工作。除了定义语法之外,实际的任务代码也需要更新。
在 Hardhat 2 中,你直接从 hre 访问 ethers 和网络信息:
// Hardhat 2
const chainId = await hre.getChainId();
const networkName = hre.network.name;
const contract = new hre.ethers.Contract(address, abi, hre.ethers.provider);
在 Hardhat 3 中,你首先创建一个连接,然后通过它访问所有内容:
// Hardhat 3
const conn = await hre.network.connect();
const chainId = conn.networkConfig.chainId;
const networkName = conn.networkName;
const contract = new conn.ethers.Contract(address, abi, conn.ethers.provider);
这种 hre.network.connect() 模式与测试中使用的模式相同(在第一部分中介绍过)。连接对象提供 ethers、networkConfig、networkName,并且如果你注册了 Ignition,还能提供 ignition 用于部署。
Hardhat 2 任务返回 void。Hardhat 3 任务返回结构化结果:
- export async function dropTask(hre, args): Promise<void> {
- // ... 执行工作 ...
- }
+ export default async function(args, hre): Promise<TaskResult> {
+ if (somethingFailed) {
+ return errorResult(new Error('描述性错误'));
+ }
+ return successfulResult<boolean>(true);
+ }
从 hardhat/utils/result 导入 successfulResult 和 errorResult。这取代了抛出错误或调用 process.exit(1) 的模式——任务运行器会根据你返回的结果类型处理错误显示和退出代码。
在 Hardhat 2 中,使用 hardhat-deploy 读取部署数据非常简单——hre.deployments.getOrNull('MerkleDrop128-42') 一次调用就能获得合约地址、构造函数参数和交易收据。
Hardhat Ignition 没有等效的 API 用于以编程方式读取过去的部署工件。它将部署数据存储在 ignition/deployments/<deploymentId>/ 目录下的文件中,但没有提供内置方式从任务代码中查询它们。以下是每个部署文件夹的样子,以我们的项目为例:
ignition/deployments/sepolia-MerkleDrop-78/
├── artifacts/ - 编译后的合约工件(ABI、字节码、源代码信息)
│ └── SignatureDrop#SignatureMerkleDrop128.json
├── build-info/ - 完整的 Solidity 编译器输入/输出,用于可重现构建
│ └── solc-0_8_23-....json
├── deployed_addresses.json - 按 future ID 索引的合约地址
└── journal.jsonl - 每行一个 JSON 的部署步骤日志
deployed_addresses.json 将 Ignition future ID 映射到部署地址:
{
"SignatureDrop#SignatureMerkleDrop128": "0xb56c499b57F720D59028f74D36Fb1571E031Cd83"
}
journal.jsonl 是部署过程的逐行日志。每一行都是一个 JSON 对象,包含一个 type 字段。我们使用了以下条目类型:
DEPLOYMENT_EXECUTION_STATE_INITIALIZE – 包含 constructorArgs、contractName 和部署者地址(from)TRANSACTION_CONFIRM – 包含交易哈希和收据,其中包含 blockNumber、blockHash 和 contractAddressDEPLOYMENT_EXECUTION_STATE_COMPLETE – 包含最终部署的地址我们创建了一个辅助类,直接解析这些文件以提取部署参数,如合约地址、构造函数参数和区块编号。如果你的任务需要读取之前 Ignition 运行的部署数据,请准备好编写自己的解析层。
至此,我们完成了从 Hardhat 2 到 Hardhat 3 的三部分迁移系列:
loadFixture 陷阱。evmVersion 验证陷阱。一旦理解了新模式,任务迁移就变得非常简单。带有 .addOption() / .build() 的构建器 API 比 Hardhat 2 的 .addParam() 更冗长,但显式类型化、短名称和惰性加载是真正的改进。最大的痛点是缺乏用于读取 Ignition 部署工件的内置 API——而 hardhat-deploy 可以无缝处理这一点。
总体而言,迁移到 Hardhat 3 需要相当大的努力,但结果是代码库更清晰、更易于维护。新的任务系统,结合原生 Solidity 测试和 Hardhat Ignition,使 Hardhat 3 成为以太坊开发工具的一个坚实进步。
- 原文链接: x.com/1inchdevs/status/2...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
作者暂未设置收款二维码