使用Hardhat的forking功能在本地模拟EVM链真实环境
Hardhat Network可以复制主网区块链状态数据到本地环境,包括所有余额和部署的合约。称为forking mainnet,可以使得本地测试模拟主网环境,但不用gas,所做的交易也不会真的发生在主网。不止以太坊主网,其他兼容EVM的区块链都可以fork。我们来看一下如何使用这个重要功能。
如下例子,是如何使用Uniswap V2将WETH兑换为DAI,是典型的DeFi应用。这里涉及多个ERC20合约,以及Uniswap合约,如果都把他们通过源代码部署在本地进行测试显然不现实,而且状态数据也很难去模拟。
我们使用Hardhat forking功能,从指定区块号分叉到本地hardhat network节点,来模拟主网状态,这个过程中主网的状态数据我们需要从归档节点去获取,所以可以通过Alchemy这样的Relay去拉。let's Go!
配置Hardhat环境
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.24",
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.g.alchemy.com/v2/" + process.env.ALCHEMY_KEY,
blockNumber: 20623798
}
}
}
};
其中ALCHEMY_KEY是Alchemy的调用Key,配置到.env文件里。
Solidity合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract UniswapV2SwapExamples {
address private constant UNISWAP_V2_ROUTER =
0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D;
address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
IUniswapV2Router private router = IUniswapV2Router(UNISWAP_V2_ROUTER);
IERC20 private weth = IERC20(WETH);
IERC20 private dai = IERC20(DAI);
// Swap WETH to DAI
function swapSingleHopExactAmountIn(uint256 amountIn, uint256 amountOutMin)
external
returns (uint256 amountOut)
{
weth.transferFrom(msg.sender, address(this), amountIn);
weth.approve(address(router), amountIn);
address[] memory path;
path = new address[](2);
path[0] = WETH;
path[1] = DAI;
uint256[] memory amounts = router.swapExactTokensForTokens(
amountIn, amountOutMin, path, msg.sender, block.timestamp
);
// amounts[0] = WETH amount, amounts[1] = DAI amount
return amounts[1];
}
// Swap DAI -> WETH -> USDC
function swapMultiHopExactAmountIn(uint256 amountIn, uint256 amountOutMin)
external
returns (uint256 amountOut)
{
dai.transferFrom(msg.sender, address(this), amountIn);
dai.approve(address(router), amountIn);
address[] memory path;
path = new address[](3);
path[0] = DAI;
path[1] = WETH;
path[2] = USDC;
uint256[] memory amounts = router.swapExactTokensForTokens(
amountIn, amountOutMin, path, msg.sender, block.timestamp
);
// amounts[0] = DAI amount
// amounts[1] = WETH amount
// amounts[2] = USDC amount
return amounts[2];
}
// Swap WETH to DAI
function swapSingleHopExactAmountOut(
uint256 amountOutDesired,
uint256 amountInMax
) external returns (uint256 amountOut) {
weth.transferFrom(msg.sender, address(this), amountInMax);
weth.approve(address(router), amountInMax);
address[] memory path;
path = new address[](2);
path[0] = WETH;
path[1] = DAI;
uint256[] memory amounts = router.swapTokensForExactTokens(
amountOutDesired, amountInMax, path, msg.sender, block.timestamp
);
// Refund WETH to msg.sender
if (amounts[0] < amountInMax) {
weth.transfer(msg.sender, amountInMax - amounts[0]);
}
return amounts[1];
}
// Swap DAI -> WETH -> USDC
function swapMultiHopExactAmountOut(
uint256 amountOutDesired,
uint256 amountInMax
) external returns (uint256 amountOut) {
dai.transferFrom(msg.sender, address(this), amountInMax);
dai.approve(address(router), amountInMax);
address[] memory path;
path = new address[](3);
path[0] = DAI;
path[1] = WETH;
path[2] = USDC;
uint256[] memory amounts = router.swapTokensForExactTokens(
amountOutDesired, amountInMax, path, msg.sender, block.timestamp
);
// Refund DAI to msg.sender
if (amounts[0] < amountInMax) {
dai.transfer(msg.sender, amountInMax - amounts[0]);
}
return amounts[2];
}
}
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
function swapTokensForExactTokens(
uint256 amountOut,
uint256 amountInMax,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
}
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount)
external
returns (bool);
function allowance(address owner, address spender)
external
view
returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount)
external
returns (bool);
}
interface IWETH is IERC20 {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
选自Solidity by Example - Uniswap V2 Swap
Hardhat部署合约并进行交互
const { ethers } = require("hardhat");
//用到的Token代币的地址,以及对应的合约ABI(仅列出使用到的函数即可)
const wethAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const daiAddress = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const usdcAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const wethABI = ["function deposit() external payable",
"function withdraw(uint256 amount) external",
"function approve(address spender, uint256 amount) external returns (bool)" //从ERC20继承来的
];
const erc20ABI = [
"function balanceOf(address account) external view returns (uint256)"
];
async function main() {
const [deployer] = await ethers.getSigners(); //hardhat network内置的账户地址的第1个
const deployerEthBalance = await ethers.provider.getBalance(deployer.address);
console.log("deployer地址:", await deployer.getAddress());
console.log("deployer地址余额 %s ETH:", ethers.formatEther(deployerEthBalance));
const factory = await ethers.getContractFactory("UniswapV2SwapExamples");
const contract = await factory.deploy();
const uniExamAddress = await contract.getAddress();
console.log("UniswapV2SwapExamples合约部署完成,地址: ", uniExamAddress);
//根据wETH合约地址构造其合约对象,还有两个入参分别是合约接口定义ABI、Provider/Signer
const wethContract = await new ethers.Contract(wethAddress, wethABI, ethers.provider);
//console.log("WETH合约地址:", await wethContract.getAddress());
const wethContractSigner = await wethContract.connect(deployer); //后面都是交易,不是只读操作了,所以需要签名
let wethAmount = ethers.parseEther("1.0");
await wethContractSigner.deposit({value: wethAmount}); //deployer用户往wETH合约存入1以太,获得1WETH
await wethContractSigner.approve(uniExamAddress, wethAmount); //deployer用户授权uniExamples合约可以划转1WETH
const contractSigned = contract.connect(deployer);
//uniExamples合约从用户的wETH账户里划转1WETH到自己账户,再授权uniswap的router合约从自己账户划转1WETH、由router合约完成1 WETH -> 1 DAI的兑换,如果兑换成功、router合约会把1 DAI转到本例中的deployer地址
let daiAmountMin = 2434012356407782979818n;
let amountOut = await contractSigned.swapSingleHopExactAmountIn(wethAmount, daiAmountMin); //要用wethAmount wETH至少兑换daiAmountMin DAI
console.log("兑换DAI:", amountOut);
console.log("deployer地址余额%s ETH", ethers.formatEther(await ethers.provider.getBalance(deployer.address)));
//查询DAI余额
const daiContract = await new ethers.Contract(daiAddress, erc20ABI, ethers.provider);
let daiBalanceOfDeployer = await daiContract.balanceOf(deployer.address);
console.log("deployer地址DAI账户余额%s ", daiBalanceOfDeployer);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行 npx hardhat run .\ignition\modules\defi.js --network hardhat
deployer地址: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
deployer地址余额 10000.0 ETH:
UniswapV2SwapExamples合约部署完成,地址: 0xe8c3F27D20472e4f3C546A3f73C04B54DD72871d
兑换DAI: ContractTransactionResponse {
provider: HardhatEthersProvider {
_hardhatProvider: LazyInitializationProviderAdapter {
_providerFactory: [AsyncFunction (anonymous)],
_emitter: [EventEmitter],
_initializingPromise: [Promise],
provider: [BackwardsCompatibilityProviderAdapter]
},
_networkName: 'hardhat',
_blockListeners: [],
_transactionHashListeners: Map(0) {},
_eventListeners: []
},
blockNumber: 20623802,
blockHash: '0x705dcd64cf24f8cb7c5dcc61e8dfe837e6becd4761a29fce2ffd595af8061bb2',
index: undefined,
hash: '0xf0acbc65209956e7f75e8b10928fdb9b528b6cbc15b3aa565f48839658d4c1a5',
type: 2,
to: '0xe8c3F27D20472e4f3C546A3f73C04B54DD72871d',
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
nonce: 752,
gasLimit: 30000000n,
gasPrice: 1535820145n,
maxPriorityFeePerGas: 1000000000n,
maxFeePerGas: 2071640290n,
maxFeePerBlobGas: null,
data: '0xe47677770000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000083f2b4fe33e562b8ea',
value: 0n,
chainId: 31337n,
signature: Signature { r: "0x99bd488a22d2f997948d9e3ec3cd4d386124b62470412b87ce50da0b736ba3c5", s: "0x27e18ed55ddcf575dcb9bac6a40a42fa05047d0910f8dde09e0fd4b1c78144d4", yParity: 0, networkV: null },
accessList: [],
blobVersionedHashes: null
}
deployer地址余额9998.997207031371041941 ETH
deployer地址DAI账户余额2435012356407782979818n
测试结果
运行结果符合预期,deployer账户结余9999以太,1个以太通过wETH -> DAI兑换为了2435个DAI, 这与今天上午(2024-8-28)的以太坊价格匹配,因为我们forking的区块号是上午出块的一个区块。
可以看到,我们编写了UniswapV2SwapExamples合约,用的是Hardhat本地模拟账户,但是交互中使用的WETH、DAI以及Uniswap V2的router合约都是跟真实主网上的业务逻辑一样的,且在指定区块号的状态数据也是一样的。这会大大提高测试DeFi应用的效率。
我们再来看下把daiAmountMin修改一下:let daiAmountMin = 2436012356407782979818n
,2436这超出了当时ETH/DAI的价格。仍然运行上面测试脚本的话会抛异常Error: VM Exception while processing transaction: reverted with reason string 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT'
参考
https://hardhat.org/hardhat-network/docs/guides/forking-other-networks