5月27日 19:59

Hardhat 主网分叉功能如何使用?

Hardhat 主网分叉(Mainnet Forking)允许开发者在本地复刻以太坊主网或测试网的当前状态,在无需花费真实 Gas 的前提下,与链上已部署的真实合约进行交互。这对测试 DeFi 协议集成、调试复杂交易路径、验证合约升级影响等场景至关重要。

工作原理

主网分叉的本质是:Hardhat 节点通过 RPC 节点读取主网的归档数据,在本地模拟出与主网一致的状态。你的本地交易不会影响真实链上数据,但可以读取主网上合约的存储值、余额等状态。

核心依赖:

  • 一个支持归档数据的 RPC 节点(如 Alchemy、Infura、QuickNode)
  • 足够的本地内存(分叉会占用较多 RAM)

基础配置

hardhat.config.js(或 .ts)中添加分叉配置:

javascript
module.exports = { solidity: "0.8.20", networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, // 如 https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY } } } };

使用 .env 文件管理 RPC URL:

shell
MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY

指定分叉区块号

固定区块号可以确保每次测试的环境一致,提高测试的可重复性:

javascript
networks: { hardhat: { forking: { url: process.env.MAINNET_RPC_URL, blockNumber: 19000000 // 锁定在此区块的主网状态 } } }

为什么要固定区块号?如果不指定,每次启动都会分叉最新区块,合约状态(如代币价格、流动性池余额)可能发生变化,导致测试间歇性失败。

模拟账户与代币水龙头

分叉环境中,你可以直接扮演主网上持有大量代币的账户,这是主网分叉最强大的能力之一:

javascript
const { ethers } = require("hardhat"); async function main() { // 模拟 Whales 账户 const whaleAddress = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; // Binance 热钱包 await network.provider.request({ method: "hardhat_impersonateAccount", params: [whaleAddress], }); const whale = await ethers.getSigner(whaleAddress); // 查询该账户的 ETH 余额 const ethBalance = await ethers.provider.getBalance(whaleAddress); console.log("ETH Balance:", ethers.formatEther(ethBalance)); // 查询该账户的 USDT 余额 const usdt = await ethers.getContractAt("IERC20", "0xdAC17F958D2ee523a2206206994597C13D831ec7"); const usdtBalance = await usdt.balanceOf(whaleAddress); console.log("USDT Balance:", ethers.formatUnits(usdtBalance, 6)); } main();

给任意地址注入 ETH(无需真实挖矿):

javascript
// 给指定地址转入 100 ETH await network.provider.send("hardhat_setBalance", [ "0xYourAddress", "0x56BC75E2D63100000", // 100 ETH 的十六进制(单位 wei) ]);

实战案例:在分叉环境测试 Uniswap 交易

以下是一个完整的测试流程,在分叉环境中用模拟账户执行真实的 Uniswap V2 交易:

javascript
const { ethers } = require("hardhat"); describe("Uniswap V2 Swap on Forked Mainnet", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const ROUTER = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"; let whale, usdt, router; before(async function () { // 模拟鲸鱼账户 await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); whale = await ethers.getSigner(WHALE); usdt = await ethers.getContractAt("IERC20", USDT); router = await ethers.getContractAt( [ "function swapExactTokensForTokens(uint,uint,address[],address,uint) external returns (uint[])", ], ROUTER ); }); it("should swap USDT for WETH", async function () { // 授权 Router 使用 USDT const swapAmount = ethers.parseUnits("1000", 6); // 1000 USDT await usdt.connect(whale).approve(ROUTER, swapAmount); // 执行兑换 const tx = await router.connect(whale).swapExactTokensForTokens( swapAmount, 0, // 最少输出(生产环境应设置滑点保护) [USDT, "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"], // USDT -> WETH whale.address, Math.floor(Date.now() / 1000) + 60 * 10 // 10 分钟 deadline ); const receipt = await tx.wait(); console.log("Swap succeeded, gas used:", receipt.gasUsed.toString()); }); });

测试 Aave 协议集成

javascript
const { ethers } = require("hardhat"); describe("Aave V3 Integration Test", function () { const WHALE = "0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503"; const AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; it("should deposit USDC into Aave V3", async function () { await network.provider.request({ method: "hardhat_impersonateAccount", params: [WHALE], }); const whale = await ethers.getSigner(WHALE); const usdc = await ethers.getContractAt("IERC20", USDC); const pool = await ethers.getContractAt( [ "function deposit(address,uint256,address,uint16) external returns (bool)", ], AAVE_POOL ); const amount = ethers.parseUnits("10000", 6); // 10,000 USDC await usdc.connect(whale).approve(AAVE_POOL, amount); await pool.connect(whale).deposit(USDC, amount, whale.address, 0); console.log("Deposit succeeded"); }); });

动态启用/禁用分叉

在测试中按需切换分叉,避免所有测试都承受分叉的性能开销:

javascript
describe("With forking enabled", function () { before(async function () { await network.provider.request({ method: "hardhat_reset", params: [ { forking: { jsonRpcUrl: process.env.MAINNET_RPC_URL, blockNumber: 19000000, }, }, ], }); }); after(async function () { // 禁用分叉,恢复普通 Hardhat 网络 await network.provider.request({ method: "hardhat_reset", params: [], }); }); it("interacts with mainnet contract", async function () { // 你的分叉测试逻辑 }); });

RPC 节点选择

服务商免费额度归档数据支持推荐场景
Alchemy300M CU/月支持开发调试首选
Infura100K 请求/天支持轻量测试
QuickNode免费套餐支持高性能需求
Chainstack3M 请求/月支持多链支持

注意:必须选择支持归档数据的套餐,否则无法指定历史区块号进行分叉。

常见问题

Q: 分叉启动后报错 "Missing trie node"? A: 你的 RPC 节点不支持指定区块号的归档数据,请切换到支持归档的套餐或去掉 blockNumber 使用最新区块。

Q: 分叉环境交易回执显示 gas 极高? A: 这是正常现象,分叉环境中的 gas 计算可能与主网不一致,不影响功能测试。

Q: impersonateAccount 后交易失败? A: 部分合约有合约白名单或代理限制。确保模拟的账户确实有权限调用目标合约,同时检查该账户在分叉区块号时是否持有足够的 ETH 支付 gas。

Q: 如何在 CI/CD 中使用分叉? A: 使用固定的 blockNumber 并配合缓存(如缓存 RPC 响应),避免每次 CI 都大量请求 RPC 节点。Alchemy 的归档节点配合 blockNumber 是最稳定的 CI 方案。

最佳实践

  • 使用环境变量存储 RPC URL,绝不将 API Key 硬编码提交到代码仓库
  • 固定 blockNumber 确保测试可重复,团队协作时所有人使用相同区块号
  • 只在需要主网状态的测试中启用分叉,普通单元测试使用本地 Hardhat 网络即可
  • 模拟高价值账户(Whales)获取测试代币,而非自己部署模拟 ERC20
  • 在 CI 中配合缓存机制减少 RPC 请求量,避免超出免费额度
  • 定期更新分叉区块号以测试与最新链上状态的兼容性
标签:Hardhat