【收集】-剖析DeFi交易产品之Uniswap:V2上篇

前言

在 DeFi 赛道中,DEX 无疑是最核心的一块,而 Uniswap 又是整个 DEX 领域中的龙头,如 SushiSwap、PancakeSwap 等都是 Fork 了 Uniswap 的。虽然网上关于 Uniswap 的文章已经挺多,但大多都只是从机制上进行介绍,很少谈及具体实现,也存在一些问题没能解答,比如:手续费分配是如何实现的?最优路径是如何得出的?TWAP 怎么用?注入流动性时返回多少 LP Token 是如何计算的?因此,我从代码层面去剖析 Uniswap,搞清楚这些问题,同时也对 Uniswap 从整体到细节都有所理解。

现在,Uniswap 有 V2 和 V3 两个版本,我们先来聊聊 V2。

开源项目

整个 UniswapV2 产品拆分出了多个小型的开源项目,主要包括:

  • uniswap-interface

  • uniswap-v2-sdk

  • uniswap-sdk-core

  • uniswap-info

  • uniswap-v2-subgraph

  • uniswap-v2-core

  • uniswap-v2-periphery

  • uniswap-lib

前三个是前端 App 项目,即提供交易的项目,对应于 https://app.uniswap.org 网页功能,展示页面都写在 uniswap-interface 项目中,uniswap-v2-sdk 和 uniswap-sdk-core 则是作为 SDK 而存在,uniswap-interface 会引用到 v2-sdk 和 sdk-core,通过 @uniswap/v2-sdk 和 @uniswap/sdk-core的方式引入到需要使用的 TS 文件中。

不过,uniswap-interface 最新代码其实是跟线上同步的,即是集成了 V3 版本的。如果只想部署 V2 版本的前端,那可以找出历史版本的项目代码进行部署,如果是不带流动性挖矿功能,推荐 2020 年 9 月份的版本,如果是带挖矿功能,那可以试试 2020 年 10 月份的版本。

uniswap-info 则是 Uniswap Analytics 项目,对应于官网页面 https://info.uniswap.org ,展示了一些统计分析数据,其数据主要是从 Subgraph 读取。uniswap-v2-subgraph 则是 Subgraph 项目了。

最后三个则是合约项目了,uniswap-v2-core 就是核心合约的实现; uniswap-v2-periphery 则提供了和 UniswapV2 进行交互的外围合约,主要就是路由合约;uniswap-lib 则封装了一些工具合约。core和 periphery 里的合约实现是我们后面要重点讲解的内容。

另外,Uniswap 其实还有一个流动性挖矿合约项目 liquidity-staker ,因为 Uniswap 的流动性挖矿只在去年上线过一段短暂的时间,所以很少人知道这个项目,但我觉得也有必要剖析下这块的实现,毕竟很多仿盘也都带有流动性挖矿功能。

最后,强烈推荐大家有时间可以看看崔棉大师的视频教程,先后发布过两套教程:

后文中有些关键内容也是我从以上视频中学到的。接着我们就来聊聊一些关键的合约实现了。

uniswap-v2-core

core 核心主要有三个合约文件:

  • UniswapV2Factory.sol:工厂合约

  • UniswapV2Pair.sol:配对合约

  • UniswapV2ERC20.sol:LP Token 合约

配对合约管理着流动性资金池,不同币对有着不同的配对合约实例,比如 USDT-WETH 这一个币对,就对应一个配对合约实例,DAI-WETH 又对应另一个配对合约实例。

LP Token 则是用户往资金池里注入流动性的一种凭证,也称为流动性代币,本质上和 Compound 的 cToken 类似。当用户往某个币对的配对合约里转入两种币,即添加流动性,就可以得到配对合约返回的 LP Token,享受手续费分成收益。

每个配对合约都有对应的一种 LP Token 与之绑定。其实,UniswapV2Pair 继承了 UniswapV2ERC20,所以配对合约本身其实也是 LP Token 合约。

工厂合约则是用来部署配对合约的,通过工厂合约的 createPair() 函数来创建新的配对合约实例。

三个合约之间的关系如下图(引自崔棉大师教程视频中的图):

工厂合约

工厂合约最核心的函数就是  createPair() ,其实现代码如下:

里面创建合约采用了 create2,这是一个汇编 opcode,这是我要重点讲解的部分。

很多小伙伴应该都知道,一般创建新合约可以使用 new 关键字,比如,创建一个新配对合约,也可以这么写:

UniswapV2Pair newPair = new UniswapV2Pair();

那为什么不使用 new 的方式,而是调用 create2 操作码来新建合约呢?使用 create2 最大的好处其实在于:可以在部署智能合约前预先计算出合约的部署地址。最关键的就是以下这几行代码:

bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
 pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}

第一行获取 UniswapV2Pair 合约代码的创建字节码 creationCode,结果值一般是这样:

0x0cf061edb29fff92bda250b607ac9973edf2282cff7477decd42a678e4f9b868

类似的,其实还有运行时的字节码 runtimeCode,但这里没有用到。

这个创建字节码其实会在 periphery 项目中的 UniswapV2Library 库中用到,是被硬编码设置的值。所以为了方便,可以在工厂合约中添加一行代码保存这个创建字节码:

bytes32 public constant INIT_CODE_PAIR_HASH = keccak256(abi.encodePacked(type(UniswapV2Pair).creationCode));

回到上面代码,第二行根据两个代币地址计算出一个盐值,对于任意币对,计算出的盐值也是固定的,所以也可以线下计算出该币对的盐值。

接着就用 assembly 关键字包起一段内嵌汇编代码,里面调用 create2 操作码来创建新合约。因为 UniswapV2Pair 合约的创建字节码是固定的,两个币对的盐值也是固定的,所以最终计算出来的 pair 地址其实也是固定的。

除了 create2 创建新合约的这部分代码之外,其他的都很好理解,我就不展开说明了。

UniswapV2ERC20合约

配对合约继承了 UniswapV2ERC20 合约,我们先来看看 UniswapV2ERC20 合约的实现,这个比较简单。

UniswapV2ERC20 是流动性代币合约,也称为 LP Token,但代币实际名称为 Uniswap V2,简称为 UNI-V2,都是直接在代码中定义好的:

string public constant name = 'Uniswap V2';
string public constant symbol = 'UNI-V2';

而代币的总量 totalSupply 最初为 0,可通过调用 _mint() 函数铸造出来,还可通过调用 _burn() 进行销毁。这两个函数的代码实现非常简单,就是直接在 totalSupply 和指定账户的 balance 上进行加减,只是,两个函数都是 internal 的,所以无法外部调用,代码如下:

function _mint(address to, uint value) internal {
 totalSupply = totalSupply.add(value);
 balanceOf[to] = balanceOf[to].add(value);
 emit Transfer(address(0), to, value);
}
function _burn(address from, uint value) internal {
 balanceOf[from] = balanceOf[from].sub(value);
 totalSupply = totalSupply.sub(value);
 emit Transfer(from, address(0), value);
}

另外,UniswapV2ERC20 还提供了一个 permit() 函数,它允许用户在链下签署授权(approve)的交易,生成任何人都可以使用并提交给区块链的签名。关于 permit 函数具体的作用和用法,网上已经有很多介绍文章,我这里就不展开了。

除此之后,剩下的都是符合 ERC20 标准的函数了。

配对合约

前面说过,配对合约是由工厂合约创建的,我们从构造函数和初始化函数中就可以看出来:

constructor() public {
 factory = msg.sender;
}
// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
 require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
 token0 = _token0;
 token1 = _token1;
}

构造函数直接将 msg.sender 设为了 factory ,factory 就是工厂合约地址。初始化函数 require 调用者需是工厂合约,而且工厂合约中只会初始化一次。

不过,不知道你有没有想到,为什么还要另外定义一个初始化函数,而不直接将 _token0 和 _token1在构造函数中作为入参进行初始化呢?这是因为用 create2 创建合约的方式限制了构造函数不能有参数。

另外,配对合约中最核心的函数有三个:mint()、burn()、swap() 。分别是添加流动性、移除流动性、兑换三种操作的底层函数。

mint() 函数

先来看看 mint() 函数,主要是通过同时注入两种代币资产来获取流动性代币:

既然这是一个添加流动性的底层函数,那参数里为什么没有两个代币投入的数量呢?这可能是大部分人会想到的第一个问题。其实,调用该函数之前,路由合约已经完成了将用户的代币数量划转到该配对合约的操作。因此,你看前五行代码,通过获取两个币的当前余额 balance0 和 balance1,再分别减去 _reserve0 和 _reserve1,即池子里两个代币原有的数量,就计算得出了两个代币的投入数量 amount0和 amount1。另外,还给该函数添加了 lock 的修饰器,这是一个防止重入的修饰器,保证了每次添加流动性时不会有多个用户同时往配对合约里转账,不然就没法计算用户的 amount0 和 amount1 了。

第 6 行代码是计算协议费用的。在工厂合约中有一个 feeTo 的地址,如果设置了该地址不为零地址,就表示添加和移除流动性时会收取协议费用,但 Uniswap 一直到现在都没有设置该地址。

接着从第 7 行到第 15 行代码则是计算用户能得到多少流动性代币了。当 totalSupply 为 0 时则是最初的流动性,计算公式为:

liquidity = √(amount0*amount1) - MINIMUM_LIQUIDITY

即两个代币投入的数量相乘后求平方根,结果再减去最小流动性。最小流动性为 1000,该最小流动性会永久锁在零地址。这么做,主要还是为了安全,具体原因可以查看白皮书和官方文档的说明。

如果不是提供最初流动性的话,那流动性则是取以下两个值中较小的那个:

liquidity1 = amount0 * totalSupply / reserve0
liquidity2 = amount1 * totalSupply / reserve1

计算出用户该得的流动性 liquidity 之后,就会调用前面说的 _mint() 函数铸造出 liquidity 数量的 LP Token 并给到用户。

接着就会调用 _update() 函数,该函数主要做两个事情,一是更新 reserve0 和 reserve1,二是累加计算 price0CumulativeLast 和 price1CumulativeLast,这两个价格是用来计算 TWAP 的,后面再讲。

倒数第 2 行则是判断如果协议费用开启的话,更新 kLast 值,即 reserve0 和 reserve1 的乘积值,该值其实只在计算协议费用时用到。

最后一行就是触发一个 Mint() 事件的发出。

burn() 函数

接着就来看看 burn() 函数了,这是移除流动性的底层函数:

该函数主要就是销毁掉流动性代币并提取相应的两种代币资产给到用户。

这里面第一个不太好理解的就是第 6 行代码,获取当前合约地址的流动性代币余额。正常情况下,配对合约里是不会有流动性代币的,因为所有流动性代币都是给到了流动性提供者的。而这里有值,其实是因为路由合约会先把用户的流动性代币划转到该配对合约里。

第 7 行代码计算协议费用和 mint() 函数一样的。

接着就是计算两个代币分别可以提取的数量了,计算公式也很简单:

amount = liquidity / totalSupply * balance
提取数量 = 用户流动性 / 总流动性 * 代币总余额

我调整了下计算顺序,这样就能更好理解了。用户流动性除以总流动性就得出了用户在整个流动性池子里的占比是多少,再乘以代币总余额就得出用户应该分得多少代币了。举例:用户的 liquidity 为 1000,totalSupply 有 10000,即是说用户的流动性占比为 10%,那假如池子里现在代币总额有 2000 枚,那用户就可分得这 2000 枚的 10% 即 200 枚。

后面的逻辑就是调用 _burn() 销毁掉流动性代币,且将两个代币资产计算所得数量划转给到用户,最后更新两个代币的 reserve。

最后两行代码也和 mint() 函数一样,就不赘述了。

swap() 函数

swap() 就是做兑换交易的底层函数了,来看看代码:

该函数有 4 个入参,amount0Out 和 amount1Out 表示兑换结果要转出的 token0 和 token1 的数量,这两个值通常情况下是一个为 0,一个不为 0,但使用闪电交易时可能两个都不为 0。to 参数则是接收者地址,最后的 data 参数是执行回调时的传递数据,通过路由合约兑换的话,该值为 0。

前 3 行代码很好理解,第一步先校验兑换结果的数量是否有一个大于 0,然后读取出两个代币的 reserve,之后再校验兑换数量是否小于 reserve

从第 6 行开始,到第 15 行结束,用了一对大括号,这主要是为了限制 _token{0,1} 这两个临时变量的作用域,防止堆栈太深导致错误。

接着,看看第 10 和 11 行,就开始将代币划转到接收者地址了。看到这里,有些小伙伴可能会产生疑问:这是个 external 函数,任何用户都可以自行调用的,没有校验就直接划转了,那不是谁都可以随便提币了?其实,在后面是有校验的,我们往下看就知道了。

第 12 行,如果 data 参数长度大于 0,则将 to 地址转为 IUniswapV2Callee 并调用其 uniswapV2Call() 函数,这其实就是一个回调函数,to 地址需要实现该接口。

第 13 和 14 行,获取两个代币当前的余额 balance{0,1} ,而这个余额是扣减了转出代币后的余额。

第 16 和 17 行则是计算出实际转入的代币数量了。实际转入的数量其实也通常是一个为 0,一个不为 0 的。要理解计算公式的原理,我举一个实例来说明。

假设转入的是 token0,转出的是 token1,转入数量为 100,转出数量为 200。那么,下面几个值将如下:

amount0In = 100
amount1In = 0
amount0Out = 0
amount1Out = 200

而 reserve0 和 reserve1 假设分别为 1000 和 2000,没进行兑换交易之前,balance{0,1} 和 reserve{0,1} 是相等的。而完成了代币的转入和转出之后,其实,balance0 就变成了 1000 + 100 - 0 = 1100,balance1 变成了 2000 + 0 - 200 = 1800。整理成公式则如下:

balance0 = reserve0 + amount0In - amout0Out
balance1 = reserve1 + amount1In - amout1Out

反推一下就得到:

amountIn = balance - (reserve - amountOut)

这下就明白代码里计算 amountIn 背后的逻辑了吧。

之后的代码则是进行扣减交易手续费后的恒定乘积校验,使用以下公式:

其中,0.003 是交易手续费率,X0 和 Y0 就是 reserve0 和 reserve1X1 和 Y1 则是 balance0 和 balance1Xin 和 Yin 则对应于 amount0In 和 amount1In。该公式成立就说明在进行这个底层的兑换之前的确已经收过交易手续费了。

总结

限于篇幅,本篇内容就先讲到这里,剩下的部分留待下篇再继续讲解。

Subscribe to 0x00pluto
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.