使用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

posted on 2024-08-28 15:54  肥兔子爱豆畜子  阅读(65)  评论(0编辑  收藏  举报

导航