Uniswap v2 学习
UniswapV2 的源码对于理解 DeFi 协议的设计和实现具有重要意义。本文将从架构、分层、模块划分、设计模式、关键特性实现、Gas 优化和安全考量等方面对 UniswapV2 合约进行详细解读。
合约架构
Uniswap v2的合约主要分为两类:core合约和periphery合约。
core合约仅包含最基础的交易功能,由于用户资金都存储在core合约里,因此需要保证core合约最简化,避免引入bug。主要包括:
- UniswapV2Factory:工厂合约,用于创建Pair合约
- UniswapV2Pair:Pair(交易对)合约,定义和交易相关的基础方法,如swap/mint/burn等
- UniswapV2ERC20:实现ERC20标准方法
periphery合约则针对用户使用场景提供更友好的接口,比如支持原生ETH交易、多路径交换等,其底层调用的是core合约。主要包括:
- UniswapV2Router02:最常用的接口合约,如添加/移除流动性,使用代币交换等
- 各种Library合约:提供计算最佳交易数量等功能
下图展示了UniswapV2合约的整体架构:
核心合约(core)
UniswapV2Pair
这是最核心的合约,每个交易对都对应一个UniswapV2Pair合约。主要功能包括:
mint
实现添加流动性。核心逻辑:
- 计算两种代币实际转入的数量(当前余额减去缓存余额)
- 计算协议手续费并铸造相应的流动性代币
- 更新储备量
关键代码:
// 计算转入数量
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
// 计算协议费用
bool feeOn = _mintFee(_reserve0, _reserve1);
// 铸造流动性代币
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
_mint(to, liquidity);
// 更新储备量
_update(balance0, balance1, _reserve0, _reserve1);
burn
实现移除流动性。核心逻辑:
- 计算协议手续费
- 根据销毁的流动性代币占总量的比例,计算可赎回的两种代币数量
- 销毁流动性代币并转移对应的代币
关键代码:
// 根据份额计算可赎回代币
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
// 销毁流动性代币
_burn(address(this), liquidity);
// 转移代币
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
// 更新储备量
_update(balance0, balance1, _reserve0, _reserve1);
swap
实现代币兑换。核心逻辑:
- 计算转入的代币数量(通过当前余额与缓存余额差值)
- 转移代币到目标地址
- 调用闪电贷回调函数(如果有)
- 根据兑换后代币余额更新价格
关键代码:
// 计算转入数量
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
// 转移代币
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
// 调用闪电贷回调
if (data.length > 0) {
IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
}
// 检查 K 值
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), "K");
// 更新储备量
_update(balance0, balance1, _reserve0, _reserve1);
UniswapV2Factory
工厂合约负责创建交易对。核心方法是createPair:
- 确保pair不存在
- 使用create2创建pair合约
- 调用pair的initialize方法
- 触发PairCreated事件
关键代码:
function createPair(address tokenA, address tokenB) external returns (address pair) {
// 确保 pair 不存在
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS');
// 使用 create2 创建合约
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
// 调用 initialize
IUniswapV2Pair(pair).initialize(token0, token1);
// 更新状态变量
getPair[token0][token1] = pair;
getPair[token1][token0] = pair;
allPairs.push(pair);
// 触发事件
emit PairCreated(token0, token1, pair, allPairs.length);
}
UniswapV2ERC20
这个合约实现了ERC20的标准接口。一个重要的方法是permit,实现了批准别的合约使用自己代币的功能,无需在合约内部调用approve。
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
// 检查过期时间
require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
// 计算签名的 digest
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);
}
外围合约(periphery)
UniswapV2Router02
这是最常用的接口合约,提供了添加/移除流动性、代币兑换等功能。
addLiquidity
添加流动性。主要逻辑:
- 计算最优数量
- 将代币发送到pair合约
- 调用mint铸造流动性代币
关键代码:
// 计算最优数量
(amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
// 将代币发送到pair合约
TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);
// 铸造流动性代币
liquidity = IUniswapV2Pair(pair).mint(to);
removeLiquidity
移除流动性。主要逻辑:
- 将流动性代币发送到pair合约
- 调用burn销毁流动性代币
- 将赎回的代币发送给用户
关键代码:
// 将流动性代币发送到pair合约
IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity);
// 销毁流动性代币
(uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to);
// 将赎回的代币发送给用户
(amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0);
swapExactTokensForTokens
使用固定数量代币A兑换尽量多的代币B。主要逻辑:
- 计算预期兑换数量
- 将代币A发送到pair合约
- 调用_swap执行兑换
关键代码:
// 计算预期兑换数量
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
// 将代币发送到 pair 合约
TransferHelper.safeTransferFrom(path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]);
// 执行兑换
_swap(amounts, path, to);
swapTokensForExactTokens
使用尽量少的代币A兑换固定数量代币B。主要逻辑:
- 计算预期兑换数量
- 将代币A发送到pair合约
- 调用_swap执行兑换
关键代码:
// 计算预期兑换数量
amounts = UniswapV2Library.getAmountsIn(factory, amountOut, path);
// 将代币发送到 pair 合约
TransferHelper.safeTransferFrom(path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]);
// 执行兑换
_swap(amounts, path, to);
Gas优化
Uniswap v2在合约编写时考虑了很多gas优化的细节。例如:
将storage变量缓存到memory
因为读写storage的gas消耗要远大于memory,所以对于需要多次读取的storage变量,可以先读取到memory中再使用。如UniswapV2Pair合约中的getReserves方法:
function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) {
_reserve0 = reserve0;
_reserve1 = reserve1;
_blockTimestampLast = blockTimestampLast;
}
再如swap函数中,先将 token0、token1 读取到内存:
function swap(...) external lock {
// ...
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
// ...
}
状态变量打包
Solidity 会将变量按照32字节对齐存储,如果能将多个变量打包到一个槽位内,就可以节省存储空间。如UniswapV2Pair合约中的reserve0、reserve1和blockTimestampLast三个变量,分别只占用112位、112位和32位,刚好可以打包到一个槽位内:
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
利用错误处理节省gas
在0.8.0版本之前,Solidity中的assert在验证失败时会消耗所有的gas,而require则会退还剩余的gas。因此可以在代码中巧妙地利用这一点,将一些不可能发生的错误用assert处理,这样反而可以节省gas。如UniswapV2Pair合约中的swap函数:
function swap(...) external lock {
// ...
uint amount0Out = amount0Out;
uint amount1Out = amount1Out;
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
// ...
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
// 这里用 assert 而不是 require
assert(balance0 >= _reserve0 - amount0Out);
assert(balance1 >= _reserve1 - amount1Out);
// ...
}
注意到这里用assert检查了balance0和balance1的值,因为这两个变量是通过balanceOf方法获取的,它们的值肯定大于等于_reserve0 - amount0Out和_reserve1 - amount1Out。所以这里用assert而不是require,可以节省gas。
安全考量
重入攻击防范
Uniswap v2在关键函数上使用了mutex锁(lock),防止重入攻击。关键代码如下:
contract UniswapV2Pair {
// ...
uint private unlocked = 1;
modifier lock() {
require(unlocked == 1, 'UniswapV2: LOCKED');
unlocked = 0;
_;
unlocked = 1;
}
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock { ... }
function mint(address to) external lock returns (uint liquidity) { ... }
function burn(address to) external lock returns (uint amount0, uint amount1) { ... }
// ...
}
注意到核心函数swap、mint、burn都使用了lock修饰器,它会在函数执行前将unlocked置为0,执行后再置为1,如果在函数执行过程中有其他合约调用这些函数,就会被mutex锁阻止,从而防止重入。
检查-生效-交互模式
为了防止重入攻击,Uniswap v2在编写代码时遵循"检查-生效-交互"模式,即先检查状态变量,再更新状态变量,最后与其他合约交互。如果先与其他合约交互,可能会发生重入攻击。
以UniswapV2Pair合约的mint函数为例:
function mint(address to) external lock returns (uint liquidity) {
// 检查
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
// 生效
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
// ...
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1);
// 交互
emit Mint(msg.sender, amount0, amount1);
}
可以看到,mint函数中先进行检查和状态更新,最后才emit事件与外部交互。
避免可预测的随机数
在开发合约时,经常会用到随机数。但在以太坊上,完全安全且不可预测的随机数是很难生成的。因为矿工可以控制块的时间戳、难度等参数,从而影响随机数的生成。
Uniswap v2在生成salt的时候,并没有使用常见的方法如blockhash、block.timestamp等,而是直接使用了token0和token1的地址。这样可以避免salt被矿工操纵。
function createPair(address tokenA, address tokenB) external returns (address pair) {
// ...
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
// ...
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
// ...
}
Uniswap v2合约代码是DeFi领域非常优秀的开源项目,它不仅实现了去中心化交易所的核心功能,而且在设计模式、gas优化、安全防护等方面都有很多值得学习的地方。通过深入分析Uniswap v2的源码,我们不仅可以了解AMM的实现原理,还能学到很多Solidity开发的最佳实践。