Hardhat 3 任务迁移详解

本文是Hardhat 2迁移到Hardhat 3系列第三部分,详细介绍了自定义任务的迁移。内容包括任务定义语法的变化(使用builder模式、显式注册)、参数定义(从addParam到addOption,支持类型和短名)、CLI参数映射、内联与懒加载两种动作模式、网络访问方式变化、结构化返回值的使用,以及如何手动读取Ignition部署工件(如deployed_addresses.json和journal.jsonl)。文章通过对比代码示例,展示了迁移前后的差异,并指出了新框架的改进和痛点。

1. 简介

第一部分 中,我们介绍了迁移到 Hardhat 3 的基础知识:基本配置变更和测试迁移。在 第二部分 中,我们用 Hardhat Ignition 替换了 hardhat-deploy。

在最后一部分,我们将介绍自定义 Hardhat 任务的迁移——这是迁移至 Hardhat 3 的最后一块拼图。我们的项目中有几个自定义任务负责管理完整的空投生命周期:生成申领链接、部署合约、收集链上统计数据,以及回收未申领的代币。

我们将介绍:

  • 任务定义语法:前后对比
  • 两种动作模式:内联与惰性加载模块
  • Hardhat 2 和 Hardhat 3 在参数定义上的差异
  • 从 Hardhat 2 到 Hardhat 3 的 CLI 参数映射
  • 任务代码内部的变化:网络访问、返回值以及读取 Ignition 工件

2. 任务定义:前后对比

在 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() 接受一个模块导入,而不是内联函数(下一节将详细介绍)。

3. 动作与内联动作

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 不会为你生成这些类型——你需要自己定义并确保匹配。

4. CLI 参数:从 Hardhat 2 到 Hardhat 3 的映射

参数映射是我们迁移过程中最令人困惑的部分之一。这个概念被重新组织,官方文档对此介绍很少——我们最终阅读了 Hardhat 3 的源代码,才理解旧参数类型如何映射到新参数类型。为了节省你的时间,这是我们整理的参考表:

Image

注意,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 枚举取代了旧的未类型化字符串参数。可用类型:STRINGINTBOOLEANBIGINTFILE。在 Hardhat 2 中,参数本质上是未类型化的字符串——你需要自己解析和验证。在 Hardhat 3 中,类型由框架强制执行,因此 ArgumentType.INT 会在任务运行之前拒绝非数字输入。
  • shortName 是一个新属性,原生支持短 CLI 标志。在 Hardhat 2 中,如果你想要 --v,就得把参数命名为 v。在 Hardhat 3 中,你给它一个描述性名称(如 ver)和一个 shortName(如 v),这样 --ver 53-v 53 都能工作。

5. 任务内部:内部的变化

除了定义语法之外,实际的任务代码也需要更新。

5.1 网络访问

在 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() 模式与测试中使用的模式相同(在第一部分中介绍过)。连接对象提供 ethersnetworkConfignetworkName,并且如果你注册了 Ignition,还能提供 ignition 用于部署。

5.2 返回值

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 导入 successfulResulterrorResult。这取代了抛出错误或调用 process.exit(1) 的模式——任务运行器会根据你返回的结果类型处理错误显示和退出代码。

5.3 读取部署工件

在 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 – 包含 constructorArgscontractName 和部署者地址(from
  • TRANSACTION_CONFIRM – 包含交易哈希和收据,其中包含 blockNumberblockHashcontractAddress
  • DEPLOYMENT_EXECUTION_STATE_COMPLETE – 包含最终部署的地址

我们创建了一个辅助类,直接解析这些文件以提取部署参数,如合约地址、构造函数参数和区块编号。如果你的任务需要读取之前 Ignition 运行的部署数据,请准备好编写自己的解析层。

6. 结论

至此,我们完成了从 Hardhat 2 到 Hardhat 3 的三部分迁移系列:

  • 第一部分介绍了 ES 模块配置、依赖更新、测试迁移以及使用模块化测试时的 loadFixture 陷阱。
  • 第二部分介绍了用 Hardhat Ignition 替换 hardhat-deploy、构建配置文件和 evmVersion 验证陷阱。
  • 第三部分介绍了新的任务 API、内联动作与模块动作、CLI 参数映射,以及构建辅助工具来弥合 Ignition 的部署工件与任务代码之间的差距。

一旦理解了新模式,任务迁移就变得非常简单。带有 .addOption() / .build() 的构建器 API 比 Hardhat 2 的 .addParam() 更冗长,但显式类型化、短名称和惰性加载是真正的改进。最大的痛点是缺乏用于读取 Ignition 部署工件的内置 API——而 hardhat-deploy 可以无缝处理这一点。

总体而言,迁移到 Hardhat 3 需要相当大的努力,但结果是代码库更清晰、更易于维护。新的任务系统,结合原生 Solidity 测试和 Hardhat Ignition,使 Hardhat 3 成为以太坊开发工具的一个坚实进步。

  • 原文链接: x.com/1inchdevs/status/2...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
1inchdevs
1inchdevs
江湖只有他的大名,没有他的介绍。