学习一下 Uniswap V2

[1] 前置知识

[1-1] AMM(Automated Market Makers)

一种去中心化的交易协议,用于在区块链网络中实现无需订单簿的资产交换。传统交易所通常依赖于买家和卖家通过订单簿来匹配交易,而AMM通过智能合约和算法自动确定资产的价格,从而进行交易。

AMM的基本原理是通过池子(Liquidity Pools)来提供流动性。流动性池通常由两种或更多种资产组成,并由流动性提供者(LP)存入这些资产。然后,AMM使用特定的算法(例如常见的x * y = k公式)来保持池子的平衡,并根据池中资产的相对比例来自动定价。这使得任何用户都可以与流动性池直接交换资产,而无需依赖订单簿。

例如,Uniswap、Sushiswap和Curve Finance都是基于AMM机制的去中心化交易所(DEX)。这些平台通过激励用户提供流动性,并收取交易费用作为奖励,来维持市场的活跃和流动性。

AMMs的主要优点包括:

  1. 去中心化:交易不需要经过中心化的中介机构,降低了操作的风险。

  2. 无需订单簿:用户可以直接与流动性池进行交易,而不需要找到匹配的买家或卖家。

  3. 流动性提供奖励:流动性提供者通过存入资产来获得交易费用的份额,从而获取被动收入。

不过,AMM也有一些挑战,例如滑点(交易价格的波动)和无常损失(流动性提供者在池子中资产价格变动时的潜在损失)。

[1-2] liquidity

假设一个 amm 中有代币 x 和代币 y

\[xy = L^2\] 这条曲线定义了一个 AMM 中合法的代币数目,只有在这条曲线上的点代表的代币数量才是合法的。因此,这条曲线也同时定义了流入流出的代币数目,因为一旦增加或减少代币x,代币y的数目也一定会随之变化。

上述方程中的 L 被称为流动性,L越大曲率越小、曲线越平滑,因此交易者交易时 pool 的稳定性更强。通俗来说就是你放进 200 个 token x,会获得接近 200 的 token y

[2] Uniswap V2 构建

[2-1] 概览

三个合约构成了 Uniswap V2: factory, router 和 pair

factory contract 用于部署 pair contract

pair contract 是一个持有一对 ERC20 代币的合约,这个合约还允许①用户添加或移除流动性(liqudity)②交易者用自己的代币交换这个 pair 合约中的代币(swap)

router contract 是用户与 pair 合于之间的中介合约,它的主要功能是安全地完成 pair 功能,并且支持多种代币的一次性交换(即 multiple hop swap)

[2-2] swap

[2-2-1] swap without fee

没有 fee 时的代币换出数目,计算公式如下

\[dy = \frac{L^2}{x1} - \frac{L^2}{x0}\]

\[dy = y_0 - \frac{y_0dx}{x_0 + dx}\]

[2-2-2] swap fee

swap fee 可以看作是换入换出 token 的手续费:

定义 swap fee rate 'f',\(0 \leq f \leq 1\)\[swap fee = f*dx\]

那么进入池子的 token x 的数目为:

\[(1-f)*dx\]

因此换出的 token x 数目为:

\[dy = \frac{(1-f)dx*y_0}{x_0+(1-f)dx}\]

[2-2-3] 具体流程

[2-2-3-1] single hop swap

用 WETH-DAI 这个代币对举例:

如果 user 想要将 WETH 换成 DAI,user 可以调用 router 中的 swapExactTokensForTokens 函数,这个函数做了一下三件事情:

①调用 TransferFrom 函数,将 WETH 转移到 pair 合约中

②调用 pair 的 swap 函数,计算流入的 WETH 能流出多少 DAI

③调用 transfer 函数,将 DAI 转移到 user 地址

[2-2-3-2] multiple hop swap

用 WETH-DAI 和 DAI-MKR 这两个代币对为例:

如果 user 想要将 WETH 换成 MKR,user 可以调用 router 中的 swapExactTokensForTokens 函数,这个函数做了一下五件事情:

①调用 TransferFrom 函数,将 WETH 转移到 WETH-DAI 这个 pair 合约中

②调用 pair 的 swap 函数,计算流入的 WETH 能流出多少 DAI

③调用 transfer 函数,将 DAI 转移到 DAI-MKR 这个 pair 合约中

④调用 pair 的 swap 函数,计算流入的 DAI 能流出多少 MKR

⑤调用 transfer 函数,将 MKR 转移到 user 的地址

[3] 代码分析

主要代码为:v2-core, v2-periphery

[3-1] v2-core

[3-1-1] UniswapV2ERC20

先来看看用于 ERC20 的变量定义

1
2
3
4
5
6
7
using SafeMath for uint; // 为 uint 使用 SafeMath 防止溢出
string public constant name = 'Uniswap V2'; // ERC20 token name
string public constant symbol = 'UNI-V2'; // ERC20 token symbol
uint8 public constant decimals = 18; // 1e18 的 18
uint public totalSupply; // 总共发行的 token 数目,这里指 LP 代币
mapping(address => uint) public balanceOf; // 指每一个 user 拥有的 LP 代币
mapping(address => mapping(address => uint)) public allowance; //

ERC712:用于格式化签名,防止重放等攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bytes32 public DOMAIN_SEPARATOR;
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
mapping(address => uint) public nonces;

event Approval(address indexed owner, address indexed spender, uint value);
event Transfer(address indexed from, address indexed to, uint value);

constructor() public {
uint chainId;
assembly {
chainId := chainid
}
// 这保证了该 separator 是“某一条链上的某一合约的某一 token 的某一版本”,防御了重放攻击
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes('1')),
chainId,
address(this)
)
);
}

其他函数与 ERC20 相似,看不懂的话可以去看我博客里关于 openzeppelin 源码分析那一篇文章里与 ERC20 有关的文章。我们来看一些 ERC20 里没有的东西:

如果说 approve 函数是 msg.sender 主动去批准 spender 去消费 msg.sender 的 token,那么 permit 函数更像是 msg.sender 打了一张借条给 spender,然后在 deadline 之前 spender 可以凭此借条去得到该合约的 token 批款。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function permit(
address owner,
address spender,
uint value,
uint deadline,
uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
bytes32 digest = keccak256(
abi.encodePacked(
'\x19\x01',
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
)
);
address recoveredAddress = ecrecover(digest, v, r, s);
require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
_approve(owner, spender, value);
}

[3-1-2] UniswapV2Factory

我们之前说过,Factory 主要用于初始化 token Pair。我们先来看一些基本的变量定义

1
2
3
4
5
6
// 这两个变量都设置了 swap fee 该给谁
address public feeTo;
address public feeToSetter;
// 记录两个 token 的代币对对应的 pair 合约的地址,比如 getPair[WETHaddress][DAIaddress]
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs; // 记录所有 Pair 合约的地址

其他类似于 getter 和 setter 的函数就不仔细说了,只说重点函数,后续的合约也是如此。

createPair 函数定义了如何创建一个 token pair。该函数首先检查了 tokenA 和 tokenB 的地址不能相同且不能为0,然后检查 (token0, token1) 这个 pair 没有创建过。之后使用 UniswapV2Pair 的 creationCode 去构建初始化一个合约,返回的地址存储在 getPair 和 allPairs 当中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
// memory 类型的前 0x20 字节存储的是动态数组的长度,因此 create2 时要 add(bytecode, 32)
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IUniswapV2Pair(pair).initialize(token0, token1);
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // populate mapping in the reverse direction
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}

[3-1-3] UniswapV2Pair

常规定义如下。值得注意的是价格累加器,该变量用于计算时间加权平均价格(TWAP),TWAP主要有以下三个作用:① 减少价格操纵 ② 提高价格稳定性 ③ 用于预言机,为其他合约提供可靠的价格信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using SafeMath  for uint;
using UQ112x112 for uint224; // UQ112x112 代表 112 位整数 112 位小数

uint public constant MINIMUM_LIQUIDITY = 10**3; // 最小流动性为 1000
bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));

address public factory;
address public token0;
address public token1;

uint112 private reserve0; // uses single storage slot, accessible via getReserves
uint112 private reserve1; // uses single storage slot, accessible via getReserves
uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves

uint public price0CumulativeLast; // 价格累加器
uint public price1CumulativeLast; // 价格累加器
uint public kLast; // reserve0 * reserve1, 最近一次 liquidity 更新时的 K

uint private unlocked = 1; // 重入锁

Pair 合约实现了两个意外处理函数skimsync

skim(address to): 当由于外部转账或其他原因导致合约中某个代币的余额多于其储备量时,可以调用 skim 函数将多余的代币转移到指定地址。这可以确保合约中的代币余额与其储备量一致,防止意外的资金滞留在合约中。比如某个用户错误地向合约地址发送了代币,导致合约的余额多于储备量。此时,可以调用 skim 函数将多余的代币转移到指定的救助地址。

sync():当合约的储备量与实际余额不一致时,可以调用 sync 函数更新储备量,使其与当前的实际余额一致。这在发生外部代币转账、合约升级或其他操作导致不一致时特别有用。比如在合约升级过程中,某些代币的余额发生了变化,但储备量没有及时更新。此时,可以调用 sync 函数同步储备量与实际余额。

1
2
3
4
5
6
7
8
9
10
11
12
// force balances to match reserves
function skim(address to) external lock {
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}

// force reserves to match balances
function sync() external lock {
_update(IERC20(token0).balanceOf(address(this)), IERC20(token1).balanceOf(address(this)), reserve0, reserve1);
}

_update 函数主要用于更新 reserves,并且增加价格累加器。由于价格累加器和 timeElapsed 都是以差值的形式被使用,因此无需考虑溢出的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
// * never overflows, and + overflow is desired
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}

函数 _mintFee 是 Uniswap V2 中用于计算和分配协议费用的函数。它根据流动性池的变化情况,决定是否铸造新的 LP 代币并将其分配给协议费用接收地址。据函数实现有下述公式

\[ \Delta_{LP} = \frac{totalSupply \times (\sqrt{r_0^* \times r_1^*} - \sqrt{r_0 \times r_1})}{5 \times \sqrt{r_0^* \times r_1^*} + \sqrt{r_0 \times r_1}} \]

其中: - ( r_0 ) 和 ( r_1 ) 是上一次更新时的储备量。 - ( r_0^* ) 和 ( r_1^* ) 是当前的储备量。

仓库代码说 "if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)",但举例可以发现铸造的 LP token 并不近似于 sqrt(k) 增量的 1/6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
address feeTo = IUniswapV2Factory(factory).feeTo();
feeOn = feeTo != address(0);
uint _kLast = kLast; // gas savings
if (feeOn) {
if (_kLast != 0) {
uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
uint rootKLast = Math.sqrt(_kLast);
if (rootK > rootKLast) {
uint numerator = totalSupply.mul(rootK.sub(rootKLast));
uint denominator = rootK.mul(5).add(rootKLast);
uint liquidity = numerator / denominator;
if (liquidity > 0) _mint(feeTo, liquidity);
}
}
} else if (_kLast != 0) {
kLast = 0;
}
}

burn 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function burn(address to) external lock returns (uint amount0, uint amount1) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];

bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));

_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Burn(msg.sender, amount0, amount1, to);
}