用 Solidity 实现恒定乘积自动做市商(CPAMM):从 Uniswap V2 极简版看懂去中心化交易所核心机制
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
本讲是 WTF-Solidity 极简入门教程的第 56 讲,围绕恒定乘积自动做市商(Constant Product Automated Market Maker,CPAMM)这一去中心化交易所(DEX)的核心机制展开。教学合约SimpleSwap由 Uniswap V2 合约简化而来,在保留 CPAMM 最核心功能的前提下,用最少的代码让你在本地复现"添加流动性 → 交易 → 移除流动性"的完整闭环。读完本文,你将掌握 AMM 的定价原理、LP(流动性提供者)份额的铸造与销毁逻辑,并能基于 OpenZeppelin 的 ERC20 模板独立实现一个可运行的极简 DEX 合约。
AMM:不再依赖订单簿的去中心化交易范式
自动做市商(Automated Market Maker,AMM)是一种运行在区块链上的算法与智能合约,它允许数字资产之间直接进行去中心化交易。与传统的订单簿模式不同,AMM 不需要买家和卖家提交买单/卖单进行撮合,而是通过一条预设的数学公式(如常数乘积公式)维护一个流动性池,让用户随时可以按公式计算出的价格完成交易。下图展示的是传统订单簿模式下的买卖挂单,而 AMM 用流动性池彻底替代了这一撮合过程:
为便于理解,下文以可乐($COLA)和美元($USD)的市场为例展开推演,并约定如下符号:
- $x$、$y$:市场中可乐和美元的总量;
- $\Delta{x}$、$\Delta{y}$:一笔交易中可乐和美元的变化量;
- $L$、$\Delta{L}$:总流动性和流动性的变化量。
恒定总和自动做市商(CSAMM):简单但流动性易枯竭
恒定总和自动做市商(Constant Sum Automated Market Maker,CSAMM)是最简单的 AMM 模型,其交易约束为:
$$k=x+y$$
其中 $k$ 为常数,即交易前后市场中两种资产数量之和保持不变。举例:市场流动性为 10 瓶可乐和 10 美元,此时 $k=20$,可乐价格为 1 美元/瓶。若你想拿出 2 美元换可乐,交易后市场美元总量变为 12,根据 $k=20$ 的约束,市场中可乐剩 8 瓶,价格仍为 1 美元/瓶,你以 1 美元/瓶的价格换到 2 瓶可乐。
CSAMM 的优点在于能保证代币相对价格不变,这在稳定币兑换场景中至关重要——大家都希望 1 USDT 总能兑换出 1 USDC。但它的致命缺点是流动性极易耗尽:在上述例子中,只需 10 美元就能把市场上全部可乐买走,其他用户将无币可换。
恒定乘积自动做市商(CPAMM):Uniswap 采用的"无限"流动性模型
恒定乘积自动做市商(CPAMM)是目前最流行的 AMM 模型,最早被 Uniswap 采用,其交易约束为:
$$k=x*y$$
其中 $k$ 为常数,即交易前后两种资产数量的乘积保持不变。沿用上例:市场有 10 瓶可乐和 10 美元,$k=100$,可乐价格为 1 美元/瓶。若你想拿出 10 美元换可乐:在 CSAMM 中能换到 10 瓶可乐并耗尽流动性;但在 CPAMM 中,交易后市场美元总量变为 20,根据 $k=100$,市场可乐剩 5 瓶,价格变为 $20/5 = 4$ 美元/瓶,你实际换得 5 瓶可乐,单价 $10/5 = 2$ 美元/瓶。
CPAMM 的核心优势是"无限"流动性:代币相对价格会随买卖动态变化,越稀缺的代币价格越高,从而避免流动性被一次性买断。上例中,一次交易让可乐从 1 美元/瓶涨到 4 美元/瓶,市场因此避免了可乐被清空的结局。
SimpleSwap:一个极简的去中心化交易所合约
下面基于 CPAMM 编写极简去中心化交易所SimpleSwap,完整代码见 SimpleSwap.sol(仓库根目录副本位于 src/56_DEX/SimpleSwap.sol)。
合约骨架:继承 ERC20 并锁定交易对
SimpleSwap继承 ERC20 代币标准,用 LP 代币记录流动性提供者的份额。构造器中指定一对代币地址token0和token1,交易所只支持这对代币;reserve0和reserve1记录合约中两种代币的储备量:
contract SimpleSwap is ERC20 { // 代币合约 IERC20 public token0; IERC20 public token1; // 代币储备量 uint public reserve0; uint public reserve1; // 构造器,初始化代币地址 constructor(IERC20 _token0, IERC20 _token1) ERC20("SimpleSwap", "SS") { token0 = _token0; token1 = _token1; } }合约的参与者分为两类:流动性提供者(Liquidity Provider,LP)向池子注入资产、获取 LP 份额并收取费用;交易者(Trader)用其中一种代币按公式兑换另一种代币。下面分别实现这两类功能。
流动性提供:LP 份额的铸造与销毁
流动性提供者给市场提供流动性,让交易者获得更好的报价和深度,并因此获得相应回报。合约需要精确记录每次注入/撤出流动性的份额。
添加流动性:addLiquidity()
根据 Uniswap V2 的设计,LP 份额按如下规则计算:
- 首次添加流动性时,LP 份额 $\Delta{L}$ 由添加代币数量乘积的平方根决定:
$$\Delta{L}=\sqrt{\Delta{x} *\Delta{y}}$$
- 非首次添加流动性时,LP 份额由添加代币数量占池子储备量的比例决定(两个比例取更小者):
$$\Delta{L}=L*\min{(\frac{\Delta{x}}{x}, \frac{\Delta{y}}{y})}$$
由于SimpleSwap继承了 ERC20 标准,计算出份额后可将其以代币形式铸造给用户。addLiquidity()的实现步骤如下:
- 将用户添加的代币转入合约(需用户事先对合约授权);
- 按公式计算流动性份额并检查铸造的 LP 数量;
- 更新合约的代币储备量;
- 给流动性提供者铸造 LP 代币;
- 释放
Mint事件。
event Mint(address indexed sender, uint amount0, uint amount1); // 添加流动性,转进代币,铸造LP // @param amount0Desired 添加的token0数量 // @param amount1Desired 添加的token1数量 function addLiquidity(uint amount0Desired, uint amount1Desired) public returns(uint liquidity){ // 将添加的流动性转入Swap合约,需事先给Swap合约授权 token0.transferFrom(msg.sender, address(this), amount0Desired); token1.transferFrom(msg.sender, address(this), amount1Desired); // 计算添加的流动性 uint _totalSupply = totalSupply(); if (_totalSupply == 0) { // 如果是第一次添加流动性,铸造 L = sqrt(x * y) 单位的LP(流动性提供者)代币 liquidity = sqrt(amount0Desired * amount1Desired); } else { // 如果不是第一次添加流动性,按添加代币的数量比例铸造LP,取两个代币更小的那个比例 liquidity = min(amount0Desired * _totalSupply / reserve0, amount1Desired * _totalSupply /reserve1); } // 检查铸造的LP数量 require(liquidity > 0, 'INSUFFICIENT_LIQUIDITY_MINTED'); // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); // 给流动性提供者铸造LP代币,代表他们提供的流动性 _mint(msg.sender, liquidity); emit Mint(msg.sender, amount0Desired, amount1Desired); }这里用到了两个内部工具函数:min()取两数最小值;sqrt()采用巴比伦方法(Babylonian method)计算整数平方根——这正是首次铸造 LP 份额 $\sqrt{x*y}$ 所需的运算:
// 取两个数的最小值 function min(uint x, uint y) internal pure returns (uint z) { z = x < y ? x : y; } // 计算平方根 babylonian method function sqrt(uint y) internal pure returns (uint z) { if (y > 3) { z = y; uint x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } }移除流动性:removeLiquidity()
当用户从池子撤出流动性 $\Delta{L}$ 时,合约销毁对应 LP 份额,并按比例返还代币,计算公式为:
$$\Delta{x}={\frac{\Delta{L}}{L} * x}$$
$$\Delta{y}={\frac{\Delta{L}}{L} * y}$$
removeLiquidity()的执行步骤:
- 获取合约中的代币余额;
- 按 LP 比例计算应转出的代币数量;
- 检查代币数量;
- 销毁 LP 份额;
- 将对应代币转账给用户;
- 更新储备量;
- 释放
Burn事件。
// 移除流动性,销毁LP,转出代币 // 转出数量 = (liquidity / totalSupply_LP) * reserve // @param liquidity 移除的流动性数量 function removeLiquidity(uint liquidity) external returns (uint amount0, uint amount1) { // 获取余额 uint balance0 = token0.balanceOf(address(this)); uint balance1 = token1.balanceOf(address(this)); // 按LP的比例计算要转出的代币数量 uint _totalSupply = totalSupply(); amount0 = liquidity * balance0 / _totalSupply; amount1 = liquidity * balance1 / _totalSupply; // 检查代币数量 require(amount0 > 0 && amount1 > 0, 'INSUFFICIENT_LIQUIDITY_BURNED'); // 销毁LP _burn(msg.sender, liquidity); // 转出代币 token0.transfer(msg.sender, amount0); token1.transfer(msg.sender, amount1); // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); emit Burn(msg.sender, amount0, amount1); }交易:从恒定乘积公式到 swap() 实现
完成流动性功能后,进入交易部分:用户用 $\Delta{x}$ 单位的 token0,能交换多少单位 token1?这可以从恒定乘积公式直接推导。
交易前:
$$k=x*y$$
交易后:
$$k=(x+\Delta{x})*(y+\Delta{y})$$
由于交易前后 $k$ 不变,联立两式可得:
$$\Delta{y}=-\frac{\Delta{x}*y}{x+\Delta{x}}$$
可见换出数量 $\Delta{y}$ 由 $\Delta{x}$、$x$、$y$ 共同决定。注意 $\Delta{x}$ 与 $\Delta{y}$ 符号相反——转入增加储备,转出减少储备。getAmountOut()正是基于此公式,给定一种资产的数量和代币对储备,计算另一种代币的换出数量:
// 给定一个资产的数量和代币对的储备,计算交换另一个代币的数量 // 由于乘积恒定 // 交换前: k = x * y // 交换后: k = (x + delta_x) * (y + delta_y) // 可得 delta_y = - delta_x * y / (x + delta_x) // 正/负号代表转入/转出 function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) public pure returns (uint amountOut) { require(amountIn > 0, 'INSUFFICIENT_AMOUNT'); require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY'); amountOut = amountIn * reserveOut / (reserveIn + amountIn); }swap():完整的代币兑换入口
swap()函数实现交易全流程:
- 调用者指定用于交换的代币数量、代币地址,以及换出另一种代币的最低数量(
amountOutMin,相当于滑点下限); - 判断是 token0 换 token1,还是 token1 换 token0;
- 利用上述公式计算换出代币数量;
- 校验换出数量是否达到用户设定的最低值,防止因链上价格波动导致严重滑点;
- 将用户代币转入合约;
- 将换出代币从合约转给用户;
- 更新合约储备量;
- 释放
Swap事件。
// swap代币 // @param amountIn 用于交换的代币数量 // @param tokenIn 用于交换的代币合约地址 // @param amountOutMin 交换出另一种代币的最低数量 function swap(uint amountIn, IERC20 tokenIn, uint amountOutMin) external returns (uint amountOut, IERC20 tokenOut){ require(amountIn > 0, 'INSUFFICIENT_OUTPUT_AMOUNT'); require(tokenIn == token0 || tokenIn == token1, 'INVALID_TOKEN'); uint balance0 = token0.balanceOf(address(this)); uint balance1 = token1.balanceOf(address(this)); if(tokenIn == token0){ // 如果是token0交换token1 tokenOut = token1; // 计算能交换出的token1数量 amountOut = getAmountOut(amountIn, balance0, balance1); require(amountOut > amountOutMin, 'INSUFFICIENT_OUTPUT_AMOUNT'); // 进行交换 tokenIn.transferFrom(msg.sender, address(this), amountIn); tokenOut.transfer(msg.sender, amountOut); }else{ // 如果是token1交换token0 tokenOut = token0; // 计算能交换出的token1数量 amountOut = getAmountOut(amountIn, balance1, balance0); require(amountOut > amountOutMin, 'INSUFFICIENT_OUTPUT_AMOUNT'); // 进行交换 tokenIn.transferFrom(msg.sender, address(this), amountIn); tokenOut.transfer(msg.sender, amountOut); } // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); emit Swap(msg.sender, amountIn, address(tokenIn), amountOut, address(tokenOut)); }值得注意的细节是:swap()中"计算换出数量"使用的是当前balanceOf(address(this))实时余额,而非上一次记录在reserve0/reserve1的值;交易完成后再用余额更新储备量。这种"先算后转"的顺序保证了合约始终以链上真实储备为依据,避免储备记录与余额不一致。
完整合约代码
SimpleSwap的完整实现如下(SPDX-License-Identifier: MIT,pragma solidity ^0.8.19;仓库内 SimpleSwap.sol 使用^0.8.34,与本仓库根目录 foundry.toml 中solc = "0.8.34"的编译配置保持一致):
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract SimpleSwap is ERC20 { // 代币合约 IERC20 public token0; IERC20 public token1; // 代币储备量 uint public reserve0; uint public reserve1; // 事件 event Mint(address indexed sender, uint amount0, uint amount1); event Burn(address indexed sender, uint amount0, uint amount1); event Swap( address indexed sender, uint amountIn, address tokenIn, uint amountOut, address tokenOut ); // 构造器,初始化代币地址 constructor(IERC20 _token0, IERC20 _token1) ERC20("SimpleSwap", "SS") { token0 = _token0; token1 = _token1; } // 取两个数的最小值 function min(uint x, uint y) internal pure returns (uint z) { z = x < y ? x : y; } // 计算平方根 babylonian method function sqrt(uint y) internal pure returns (uint z) { if (y > 3) { z = y; uint x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } } // 添加流动性,转进代币,铸造LP // 如果首次添加,铸造的LP数量 = sqrt(amount0 * amount1) // 如果非首次,铸造的LP数量 = min(amount0/reserve0, amount1/reserve1)* totalSupply_LP // @param amount0Desired 添加的token0数量 // @param amount1Desired 添加的token1数量 function addLiquidity(uint amount0Desired, uint amount1Desired) public returns(uint liquidity){ // 将添加的流动性转入Swap合约,需事先给Swap合约授权 token0.transferFrom(msg.sender, address(this), amount0Desired); token1.transferFrom(msg.sender, address(this), amount1Desired); // 计算添加的流动性 uint _totalSupply = totalSupply(); if (_totalSupply == 0) { // 如果是第一次添加流动性,铸造 L = sqrt(x * y) 单位的LP(流动性提供者)代币 liquidity = sqrt(amount0Desired * amount1Desired); } else { // 如果不是第一次添加流动性,按添加代币的数量比例铸造LP,取两个代币更小的那个比例 liquidity = min(amount0Desired * _totalSupply / reserve0, amount1Desired * _totalSupply /reserve1); } // 检查铸造的LP数量 require(liquidity > 0, 'INSUFFICIENT_LIQUIDITY_MINTED'); // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); // 给流动性提供者铸造LP代币,代表他们提供的流动性 _mint(msg.sender, liquidity); emit Mint(msg.sender, amount0Desired, amount1Desired); } // 移除流动性,销毁LP,转出代币 // 转出数量 = (liquidity / totalSupply_LP) * reserve // @param liquidity 移除的流动性数量 function removeLiquidity(uint liquidity) external returns (uint amount0, uint amount1) { // 获取余额 uint balance0 = token0.balanceOf(address(this)); uint balance1 = token1.balanceOf(address(this)); // 按LP的比例计算要转出的代币数量 uint _totalSupply = totalSupply(); amount0 = liquidity * balance0 / _totalSupply; amount1 = liquidity * balance1 / _totalSupply; // 检查代币数量 require(amount0 > 0 && amount1 > 0, 'INSUFFICIENT_LIQUIDITY_BURNED'); // 销毁LP _burn(msg.sender, liquidity); // 转出代币 token0.transfer(msg.sender, amount0); token1.transfer(msg.sender, amount1); // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); emit Burn(msg.sender, amount0, amount1); } // 给定一个资产的数量和代币对的储备,计算交换另一个代币的数量 function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) public pure returns (uint amountOut) { require(amountIn > 0, 'INSUFFICIENT_AMOUNT'); require(reserveIn > 0 && reserveOut > 0, 'INSUFFICIENT_LIQUIDITY'); amountOut = amountIn * reserveOut / (reserveIn + amountIn); } // swap代币 // @param amountIn 用于交换的代币数量 // @param tokenIn 用于交换的代币合约地址 // @param amountOutMin 交换出另一种代币的最低数量 function swap(uint amountIn, IERC20 tokenIn, uint amountOutMin) external returns (uint amountOut, IERC20 tokenOut){ require(amountIn > 0, 'INSUFFICIENT_OUTPUT_AMOUNT'); require(tokenIn == token0 || tokenIn == token1, 'INVALID_TOKEN'); uint balance0 = token0.balanceOf(address(this)); uint balance1 = token1.balanceOf(address(this)); if(tokenIn == token0){ // 如果是token0交换token1 tokenOut = token1; // 计算能交换出的token1数量 amountOut = getAmountOut(amountIn, balance0, balance1); require(amountOut > amountOutMin, 'INSUFFICIENT_OUTPUT_AMOUNT'); // 进行交换 tokenIn.transferFrom(msg.sender, address(this), amountIn); tokenOut.transfer(msg.sender, amountOut); }else{ // 如果是token1交换token0 tokenOut = token0; // 计算能交换出的token1数量 amountOut = getAmountOut(amountIn, balance1, balance0); require(amountOut > amountOutMin, 'INSUFFICIENT_OUTPUT_AMOUNT'); // 进行交换 tokenIn.transferFrom(msg.sender, address(this), amountIn); tokenOut.transfer(msg.sender, amountOut); } // 更新储备量 reserve0 = token0.balanceOf(address(this)); reserve1 = token1.balanceOf(address(this)); emit Swap(msg.sender, amountIn, address(tokenIn), amountOut, address(tokenOut)); } }合约依赖 OpenZeppelin 的 ERC20 实现(@openzeppelin/contracts/token/ERC20/ERC20.sol),该依赖在本仓库中位于 lib/openzeppelin-contracts,foundry.toml中已配置了对应的 remapping,可直接编译运行。
Remix 复现:7 步跑通极简 DEX
在 Remix IDE 中按以下 7 步即可完整复现交易所的添加流动性、查询储备、交易全流程:
部署两个 ERC20 代币合约(token0 和 token1),记录各自的合约地址,例如可选用仓库中已有的 ERC20 实现(如 src/31_ERC20/ERC20.sol)作为代币来源。
部署
SimpleSwap合约,将上一步记录的两个代币地址分别填入构造参数_token0与_token1。调用两个 ERC20 代币的
approve()函数,分别向SimpleSwap合约授权 1000 单位代币——这是addLiquidity()内部执行transferFrom的前提。调用
SimpleSwap合约的addLiquidity(),token0 和 token1 各添加 100 单位,为交易所注入首批流动性。调用
SimpleSwap合约的balanceOf()查看用户的 LP 份额,这里应得到 100($\sqrt{100*100}=100$),验证首次添加流动性的份额公式。调用
SimpleSwap合约的swap()进行代币交易:amountIn填 100,tokenIn填 token0 地址,amountOutMin填 0(不限制滑点),用 100 单位 token0 兑换 token1。调用
SimpleSwap合约的reserve0和reserve1查看交易后的储备量,应得到 200 和 50——上一步用 100 单位 token0 换出了 50 单位 token1($\frac{100*100}{100+100}=50$),如下方截图所示,交易后的储备变化与恒定乘积公式完全吻合:
每一步的 Remix 操作界面截图(创建 ERC20、传入代币地址、授权、调用 addLiquidity、查看 LP 余额、发起 swap)均可参考 56_DEX/img 目录下的 56-2 至 56-11 系列图片。
总结与延伸
本讲介绍了恒定乘积自动做市商(CPAMM),并基于它实现了一个极简去中心化交易所SimpleSwap,覆盖了三大核心能力:
- LP 份额管理:首次添加按 $\sqrt{x*y}$ 铸造份额,非首次按 $\min(\frac{\Delta{x}}{x}, \frac{\Delta{y}}{y})$ 比例铸造;移除时按份额比例销毁并返还代币;
- 恒定乘积定价:由 $x*y=k$ 推导出 $\Delta{y}=-\frac{\Delta{x}*y}{x+\Delta{x}}$,并通过
getAmountOut()落地; - 滑点保护:
swap()中的amountOutMin参数让交易者对最差成交价设下限。
当然,极简版SimpleSwap还省略了很多生产级 DEX 的必要机制:交易费用、治理、闪电贷保护、价格预言机、手续费分成等。若想深入了解 Uniswap V2/V3 的完整实现,可继续对比阅读本仓库后续相关章节(如 ERC20、ERC721 等标准实现章节),并结合 Topics 目录下的进阶笔记做进一步研究。作为个人学习实践,建议在 Remix 中完整走一遍上述 7 步流程,亲手观察储备量与价格随交易的动态变化,这将是理解 AMM 机制最直观的方式。
【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考