Uniswap v2 学习

Uniswap v2 学习
Photo by Annie Spratt / Unsplash

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

实现添加流动性。核心逻辑:

  1. 计算两种代币实际转入的数量(当前余额减去缓存余额)
  2. 计算协议手续费并铸造相应的流动性代币
  3. 更新储备量

关键代码:

// 计算转入数量
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

实现移除流动性。核心逻辑:

  1. 计算协议手续费
  2. 根据销毁的流动性代币占总量的比例,计算可赎回的两种代币数量
  3. 销毁流动性代币并转移对应的代币

关键代码:

// 根据份额计算可赎回代币
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

实现代币兑换。核心逻辑:

  1. 计算转入的代币数量(通过当前余额与缓存余额差值)
  2. 转移代币到目标地址
  3. 调用闪电贷回调函数(如果有)
  4. 根据兑换后代币余额更新价格

关键代码:

// 计算转入数量
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:

  1. 确保pair不存在
  2. 使用create2创建pair合约
  3. 调用pair的initialize方法
  4. 触发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

添加流动性。主要逻辑:

  1. 计算最优数量
  2. 将代币发送到pair合约
  3. 调用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

移除流动性。主要逻辑:

  1. 将流动性代币发送到pair合约
  2. 调用burn销毁流动性代币
  3. 将赎回的代币发送给用户

关键代码:

// 将流动性代币发送到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。主要逻辑:

  1. 计算预期兑换数量
  2. 将代币A发送到pair合约
  3. 调用_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。主要逻辑:

  1. 计算预期兑换数量
  2. 将代币A发送到pair合约
  3. 调用_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开发的最佳实践。

Read more

Vue.js异步更新与nextTick机制深度解析(上篇)

Vue.js异步更新与nextTick机制深度解析(上篇)

本文目标 学完本文,你将能够: * 理解Vue.js为什么采用异步更新策略 * 掌握更新队列的设计思想和实现机制 * 深入理解Event Loop在Vue中的应用 * 了解nextTick的多种实现方式 系列导航 上一篇: Diff算法深度剖析 | 下一篇: Vue.js异步更新与nextTick机制(下篇) | 组件系统架构 引言:为什么DOM更新是异步的? 在Vue.js开发中,你可能遇到过这样的场景: // 场景1:连续修改数据 export default { data() { return { count: 0 } }, methods: { increment() { // 如果每次修改都立即更新DOM,会触发3次DOM更新 this.count++ // 触发一次? this.count++ // 触发一次? this.count++ // 触发一次? // 实际上:Vue只会触发一次DOM更新!

Vue.js组件系统架构深度解析

本文目标 学完本文,你将能够: * 理解Vue.js组件从创建到销毁的完整生命周期 * 掌握组件实例化和初始化的内部流程 * 深入理解父子组件通信的底层机制 * 学会实现完整的插槽系统(包括作用域插槽) * 掌握动态组件和异步组件的实现原理 * 应用组件级别的性能优化技巧 系列导航 上一篇: 异步更新与nextTick(下篇) | 下一篇: 状态管理模式 引言:组件是如何工作的? 在Vue.js开发中,我们每天都在使用组件。但你是否想过: // 当我们这样定义一个组件 const MyComponent = { data() { return { count: 0 } }, template: '<button @click="count++">{{ count }}</button>' } // 并使用它时 new Vue({ components: { MyComponent }, template:

Vue.js状态管理模式:构建可扩展的应用架构

本文目标 学完本文,你将能够: * 理解为什么大型应用需要状态管理 * 掌握Vuex的核心设计模式和实现原理 * 实现一个简化版的状态管理库 * 理解模块化和命名空间的设计思想 * 掌握大型应用的状态管理最佳实践 * 了解现代状态管理方案的演进 系列导航 上一篇: 组件系统架构 | 下一篇: 性能优化实践 1. 为什么需要状态管理? 1.1 组件通信的困境 在大型Vue.js应用中,组件间的通信会变得异常复杂: // 问题场景:多层级组件的状态共享 // GrandParent.vue <template> <Parent :user="user" @update-user="updateUser" /> </template> // Parent.vue <template> <Child

Vue.js依赖收集与追踪机制深度剖析

本文目标 学完本文,你将能够: * 理解Vue.js如何精确知道哪些组件需要更新 * 掌握Dep、Watcher、Observer三大核心类的协作机制 * 深入理解依赖收集的时机和完整过程 * 能够手写一个完整的依赖收集系统 * 解决实际开发中的依赖追踪问题 系列导航 上一篇: 响应式系统核心原理 | 下一篇: Virtual DOM实现详解 引言:为什么Vue知道哪些组件需要更新? 在使用Vue.js时,你是否想过这样一个问题:当我们修改一个数据时,Vue是如何精确地知道哪些组件用到了这个数据,并只更新这些组件的? // 假设有这样的场景 const app = new Vue({ data: { user: { name: 'John', age: 25 } } }); // 组件A只用到了user.name // 组件B只用到了user.age // 组件C同时用到了name和age // 当我们修改user.name时 app.user.name = 'Jane&