Uniswap-v2架构

Uniswap v2的代码结构采用了Core-Periphery,这种架构将交易对的数据状态和底层函数这类上链就不能更改的部分归入Uniswap Core,而将非核心功能的代码放入Uniswap Periphery。

为什么要使用Core-Periphery代码结构

在研究Uniswap v2源码时,我对它的代码结构产生了很大的疑惑,为什么要使用Core-Periphery代码结构。研究v2代码之前,我也对Maker协议的源码进行过分析,它们的合约代码有一个特点:底层逻辑和上层业务是分离的。

为什么它们合约结构会有这个特点?

我们可以从区块链的特性解释。我们知道一旦合约上链,其代码内容就不可篡改。如果我们将代码的所有逻辑写入一个合约中,那么我们在合约上链之后就不能对业务功能进行升级了。而我们可以讲合约代码分为多个逻辑,比如一部分是不可修改的底层逻辑代码,另一部分是建立底层逻辑上可以更换的。

v2_9

v2-periphery中我们也能够发现Core-Periphery代码结构带来的好处,仓库中有两个UniswapV2Router合约,其中Router02是Router01的改进版。

Core和Periphery包含的代码

了解Core-Periphery代码结构后,我们分析下Core和Periphery合约构成。Core和Periphery将swap、流动性管理分成了两部分,Core合约包括了合约的底层逻辑,这里的底层逻辑是向用户Tranfer代币。而Periphery的逻辑是用户向交易池Transfer代币。

Core

Core将swap的卖token的过程,即发送token到交易者地址的功能放在UniswapV2Pair,这过程还会检查流通性k的合法性(knew >= kpervious)。此外添加流通性和移除流动性需要使用的mint和burn也在该合约实现。

  • UniswapV2ERC20
  • UniswapV2Factory
  • UniswapV2Pair

Periphery

Periphery将swap的买token的过程,即发送token到pair地址的功能放在UniswapV2Router02。添加流通性和移除流动性之前需要transfer的token也会在该部分实现。

  • UniswapV2Router01
  • UniswapV2Router02
  • UniswapV2Migrator

Uniswap-v2 Core

Uniswap-v2 core是一个Uniswap核心部分,其UniswapV2ERC20是uniswap项目的LPT,每当用户添加流动性就能够mint一笔LPT,同样用户也可以burn一笔LPT移除流动性。

流动性管理和交易由UniswapV2Pair负责,swap的计算和流动性相关计算笔者在Uniswap-v1介绍和源码分析 - Salbt’s blog中进行了讲解。UniswapV2Pair实现了TWAP和闪电贷,我们会在下面深入。

然后v1中还有一个工厂合约,在v2中对工厂进行了升级,v2使用ceate2创建合约能计算出UniswapV2Pair具体地址。在UniswapV2Factory会深入讲解create2这个指令。

UniswapV2ERC20

其中UniswapV2ERC20作为项目的LPT,它是一个ERC20标准的Token,当用户添加流动性时就能获得UniswapV2ERC20代币(Uni)。

这里展现UniswapV2ERC20的元数据,ERC20标准不作过多介绍,详细查看:ERC-20: Token Standard (ethereum.org)

1
2
3
4
5
6
7
8
contract UniswapV2ERC20 {
using SafeMath for uint;

string public constant name = 'Uniswap V2';
string public constant symbol = 'UNI-V2';
uint8 public constant decimals = 18;
// ...
}

UniswapV2Factory

UniswapV2Factor功能大致能分为两个:打开手续费开关和创建pair。打开手续费通过setFeeTo(address _feeTo)函数实现,如果地址没有指定,交易池就不会收取协议费用。

这里我们重点分析UniswapV2Factory的createPair函数。

  • v2的createPair允许添加两个token地址。
  • v2使用create2创建合约,等下会介绍create2创建合约的优势。
  • v2会将ERC20 pair存储在合约中,不同ERC20之间的交易可以通过路由找寻。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
// 对token进行排序,这样就能支持任意的无序token pair创建
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
// 不允许已存在的pair创建
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS');
// 创建合约
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);
// 存储ERC20 pair到工厂中
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // populate mapping in the reverse direction
allPairs.push(pair);
// 触发事件
emit PairCreated(token0, token1, pair, allPairs.length);
}

create2

上面介绍了,Uniswap工厂合约使用内联汇编中的create2创建合约,下面就详细介绍create2的作用。

create2(value, offset, size, salt):create2和create功能相同,能够从内存中指定offset提供的初始化代码创建新合约。但是create2部署的合约会使用salt能得到一个确定的合约地址。

  • value:向创建合约发送的ETH,单位为wei。
  • offset:内存中的字节偏移量,以字节为单位,新帐户的初始化代码。。
  • size:要复制代码的大小。
  • salt:用于在确定地址上创建新帐户的32字节值。
1
2
3
4
5
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}

解释上面的代码:

  • 通过type(UniswapV2Pair).creationCode获得包含创建合约字节码的内存字节数组。
  • 用输入的token pair参数作为salt,并使用abi.encodePacked进行编码。
  • 使用create2创建合约。

用create2创建一个合约(工厂模式)

工厂代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Part.sol";

interface IPart {
function initialize(uint, uint) external;
}

contract Factory {

function create2Test(uint _type, uint _szie) external returns (address part) {
bytes memory bytecode = type(Part).creationCode;
bytes32 salt = keccak256(abi.encodePacked(_type,_szie));
assembly {
part := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IPart(part).initialize(_type, _szie);
}
}

产品代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Part {

uint public Type;
uint public Szie;

function initialize(uint _type, uint _szie) external {
Type =_type;
Szie = _szie;
}

}

现在我们研究一下create2代码在EVM是怎样工作从而来理解create2。代码部署之后,我们使用create2Test部署part合约,进入remix调试模式。

opcode跳到create2附近

v2_10

此时我们观察EVM栈和内存的状态,内存中地址0x80是存放bytecode(part合约)的长度值,我们能够知道合约大小为0x1a7,合约代码存储内存中0xc0~0x240。

v2_11

使用调试器进入到0186步,这时栈会执行cretae2指令。我们继续观察栈的状态,此时栈压入了四个值,分别是:0x000xa00x1a70xcc69885fda6bcc1a4ace058b4a62bf5e179ea78fd58a1ccd71c22cc9b688792f

我们分析一下这几个值是如何计算的:

  • 首先0xcc69…792f是两个参数的keccak256得到的,在create2参数入栈时,这个hash值就计算在栈顶,通过DUP将其复制在栈顶
  • 然后栈读到MLOAD命令,将bytecode在内存中的偏移位置(offset)0x80中的值0x1a7加载到栈顶。
  • 之后使用ADD命令,将bytecode在内存中的偏移位置(offset)0x80加上32 byte,获得的值是bytecode字节码开始的位置0xa0。我们可以简单的验证一下,使用0xa0+0x1a7(合约代码起始位置+合约长度)能计算出合约字节结束在内存中0x240位置

注:该部分需要掌握solidity储存布局和EVM指令集相关知识。可以参考笔者以下文章:合约安全(一)-数据存储 - Salbt’s blog深入EVM(二)-EVM指令集 - Salbt’s blog

v2_12

UniswapV2Pair

UniswapV2Pair合约继承了UniswapV2ERC20,它在pair中会跟踪两个ERC20的储备金数量,并用blockTimestampLast记录储备金更新的时间,可以使用getReserves()函数获取这三个值。实现了两个TWAP的累加器。

  • reserve0,reserve1:两中Token储备金数量。
  • blockTimestampLast:上一次储备金更新的时间。
  • price0CumulativeLast,price1CumulativeLast:TWAP累计器
  • kLast:流动性,kLast=reserve0 * reserve1
1
2
3
4
5
6
7
8
9
10
11
contract UniswapV2Pair is UniswapV2ERC20 {

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, as of immediately after the most recent liquidity

}

UniswapV2Pair功能就是向Periphery提供修改上面参数功能。在Uniswap进行业务时,会使用swap买卖token,需要更新池中reserves的值并更新TWAP。使用addLiquidity会增加流动性并mint LPT、使用removeLiquidity会减少流动性并burn LPT

更新Reserver和TWAP

_update首先会更新TWAP累加器:

  • dT = blockTimestamp(当前时间) - blockTimestampLast(上一次更新时间)。
  • price Acc = price0CumulativeLast + (_reserve1/ _reserve0)*dT

然后根据输入的新的token的储备金balance更新reserve。

  • reserve = uint112(balance)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// update reserves and, on the first call per block, price accumulators
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
require(balance0 <= type(uint112).max && balance1 <= type(uint112).max, '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);
}

具有闪电贷的Swap

UniswapV2Pair包含了最低层次的Swap,这个swap负责将卖出的token通过safeTransfer发送给to地址。如果传入的参数包含data部分,swap会进入一个回调函数uniswapV2Call触发flash swap。具体代码过程如下:

  • 检查输出token是否合法,不能通过有两个amount0Out > 0。
  • 获取当前两个token的储备金,并检查amountOut是否超过reserve。
  • 检查to的地址是否合法,to地址不能为token地址。
  • 转账
    • 如果amountOut>0就向to转账。
    • 如果data存在,转账之后会进入到uniswapV2Call函数触发flash swap。
  • 计算新流动性knew >= kpervious
    • 其中knew = (xnew - dx * 0.03) * ynew
    • kpervious = xpervious * ypervious
  • 使用_update更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
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;
uint balance1;
{ // scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
// callflash swap
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}

_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

使用flash swap要实现IUniswapV2Callee接口,可以参考代码:Tutorials/Flash_swap/contracts/flashswap.sol at main · UV-Labs/Tutorials (github.com)

手续费

如果开启手续费,Uniswap会在mint和brun时收取协议费用。合约的_mintFee函数会根据收取1/6的LPT增值部分,通过增发LPT的方式分走这部分利润。

在UniswapV2Factor能够指定协议费用的收取地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)
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;
}
}

Uniswap-v2 Periphery

Uniswap-v2 Periphery有两个函数:UniswapV2Migrator、UniswapV2Router02(UniswapV2Router01是02的旧版本)。

UniswapV2Migrator能够将v1池子迁移到v2的合约,这里不过多介绍,可以直接去了解源码:v2-periphery/contracts/UniswapV2Migrator.sol at master · Uniswap/v2-periphery (github.com)

UniswapV2Router02

UniswapV2Router02实现了uniswap的业务代码,比如:addLiquidity、addLiquidityETH、removeLiquidity、removeLiquidityETH、swapExactTokensForTokens和swapTokensForExactTokens等方法。

我们讲到UniswapV2Pair中的swap是最底层的功能函数,负责将买到的token发送给交易者。而UniswapV2Router02提供的swap会先将卖出的token发送给Pair。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint[] memory amounts) {
amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
TransferHelper.safeTransferFrom(
path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
);
_swap(amounts, path, to);
}

我们可以从其中一个swap功能看到,在调用_swap前会先使用TransferHelper.safeTransferFrom发送卖出的token。

寻找任意pair之间的路径

下面分析Periphery如何进行任意pair对之间的交易。

  • 使用for对path进行遍历。
  • 每次遍历获得前一个token和后一个token的pair。
  • 在pair中进行交换:
    • 如果没有遍历完,交换得到的token应该发送给下一个pair,回到开始继续。
    • 如果遍历完,将得到的token发送给to地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// **** SWAP ****
// requires the initial amount to have already been sent to the first pair
function _swap(uint[] memory amounts, address[] memory path, address _to) internal virtual {
for (uint i; i < path.length - 1; i++) {
(address input, address output) = (path[i], path[i + 1]);
(address token0,) = UniswapV2Library.sortTokens(input, output);
uint amountOut = amounts[i + 1];
(uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOut) : (amountOut, uint(0));
address to = i < path.length - 2 ? UniswapV2Library.pairFor(factory, output, path[i + 2]) : _to;
IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output)).swap(
amount0Out, amount1Out, to, new bytes(0)
);
}
}

图例:

v2_13

UniswapV2Router02相比于UniswapV2Router01提供swap的限价订单,基于_swapSupportingFeeOnTransferTokens底层函数实现swapExactTokensForTokensSupportingFeeOnTransferTokens、swapExactETHForTokensSupportingFeeOnTransferTokens等。

Uniswap工具库

Uniswap v2使用了多个数学库,比如FixedPoint、Math、SafeMath和UQ112x112,感兴趣的可以去看一下源码。我们需要关注的是UniswapV2Library和UniswapV2OracleLibrary。

UniswapV2Library提供都是无需gas费的方法,能够查询pair地址、pair之间价格、获得pair储备金。

UniswapV2OracleLibrary提供查看TWAP的方法。

注:使用pairFor生成地址时,需要更改其中的硬编码部分。计算pair地址部分使用了c779f884b0d3b96c99d18260ba7f1b2c9a66dcddcacbcdf30f304d308cd4976e。需要使用keccak256(type(UniswapV2Pair).creationCode)重新计算init code hash

Periphery计算pair地址也是通过这种方式得到的,如果不更改,合约添加流动时就会报错。

重新计算 init code hash

1
2
3
function pairCodeHash() external pure returns (bytes32) {
return keccak256(type(UniswapV2Pair).creationCode);
}

参考

  1. Uniswap Labs (github.com)
  2. https://github.com/Jeiwan/uniswapv2-contracts
  3. Flash Swaps! Learn how to use Uniswap’s Flashswap feature on the mainnet | BuildBear Labs (medium.com)