剖析DeFi交易产品之Uniswap:V2中篇

剖析UniswapV2系列的第二篇,主要讲解路由合约的实现

前言

上篇我们主要讲了 UniswapV2 整体分为了哪些项目,并重点讲解了 uniswap-v2-core 的核心代码实现。这篇我们来看看 uniswap-v2-periphery

uniswap-v2-periphery

periphery 项目的结构很简单,如下:

  • UniswapV2Migrator.sol:迁移合约,从 V1 迁移到 V2 的合约
  • UniswapV2Router01.sol:路由合约 01 版本
  • UniswapV2Router02.sol:路由合约 02 版本,相比 01 版本主要增加了几个支持交税费用的函数
  • interfaces:接口都统一放在该目录下
  • libraries:存放用到的几个库文件
  • test:里面有几个测试用的合约
  • examples:一些很有用的示例合约,包括 TWAP、闪电兑换等

当然,我们没必要每个合约都讲,主要讲解最核心的 UniswapV2Router02.sol,即路由合约。

UniswapV2Library

讲路由合约之前,我想先聊聊 UniswapV2Library 这个库,路由合约很多函数的实现逻辑都用到了这个库提供的函数。 UniswapV2Library 主要提供了以下这些函数:

  • sortTokens:对两个 token 进行排序
  • pairFor:计算出两个 token 的 pair 合约地址
  • getReserves:获取两个 token 在池子里里的储备量
  • quote:根据给定的两个 token 的储备量和其中一个 token 数量,计算得到另一个 token 等值的数值
  • getAmountOut:根据给定的两个 token 的储备量和输入的 token 数量,计算得到输出的 token 数量,该计算会扣减掉 0.3% 的手续费
  • getAmountIn:根据给定的两个 token 的储备量和输出的 token 数量,计算得到输入的 token 数量,该计算会扣减掉 0.3% 的手续费
  • getAmountsOut:根据兑换路径和输入数量,计算得到兑换路径中每个交易对的输出数量
  • getAmountsIn:根据兑换路径和输出数量,计算得到兑换路径中每个交易对的输入数量

其中,第一个关键函数就是 pairFor,用来计算得到两个 token 的配对合约地址,其代码实现是这样的:

image20210920140902958.png

可以看到,有个「init code hash」是硬编码的。该值其实是 UniswapV2Pair 合约的 creationCode 的哈希值。在「上篇」我们有提到,可以在 UniswapV2Factory 合约中添加以下常量获取到该值:

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

另外,INIT_CODE_PAIR_HASH 的值是带有 0x 开头的。而以上硬编码的 init code hash 前面已经加了 hex 关键字,所以单引号里的哈希值就不再需要 0x 开头。

接着,来看看 getAmountOut 的实现:

image20210921145914470.png

根据 AMM 的原理,恒定乘积公式「x * y = K」,兑换前后 K 值不变。因此,在不考虑交易手续费的情况下,以下公式会成立:

reserveIn * reserveOut = (reserveIn + amountIn) * (reserveOut - amountOut)

将公式右边的表达式展开,并推导下,就变成了:

reserveIn * reserveOut = reserveIn * reserveOut + amountIn * reserveOut - (reserveIn + amountIn) * amountOut
->
amountIn * reserveOut = (reserveIn + amountIn) * amountOut
->
amountOut = amountIn * reserveOut / (reserveIn + amountIn)

而实际上交易时,还需要扣减千分之三的交易手续费,所以实际上:

amountIn = amountIn * 997 / 1000

代入上面的公式后,最终结果就变成了:

amountOut = (amountIn * 997 / 1000) * reserverOut / (reserveIn + amountIn * 997 / 1000)
->
amountOut = amountIn * 997 * reserveOut / 1000 * (reserveIn + amountIn * 997 / 1000)
->
amountOut = amountIn * 997 * reserveOut / (reserveIn * 1000 + amountIn * 997)

这即是最后代码实现中的计算公式了。

getAmountIn 是类似的,就不展开说明了。

最后,再来看看 getAmountsOut 的代码实现:

image20210921163841910.png

该函数会计算 path 中每一个中间资产和最终资产的数量,比如 path 为 [A,B,C],则会先将 A 兑换成 B,再将 B 兑换成 C。返回值则是一个数组,第一个元素是 A 的数量,即 amountIn,而第二个元素则是兑换到的代币 B 的数量,最后一个元素则是最终要兑换得到的代币 C 的数量。

从代码中还可看到,每一次兑换其实都调用了 getAmountOut 函数,这也意味着每一次中间兑换都会扣减千分之三的交易手续费。那如果兑换两次,实际支付假设为 1000,那最终实际兑换得到的价值只剩下:

1000 * (1 - 0.003) * (1 - 0.003) = 994.009

即实际支付的交易手续费将近千分之六了。兑换路径越长,实际扣减的交易手续费会更多,所以兑换路径一般不宜过长。

UniswapV2Router02

UniswapV2Router02 路由合约是与用户进行交互的入口,主要提供了添加流动性、移除流动性兑换的系列接口,并提供了几个查询接口。

添加流动性接口

添加流动性,本质上就是支付两种代币,换回对应这两种代币的流动性代币 LP-Token。

添加流动性的接口有两个:

  • addLiquidity:该接口支持添加两种 ERC20 代币作为流动性
  • addLiquidityETH:与上一个接口不同,该接口提供的流动性资产,其中有一个是 ETH

我们先来看看第一个接口的实现代码:

image20210921171836515.png

先介绍下该接口的几个入参。tokenA 和 tokenB 就是配对的两个代币,tokenADesired 和 tokenBDesired 是预期支付的两个代币的数量,amountAMin 和 amountBMin 则是用户可接受的最小成交数量,to 是接收流动性代币的地址,deadline 是该笔交易的有效时间,如果超过该时间还没得到交易处理就直接失效不进行交易了。

这几个参数,amountAMin 和 amountBMin 有必要再补充说明一下。该值一般是由前端根据预期值和滑点值计算得出的。比如,预期值 amountADesired 为 1000,设置的滑点为 0.5%,那就可以计算得出可接受的最小值 amountAMin 为 1000 * (1 - 0.5%) = 995。

再来看代码实现逻辑,第一步是先调用内部函数 _addLiquidity()。来看看该函数的实现代码:

image20210921194054694.png

该函数的返回值 amountA 和 amountB 是最终需要支付的数量。

实现逻辑还是比较简单的。先通过工厂合约查一下这两个 token 的配对合约是否已经存在,如果不存在则先创建该配对合约。接着读取出两个 token 的储备量,如果储备量都为 0,那两个预期支付额就是成交量。否则,根据两个储备量和 tokenA 的预期支付额,计算出需要支付多少 tokenB,如果计算得出的结果值 amountBOptimal 不比 amountBDesired 大,且不会小于 amountBMin,就可将 amountADesired 和该 amountBOptimal 作为结果值返回。如果 amountBOptimal 大于 amountBDesired,则根据 amountBDesired 计算得出需要支付多少 tokenA,得到 amountAOptimal,只要 amountAOptimal 不大于 amountADesired 且不会小于 amountAMin,就可将 amountAOptimal 和 amountBDesired 作为结果值返回。

再回到 addLiquidity 函数的实现,计算得出两个 token 实际需要支付的数量之后,调用了 UniswapV2Library 的 pairFor 函数计算出配对合约地址,接着就往 pair 地址进行转账了。因为用了 transferFrom 的方式,所以用户调用该函数之前,其实是需要先授权给路由合约的。

最后再调用 pair 合约的 mint 接口就可以得到流动性代币 liquidity 了。

以上就是 addLiquidity 的基本逻辑,很简单,所以非常好理解。

而 addLiquidityETH 则支付的其中一个 token 则是 ETH,而不是 ERC20 代币。来看看其代码实现:

image20210921212944219.png

可看到,入参不再是两个 token 地址,而只有一个 token 地址,因为另一个是以太坊主币 ETH。预期支付的 ETH 金额也是直接从 msg.value 读取的,所以入参里也不需要 ETH 的 Desired 参数。但是会定义 amountETHMin 表示愿意接受成交的 ETH 最小额。

实现逻辑上,请注意,调用 _addLiquidity 时传入的第二个参数是 WETH。其实,addLiquidityETH 实际上也是将 ETH 转为 WETH 进行处理的。可以看到代码中还有这么一行:

IWETH(WETH).deposit{value: amountETH}();

这就是将用户转入的 ETH 转成了 WETH。

而最后一行代码则会判断,如果一开始支付的 msg.value 大于实际需要支付的金额,多余的部分将返还给用户。

移除流动性接口

移除流动性本质上就是用流动性代币兑换出配对的两个币。

移除流动性的接口有 6 个:

  • removeLiquidity:和 addLiquidity 相对应,会换回两种 ERC20 代币
  • removeLiquidityETH:和 addLiquidityETH 相对应,换回的其中一种是主币 ETH
  • removeLiquidityWithPermit:也是换回两种 ERC20 代币,但用户会提供签名数据使用 permit 方式完成授权操作
  • removeLiquidityETHWithPermit:也是使用 permit 完成授权操作,换回的其中一种是主币 ETH
  • removeLiquidityETHSupportingFeeOnTransferTokens:名字真长,功能和 removeLiquidityETH 一样,不同的地方在于支持转账时支付费用
  • removeLiquidityETHWithPermitSupportingFeeOnTransferTokens:功能和上一个函数一样,但支持使用链下签名的方式进行授权

removeLiquidity 是这些接口中最核心的一个,也是其它几个接口的元接口。来看看其代码实现?

image20210921231526704.png

代码逻辑很简单,就 7 行代码。第一行,先计算出 pair 合约地址;第二行,将流动性代币从用户划转到 pair 合约;第三行,执行 pair 合约的 burn 函数实现底层操作,返回了两个代币的数量;第四行对两个代币做下排序;第五行根据排序结果确定 amountA 和 amountB;最后两行检验是否大于滑点计算后的最小值。

removeLiquidityETH 也同样简单,其实现代码如下:

image20210921233738867.png

因为流动性池子里实际存储的是 WETH,所以第一步调用 removeLiquidity 时第二个参数传的是 WETH。之后再调用 WETH 的 withdraw 函数将 WETH 转为 ETH,再将 ETH 转给用户。

removeLiquidityWithPermit 则是使用链下签名进行授权操作的,实现代码如下:

image20210921234539686.png

其实就是在调用实际的 removeLiquidity 之前先用 permit 方式完成授权操作。

removeLiquidityETHWithPermit 也一样的,就不看代码了。

接着,来看看 removeLiquidityETHSupportingFeeOnTransferTokens 函数,先看看其代码实现:

image20210922000049118.png

该函数功能和 removeLiquidityETH 一样,但对比一下,就会发现主要不同点在于:

  1. 返回值没有 amountToken;
  2. 调用 removeLiquidity 后也没有 amountToken 值返回
  3. 进行 safeTransfer 时传值直接读取当前地址的 token 余额。

有一些项目 token,其合约实现上,在进行 transfer 的时候,就会扣减掉部分金额作为费用,或作为税费缴纳,或锁仓处理,或替代 ETH 来支付 GAS 费。总而言之,就是某些 token 在进行转账时是会产生损耗的,实际到账的数额不一定就是传入的数额。该函数主要支持的就是这类 token。

兑换接口

兑换接口则多达 9 个:

  • swapExactTokensForTokens:用 ERC20 兑换 ERC20,但支付的数量是指定的,而兑换回的数量则是未确定的
  • swapTokensForExactTokens:也是用 ERC20 兑换 ERC20,与上一个函数不同,指定的是兑换回的数量
  • swapExactETHForTokens:指定 ETH 数量兑换 ERC20
  • swapTokensForExactETH:用 ERC20 兑换成指定数量的 ETH
  • swapExactTokensForETH:用指定数量的 ERC20 兑换 ETH
  • swapETHForExactTokens:用 ETH 兑换指定数量的 ERC20
  • swapExactTokensForTokensSupportingFeeOnTransferTokens:指定数量的 ERC20 兑换 ERC20,支持转账时扣费
  • swapExactETHForTokensSupportingFeeOnTransferTokens:指定数量的 ETH 兑换 ERC20,支持转账时扣费
  • swapExactTokensForETHSupportingFeeOnTransferTokens:指定数量的 ERC20 兑换 ETH,支持转账时扣费

这么多个接口,我们就看看几个具有代表性的接口即可。首先是 swapExactTokensForTokens,其实现代码如下:

image20210922132105076.png

这是指定 amountIn 的兑换,比如用 tokenA 兑换 tokenB,那 amountIn 就是指定支付的 tokenA 的数量,而兑换回来的 tokenB 的数量自然是越多越好。

关于入参的 path,是由前端 SDK 计算出最优路径后传给合约的。至于前端是如何计算得出最优路径的,具体的算法我没去研究过前端 SDK 的实现,但在我之前写过的一篇文章《这几天我写了一个DEX交易聚合器》中有讲到我的一些思路,感兴趣的朋友可以去看一看。

swapExactTokensForTokens 的实现逻辑就 4 行代码而已,非常简单。第一行计算出兑换数量,第二行判断是否超过滑动计算后的最小值,第三行将支付的代币转到 pair 合约,第四行再调用兑换的内部函数。那么,再来看看这个兑换的内部函数是如何实现的:

image20210922131920155.png

可看到,其实现逻辑也不复杂,主要就是遍历整个兑换路径,并对路径中每两个配对的 token 调用 pair 合约的兑换函数,实现底层的兑换处理。

接着,来看看 swapTokensForExactTokens 的实现:

image20210922153618336.png

这是指定 amountOut 的兑换,比如用 tokenA 兑换 tokenB,那 amountOut 就是指定想要换回的 tokenB 的数量,而需要支付的 tokenA 的数量则是越少越好。因此,其实现代码,第一行其实就是用 amountOut 来计算得出需要多少 amountIn。返回的 amounts 数组,第一个元素就是需要支付的 tokenA 数量。其他的代码逻辑都很好理解了。

接着,来看看指定 ETH 的兑换,就以 swapExactETHForTokens 为例:

image20210922155307659.png

支付的 ETH 数量是从 msg.value 中读取的。而且,可看到还调用了 WETH 的 deposit 函数,将 ETH 转为了 WETH 之后再转账给到 pair 合约。这说明和前面的流动性接口一样,是将 ETH 转为 WETH 进行底层处理的。

其他几个兑换接口的逻辑也是差不多的,就不再一一讲解了。剩下的主要想聊聊支持转账时扣费的接口,就以 swapExactTokensForTokensSupportingFeeOnTransferTokens 为例,该接口的实现代码如下:

image20210923223540938.png

实现逻辑就只有 4 步,第一步先将 amountIn 转账给到 pair 合约,第二步读取出接收地址在兑换路径中最后一个代币的余额,第三步调用内部函数实现路径中每一步的兑换,第四步再验证接收者最终兑换得到的资产数量不能小于指定的最小值。

因为此类代币转账时可能会有损耗,所以就无法使用恒定乘积公式计算出最终兑换的资产数量,因此用交易后的余额减去交易前的余额来计算得出实际值。

而核心逻辑其实都在内部函数 _swapSupportingFeeOnTransferTokens 中实现,其代码如下:

image20210922173948534.png

这里面最核心也较难理解的逻辑可能就是 amountInput 的计算,即理解好这一行代码,其他都很好理解:

amountInput = IERC20(input).balanceOf(address(pair)).sub(reserveInput);

因为 input 代币转账时可能会有损耗,所以在 pair 合约里实际收到多少代币,只能通过查出 pair 合约当前的余额,再减去该代币已保存的储备量,这才能计算出来实际值。

其他代码就比较容易理解,就不展开说明了。而其他支持此类转账代币的兑换接口,和前面说的兑换接口也是类似的,所以也不一一讲解了。

查询接口

查询接口有 5 个:

  • quote
  • getAmountOut
  • getAmountIn
  • getAmountsOut
  • getAmountsIn

这几个查询接口的实现都是直接调用 UniswapV2Library 库对应的函数,所以也无需再赘述了。

总结

本篇文章核心就是讲解路由合约的实现,因为接口比较多,就没有全部都展开进行阐述,但核心逻辑基本都已经讲解了。下篇再来聊聊质押挖矿合约,以及 TWAP。


扫描以下二维码即可关注公众号(公众号名称:Keegan小钢)

image.png

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
Keegan小钢
Keegan小钢 - 技术负责人

27 篇文章, 1276 学分