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

ngrok本地调试原理及Telegram mini app cookie path 问题

ngrok本地调试原理及Telegram mini app cookie path 问题

在现代web开发中,本地调试是一个非常重要的环节。然而,当我们需要将本地开发的应用暴露到公网以便进行测试时,就会遇到一些挑战。本文将详细介绍如何使用ngrok实现内网穿透进行本地调试,特别是在Telegram小程序开发场景中的应用,以及可能遇到的常见问题及其解决方案。 ngrok原理 ngrok是一个反向代理工具,它可以将本地服务器安全地暴露到公网。下面是ngrok的工作原理: 1. 用户启动ngrok客户端,并指定要暴露的本地端口。 2. ngrok客户端与ngrok云服务建立安全的通道。 3. ngrok云服务生成一个公网可访问的URL。 4. 当外部请求到达这个URL时,ngrok云服务将请求通过安全通道转发到本地ngrok客户端。 5. 本地ngrok客户端将请求转发到指定的本地端口。 6. 本地服务器处理请求并返回响应,响应通过相同的路径返回给客户端。 Telegram小程序调试场景 在Telegram小程序开发中,我们经常需要使用ngrok来进行本地调试。以下是具体步骤: 1. 启动本地开发服务器(例如运行在localhost:3000)。

TypeScript:从架构分层设计到IOC和AOP

TypeScript:从架构分层设计到IOC和AOP

TypeScript作为JavaScript的超集,为开发者提供了强大的类型系统和面向对象编程能力。然而,要在大型项目中充分发挥TypeScript的优势,我们需要深入理解软件架构原则和设计模式。本文将探讨如何使用TypeScript构建一个健壮的应用架构,涵盖分层设计、常见设计模式、控制反转(IOC)和面向切面编程(AOP)等高级概念。 分层架构 分层架构是组织大型应用程序的常用方法。它有助于关注点分离,使得每一层都可以独立开发和测试。一个典型的分层架构包括: 1. 表现层(Presentation Layer) 2. 业务逻辑层(Business Logic Layer) 3. 数据访问层(Data Access Layer) 4. 数据库(Database) 让我们使用图表来可视化这个架构: 接下来,我们将探讨每一层中可能使用的设计模式,并通过TypeScript代码示例来说明如何实现这些模式。 表现层 表现层负责处理用户界面和用户交互。在这一层,我们经常使用MVC(Model-View-Controller)或MVVM(Model-View-ViewM

Jotai v2: React状态管理的新篇章

Jotai v2: React状态管理的新篇章

Jotai是一个为React应用设计的轻量级状态管理库。2023年3月,Jotai发布了v2.0版本,带来了许多新特性和改进。本文将深入探讨Jotai v2的使用方法、适用场景、设计理念、源码结构以及核心功能的实现原理。 版本信息 本文讨论的是Jotai v2.0.3版本,发布于2023年5月。你可以通过以下命令安装 npm install [email protected] 基本使用 Jotai的核心概念是"atom"。atom是最小的状态单位,可以存储任何JavaScript值。让我们看一个简单的例子: import { atom, useAtom } from 'jotai' // 创建一个atom const countAtom = atom(0) function Counter() { // 使用atom const [count, setCount] = useAtom(

加密货币交易所十二:安全性和风险控制

加密货币交易所十二:安全性和风险控制

在加密货币合约交易所中,安全性和风险控制是至关重要的。这不仅关系到交易所的声誉和用户的资产安全,也直接影响到整个加密货币生态系统的稳定性。本章将详细探讨合约交易所在安全性和风险控制方面的关键策略和实施方法。 多重签名机制 多重签名(MultiSig)是一种强大的安全机制,要求多个私钥来授权交易,大大降低了单点故障和内部欺诈的风险。 概念解释 多重签名是一种需要多个私钥来签署和授权交易的加密技术。例如,在一个 2-of-3 多重签名设置中,需要三个私钥中的任意两个来完成交易。 在合约交易所中的应用 热钱包管理: * 设置:通常采用 2-of-3 或 3-of-5 的多重签名方案。 * 应用:每次从热钱包转出大额资金时,需要多个管理员的授权。 冷钱包管理: * 设置:可能采用更严格的 3-of-5 或 4-of-7 方案。 * 应用:定期将热钱包中的多余资金转移到冷钱包时使用。 智能合约升级: * 设置:可能需要多个核心开发者和安全审计员的签名。 * 应用:在升级关键智能合约时,确保变更经过充分审核和授权。 实现考虑 密钥管理: * 使用硬件安全