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

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

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

33eeded88d1001e931a40a41af0e7bec55e79783.jpg

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点应该要落在曲线上。 image20210717105238162.png

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这条曲线。 image20210717113439641.png

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字符串,与构成消息的项目几乎没有上下文。 eth_sign.png

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

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的资产价格瞬时下跌,然后再第二笔交易中,利用基于此瞬时价格作为预言机的合约,比如一些清算服务等,去进行清算等,第三笔交易再将价格拉回原位。甚至此三笔交易可以在一个原子交易中完成。 image32.png

三明治攻击

image20210718143319864.png

时间加权平均价格

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位为小数。 _.png

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

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

5 条评论

请先 登录 后评论
bixia1994
bixia1994

互联网小工

34 篇文章, 275 学分