第1章:流动性池搭建实战——用Hardhat部署一个ERC20代币对池

各位同学好,我是老李。今天咱们直接上手干点实在的——用Hardhat搭一个简单的ERC20代币对流动性池。说实话,很多教程一上来就讲Uniswap V3的复杂架构,我个人觉得没必要。你先把最基础的池子跑通,后面那些花哨的东西自然就理解了。

1.1 准备工作:环境与合约

先说说环境。我习惯用Hardhat,因为它调试方便,跑测试也快。你本地装好Node.js和npm就行,版本别太老,16以上吧。

mkdir lp-pool-demo && cd lp-pool-demo
npm init -y
npm install --save-dev hardhat @nomiclabs/hardhat-waffle ethereum-waffle chai
npx hardhat init

嗯,这里要注意:选「Create an empty hardhat.config.js」,别选那个带示例项目的。我们从头写,印象更深。

接下来是两个ERC20代币合约。我直接写个简化版,不带那些花里胡哨的权限控制,够用就行。

// contracts/TokenA.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract TokenA is ERC20 {
    constructor() ERC20("Token A", "TKA") {
        _mint(msg.sender, 1000000 * 10 ** decimals());
    }
}

TokenB同理,换个名字和符号。记得把两个合约都部署到本地网络。

1.2 核心:流动性池合约

池子合约才是重头戏。我设计了一个极简版本,只保留最核心的功能:初始化、添加流动性、移除流动性。你想想看,Uniswap V2的池子核心逻辑其实也就这么几行。

// contracts/SimpleLiquidityPool.sol
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SimpleLiquidityPool {
    using SafeERC20 for IERC20;

    IERC20 public token0;
    IERC20 public token1;
    uint256 public reserve0;
    uint256 public reserve1;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    event Mint(address indexed sender, uint256 amount0, uint256 amount1);
    event Burn(address indexed sender, uint256 amount0, uint256 amount1);

    constructor(address _token0, address _token1) {
        token0 = IERC20(_token0);
        token1 = IERC20(_token1);
    }

    // 初始化流动性
    function initialize(uint256 amount0, uint256 amount1) external {
        require(totalSupply == 0, "already initialized");
        token0.safeTransferFrom(msg.sender, address(this), amount0);
        token1.safeTransferFrom(msg.sender, address(this), amount1);
        uint256 liquidity = sqrt(amount0 * amount1);
        _mint(msg.sender, liquidity);
        reserve0 = amount0;
        reserve1 = amount1;
    }

    // 添加流动性
    function addLiquidity(uint256 amount0, uint256 amount1) external {
        require(totalSupply > 0, "not initialized");
        // 按当前比例计算应添加的数量
        uint256 amount0Optimal = (amount1 * reserve0) / reserve1;
        uint256 amount1Optimal = (amount0 * reserve1) / reserve0;
        // 取较小值
        if (amount0Optimal <= amount0) {
            amount0 = amount0Optimal;
        } else {
            amount1 = amount1Optimal;
        }
        token0.safeTransferFrom(msg.sender, address(this), amount0);
        token1.safeTransferFrom(msg.sender, address(this), amount1);
        uint256 liquidity = (totalSupply * amount0) / reserve0;
        _mint(msg.sender, liquidity);
        reserve0 += amount0;
        reserve1 += amount1;
    }

    // 移除流动性
    function removeLiquidity(uint256 liquidity) external {
        require(balanceOf[msg.sender] >= liquidity, "insufficient LP tokens");
        uint256 amount0 = (liquidity * reserve0) / totalSupply;
        uint256 amount1 = (liquidity * reserve1) / totalSupply;
        _burn(msg.sender, liquidity);
        reserve0 -= amount0;
        reserve1 -= amount1;
        token0.safeTransfer(msg.sender, amount0);
        token1.safeTransfer(msg.sender, amount1);
    }

    function _mint(address to, uint256 amount) internal {
        totalSupply += amount;
        balanceOf[to] += amount;
    }

    function _burn(address from, uint256 amount) internal {
        totalSupply -= amount;
        balanceOf[from] -= amount;
    }

    function sqrt(uint256 y) internal pure returns (uint256 z) {
        if (y > 3) {
            z = y;
            uint256 x = y / 2 + 1;
            while (x < z) {
                z = x;
                x = (y / x + x) / 2;
            }
        } else if (y != 0) {
            z = 1;
        }
    }
}

核心逻辑一句话:添加流动性时按当前池子比例存入代币,移除时按LP份额比例取出。价格由储备量决定,这就是AMM最朴素的思想。

1.3 部署脚本与测试

部署脚本我习惯写在scripts目录下。先部署两个代币,再部署池子,最后初始化。

// scripts/deploy.js
async function main() {
    const [deployer] = await ethers.getSigners();
    console.log("Deploying with account:", deployer.address);

    // 部署代币
    const TokenA = await ethers.getContractFactory("TokenA");
    const tokenA = await TokenA.deploy();
    await tokenA.deployed();
    console.log("TokenA deployed to:", tokenA.address);

    const TokenB = await ethers.getContractFactory("TokenB");
    const tokenB = await TokenB.deploy();
    await tokenB.deployed();
    console.log("TokenB deployed to:", tokenB.address);

    // 部署池子
    const SimpleLiquidityPool = await ethers.getContractFactory("SimpleLiquidityPool");
    const pool = await SimpleLiquidityPool.deploy(tokenA.address, tokenB.address);
    await pool.deployed();
    console.log("Pool deployed to:", pool.address);

    // 初始化流动性
    const amount0 = ethers.utils.parseEther("1000");
    const amount1 = ethers.utils.parseEther("2000");
    await tokenA.approve(pool.address, amount0);
    await tokenB.approve(pool.address, amount1);
    await pool.initialize(amount0, amount1);
    console.log("Pool initialized with 1000 TKA and 2000 TKB");
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});

跑一下看看:

npx hardhat run scripts/deploy.js --network localhost

如果看到「Pool initialized」的日志,恭喜你,池子跑通了。我记得第一次跑通时还挺兴奋的,虽然就几行代码,但那种「我造了一个DEX」的感觉确实不错。

1.4 避坑指南

我曾经踩过的坑:

  • 精度问题:初始化时amount0和amount1的比例决定了初始价格。如果你设成1:1,那1个TKA就值1个TKB。但实际项目中,代币精度可能不同(比如USDC是6位,WETH是18位),一定要用parseUnits统一处理。
  • 滑点保护:这个极简版没有滑点保护。真实场景中,用户添加流动性时如果池子比例变化太大,应该允许用户设置最大偏差。我早期一个项目就因为这个被MEV机器人薅了羊毛。
  • 重入攻击:虽然用了SafeERC20,但removeLiquidity函数中先更新状态再转账的顺序是安全的。如果你反过来写,就可能被重入。记住:先减后转,先改后发

1.5 知识体系图

下面这张图帮你理清整个流程:

流动性池搭建核心流程 1. 部署ERC20代币 TokenA + TokenB 2. 部署流动性池 传入两个代币地址 3. 初始化流动性 首次注入代币对 4a. 添加流动性 按比例存入,获得LP 4b. 移除流动性 销毁LP,取回代币 4c. 查询储备 reserve0/reserve1 核心公式:k = reserve0 * reserve1(恒定乘积)

1.6 实战小贴士

我个人习惯:

  • 本地测试时,先用Hardhat的console.log打印储备量变化,确认每一步都符合预期。
  • 初始化时,我建议先小额测试(比如0.1个代币),确认逻辑无误后再上大额。
  • 如果你在测试网上部署,记得先领测试币。我一般用Goerli,水龙头一抓一大把。

好了,这一章的内容就到这里。你跟着把代码跑通,理解初始化、添加、移除这三个操作,后面讲无常损失、做市策略时你才能跟得上。记住:纸上得来终觉浅,绝知此事要躬行。动手敲一遍,比看十遍教程都管用。

专注资料整理