Skip to main content

WTF Solidity极简入门: 56. 去中心化交易所

我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。

推特:@0xAA_Science@WTFAcademy_

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity


这一讲,我们将介绍恒定乘积自动做市商(Constant Product Automated Market Maker, CPAMM),它是去中心化交易所的核心机制,被Uniswap,PancakeSwap等一系列DEX采用。教学合约由Uniswap-v2合约简化而来,包括了CPAMM最核心的功能。

自动做市商

自动做市商(Automated Market Maker,简称 AMM)是一种算法,或者说是一种在区块链上运行的智能合约,它允许数字资产之间的去中心化交易。AMM 的引入开创了一种全新的交易方式,无需传统的买家和卖家进行订单匹配,而是通过一种预设的数学公式(比如,常数乘积公式)创建一个流动性池,使得用户可以随时进行交易。

接下来,我们以可乐(COLA)和美元(COLA)和美元(USD)的市场为例,给大家介绍 AMM。为了方便,我们规定一下符号: xxyy 分别表示市场中可乐和美元的总量, Δx\Delta xΔy\Delta y 分别表示一笔交易中可乐和美元的变化量,LLΔL\Delta L 表示总流动性和流动性的变化量。

恒定总和自动做市商

恒定总和自动做市商(Constant Sum Automated Market Maker, CSAMM)是最简单的自动做市商模型,我们从它开始。它在交易时的约束为:

k=x+yk=x+y

其中 kk 为常数。也就是说,在交易前后市场中可乐和美元数量的总和保持不变。举个例子,市场中流动性有 10 瓶可乐和 10 美元,此时 k=20k=20,可乐的价格为 1 美元/瓶。我很渴,想拿出 2 美元来换可乐。交易后市场中的美元总量变为 12,根据约束k=20k=20,交易后市场中有 8 瓶可乐,价格为 1 美元/瓶。我在交易中得到了 2 瓶可乐,价格为 1 美元/瓶。

CSAMM 的优点是可以保证代币的相对价格不变,这点在稳定币兑换中很重要,大家都希望 1 USDT 总能兑换出 1 USDC。但它的缺点也很明显,它的流动性很容易耗尽:我只需要 10 美元,就可以把市场上可乐的流动性耗尽,其他想喝可乐的用户就没法交易了。

下面我们介绍拥有”无限“流动性的恒定乘积自动做市商。

恒定乘积自动做市商

恒定乘积自动做市商(CPAMM)是最流行的自动做市商模型,最早被 Uniswap 采用。它在交易时的约束为:

k=xyk=x*y

其中 kk 为常数。也就是说,在交易前后市场中可乐和美元数量的乘积保持不变。同样的例子,市场中流动性有 10 瓶可乐和 10 美元,此时 k=100k=100,可乐的价格为 1 美元/瓶。我很渴,想拿出 10 美元来换可乐。如果在 CSAMM 中,我的交易会换来 10 瓶可乐,并耗尽市场上可乐的流动性。但在 CPAMM 中,交易后市场中的美元总量变为 20,根据约束 k=100k=100,交易后市场中有 5 瓶可乐,价格为 20/5=420/5 = 4 美元/瓶。我在交易中得到了 5 瓶可乐,价格为 10/5=210/5 = 2 美元/瓶。

CPAMM 的优点是拥有“无限”流动性:代币的相对价格会随着买卖而变化,越稀缺的代币相对价格会越高,避免流动性被耗尽。上面的例子中,交易让可乐从 1 美元/瓶 上涨到 4 美元/瓶,从而避免市场上的可乐被买断。

下面,让我们建立一个基于 CPAMM 的极简的去中心化交易所。

去中心化交易所

下面,我们用智能合约写一个去中心化交易所 SimpleSwap,支持用户交易一对代币。

SimpleSwap 继承了 ERC20 代币标准,方便记录流动性提供者提供的流动性。在构造器中,我们指定一对代币地址 token0token1,交易所仅支持这对代币。reserve0reserve1 记录了合约中代币的储备量。

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)和交易者(Trader)。下面我们分别实现这两部分的功能。

流动性提供

流动性提供者给市场提供流动性,让交易者获得更好的报价和流动性,并收取一定费用。

首先,我们需要实现添加流动性的功能。当用户向代币池添加流动性时,合约要记录添加的LP份额。根据 Uniswap V2,LP份额如下计算:

  1. 代币池被首次添加流动性时,LP份额 ΔL\Delta{L} 由添加代币数量乘积的平方根决定:

    ΔL=ΔxΔy\Delta{L}=\sqrt{\Delta{x} *\Delta{y}}

  2. 非首次添加流动性时,LP份额由添加代币数量占池子代币储备量的比例决定(两个代币的比例取更小的那个):

    ΔL=Lmin(Δxx,Δyy)\Delta{L}=L*\min{(\frac{\Delta{x}}{x}, \frac{\Delta{y}}{y})}

因为 SimpleSwap 合约继承了 ERC20 代币标准,在计算好LP份额后,可以将份额以代币形式铸造给用户。

下面的 addLiquidity() 函数实现了添加流动性的功能,主要步骤如下:

  1. 将用户添加的代币转入合约,需要用户事先给合约授权。
  2. 根据公式计算添加的流动性份额,并检查铸造的LP数量。
  3. 更新合约的代币储备量。
  4. 给流动性提供者铸造LP代币。
  5. 释放 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);
}

接下来,我们需要实现移除流动性的功能。当用户从池子中移除流动性 ΔL\Delta{L} 时,合约要销毁LP份额代币,并按比例将代币返还给用户。返还代币的计算公式如下:

Δx=ΔLLx\Delta{x}={\frac{\Delta{L}}{L} * x} Δy=ΔLLy\Delta{y}={\frac{\Delta{L}}{L} * y}

下面的 removeLiquidity() 函数实现移除流动性的功能,主要步骤如下:

  1. 获取合约中的代币余额。
  2. 按LP的比例计算要转出的代币数量。
  3. 检查代币数量。
  4. 销毁LP份额。
  5. 将相应的代币转账给用户。
  6. 更新储备量。
  7. 释放 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合约中,用户可以使用一种代币交易另一种。那么我用 Δx\Delta{x}单位的 token0,可以交换多少单位的 token1 呢?下面我们来简单推导一下。

根据恒定乘积公式,交易前:

k=xyk=x*y

交易后,有:

k=(x+Δx)(y+Δy)k=(x+\Delta{x})*(y+\Delta{y})

交易前后 kk 值不变,联立上面等式,可以得到:

Δy=Δxyx+Δx\Delta{y}=-\frac{\Delta{x}*y}{x+\Delta{x}}

因此,可以交换到的代币数量 Δy\Delta{y}Δx\Delta{x}xx,和 yy 决定。注意 Δx\Delta{x}Δy\Delta{y} 的符号相反,因为转入会增加代币储备量,而转出会减少。

下面的 getAmountOut() 实现了给定一个资产的数量和代币对的储备,计算交换另一个代币的数量。

// 给定一个资产的数量和代币对的储备,计算交换另一个代币的数量
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() 函数实现了交易代币的功能,主要步骤如下:

  1. 用户在调用函数时指定用于交换的代币数量,交换的代币地址,以及换出另一种代币的最低数量。
  2. 判断是 token0 交换 token1,还是 token1 交换 token0。
  3. 利用上面的公式,计算交换出代币的数量。
  4. 判断交换出的代币是否达到了用户指定的最低数量,这里类似于交易的滑点。
  5. 将用户的代币转入合约。
  6. 将交换的代币从合约转给用户。
  7. 更新合约的代币储备量。
  8. 释放 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 合约

SimpleSwap 的完整代码如下:

// 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 (https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#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);
}

// 给定一个资产的数量和代币对的储备,计算交换另一个代币的数量
// 由于乘积恒定
// 交换前: 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代币
// @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));
}
}

Remix 复现

  1. 部署两个ERC20代币合约(token0 和 token1),并记录它们的合约地址。

  2. 部署 SimpleSwap 合约,并将上面的代币地址填入。

  3. 调用两个ERC20代币的approve()函数,分别给 SimpleSwap 合约授权 1000 单位代币。

  4. 调用 SimpleSwap 合约的 addLiquidity() 函数给交易所添加流动性,token0 和 token1 分别添加 100 单位。

  5. 调用 SimpleSwap 合约的 balanceOf() 函数查看用户的LP份额,这里应该为 100。(100100=100\sqrt{100*100}=100

  6. 调用 SimpleSwap 合约的 swap() 函数进行代币交易,用 100 单位的 token0。

  7. 调用 SimpleSwap 合约的 reserve0reserve1 函数查看合约中的代币储备粮,应为 200 和 50。上一步我们利用 100 单位的 token0 交换了 50 单位的 token 1(100100100+100=50\frac{100*100}{100+100}=50)。

总结

这一讲,我们介绍了恒定乘积自动做市商,并写了一个极简的去中心化交易所。在极简Swap合约中,我们有很多没有考虑的部分,例如交易费用以及治理部分。如果你对去中心化交易所感兴趣,推荐你阅读 Programming DeFi: Uniswap V2Uniswap v3 book ,更深入的学习。