Hardhat v3 + Yarn 4 + ESM + toolbox-viem 开发实例

184 阅读6分钟

Hardhat v3 + Yarn 4 + ESM + toolbox-viem 开发实例

getting-started-with-hardhat-3 官方文档

创建项目

  • 创建项目目录
> mkdir hardhat-defi-fcc
> cd hardhat-defi-fcc/
  • 初始化hardhat:

Yarn 4 (Berry) + PnP 模式不会在 node_modules 下生成包,而是通过 .pnp.cjs loader 在运行时解析依赖路径。 Hardhat CLI 会在缓存路径(比如 C:\Users<用户名>\AppData\Local\Yarn\Berry\cache\…)查找自己。 如果缓存路径里有非 ASCII 字符(中文、特殊字符),Node 的 ESM/路径解析在 Windows 下有时会失败,Hardhat 找不到自己 → 报 HHE22。

# Be sure to set `nodeLinker` to `node-modules` in your `.yarnrc.yml` file first
yarn dlx hardhat --init

这里的意思是执行此命令前,在项目创建 .yarnrc.yml 文件,并指定:nodeLinker: node-modules

如果在执行 yarn dlx hardhat --init 前忘记添加了 .yarnrc.yml 文件,则按下列步骤进行修复:

# 删除 PnP 产物
> rm -f .pnp.cjs .pnp.loader.mjs .pnp.data.json
> rm -rf .yarn/cache

# 添加 .yarnrc.yml 文件

# 重新安装依赖
> yarn install
  • 安装依赖

Hardhat v3 把很多功能拆成了单独插件(ignitionviemkeystorenetwork-helpers等), hardhat-toolbox-viem 又是一个“组合插件”,但它只声明 peerDependencies,自己不直接装 → 相当于只告诉你“需要这些”,具体还得你自己装。 在 npm / pnpm 里,这些 peerDependencies 只是警告,但 Yarn 4 + PnP 强制检查,一个没装就报错,要一个个补齐。

{
  "name": "hardhat-defi-fcc",
  "version": "1.0.0",
  "type": "module",
  "devDependencies": {
    "@nomicfoundation/hardhat-ignition-viem": "^3.0.3",
    "@nomicfoundation/hardhat-keystore": "^3.0.1",
    "@nomicfoundation/hardhat-network-helpers": "^3.0.0",
    "@nomicfoundation/hardhat-node-test-runner": "^3.0.3",
    "@nomicfoundation/hardhat-toolbox-viem": "^5.0.0",
    "@nomicfoundation/hardhat-verify": "^3.0.2",
    "@nomicfoundation/hardhat-viem": "^3.0.0",
    "@nomicfoundation/hardhat-viem-assertions": "^3.0.2"
  }
}

为了避免一个个添加麻烦,可以一次性添加上面列的所有组件。

  • 编译合约
> yarn hardhat compile
Compiling your Solidity contracts...
Compiled 1 Solidity file with solc 0.8.28 (evm target: cancun)

至此,项目创建完成,其他操作和 hardhat v2.* 差不多,细节可参考官方文档。

选择框架组合

Hardhat 随着版本升级,有很多种不同的框架组合,不同的组合之间可能存在兼容问题,需要慎重选择。

Hardhat v3 + ESM + TypeScript 项目 package.json 里有:

{
  "type": "module"
}

Hardhat v3 默认用 ESM(ECMAScript Modules) 模式(是 JavaScript 官方标准的模块化方案), TypeScript tsconfig.json 配置了:

{
"moduleResolution": "node16"
}

NodeESM 模式里,所有相对导入路径必须明确文件扩展名,Node 运行时只认识 .js 文件,不会去找 .ts TypeScriptnode16/nodenext 模式下,也要遵循这个规则,编译器检查时会报错。故正确的导入方式:

// helper-hardhat-config.ts
export const networkConfig = { ... };

import { networkConfig } from "./helper-hardhat-config.js";

TS 编译时,ts-node / Hardhat 会自动处理 .ts → .js,Node 运行时能正确找到文件。

Hardhat Runtime Environment

HRE = Hardhat Runtime Environment,也就是 Hardhat 在运行时自动注入的对象。执行 run/test/deploy 等命令时, Hardhat 会先加载 hardhat.config.ts 中的导入项,构建一个大对象,里面放了所有插件、工具和上下文。 其中常用的有:

  • hre.ethers → 插件 @nomicfoundation/hardhat-ethers 提供的 ethers.js 封装
  • hre.network → 当前使用的网络配置(名字、链 ID、provider 等)
  • hre.artifacts → 编译好的合约 ABI、字节码文件
  • hre.run → 运行 hardhat 任务的 API

Hardhat v3 + Yarn 4 PnP + ESM = 官方不支持的组合,不能用 hre,在这种组合下,HRE 里的 ethers 及其他插件功能无法可靠使用,因为 Hardhat 内部很多插件访问了非导出路径, 而 ESM + PnP 会严格限制访问这些路径,所以不能依赖 hre,用 HRE 的 deploy / getSigners / artifacts 等都可能报错。

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './common/bigInt' is not defined by "exports" in ...

解决办法就是使用 Viem + ethers v6 原生客户端。

Hardhat Viem

Hardhat-viem for developer

Hardhat 的野望是将 Viem 打造成官方推荐的现代以太坊客户端,让 Hardhat 项目可以直接用 Viem API, Viem 可以替代 ethers.js 做钱包管理、合约部署、RPC 调用等。

  1. 轻量 + TypeScript 原生支持

    • ethers.jsJS 设计,TypeScript 支持需要额外类型
    • Viem 从一开始就是 TS-first,类型安全更好
  2. 更现代的 API 设计,方法清晰,签名和部署操作统一;

  3. 支持现代链 RPC 特性

  • 更好兼容 ESM / v3 Hardhat
  • HRE 插件体系在 Hardhat v3 + ESM + PnP 下不稳定
  • Viem 插件直接用客户端,不依赖 HRE
import { network } from "hardhat";
import { parseEther, formatEther } from "viem/utils";
import IWethAbi from "../constants/abis/IWeth.json";
import { networkConfig } from "../helper-hardhat-config.js";
import { toHexAddress } from "../utils/toHexAddress.js";
import { waitForTx } from "../utils/waitForTx.js";


// 转 0.001 ETH
export const AMOUNT = parseEther("0.001");

async function main() {
    // 选择网络
    const connection = await network.connect();
    const chainId = connection.networkConfig.chainId?.toString();
    const { viem } = connection;
    console.log(`Connected to network: ${connection.networkName} (chainId: ${chainId})`);

    // 获取 WETH 合约地址
    const wethAddress = networkConfig[chainId as string]?.wethToken;
    if (!wethAddress) throw new Error("No WETH token configured for this chain");

    const publicClient = await viem.getPublicClient();

    const walletClients = await viem.getWalletClients();
    if (walletClients.length < 1) {
        throw new Error("No wallet clients available. Make sure accounts are configured for this network.");
    }

    const walletClient = walletClients[0];
    console.log(`Using account: ${walletClient.account.address}`);

    // 调用合约接口,发送 eth
    const txHash = await walletClient.writeContract({
        address: toHexAddress(wethAddress),
        abi: IWethAbi,
        functionName: "deposit",
        value: AMOUNT,
    });
    console.log(`Deposit tx sent: ${txHash}`);

    // 等待交易确认(可选)
    const receipt = await waitForTx(publicClient, txHash, 1);
    console.log(`Deposit tx confirmed in block: ${receipt.blockNumber}`);

    // 查询 WETH 余额
    const balance = await publicClient.readContract({
        address: toHexAddress(wethAddress),
        abi: IWethAbi,
        functionName: "balanceOf",
        args: [walletClient.account.address as `0x${string}`],
    }) as bigint;

    console.log(`Got ${formatEther(balance)} WETH`);
}

main()
    .then(() => process.exit(0))
    .catch(error => {
        console.error(error)
        process.exit(1)
    })

Viem 脚本是完全独立的 TypeScript/JS 文件,HardhatUserConfig 只在 Hardhat 插件内部有用,Viem 不会自动加载。

运行脚本

yarn hardhat run .\scripts\getWethForViem.ts --network=sepolia

ethers V6 实现

Hardhat v3+ 官方已经不再内置 ethers, 如要使用需手动继承,官方建议使用 toolbox-viem

  • 添加组件
yarn add --dev ethers
  • 代码
import { ethers } from "ethers" // hardhat v3+ 移除了 hre.ethers,需单独引入
import { networkConfig } from "../helper-hardhat-config.js"
import IWethAbi from "../constants/abis/IWeth.json";
import { network } from "hardhat";

export const AMOUNT = (ethers.parseEther("0.001")).toString()

async function main() {
    // Hardhat v3+ 获取网络连接
    const connection = await network.connect(); // NetworkConnection
    const networkConfigInfo = connection.networkConfig;

    let rpcUrl: string;
    if (networkConfigInfo.type === "http") {
        const url = await networkConfigInfo.url.getUrl();
        rpcUrl = url;
    } else {
        // 模拟网络使用本地节点
        rpcUrl = "http://127.0.0.1:8545";
    }
    console.log(`Using RPC URL: ${rpcUrl}`);

    const provider = new ethers.JsonRpcProvider(rpcUrl);
    let signer: ethers.Signer;
    if (networkConfigInfo.type === "http") {
        const accounts = networkConfigInfo.accounts;

        if (accounts === "remote") {
            throw new Error("Remote network: need to provide private key explicitly");
        } else if (Array.isArray(accounts)) {
            // 数组类型,取第一个私钥
            const privateKey = await accounts[0].get();
            signer = new ethers.Wallet(privateKey, provider);
        } else if (typeof accounts === "object" && "mnemonic" in accounts) {
            const mnemonic = await accounts.mnemonic.get();
            signer = ethers.Wallet.fromPhrase(mnemonic).connect(provider);
        } else {
            throw new Error("Unsupported accounts config type");
        }
    } else {
        signer = await provider.getSigner(0);
    }
    const signAddress = await signer.getAddress();
    console.log(`Using account: ${signAddress}`);

    const chainId :number = Number(networkConfigInfo.chainId);
    console.log(`Connected to network: ${connection.networkName} (chainId: ${chainId})`);

    // 创建合约实例
    const iWeth = new ethers.Contract(
        networkConfig[chainId].wethToken!,
        IWethAbi,
        signer,
    );

    /*
     * 公共 RPC 节点(如 Sepolia)对 eth_estimateGas 或 eth_call 某些 payable 函数处理慢或限制多次调用
     * ethers v6 在发送前会重试 gasEstimate,默认重试次数有限制,可能导致交易失败,报错 “exceeded maximum retry limit”
     * 建议使用 viem 版本脚本,或自行设置合理的 gasLimit 避免估算
    */
    const txResponse = await iWeth.deposit({ // 在主网会超时,“exceeded maximum retry limit”, 建议用 viem 版本脚本
        value: AMOUNT,
        gasLimit: 100_000, // 手动设置合理 gasLimit
    })
    console.log(`Deposit tx sent: ${txResponse.hash}`);
    await txResponse.wait(1)

    const wethBalance = await iWeth.balanceOf(signer)
    console.log(`Got ${wethBalance.toString()} WETH`)
}

main()
    .then(() => process.exit(0))
    .catch(error => {
        console.error(error)
        process.exit(1)
    })

  • 运行脚本
# 启动节点
yarn hardhat node --network hardhatMainnet
# 运行脚本
yarn hardhat run .\scripts\getWeth.ts --network hardhatMainnet

官方推荐使用 viem,ethers 实现复杂,混用了 viem,且远程 RPC 调用机制很容易超时。

环境变量设置

hardhat 中,经常需要配置一些环境变量,如网络参数或钱包私钥等。

const config: HardhatUserConfig = {
  networks: { // Hardhat 网络配置,但是在 Viem 中不使用 Hardhat 网络配置
    hardhatMainnet: {
      type: "edr-simulated",
      chainType: "l1", // 模拟主网
      chainId: 1,
    },
    hardhatOp: {
      type: "edr-simulated", // 内部模拟链(Experimental Dev RPC),用于本地快速测试/模拟 L1/L2
      chainType: "op", // 模拟 Optimism
      chainId: 10,
    },
    sepolia: {
      chainId: 11155111,
      type: "http", // 普通 RPC HTTP 网络,例如 Sepolia / Mainnet
      chainType: "l1",
      url: configVariable("SEPOLIA_RPC_URL"),
      accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
    },
  },
};

SEPOLIA_RPC_URL 和 SEPOLIA_PRIVATE_KEY 为环境变量。

环境变量的设置有两种方式:

  • 通过系统命令在命令行工具设置
  • 通过 .env 文件配置
    • 添加组件
    yarn add --dev dotenv
    
    • hardhat.config.ts 中显示导入
    import "dotenv/config";
    

验证合约

hardhat verify contract

编译器版本

hardhat.config.ts 中编写编译器版本配置项:

const config: HardhatUserConfig = {
  solidity: {
    // compilers: [ // 和 Profiles 二选一,不能同时使用
    //   {
    //     version: "0.8.28",
    //   },
    //   {
    //     version: "0.5.8",
    //   }
    // ],
    profiles: {
      default: {
        version: "0.8.28",
      },
      legacy: {
        version: "0.5.8", // 兼容旧版合约
      },
      production: {
        version: "0.8.28",
        settings: {
          optimizer: {
            enabled: true,
            runs: 200,
          },
        },
      },
    },
  }
};

编译命令:

> yarn hardhat compile --build-profile <profile-item>

fork network

假设要在本地网络测试一个部署在 sepolia 网络的合约,可以在启动本地节点的时候,fork 对应网络的一条子链。

hardhat.config.ts 中编写 fork 配置项:

const config: HardhatUserConfig = {
  networks: {
      hardhatMainnet: {
          forking: { // sepolia forking from block 3200000, testing contracts on sepolia fork
              url: configVariable("SEPOLIA_RPC_URL"),
              blockNumber: 3200000,
          },
          type: "edr-simulated",
          chainType: "l1", // 模拟主网
          chainId: 1,
      }
  }
};

这样,在执行测试或部署命令时,带上 --network hardhatMainnet 参数将会从 sepolia 网络 fork 一条子链。

> yarn hardhat run .\scripts\getWethForViem.ts --network hardhatMainnet
Compiling your Solidity contracts...

Nothing to compile

Connected to network: hardhatMainnet (chainId: 1)
Using account: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Deposit tx sent: 0x6146f92c0c267c258e8a6c803f618718a64a7baf606d5e62d873e4af0e7d23e7
Deposit tx confirmed in block: 3200001
Got 0.001 WETH