迁移到Hardhat 3 - 第3部分:任务迁移

1inch_ 发布于 2026-05-02 阅读 139

本文是Hardhat 2迁移到Hardhat 3系列的最后一部分,专门介绍自定义任务的迁移。

1. 引言

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

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

我们将涵盖以下内容:

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

2. 任务定义:迁移前后对比

Hardhat 3 中任务的定义方式发生了重大变化。任务通过构建器(builder)创建,并且必须在配置中显式注册。参数是带有类型定义的结构化对象。操作可以从单独的模块中延迟加载。

以下是我们的 drop 任务在迁移前后的样子。

迁移前 (Hardhat 2):

import { task } from 'hardhat/config';
import { dropTask } from './src/tasks/hardhat-drop-task';

task('drop', '生成 Merkle 空投链接、部署合约并验证所有生成的领取链接')
    .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', '生成 Merkle 空投链接、部署合约并验证所有生成的领取链接')
    .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', '生成 Merkle 空投链接...')
    .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 2 到 Hardhat 3 的映射是我们迁移过程中最令人困惑的部分之一。相关概念被重新组织,而且关于此主题的官方文档很少——我们最终阅读了 Hardhat 3 的源代码来理解旧参数类型如何映射到新参数。为了节省你的精力,以下是我们整理的参考表:

Hardhat 2 Hardhat 3 变化内容
addParam(name, ...) 无直接 1:1 等效项 HH3 中没有 HH2 必需参数的直接等效项。始终需要设置默认值,验证由用户自行完成。
addOptionalParam(name, ...) addOption({ name, type, defaultValue, ... }) 替代 HH2 的可选命名参数。
addFlag(name, ...) addFlag({ name, ... }) HH2 和 HH3 行为等效。
- addLevel({ name, ... }) 这是 HH3 新增的;addLevel 定义了一个接受非负整数且默认值为 0 的选项。当该选项具有短名称(如 -v)时,重复使用它会增加级别,例如 -vvvv 表示级别 4(相当于 --verbosity 4)。
addPositionalParam(name, ...) addPositionalArgument({ name, type }) 与命名选项不同,位置参数可以是必需的——只需省略 defaultValue
addOptionalPositionalParam(name, ...) addPositionalArgument({ ..., defaultValue }) 替代 HH2 的可选位置参数。提供 defaultValue 使其变为可选。
addVariadicPositionalParam(name, ...) addVariadicArgument({ name, type }) 与位置参数类似,可变参数可以是必需的——只需省略 defaultValue
addOptionalVariadicPositionalParam(name, ...) addVariadicArgument({ ..., defaultValue }) 替代 HH2 的可选可变位置参数。提供 defaultValue 使其变为可选。

请注意,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 中,你可以给它一个描述性的 name(如 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-deploy 的 Hardhat 2 中,读取部署数据非常简单——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 - 包含交易 hash 和包含 blockNumberblockHashcontractAddressreceipt
  • DEPLOYMENT_EXECUTION_STATE_COMPLETE - 包含最终部署的 address

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

6. 结论

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

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

一旦我们理解了新的模式,任务迁移就变得简单了。与 Hardhat 2 的 .addParam() 相比,使用 .addOption() / .build() 的构建器 API 更加冗长,但显式的类型、短名称和延迟加载是真正的改进。最大的痛点在于缺少用于读取 Ignition 部署工件的内置 API——而 hardhat-deploy 能够无缝处理这个问题。

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

  • 原文链接: github.com/1inch/merkle-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论