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 把很多功能拆成了单独插件(ignition、viem、keystore、network-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"
}
在 Node 的 ESM 模式里,所有相对导入路径必须明确文件扩展名,Node 运行时只认识 .js 文件,不会去找 .ts
TypeScript 在 node16/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 打造成官方推荐的现代以太坊客户端,让 Hardhat 项目可以直接用 Viem API,
Viem 可以替代 ethers.js 做钱包管理、合约部署、RPC 调用等。
-
轻量 + TypeScript 原生支持
ethers.js为JS设计,TypeScript支持需要额外类型Viem从一开始就是TS-first,类型安全更好
-
更现代的 API 设计,方法清晰,签名和部署操作统一;
-
支持现代链 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.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