当面试官问你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 <= 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.sender
和nonce
相关。其具体地址的值为经过RLP编码后的msg.sender
和 Nonce
的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 )
- 分类:智能合约
评论