主页 > 如何退出imtoken > 浅析DeFi交易产品Uniswap:V2篇一

浅析DeFi交易产品Uniswap:V2篇一

如何退出imtoken 2023-11-14 05:08:01

前言

在DeFi赛道中,DEX无疑是最核心的一块,而Uniswap则是整个DEX领域的老大,比如SushiSwap、PancakeSwap等都是从Uniswap中分叉出来的。 虽然网上关于Uniswap的文章不少,但大多只是介绍机制,很少谈具体实现,还有一些问题无法回答,比如:手续费是怎么分配的意识到了吗? 最优路径是如何得出的? 如何使用 TWAP? 注入流动性时,如何计算返还多少LP Token? 因此,我从代码层面分析Uniswap,搞清楚这些问题,同时从整体到细节去了解Uniswap。

现在,Uniswap 有两个版本,V2 和 V3。 先说V2。

开源项目

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

前三个是前端App项目,即提供交易的项目,对应网页功能,展示页面都写在uniswap-interface项目中,uniswap-v2-sdk和uniswap-sdk-core作为存在SDKs,和uniswap-接口会引用v2-sdk和sdk-core,通过@uniswap/v2-sdk和@uniswap/sdk-core引入到需要使用的TS文件中。

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

uniswap-info是一个Uniswap Analytics项目,对应官网页面,展示一些统计分析数据,数据主要是从Subgraph中读取。 uniswap-v2-subgraph 是一个 Subgraph 项目。

后三个是合同项目。 uniswap-v2-core是核心合约的实现; uniswap-v2-periphery提供与UniswapV2交互的外围合约,主要是路由合约; uniswap-lib 封装了一些工具合约。 core和periphery中的合约实现是我们后面要重点关注的。

另外,Uniswap其实还有一个流动性挖矿合约项目,liquidity-staker,因为Uniswap的流动性挖矿在去年才上线很短的时间,所以这个项目知道的人很少,但是我觉得有必要分析一下这个之后所有,很多仿盘也有流动性挖矿的功能。

最后,强烈推荐大家有空的话,看看崔勉大师的视频教程。 他发布了两套教程:

下面文章中的一些重点内容也是我从上面的视频中了解到的。 然后说说一些关键的合约实现。

uniswap-v2-核心

core 主要有三个合约文件:

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

LP Token是用户向资金池注入流动性的凭证,也称为liquidity token,性质类似于Compound的cToken。 当用户将两个币转入某个货币对的配对合约,即增加流动性时,可以获得配对合约返还的LP Token,享受佣金分成收益。

usdt账户交易码_商业银行对交易账户头寸按市值_电子账户交易密码

每个配对合约都绑定了相应的 LP Token。 事实上,UniswapV2Pair继承了UniswapV2ERC20,所以配对合约本身其实就是一个LP Token合约。

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

三个合约的关系如下图所示(引用自崔勉大师教程视频中的图):

usdt账户交易码_电子账户交易密码_商业银行对交易账户头寸按市值

工厂合同

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

usdt账户交易码_电子账户交易密码_商业银行对交易账户头寸按市值

合约是使用create2创建的,这是一个汇编操作码,这是我要重点关注的部分。

很多朋友应该都知道,一般可以使用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,只是这里没有使用。

这个创建字节码实际上会在外围项目中的UniswapV2Library库中使用,这是一个硬编码值。 所以为了方便,可以在工厂合约中加入一行代码来保存这个创建字节码:

商业银行对交易账户头寸按市值_usdt账户交易码_电子账户交易密码

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

回到上面的代码,第二行根据两个令牌地址计算盐值。 对于任何货币对,计算出的盐值也是固定的,因此也可以离线计算货币对的盐值。

然后使用 assembly 关键字包装一段嵌入式汇编代码,它调用 create2 操作码来创建新合约。 因为UniswapV2Pair合约的创建字节码是固定的,两个货币对的salt值也是固定的,所以最终计算出的pair地址其实也是固定的。

除了create2创建新合约的代码外,其他都很好理解,就不多解释了。

Uniswap V2ERC20 合约

配对合约继承了 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和指定账户的余额上加减。 但是,这两个函数都是内部函数,因此不能从外部调用它们。 代码如下:

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() 函数,允许用户在链下签署批准的交易,生成任何人都可以使用并提交到区块链的签名。 关于permit函数的具体作用和用法,网上已经有很多介绍文章,这里就不展开了。

之后,剩下的就是符合ERC20标准的函数。

匹配合同

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

constructor() public {
 factory = msg.sender;
}

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

构造函数直接将msg.sender设置为factory,factory是工厂合约的地址。 初始化函数 require 的调用者需要是一个工厂合约,工厂合约只会被初始化一次。

usdt账户交易码_商业银行对交易账户头寸按市值_电子账户交易密码

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

此外,配对合约中还有三个核心函数:mint()、burn()、swap()。 它们是增加流动性、去除流动性、交换三个操作的底层函数。

薄荷()函数

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

usdt账户交易码_商业银行对交易账户头寸按市值_电子账户交易密码

既然这是增加流动性的底层功能,为什么参数中没有投入两个代币的数量呢? 这大概是大多数人第一个想到的问题。 实际上,在调用该函数之前,路由合约已经完成了将用户的token金额转入配对合约的操作。 所以,你看前五行代码,通过获取两个币的当前余额balance0和balance1,然后分别减去_reserve0和_reserve1,也就是池中两个token的原始数量,两个token被计算。 输入的硬币数量为 amount0 和 amount1。 另外在这个函数中增加了一个锁装饰器,是一个防止重入的装饰器,保证了每次增加流动性时不会有多个用户同时向匹配的合约转钱,否则不会被可以统计用户 amount0 和 amount1 都起来了。

第六行代码是计算协议费用。 工厂合同中有一个 feeTo 地址。 如果这个地址设置为非零地址,意味着在增加和移除流动性时会收取协议费用,但Uniswap直到现在都没有设置这个地址。

那么第7行到第15行的代码就是计算用户可以获得多少流动性代币。 当totalSupply为0时,为初始流动性,计算公式为:

liquidity = √(amount0*amount1) - MINIMUM_LIQUIDITY

即,将两种代币投入的数量相乘并开平方,再减去最小流动性。 最低流动性为1000,永久锁定在零地址。 这主要是为了安全。 具体原因请参考白皮书和官方文档的说明。

如果未提供初始流动性,则流动性为以下两个值中的较小者:

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

在计算出用户应得的流动性后,会调用前面提到的_mint()函数,用流动性的数量铸造LP Token,并提供给用户。

然后将调用 _update() 函数。 这个函数主要做了两件事。 一个是更新reserve0和reserve1,另一个是累加计算price0CumulativeLast和price1CumulativeLast。 这两个价格用来计算TWAP,后面会讲到。

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

最后一行是触发 Mint() 事件的发布。

usdt账户交易码_商业银行对交易账户头寸按市值_电子账户交易密码

燃烧()函数

接下来我们看一下burn()函数,它是去除流动性的底层函数:

商业银行对交易账户头寸按市值_电子账户交易密码_usdt账户交易码

该功能主要是销毁流动性代币,提取对应的两种代币资产给用户。

第一个不太好理解的是第六行代码,获取当前合约地址的流动性代币余额。 在正常情况下,匹配合约中不会有流动性代币,因为所有的流动性代币都给了流动性提供者。 而这里是有价值的usdt账户交易码,其实是因为路由合约会先将用户的流动性代币转移到匹配合约中。

第 7 行以与 mint() 函数相同的方式计算协议费用。

下一步是计算可以分别提取的两个代币的数量。 计算公式也很简单:

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

我调整了计算顺序,以便更好地理解它。 将用户的流动性除以总流动性,得到用户在整个流动性池中的比例,再乘以总代币余额,得到该用户应该分配多少代币。 例如:用户的流动性是1000,totalSupply是10000,也就是说用户的流动性比例是10%,那么如果池子里总共有2000个token,那么用户可以获得2000个token中的10%。 200 件。

下面的逻辑是调用_burn()销毁流动性代币,并将计算出的两种代币资产数量转移给用户,最后更新两种代币的储备金。

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

交换()函数

swap()是兑换交易的底层函数,我们看一下代码:

usdt账户交易码_商业银行对交易账户头寸按市值_电子账户交易密码

该函数有4个入参,amount0Out和amount1Out分别代表兑换后要转出的token0和token1的数量。 通常这两个值一个为0,另一个不为0,但是在使用闪电交易时,两个都可能不为0。to参数是接收方的地址,最后一个data参数是转账数据执行回调时。 如果通过路由合约交换,则值为0。

前 3 行代码很容易理解。 第一步是检查兑换结果是否大于0,然后读取两个代币的reserve,再检查兑换金额是否小于reserve。

电子账户交易密码_商业银行对交易账户头寸按市值_usdt账户交易码

第6行到第15行用了一对花括号usdt账户交易码,主要是限制_token{0,1}这两个临时变量的作用范围,防止栈太深导致错误。

接下来,查看第 10 行和第 11 行,开始将代币转移到收件人地址。 看到这里,可能有小伙伴会有疑问:这是一个外部函数,任何用户都可以调用,不验证就直接调用了。 不是说任何人都可以随便提币吗? 其实后面还有一个验证,我们往下看就知道了。

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

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

第 16 和 17 行计算实际转移的代币数量。 事实上,实际转移的数量通常是一个为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 背后的逻辑了。

下面的代码是检查扣除交易手续费后的常数积,使用以下公式:

商业银行对交易账户头寸按市值_usdt账户交易码_电子账户交易密码

其中0.003为交易费率,X0和Y0为reserve0和reserve1,X1和Y1为balance0和balance1,Xin和Yin分别对应amount0In和amount1In。 这个公式的成立意味着交易手续费确实已经在底层交易所之前收取了。

总结

限于篇幅,本文的内容先说到这里,剩下的会在下一篇文章中进行说明。