第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.6 实战小贴士
我个人习惯:
- 本地测试时,先用Hardhat的console.log打印储备量变化,确认每一步都符合预期。
- 初始化时,我建议先小额测试(比如0.1个代币),确认逻辑无误后再上大额。
- 如果你在测试网上部署,记得先领测试币。我一般用Goerli,水龙头一抓一大把。
好了,这一章的内容就到这里。你跟着把代码跑通,理解初始化、添加、移除这三个操作,后面讲无常损失、做市策略时你才能跟得上。记住:纸上得来终觉浅,绝知此事要躬行。动手敲一遍,比看十遍教程都管用。