当面试官问你Uniswap的时候,你应该想到什么?

这段时间非常有幸跟行业内人士聊了一会,算是第一次正式接触到区块链行业内从业者,很激动。整体感觉这个行业充满了变化,挑战和机遇,它会给年轻人机会,目前我正在考虑转行该行业,希望能够在三年后回首自己的选择时能够不后悔,不遗憾。

> 这段时间非常有幸跟行业内人士聊了一会,算是第一次正式接触到区块链行业内从业者,很激动。整体感觉这个行业充满了变化,挑战和机遇,它会给年轻人机会,目前我正在考虑转行该行业,希望能够在三年后回首自己的选择时能够不后悔,不遗憾。 ![33eeded88d1001e931a40a41af0e7bec55e79783.jpg](https://img.learnblockchain.cn/attachments/2021/07/jw9q0pSM60f3e517ca0f4.jpg) ## Uniswap的行业地位 uniswap是以太坊上的一个DAPP应用,后续的DEFI繁荣,出现各种各样的SWAP,其代码鼻祖就是Uniswap。 ## AMM 什么是AMM,价格如何移动? AMM,全称Automated Market Makers,翻译过来是自动做市商。其基础模型来源于Vitalik于2017年发表的[博客](https://vitalik.ca/general/2017/06/22/marketmakers.html),讨论了“恒定乘机公式”,即每一个Uniswap Pair 中存有两种资产,并为这两种资产提供流动性。在为资产提供流动性时,保持两种Token储备的乘积不能减少的不变性。交易者须在交易中支付30个基点的费用,这些费用将用于流动性提供者。即保证$R_A * R_B \geq K$ 下面我们将分别结合`swap,mint,burn`来讨论AMM曲线的移动。 ### Swap 首先我们结合Uniswap中的简化版Swap代码来分析下价格移动 ```js // 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 { //拿到swap之前的tokenA和tokenB的余额 (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings //拿到此时刻的tokenA和tokenB的余额 balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); //乐观转账给address To if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens //通过余额的差值计算得到要交换的Token的数量 uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0; uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0; //扣除书续费,验证转账后的余额满足 X*Y >= K的要求 { // 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); } ``` 可以看到,如下图要从A点移动到B点,通过调用Uniswap的swap函数,需要先转账给pair合约一定数量($X_1-X_0$)的tokenB,然后pair合约将对应数量的tokenA转账出去到目标地址。则转装出去的tokenA的数量为$Y_0-Y_1$, 在忽略手续费的情况下,此时的B点应该要落在曲线上。 ![image20210717105238162.png](https://img.learnblockchain.cn/attachments/2021/07/rbuQJaMz60f3e528e4f29.png) ### Mint ```js function mint(address to) external lock returns (uint liquidity) { //拿到调用mint前的tokenA和tokenB的余额 (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings //拿到此时刻的TokenA和TokenB的余额 uint balance0 = IERC20(token0).balanceOf(address(this)); uint balance1 = IERC20(token1).balanceOf(address(this)); //计算得到打进账户的两种Token的数量 uint amount0 = balance0.sub(_reserve0); uint amount1 = balance1.sub(_reserve1); bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee if (_totalSupply == 0) { //首次铸币 liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } else { //非首次铸币 liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1); } //发送LP Token _mint(to, liquidity); // 更新账户余额 _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date } ``` 可以看到,要让图中点A移动到点B,通过调用Uniswap中的Mint函数实现。如图中A点所示,其TokenX=2, TokenY=5000, 在保证$dy/dx$不变的情况下,即价格稳定的情况下,向Pair合约中,转账TokenX=1,TokenY=2500,此时系统的K值会由最初的10000增长为22500,点A移动到点B。即价格移动曲线也更新为最新的K=22500这条曲线。 ![image20210717113439641.png](https://img.learnblockchain.cn/attachments/2021/07/uJBTSEHA60f3e535b476c.png) ### Burn ```js // this low-level function should be called from a contract which performs important safety checks function burn(address to) external lock returns (uint amount0, uint amount1) { (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings address _token0 = token0; // gas savings address _token1 = token1; // gas savings uint balance0 = IERC20(_token0).balanceOf(address(this)); uint balance1 = IERC20(_token1).balanceOf(address(this)); uint liquidity = balanceOf[address(this)]; bool feeOn = _mintFee(_reserve0, _reserve1); uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED'); _burn(address(this), liquidity); _safeTransfer(_token0, to, amount0); _safeTransfer(_token1, to, amount1); balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date emit Burn(msg.sender, amount0, amount1, to); } ``` Burn方法是Mint方法的逆向,先向Pair合约中,转账一定量的LP token,然后通过Pair合约向外转账tokenA, tokenB. 使得上图中的B点回落到A点。 ## 数值精度 由于solidity中并不原生支持小数,Uniswap采用UQ112.112这种编码方式来存储价格信息。UQ112.112意味着该数值采取224位来编码值,前112位存放小数点前的值,其范围是$[0,2^{112}-1]$,后112位存放小数点后的值,其精度可以达到$\frac{1}{2^{112}}$. ```js library UQ112x112 { uint224 constant Q112 = 2**112; // 编码:将一个uint112的值编码为uint224 //0000000000000000000000000000000000000000000000000000000000000001 => 0x01 uint112 //00000000 => 前32位留空 //0000000000000000000000000001 => 整数部分 //0000000000000000000000000000 => 小数部分 function encode(uint112 y) internal pure returns (uint224 z) { z = uint224(y) * Q112; // never overflows } // divide a UQ112x112 by a uint112, returning a UQ112x112 function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) { z = x / uint224(y); } } ``` Uniswap选择UQ112.112的原因:因为UQ112.112可以被存储位uint224,这会留下32位的空闲Storage插槽位。再一个是每个Pair合约的reserve0和reserve1都是Uint112,同样会留下32位的空闲插槽位。特别的是pair合约的reserve连同最近一个区块的时间戳一起存储时,该时间戳会对$2^{32}$取余数称为uint32格式的数值。加之,尽管在任意给定时刻的价格都是UQ112.112格式,保证为uint224位,一段时间累积的价格并不是该格式。在储存插槽中末端的额外的32位可以用来储存累积的价格的溢出部分。这种设计意味着在每一个区块的第一笔交易中只需要额外的三次SSTORE操作(目前的开销是15000gas)。 选择UQ112.112的缺点:最末端的32位用来储存时间戳会溢出,事实上一个Uint32的时间戳溢出点是02/07/2106. ```js 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 上述三个参数刚好占据一个slot,一个slot 32位,上面三个参数编码后就是14+14+4=32位 该slot中的数据排列是: blockTime|Reserve1|Reserve0 //00000000 => blockTimestampLast //0000000000000000000000000000 => reserve1 //0000000000000000000000000000 => reserve0 ``` 为什么可以看到合约中存在`x*1000/1000`,目的是什么? 一方面是因为solidity中没有小数的概念,另一方面是为了保证数值精度 ```js uint256 reward = (amountDeposited * 100) / totalDeposits; 与 uint256 rewardInWei = (amountDeposited * 100 * 10 ** 18) / totalDeposites; 如果amountDeposited=10000000, totalDeposits = 400+1000000: reward=99 如果amountDeposited = 100, totalDeposits = 400 + 1000000 reward = 0 ether rewardInWei = 99960015993602 wei = 0.00009996 ether ``` ## 闪电兑 Uniswap V2添加了闪电兑功能,允许用户在付钱之前接受和使用Token资产,只要他们在同一笔原子交易中。闪电兑换功能在swap函数中实现:` IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);` 也可以写成 `address(to).callWithSigature("uniswapV2Call(address,uint256,uint256,bytes)", msg.sender, amount0Out, amount1Out, data)`. 当回调函数完成后,合于会检查新的账户余额并确认常数K满足要求(扣除手续费后的常数K). 如果合约没有足够的余额,则会回退整笔交易。 用户也可以用同一种Token来偿还给Pair合约,而不是必须完成swap交易。这事实上是允许任何人闪电贷任何一种储存在Pair中的资产,只需要支付0.3%的手续费即可。 ```js // 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 { //拿到swap之前的tokenA和tokenB的余额 (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings //拿到此时刻的tokenA和tokenB的余额 balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); //乐观转账给address To if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens //闪电兑-执行用户定义的回调合约,在乐观转账给address To和确保K值不减之间时调用 if(data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); //通过余额的差值计算得到要交换的Token的数量 uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0; uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0; //扣除书续费,验证转账后的余额满足 X*Y >= K的要求 { // 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); } ``` ## LP TOKEN初始供应 要理解LP token的供应值,就需要理解什么是LP token。 在Uniswap中,LP token在mint中被铸造出来,在burn中销毁。除开首次铸造,非首次铸造的LP token数量都是与LP token的当前总量成线性关系。LP token的价值来源于流动性的提供,即mint方法。LP token的增值逻辑是时间段($t_1$,$t_2$)间的swap交易手续费的累计。swap的交易手续费又可以表现为时间段($t_1$,$t_2$)对应的($\sqrt{k_1}$,$\sqrt{k_2}$)的增值。 ### 首次铸币 首次铸币时,如何确定LP token的初始供应的数量呢? $$ s_{minted} = \sqrt{x_{deposited}\cdot y_{deposited}}=\sqrt{k} $$ 该公式确保了任意时刻的LP token的价值与初始供应的tokenA,tokenB的比例无关。例如,一个流动性提供者存入2 tokenA,200 tokenB,此时tokenA/tokenB的价格为100,则该流动性提供者会获得$\sqrt{2\cdot 200}=20$ LP token。该20份 LP token的价值就是2 tokenA, 200 tokenB, 以及此时间段间积累的手续费。如果一个流动性提供者最初存入的是2 tokenA, 800 tokenB, 此时tokenA/tokenB的价格为400,则该流动性提供者会获得$\sqrt{2\cdot 800}=40$ LP token。 ### 非首次铸币 非首次铸币时,LP token的供应量为: $$ s_{minted}=\frac{x_{deposited}}{x_{starting}}\cdot s_{starting} $$ 上述公式保证了LP token的价值永远不会低于$\sqrt{k}$. 然而,当我们对比mint部分代码时,会发现如下一行代码: ```js if (_totalSupply == 0) { liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } ``` 为什么在首次铸币时,要向address(0) 传送一部分LPtoken呢? 因为根据上述公式,存在如下的一种攻击模式,称为LP token操纵 ```mermaid sequenceDiagram opt 首次铸币 攻击者->>Pair合约: tokenA.transfer(pair,1 wei) 攻击者->>Pair合约: tokenB.transfer(pair,1 wei) 攻击者->>Pair合约: pair.mint(msg.sender) Pair合约 ->> 攻击者: LP token=1 wei end opt 二次铸币 攻击者->>Pair合约: tokenA.transfer(pair,10**22 wei) 攻击者->>Pair合约: tokenB.transfer(pair,10**22 wei) 攻击者->>Pair合约: pair.mint(msg.sender) Pair合约 ->> 攻击者: LP token=10**22 wei end opt SYNC 同步余额 攻击者->>Pair合约: pair.sync() end opt 三次铸币 受害者->>Pair合约: tokenA.transfer(pair,10**22 wei) 受害者->>Pair合约: tokenB.transfer(pair,10**22 wei) 受害者->>Pair合约: pair.mint(msg.sender) Pair合约 ->> 受害者: LP token=? wei end ``` ## 平台手续费 Uniswap支持0.05%的平台手续费,它可以在Factory合约中设置开或者关。如果被设置为收取平台手续费,则该平台手续费会转账给feeTo地址(该地址是在工厂合约中设置)。 ### 收取平台手续费的位点 如果设置了feeTo地址,Uniswap将会开始收取0.05%的平台手续费,其为流动性提供者收取的0.3%中的$\frac1{6}$, 即交易者仍然只会支付0.3%的手续费,其中$\frac5{6}$会支付给流动性提供者,剩余的$\frac1{6}$会支付给feeTo地址。如果每一笔交易都将其手续费的1/6实时打给feeTo地址会极度消耗gas。为了避免这种情况,累计的手续费只会在提供流动性和移除流动性时计算。合约会计算累计的手续费,并铸造新的LP token给到feeTo地址,在任何token被铸造或者销毁前进行。 ```js function mint(address to) external lock returns (uint liquidity) { //拿到调用mint前的tokenA和tokenB的余额 //拿到此时刻的TokenA和TokenB的余额 //计算得到打进账户的两种Token的数量 // 在实际创建任何LPtoken之前,先计算mintFee,即先把平台手续费对应的LP token发放了。 bool feeOn = _mintFee(_reserve0, _reserve1); if (_totalSupply == 0) { //首次铸币 } else { //非首次铸币 } //发送LP Token // 更新账户余额 if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date } function _mintFee(uint112 resserve0, uint112 reserve1) private returns (bool feeOn) { //拿到工厂合约设置的feeTo地址 address feeTo = IUniswapFactory(factory).feeTo(); //根据feeTo地址判断是否开启收取平台手续费 feeOn = (feeTo == address(0)); //如果收取平台手续费 if (feeOn) { if (KLast == 0) { //判断是不是首次铸币,首次铸币不收取平台手续费,因为此时没有swap,压根就没有手续费 return; } else { //如果不是首次铸币,计算要发放的LP token的数量,转给feeTo地址 // s1 = totalSupply, k1 = KLast, k2 = reserve0 * reserve1 uint root_k1 = math.sqrt(KLast); uint root_k2 = math.sqrt(uint256(reserve0).mul(uint256(reserve1))); require(root_k2 > root_k1, "root_k2 <= root_k1"); uint numerator = root_k2.sub(root_k1); uint denominator = root_k2.mul(5).add(root_k1); uint amount = numerator.div(denominator).mul(totalSupply); _mint(feeTo, amount); } } else if (KLast != 0) { //如果不收取平台手续费,然而KLast不为0,则将其设置为0; KLast = 0; } } ``` ### 平台手续费的数量 现在最大的问题是,如何计算发放给feeTo地址的LP token的数量。 首先要明确一点,手续费的收集体现在K值的增加上。由于每一笔swap都收取了0.3%的手续费,其会导致$\sqrt{k}$值缓慢增加。对比$t_1$和$t_2$时刻的$\sqrt{k_1}$和$\sqrt{k_2}$, 其差值即为$t_2$与$t_1$时刻间的手续费,则在任意时间段($t_1$,$t_2$)间,其手续费占当前时刻$t_2$的比重为: $$ f_{1,2}=\frac{\sqrt{k_2}-\sqrt{k_1}}{\sqrt{k_2}} $$ 此时,我们假设$t_2$时刻的LPtoken的总供应量为$s_1$,需要发放给feeTo地址的LP token的量为$s_m$, 由于LP token不能减发,且发送给feeTo地址的LPtoken在发送给流动性提供者的LPtoken之前发放,故需要满足如下等式: $$ \frac{s_m}{s_m+s_1} = \phi \times f_{1,2} $$ 其中$\phi$为平台手续费所占所有手续费的比例,此处为1/6 由方程式1,2得到,此时的发放给feeTo地址的平台手续费为: $$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{(1/\phi-1)\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$ 带入$\phi=1/6$后得到: $$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{5\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$ ## 元交易 由Uniswap Pair合约中铸造出的LP token天然的支持元交易。这意味着用户可以通过签名的方式授权转移LP token, 而不是必须由用户地址发起的以太坊上的交易。任何人可以通过调用permit函数提交他们的签名,付给gas费用,并同时进行多笔交易。 ```js // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; constructor() public { uint chainId; assembly { chainId := chainid } DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'), keccak256(bytes(name)), keccak256(bytes('1')), chainId, address(this) ) ); } function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external { require(deadline >= block.timestamp, 'UniswapV2: EXPIRED'); 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); } ``` 要理解Uniswap V2中提到的元交易部分,就需要理解[EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md) ### EIP-712 动机 旨在提高链外消息签名的可用性,以便在链上使用。我们看到越来越多的采用链外消息签名,因为它节省了gas,减少了区块链上的交易数量。当前签名的消息是向用户显示的不透明的hex字符串,与构成消息的项目几乎没有上下文。 ![eth_sign.png](https://img.learnblockchain.cn/attachments/2021/07/vpHuLGKb60f3e55d3b9da.png) EIP-712确定了编码数据及其结构,允许它显示给用户在签名时进行验证。以下是用户在签署 EIP712 消息时可以显示的内容的示例。 ![image20210718104941519.png](https://img.learnblockchain.cn/attachments/2021/07/KgXyiQrR60f3e569c4cb2.png) EIP-712 在EIP-191的基础上,进一步提出了一种结构化的签名信息,用于在前端(线下)签名时展示相应的信息,而不是一串难以理解的hex串。其在设计时,主要考虑到两方面特性:1.确保可确认性。如果不是确定性的,则哈希可能从签名到验证的瞬间而有所不同,导致签名被错误地拒绝 2. 可注射性。如果它不是注射的,那么我们的输入集中有两个不同的元素,它们对相同的值进行哈希值,导致签名对不同的不相关消息有效。 ### EIP-712 结构化签名信息 $$ \mathtt{T}\cup\mathtt{B^{8n}}\cup\mathtt{S} $$ - 对于交易数据$T$, 其编码方式仍然遵循RLP编码方式 - 对于bytes数据$B^{8n}$, 其编码方式遵循EIP-191, 即 $ encode(B^{8n})= $\x19"Ethereum Signed Message:\n"$ \ ||\ len(message)\ ||\ message $ 其中len(message)是没有0开头的Ascii编码值 $$ $$ - 对于结构化数据$S$, 其编码方式遵循EIP-712, 即 $$ encode(domainSeparator,message)= \\ x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$ ### DomainSeparator域定义 domainSeparator是一个结构体,名为EIP712Domain: ```js struct EIP712Domain{ string name, //用户可读的域名,如DAPP的名字 string version, // 目前签名的域的版本号 uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1 address verifyingContract, // 用于验证签名的合约地址 bytes32 salt // 随机数 } ``` 该结构体必须按照如上定义的字段和顺序定义,且如果有不需要的字段,可以跳过,但是不能改变该结构体内部顺序。 则对于DomainSeparator的编码方式应遵循如下编码方式: $$ hashStruct(S)=keccak256(typeHash\ ||\ encodeData(S))\\ typeHash=keccak256(encodeType(typeOf(S))) $$ 对于上述结构体EIP712Domain来讲: #### encodeType编码 encodeType编码方式与函数选择器的编码方式不同,其包含了变量名和空格。 $$ encodeType=EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) $$ 对于encodeType编码,如果结构体中包含结构体,则只需要按照字母顺序将结构体依次编码即可 如针对结构体: ```js struct Transaction{ Person from, Person to, Asset tx } struct Asset{ address token, uint256 amount } struct Person{ address wallet, string name } ``` 编码后的encodeType为: ```js Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name) ``` #### encodeData编码 encodeData编码要求所编码的每一位都占据32bytes,且编码的顺序要与encodeType中定义的顺序一致。针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 即内容的hash值。针对固定长度的类型,如bool 值其编码为Uint256, address 编码为uint160, bytes1~bytes32 编码为左对齐的bytes32,右侧补零。 故针对DomainSepeartor,其编码值为:因为每一项必须是32bytes,所以用abi.encode,而不用abi.encodePacked ```js abi.encode( keccak(bytes(name)), keccak(bytes(version)), uint256(chianid), uint256(uint160(verifyingContract)) ); ``` 结合起来,故知domainSeperator的值应为: ```js DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), // encodeType keccak256(bytes(name)), // encodeData-name keccak256(bytes("1")), // encodeData-version chianId, // encodeData-chainId uint256(address(this)) // encodeData-address ) ); ``` ### 结构化数据编码 $$ encode(domainSeparator,message)=\\x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$ 根据上述公式,在uniswap中,我们需要明确需要编码的struct,即用户调用时显示在前端off-chain部分的信息: ```js struct Permit{ address owner, address spender, uint256 value, uint256 nonce, uint256 deadline } ``` 则该Permit的encodeType为: ```js encodeType(Permit) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)") ``` 则该Permit的encodeData为:因为每一项都必须是32bytes,所以使用abi.encode, 而不是用abi.encodePacked ```js abi.encode( uint256(uint160(owner)), uint256(uint160(spender)), value, nonce, deadline ) ``` 故该Permit的结构化签名信息应为: ```js bytes memory mssage = abi.encodePacked( \x19\x01, DOMAIN_SEPERATOR, keccak256( abi.encode( keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), uint256(uint160(owner)), uint256(uint160(spender)), value, nonces[owner]++, deadline )) ) ``` 结合Uniswap中的元数据定义看,在Permit函数中,对上述消息Hash后,然后通过`ecrecover`方法来验证address地址。当验证无误时,执行`_approve`方法。 ## 确定PAIR合约地址 uniswap V2在工厂合约中,使用了create2关键字来创建合约。下面我们结合黄皮书,来进一步学习下create2与create $$ a \equiv A(s, \boldsymbol{\sigma}[s]_{\mathrm{n}} - 1, \zeta, \mathbf{i}) $$ $$ A(s, n, \zeta, \mathbf{i}) \equiv \mathcal{B}_{96..255}\Big(\mathtt{KEC}\big(B(s, n, \zeta, \mathbf{i})\big)\Big) \\ $$ $$ B(s, n, \zeta, \mathbf{i}) \equiv \begin{cases} \mathtt{RLP}\big(\;(s, n)\;\big) \text{if}\ \zeta = \varnothing \\ (255) \cdot s \cdot \zeta \cdot \mathtt{KEC}(\mathbf{i}) \text{otherwise} \end{cases} $$ ### create 对于create关键字,其随机数$\zeta=\varnothing$, 则新生成的合约地址仅和`msg.sender`和`nonce`相关。其具体地址的值为经过RLP编码后的`msg.sender` 和 `Nonce` 的bytes,然后经过哈希后的最右侧160位。 如下: ```js msg.sender = 0xfefefefefefefefefefefefefefefefefefefefe nonce = 1 (msg.sender, nonce) = [0xfefefefefefefefefefefefefefefefefefefefe,1] RLP((sender,nonce)): 此为list,需按照list进行RLP编码 首先是msg.sender 的RLP编码: 0x94fefefefefefefefefefefefefefefefefefefefe (0x80+0x14) 然后是nonce 的RLP编码:01 所以(sender,nonce) 的RLP编码是: 0xD694fefefefefefefefefefefefefefefefefefefefe01 (0xc0+0x16) 则Keccak256(RLP((sender,nonce)))= 0x1eee6eebe7cda42d60b04fbbf862acff87608602aa2cb646f5d67560f5c3e9d7 则生成的地址为:0xf862acff87608602aa2cb646f5d67560f5c3e9d7 右侧160位 ``` ### create2 对于create2关键字,其随机数$\zeta \neq \varnothing$, 在uniswap v2中,该随机数定义为:$\zeta= keccak256(abi.encodePacked(token0, token1));$ 则create2地址应为: ```js bytes32 memory salt = keccak256(abi.encodePacked(token0,token1)); bytes memory init_code = type(UniswapV2Pair).creationCode; B = abi.encodePacked( hex'ff', address(factory), salt, keccak256(init_code) ); address pair = address(uint160(keccak256(B))); ``` 那么为什么要选用create2这一opcode,而不是create呢? 原因在于create生成的地址与nonce相关,即与pair合约生成的顺序相关。我们希望pair合约的地址与该顺寻无关,故选用create2这一opcode。 ## 预言机 整个Uniswap V2的预言机部分代码仅有4-5行,然而其却实现了时间平均价格,取代容易被操纵的瞬时价格,成为一个更加稳定的价格预言机。 下面我们将结合白皮书和代码部分来分析Uniswap V2中,该预言机是如何设计以及为什么需要这样设计 首先是价格,某时刻t的资产价格a/b 为此时刻的reserve_tokenA 与 reserve_tokenB的比值 $$ p_t=\frac{r_{t}^{a}}{r_t^{b}} $$ ### 瞬时价格 如果用瞬时价格作为预言机会出现什么问题? 瞬时价格容易被操纵,可以通过第一笔交易swap,让A/B的资产价格瞬时下跌,然后再第二笔交易中,利用基于此瞬时价格作为预言机的合约,比如一些清算服务等,去进行清算等,第三笔交易再将价格拉回原位。甚至此三笔交易可以在一个原子交易中完成。 ![image32.png](https://img.learnblockchain.cn/attachments/2021/07/z78NmZ9H60f3e57ce82c0.png) 三明治攻击 ![image20210718143319864.png](https://img.learnblockchain.cn/attachments/2021/07/54qinDXR60f3e5852597e.png) ### 时间加权平均价格 uniswap v2通过测量和记录每一个区块最开始的第一笔交易的价格。相较于同一个区块内的价格,该价格非常难以操纵。具体来说,Uniswap v2通过记录每个区块中有人与合约互动时的累积价格总和,来累积这个价格。 每个价格的权重是自上一个区块更新以来所经过的时间。这意味着,在任何给定的时间,累积器的值(在被更新后)都应该被加权。这意味着在任何特定时间(更新后),累积器的值应该是合约历史上每一秒钟的现货价格的总和。 $$ a_t=\sum_{i=1}^{t}{p_i} $$ 为了估计从时间$t_1$到$t_2$的时间加权平均价格,一个外部调用者可以在$t_1$时检查累积器的值,然后在$t_2$时再次检查,将该值减去第一个值,然后再除以所经过的秒数。(请注意合约本身并不存储这个累积器的历史值--调用者必须在周期开始时调用合约来读取和存储这个值) $$ p_{t_1,t_2}=\frac{\sum_{i=t_1}^{t_2}{p_i}}{t_2-t_1}=\frac{\sum_{i=1}^{t_2}p_i-\sum_{i=1}^{t_1}{p_i}}{t_2-t_1}=\frac{a_{t_2}-a_{t_1}}{t_2-t_1} $$ 下面我们结合代码来看下具体如何实现: ```js uint public price0CumulativeLast; uint public price1CumulativeLast; // 只在每个区块的最开始的第一笔交易处记录price,并且将价格按照时间权重进行累加得到a_t function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private { // 判断是否是该区块的第一笔交易,只要该block的timestamp与上一次记录的Block.timestamp不一样,就说明不是同一个块 uint32 timestamp = uint32(uint(block.timestamp)%(2**32)); if (timestamp > prev_timestamp) { //如果是,计算出瞬时价格pi //uint256 priceAtoB = uint256(_reserve0).div(uint256(_reserve1)); uint256 priceAtoB = uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)); // 得到的price是uint224位编码,头32位留空给溢出用,前112位为正数部分,后112位为小数部分 uint256 priceBtoA = uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)); //按照时间权重累加到a_t上 price0CumulativeLast += (timestamp-prev_timestamp).mul(priceBtoA); price1CumulativeLast += (timestamp-prev_timestamp).mul(priceAtoB); } prev_timestamp = timestamp; } ``` 最后得到的`price1CumulativeLast`虽然是`uint256`,但其实实质上仍然是`UQ112.112`编码的数值。前32位留空给溢出,前112位为正数,后112位为小数。 ![_.png](https://img.learnblockchain.cn/attachments/2021/07/0TJgLL4L60f3ea75bd329.png)

这段时间非常有幸跟行业内人士聊了一会,算是第一次正式接触到区块链行业内从业者,很激动。整体感觉这个行业充满了变化,挑战和机遇,它会给年轻人机会,目前我正在考虑转行该行业,希望能够在三年后回首自己的选择时能够不后悔,不遗憾。

Uniswap的行业地位

uniswap是以太坊上的一个DAPP应用,后续的DEFI繁荣,出现各种各样的SWAP,其代码鼻祖就是Uniswap。

AMM

什么是AMM,价格如何移动?

AMM,全称Automated Market Makers,翻译过来是自动做市商。其基础模型来源于Vitalik于2017年发表的博客,讨论了“恒定乘机公式”,即每一个Uniswap Pair 中存有两种资产,并为这两种资产提供流动性。在为资产提供流动性时,保持两种Token储备的乘积不能减少的不变性。交易者须在交易中支付30个基点的费用,这些费用将用于流动性提供者。即保证$R_A * R_B \geq K$

下面我们将分别结合swap,mint,burn来讨论AMM曲线的移动。

Swap

首先我们结合Uniswap中的简化版Swap代码来分析下价格移动

// 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 {
        //拿到swap之前的tokenA和tokenB的余额
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        //拿到此时刻的tokenA和tokenB的余额
            balance0 = IERC20(_token0).balanceOf(address(this));
            balance1 = IERC20(_token1).balanceOf(address(this));
        //乐观转账给address To
            if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
            if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        //通过余额的差值计算得到要交换的Token的数量
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        //扣除书续费,验证转账后的余额满足 X*Y >= K的要求
        { // 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);
    }

可以看到,如下图要从A点移动到B点,通过调用Uniswap的swap函数,需要先转账给pair合约一定数量($X_1-X_0$)的tokenB,然后pair合约将对应数量的tokenA转账出去到目标地址。则转装出去的tokenA的数量为$Y_0-Y_1$, 在忽略手续费的情况下,此时的B点应该要落在曲线上。

Mint

function mint(address to) external lock returns (uint liquidity) {
        //拿到调用mint前的tokenA和tokenB的余额
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        //拿到此时刻的TokenA和TokenB的余额
        uint balance0 = IERC20(token0).balanceOf(address(this));
        uint balance1 = IERC20(token1).balanceOf(address(this));
        //计算得到打进账户的两种Token的数量
        uint amount0 = balance0.sub(_reserve0);
        uint amount1 = balance1.sub(_reserve1);

        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        if (_totalSupply == 0) {
         //首次铸币
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
           _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            //非首次铸币
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
        }
        //发送LP Token
        _mint(to, liquidity);
        // 更新账户余额
        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    }

可以看到,要让图中点A移动到点B,通过调用Uniswap中的Mint函数实现。如图中A点所示,其TokenX=2, TokenY=5000, 在保证$dy/dx$不变的情况下,即价格稳定的情况下,向Pair合约中,转账TokenX=1,TokenY=2500,此时系统的K值会由最初的10000增长为22500,点A移动到点B。即价格移动曲线也更新为最新的K=22500这条曲线。

Burn

// this low-level function should be called from a contract which performs important safety checks
    function burn(address to) external lock returns (uint amount0, uint amount1) {
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        address _token0 = token0;                                // gas savings
        address _token1 = token1;                                // gas savings
        uint balance0 = IERC20(_token0).balanceOf(address(this));
        uint balance1 = IERC20(_token1).balanceOf(address(this));
        uint liquidity = balanceOf[address(this)];

        bool feeOn = _mintFee(_reserve0, _reserve1);
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
        amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
        require(amount0 > 0 && amount1 > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED');
        _burn(address(this), liquidity);
        _safeTransfer(_token0, to, amount0);
        _safeTransfer(_token1, to, amount1);
        balance0 = IERC20(_token0).balanceOf(address(this));
        balance1 = IERC20(_token1).balanceOf(address(this));

        _update(balance0, balance1, _reserve0, _reserve1);
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
        emit Burn(msg.sender, amount0, amount1, to);
    }

Burn方法是Mint方法的逆向,先向Pair合约中,转账一定量的LP token,然后通过Pair合约向外转账tokenA, tokenB. 使得上图中的B点回落到A点。

数值精度

由于solidity中并不原生支持小数,Uniswap采用UQ112.112这种编码方式来存储价格信息。UQ112.112意味着该数值采取224位来编码值,前112位存放小数点前的值,其范围是$[0,2^{112}-1]$,后112位存放小数点后的值,其精度可以达到$\frac{1}{2^{112}}$.

library UQ112x112 {
    uint224 constant Q112 = 2**112;

    // 编码:将一个uint112的值编码为uint224
    //0000000000000000000000000000000000000000000000000000000000000001 => 0x01 uint112
    //00000000 => 前32位留空
    //0000000000000000000000000001 => 整数部分
    //0000000000000000000000000000 => 小数部分
    function encode(uint112 y) internal pure returns (uint224 z) {
        z = uint224(y) * Q112; // never overflows
    }
    // divide a UQ112x112 by a uint112, returning a UQ112x112
    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
        z = x / uint224(y);
    }
}

Uniswap选择UQ112.112的原因:因为UQ112.112可以被存储位uint224,这会留下32位的空闲Storage插槽位。再一个是每个Pair合约的reserve0和reserve1都是Uint112,同样会留下32位的空闲插槽位。特别的是pair合约的reserve连同最近一个区块的时间戳一起存储时,该时间戳会对$2^{32}$取余数称为uint32格式的数值。加之,尽管在任意给定时刻的价格都是UQ112.112格式,保证为uint224位,一段时间累积的价格并不是该格式。在储存插槽中末端的额外的32位可以用来储存累积的价格的溢出部分。这种设计意味着在每一个区块的第一笔交易中只需要额外的三次SSTORE操作(目前的开销是15000gas)。

选择UQ112.112的缺点:最末端的32位用来储存时间戳会溢出,事实上一个Uint32的时间戳溢出点是02/07/2106.

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
上述三个参数刚好占据一个slot,一个slot 32位,上面三个参数编码后就是14+14+4=32位
该slot中的数据排列是:
blockTime|Reserve1|Reserve0
//00000000 => blockTimestampLast
//0000000000000000000000000000 => reserve1
//0000000000000000000000000000 => reserve0

为什么可以看到合约中存在x*1000/1000,目的是什么?

一方面是因为solidity中没有小数的概念,另一方面是为了保证数值精度

uint256 reward = (amountDeposited * 100) / totalDeposits;
与
uint256 rewardInWei = (amountDeposited * 100 * 10 ** 18) / totalDeposites;

如果amountDeposited=10000000, totalDeposits = 400+1000000:
reward=99
如果amountDeposited = 100, totalDeposits = 400 + 1000000
reward = 0 ether
rewardInWei = 99960015993602 wei = 0.00009996 ether

闪电兑

Uniswap V2添加了闪电兑功能,允许用户在付钱之前接受和使用Token资产,只要他们在同一笔原子交易中。闪电兑换功能在swap函数中实现:IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); 也可以写成 address(to).callWithSigature("uniswapV2Call(address,uint256,uint256,bytes)", msg.sender, amount0Out, amount1Out, data). 当回调函数完成后,合于会检查新的账户余额并确认常数K满足要求(扣除手续费后的常数K). 如果合约没有足够的余额,则会回退整笔交易。

用户也可以用同一种Token来偿还给Pair合约,而不是必须完成swap交易。这事实上是允许任何人闪电贷任何一种储存在Pair中的资产,只需要支付0.3%的手续费即可。

// 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 {
        //拿到swap之前的tokenA和tokenB的余额
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        //拿到此时刻的tokenA和tokenB的余额
            balance0 = IERC20(_token0).balanceOf(address(this));
            balance1 = IERC20(_token1).balanceOf(address(this));
        //乐观转账给address To
            if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
            if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        //闪电兑-执行用户定义的回调合约,在乐观转账给address To和确保K值不减之间时调用
        if(data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        //通过余额的差值计算得到要交换的Token的数量
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        //扣除书续费,验证转账后的余额满足 X*Y >= K的要求
        { // 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);
    }

LP TOKEN初始供应

要理解LP token的供应值,就需要理解什么是LP token。

在Uniswap中,LP token在mint中被铸造出来,在burn中销毁。除开首次铸造,非首次铸造的LP token数量都是与LP token的当前总量成线性关系。LP token的价值来源于流动性的提供,即mint方法。LP token的增值逻辑是时间段($t_1$,$t_2$)间的swap交易手续费的累计。swap的交易手续费又可以表现为时间段($t_1$,$t_2$)对应的($\sqrt{k_1}$,$\sqrt{k_2}$)的增值。

首次铸币

首次铸币时,如何确定LP token的初始供应的数量呢?

$$ s{minted} = \sqrt{x{deposited}\cdot y_{deposited}}=\sqrt{k} $$

该公式确保了任意时刻的LP token的价值与初始供应的tokenA,tokenB的比例无关。例如,一个流动性提供者存入2 tokenA,200 tokenB,此时tokenA/tokenB的价格为100,则该流动性提供者会获得$\sqrt{2\cdot 200}=20$ LP token。该20份 LP token的价值就是2 tokenA, 200 tokenB, 以及此时间段间积累的手续费。如果一个流动性提供者最初存入的是2 tokenA, 800 tokenB, 此时tokenA/tokenB的价格为400,则该流动性提供者会获得$\sqrt{2\cdot 800}=40$ LP token。

非首次铸币

非首次铸币时,LP token的供应量为:

$$ s{minted}=\frac{x{deposited}}{x{starting}}\cdot s{starting} $$

上述公式保证了LP token的价值永远不会低于$\sqrt{k}$.

然而,当我们对比mint部分代码时,会发现如下一行代码:

if (_totalSupply == 0) {
    liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
   _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
}

为什么在首次铸币时,要向address(0) 传送一部分LPtoken呢?

因为根据上述公式,存在如下的一种攻击模式,称为LP token操纵

sequenceDiagram

    opt 首次铸币
    攻击者->>Pair合约: tokenA.transfer(pair,1 wei)
    攻击者->>Pair合约: tokenB.transfer(pair,1 wei)
    攻击者->>Pair合约: pair.mint(msg.sender)
    Pair合约 ->> 攻击者: LP token=1 wei 
    end
    opt 二次铸币
    攻击者->>Pair合约: tokenA.transfer(pair,10**22 wei)
    攻击者->>Pair合约: tokenB.transfer(pair,10**22 wei)
    攻击者->>Pair合约: pair.mint(msg.sender)
    Pair合约 ->> 攻击者: LP token=10**22 wei 
    end
    opt SYNC 同步余额
    攻击者->>Pair合约: pair.sync()
    end
    opt 三次铸币
    受害者->>Pair合约: tokenA.transfer(pair,10**22 wei)
    受害者->>Pair合约: tokenB.transfer(pair,10**22 wei)
    受害者->>Pair合约: pair.mint(msg.sender)
    Pair合约 ->> 受害者: LP token=? wei 
    end

平台手续费

Uniswap支持0.05%的平台手续费,它可以在Factory合约中设置开或者关。如果被设置为收取平台手续费,则该平台手续费会转账给feeTo地址(该地址是在工厂合约中设置)。

收取平台手续费的位点

如果设置了feeTo地址,Uniswap将会开始收取0.05%的平台手续费,其为流动性提供者收取的0.3%中的$\frac1{6}$, 即交易者仍然只会支付0.3%的手续费,其中$\frac5{6}$会支付给流动性提供者,剩余的$\frac1{6}$会支付给feeTo地址。如果每一笔交易都将其手续费的1/6实时打给feeTo地址会极度消耗gas。为了避免这种情况,累计的手续费只会在提供流动性和移除流动性时计算。合约会计算累计的手续费,并铸造新的LP token给到feeTo地址,在任何token被铸造或者销毁前进行。

function mint(address to) external lock returns (uint liquidity) {
        //拿到调用mint前的tokenA和tokenB的余额
        //拿到此时刻的TokenA和TokenB的余额
        //计算得到打进账户的两种Token的数量
       // 在实际创建任何LPtoken之前,先计算mintFee,即先把平台手续费对应的LP token发放了。
        bool feeOn = _mintFee(_reserve0, _reserve1);
        if (_totalSupply == 0) {
         //首次铸币
        } else {
            //非首次铸币
        }
        //发送LP Token
        // 更新账户余额
        if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    }
function _mintFee(uint112 resserve0, uint112 reserve1) private returns (bool feeOn) {
        //拿到工厂合约设置的feeTo地址
        address feeTo = IUniswapFactory(factory).feeTo();
        //根据feeTo地址判断是否开启收取平台手续费
        feeOn = (feeTo == address(0));
        //如果收取平台手续费
        if (feeOn) {
            if (KLast == 0) {
            //判断是不是首次铸币,首次铸币不收取平台手续费,因为此时没有swap,压根就没有手续费
                return;
            } else {
            //如果不是首次铸币,计算要发放的LP token的数量,转给feeTo地址
                // s1 = totalSupply, k1 = KLast, k2 = reserve0 * reserve1
                uint root_k1 = math.sqrt(KLast);
                uint root_k2 = math.sqrt(uint256(reserve0).mul(uint256(reserve1)));
                require(root_k2 > root_k1, "root_k2 &lt;= root_k1");
                uint numerator = root_k2.sub(root_k1);
                uint denominator = root_k2.mul(5).add(root_k1);
                uint amount = numerator.div(denominator).mul(totalSupply);
                _mint(feeTo, amount);
            }
        } else if (KLast != 0) {
        //如果不收取平台手续费,然而KLast不为0,则将其设置为0;    
            KLast = 0;
        }
   }

平台手续费的数量

现在最大的问题是,如何计算发放给feeTo地址的LP token的数量。

首先要明确一点,手续费的收集体现在K值的增加上。由于每一笔swap都收取了0.3%的手续费,其会导致$\sqrt{k}$值缓慢增加。对比$t_1$和$t_2$时刻的$\sqrt{k_1}$和$\sqrt{k_2}$, 其差值即为$t_2$与$t_1$时刻间的手续费,则在任意时间段($t_1$,$t_2$)间,其手续费占当前时刻$t_2$的比重为:

$$ f_{1,2}=\frac{\sqrt{k_2}-\sqrt{k_1}}{\sqrt{k_2}} $$

此时,我们假设$t_2$时刻的LPtoken的总供应量为$s_1$,需要发放给feeTo地址的LP token的量为$s_m$, 由于LP token不能减发,且发送给feeTo地址的LPtoken在发送给流动性提供者的LPtoken之前发放,故需要满足如下等式:

$$ \frac{s_m}{s_m+s1} = \phi \times f{1,2} $$

其中$\phi$为平台手续费所占所有手续费的比例,此处为1/6

由方程式1,2得到,此时的发放给feeTo地址的平台手续费为:

$$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{(1/\phi-1)\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$

带入$\phi=1/6$后得到:

$$ s_m=\frac{\sqrt{k_2}-\sqrt{k_1}}{5\cdot \sqrt{k_2} + \sqrt{k_1}}\times s_1 $$

元交易

由Uniswap Pair合约中铸造出的LP token天然的支持元交易。这意味着用户可以通过签名的方式授权转移LP token, 而不是必须由用户地址发起的以太坊上的交易。任何人可以通过调用permit函数提交他们的签名,付给gas费用,并同时进行多笔交易。

// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
constructor() public {
    uint chainId;
    assembly {
        chainId := chainid
    }
    DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
    );
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
    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);
}

要理解Uniswap V2中提到的元交易部分,就需要理解EIP-712

EIP-712 动机

旨在提高链外消息签名的可用性,以便在链上使用。我们看到越来越多的采用链外消息签名,因为它节省了gas,减少了区块链上的交易数量。当前签名的消息是向用户显示的不透明的hex字符串,与构成消息的项目几乎没有上下文。

EIP-712确定了编码数据及其结构,允许它显示给用户在签名时进行验证。以下是用户在签署 EIP712 消息时可以显示的内容的示例。

EIP-712 在EIP-191的基础上,进一步提出了一种结构化的签名信息,用于在前端(线下)签名时展示相应的信息,而不是一串难以理解的hex串。其在设计时,主要考虑到两方面特性:1.确保可确认性。如果不是确定性的,则哈希可能从签名到验证的瞬间而有所不同,导致签名被错误地拒绝 2. 可注射性。如果它不是注射的,那么我们的输入集中有两个不同的元素,它们对相同的值进行哈希值,导致签名对不同的不相关消息有效。

EIP-712 结构化签名信息

$$ \mathtt{T}\cup\mathtt{B^{8n}}\cup\mathtt{S} $$

  • 对于交易数据$T$, 其编码方式仍然遵循RLP编码方式
  • 对于bytes数据$B^{8n}$, 其编码方式遵循EIP-191, 即

    $ encode(B^{8n})= $\x19"Ethereum Signed Message:\n"$ \ ||\ len(message)\ ||\ message $ 其中len(message)是没有0开头的Ascii编码值

    $$

    $$

  • 对于结构化数据$S$, 其编码方式遵循EIP-712, 即

    $$ encode(domainSeparator,message)= \ x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$

DomainSeparator域定义

domainSeparator是一个结构体,名为EIP712Domain:

struct EIP712Domain{
    string name, //用户可读的域名,如DAPP的名字
    string version, // 目前签名的域的版本号
    uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1
    address verifyingContract, // 用于验证签名的合约地址
    bytes32 salt // 随机数
}

该结构体必须按照如上定义的字段和顺序定义,且如果有不需要的字段,可以跳过,但是不能改变该结构体内部顺序。

则对于DomainSeparator的编码方式应遵循如下编码方式:

$$ hashStruct(S)=keccak256(typeHash\ ||\ encodeData(S))\ typeHash=keccak256(encodeType(typeOf(S))) $$

对于上述结构体EIP712Domain来讲:

encodeType编码

encodeType编码方式与函数选择器的编码方式不同,其包含了变量名和空格。

$$ encodeType=EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) $$

对于encodeType编码,如果结构体中包含结构体,则只需要按照字母顺序将结构体依次编码即可

如针对结构体:

struct Transaction{
    Person from,
    Person to,
    Asset tx
}
struct Asset{
    address token,
    uint256 amount
}
struct Person{
    address wallet,
    string name
}

编码后的encodeType为:

Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

encodeData编码

encodeData编码要求所编码的每一位都占据32bytes,且编码的顺序要与encodeType中定义的顺序一致。针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 即内容的hash值。针对固定长度的类型,如bool 值其编码为Uint256, address 编码为uint160, bytes1~bytes32 编码为左对齐的bytes32,右侧补零。

故针对DomainSepeartor,其编码值为:因为每一项必须是32bytes,所以用abi.encode,而不用abi.encodePacked

abi.encode(
    keccak(bytes(name)),
    keccak(bytes(version)),
    uint256(chianid),
    uint256(uint160(verifyingContract))
);

结合起来,故知domainSeperator的值应为:

DOMAIN_SEPARATOR = keccak256(
    abi.encode(
        keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), // encodeType
        keccak256(bytes(name)), // encodeData-name
        keccak256(bytes("1")), // encodeData-version
        chianId, // encodeData-chainId
        uint256(address(this)) // encodeData-address
    )
);

结构化数据编码

$$ encode(domainSeparator,message)=\x19x01\ ||\ domainSeparator\ ||\ hashStruct(message) $$

根据上述公式,在uniswap中,我们需要明确需要编码的struct,即用户调用时显示在前端off-chain部分的信息:

struct Permit{
    address owner,
    address spender,
    uint256 value,
    uint256 nonce,
    uint256 deadline
}

则该Permit的encodeType为:

encodeType(Permit) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")

则该Permit的encodeData为:因为每一项都必须是32bytes,所以使用abi.encode, 而不是用abi.encodePacked

abi.encode(
    uint256(uint160(owner)),
    uint256(uint160(spender)),
    value,
    nonce,
    deadline
)

故该Permit的结构化签名信息应为:

bytes memory mssage = abi.encodePacked(
    \x19\x01,
    DOMAIN_SEPERATOR,
    keccak256(
        abi.encode(
            keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
            uint256(uint160(owner)),
            uint256(uint160(spender)),
            value,
            nonces[owner]++,
            deadline
        ))
)

结合Uniswap中的元数据定义看,在Permit函数中,对上述消息Hash后,然后通过ecrecover方法来验证address地址。当验证无误时,执行_approve方法。

确定PAIR合约地址

uniswap V2在工厂合约中,使用了create2关键字来创建合约。下面我们结合黄皮书,来进一步学习下create2与create

$$ a \equiv A(s, \boldsymbol{\sigma}[s]_{\mathrm{n}} - 1, \zeta, \mathbf{i}) $$

$$ A(s, n, \zeta, \mathbf{i}) \equiv \mathcal{B}_{96..255}\Big(\mathtt{KEC}\big(B(s, n, \zeta, \mathbf{i})\big)\Big) \ $$

$$ B(s, n, \zeta, \mathbf{i}) \equiv \begin{cases} \mathtt{RLP}\big(\;(s, n)\;\big) \text{if}\ \zeta = \varnothing \ (255) \cdot s \cdot \zeta \cdot \mathtt{KEC}(\mathbf{i}) \text{otherwise} \end{cases} $$

create

对于create关键字,其随机数$\zeta=\varnothing$, 则新生成的合约地址仅和msg.sendernonce相关。其具体地址的值为经过RLP编码后的msg.senderNonce 的bytes,然后经过哈希后的最右侧160位。

如下:

msg.sender = 0xfefefefefefefefefefefefefefefefefefefefe
nonce = 1
(msg.sender, nonce) = [0xfefefefefefefefefefefefefefefefefefefefe,1]
RLP((sender,nonce)): 此为list,需按照list进行RLP编码
首先是msg.sender 的RLP编码: 0x94fefefefefefefefefefefefefefefefefefefefe (0x80+0x14)
然后是nonce 的RLP编码:01
所以(sender,nonce) 的RLP编码是: 0xD694fefefefefefefefefefefefefefefefefefefefe01 (0xc0+0x16)
则Keccak256(RLP((sender,nonce)))= 0x1eee6eebe7cda42d60b04fbbf862acff87608602aa2cb646f5d67560f5c3e9d7
则生成的地址为:0xf862acff87608602aa2cb646f5d67560f5c3e9d7 右侧160位

create2

对于create2关键字,其随机数$\zeta \neq \varnothing$, 在uniswap v2中,该随机数定义为:$\zeta= keccak256(abi.encodePacked(token0, token1));$ 则create2地址应为:

bytes32 memory salt = keccak256(abi.encodePacked(token0,token1));
bytes memory init_code = type(UniswapV2Pair).creationCode;
B = abi.encodePacked(
    hex'ff',
    address(factory),
    salt,
    keccak256(init_code)
);
address pair = address(uint160(keccak256(B)));

那么为什么要选用create2这一opcode,而不是create呢?

原因在于create生成的地址与nonce相关,即与pair合约生成的顺序相关。我们希望pair合约的地址与该顺寻无关,故选用create2这一opcode。

预言机

整个Uniswap V2的预言机部分代码仅有4-5行,然而其却实现了时间平均价格,取代容易被操纵的瞬时价格,成为一个更加稳定的价格预言机。

下面我们将结合白皮书和代码部分来分析Uniswap V2中,该预言机是如何设计以及为什么需要这样设计

首先是价格,某时刻t的资产价格a/b 为此时刻的reserve_tokenA 与 reserve_tokenB的比值

$$ pt=\frac{r{t}^{a}}{r_t^{b}} $$

瞬时价格

如果用瞬时价格作为预言机会出现什么问题?

瞬时价格容易被操纵,可以通过第一笔交易swap,让A/B的资产价格瞬时下跌,然后再第二笔交易中,利用基于此瞬时价格作为预言机的合约,比如一些清算服务等,去进行清算等,第三笔交易再将价格拉回原位。甚至此三笔交易可以在一个原子交易中完成。

三明治攻击

时间加权平均价格

uniswap v2通过测量和记录每一个区块最开始的第一笔交易的价格。相较于同一个区块内的价格,该价格非常难以操纵。具体来说,Uniswap v2通过记录每个区块中有人与合约互动时的累积价格总和,来累积这个价格。

每个价格的权重是自上一个区块更新以来所经过的时间。这意味着,在任何给定的时间,累积器的值(在被更新后)都应该被加权。这意味着在任何特定时间(更新后),累积器的值应该是合约历史上每一秒钟的现货价格的总和。

$$ at=\sum{i=1}^{t}{p_i} $$

为了估计从时间$t_1$到$t_2$的时间加权平均价格,一个外部调用者可以在$t_1$时检查累积器的值,然后在$t_2$时再次检查,将该值减去第一个值,然后再除以所经过的秒数。(请注意合约本身并不存储这个累积器的历史值--调用者必须在周期开始时调用合约来读取和存储这个值)

$$ p_{t_1,t2}=\frac{\sum{i=t_1}^{t_2}{p_i}}{t_2-t1}=\frac{\sum{i=1}^{t_2}pi-\sum{i=1}^{t_1}{p_i}}{t_2-t1}=\frac{a{t2}-a{t_1}}{t_2-t_1} $$

下面我们结合代码来看下具体如何实现:

uint public price0CumulativeLast;
uint public price1CumulativeLast;
// 只在每个区块的最开始的第一笔交易处记录price,并且将价格按照时间权重进行累加得到a_t
function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    // 判断是否是该区块的第一笔交易,只要该block的timestamp与上一次记录的Block.timestamp不一样,就说明不是同一个块
    uint32 timestamp = uint32(uint(block.timestamp)%(2**32));
    if (timestamp > prev_timestamp) {
        //如果是,计算出瞬时价格pi
        //uint256 priceAtoB = uint256(_reserve0).div(uint256(_reserve1));
        uint256 priceAtoB = uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)); // 得到的price是uint224位编码,头32位留空给溢出用,前112位为正数部分,后112位为小数部分
        uint256 priceBtoA = uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1));
        //按照时间权重累加到a_t上
        price0CumulativeLast += (timestamp-prev_timestamp).mul(priceBtoA);
        price1CumulativeLast += (timestamp-prev_timestamp).mul(priceAtoB);
    }
    prev_timestamp = timestamp;
}

最后得到的price1CumulativeLast虽然是uint256,但其实实质上仍然是UQ112.112编码的数值。前32位留空给溢出,前112位为正数,后112位为小数。

区块链技术网。

  • 发表于 2021-07-18 16:26
  • 阅读 ( 1605 )
  • 学分 ( 107 )
  • 分类:智能合约

评论