用 Solidity 实现恒定乘积自动做市商(CPAMM):从 Uniswap V2 极简版看懂去中心化交易所核心机制
发布时间:2026/9/14 19:19:31来源:尧图网络
用 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 MakerCPAMM这一去中心化交易所DEX的核心机制展开。教学合约SimpleSwap由 Uniswap V2 合约简化而来在保留 CPAMM 最核心功能的前提下用最少的代码让你在本地复现添加流动性 → 交易 → 移除流动性的完整闭环。读完本文你将掌握 AMM 的定价原理、LP流动性提供者份额的铸造与销毁逻辑并能基于 OpenZeppelin 的 ERC20 模板独立实现一个可运行的极简 DEX 合约。AMM不再依赖订单簿的去中心化交易范式自动做市商Automated Market MakerAMM是一种运行在区块链上的算法与智能合约它允许数字资产之间直接进行去中心化交易。与传统的订单簿模式不同AMM 不需要买家和卖家提交买单/卖单进行撮合而是通过一条预设的数学公式如常数乘积公式维护一个流动性池让用户随时可以按公式计算出的价格完成交易。下图展示的是传统订单簿模式下的买卖挂单而 AMM 用流动性池彻底替代了这一撮合过程为便于理解下文以可乐$COLA和美元$USD的市场为例展开推演并约定如下符号$x$、$y$市场中可乐和美元的总量$\Delta{x}$、$\Delta{y}$一笔交易中可乐和美元的变化量$L$、$\Delta{L}$总流动性和流动性的变化量。恒定总和自动做市商CSAMM简单但流动性易枯竭恒定总和自动做市商Constant Sum Automated Market MakerCSAMM是最简单的 AMM 模型其交易约束为$$kxy$$其中 $k$ 为常数即交易前后市场中两种资产数量之和保持不变。举例市场流动性为 10 瓶可乐和 10 美元此时 $k20$可乐价格为 1 美元/瓶。若你想拿出 2 美元换可乐交易后市场美元总量变为 12根据 $k20$ 的约束市场中可乐剩 8 瓶价格仍为 1 美元/瓶你以 1 美元/瓶的价格换到 2 瓶可乐。CSAMM 的优点在于能保证代币相对价格不变这在稳定币兑换场景中至关重要——大家都希望 1 USDT 总能兑换出 1 USDC。但它的致命缺点是流动性极易耗尽在上述例子中只需 10 美元就能把市场上全部可乐买走其他用户将无币可换。恒定乘积自动做市商CPAMMUniswap 采用的无限流动性模型恒定乘积自动做市商CPAMM是目前最流行的 AMM 模型最早被 Uniswap 采用其交易约束为$$kx*y$$其中 $k$ 为常数即交易前后两种资产数量的乘积保持不变。沿用上例市场有 10 瓶可乐和 10 美元$k100$可乐价格为 1 美元/瓶。若你想拿出 10 美元换可乐在 CSAMM 中能换到 10 瓶可乐并耗尽流动性但在 CPAMM 中交易后市场美元总量变为 20根据 $k100$市场可乐剩 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 ProviderLP向池子注入资产、获取 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这可以从恒定乘积公式直接推导。交易前$$kx*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: MITpragma 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-contractsfoundry.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填 100tokenIn填 token0 地址amountOutMin填 0不限制滑点用 100 单位 token0 兑换 token1。调用SimpleSwap合约的reserve0和reserve1查看交易后的储备量应得到 200 和 50——上一步用 100 单位 token0 换出了 50 单位 token1$\frac{100*100}{100100}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*yk$ 推导出 $\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),仅供参考
网站建设高端定制企业官网