# 剖析DeFi交易产品之Uniswap：V2下篇

By [Keegan小钢](https://paragraph.com/@keeganlee) · 2022-01-16

---

前言
--

[上篇](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494337&idx=1&sn=8a007959e5535b2603a6a0e1096be702&chksm=8b685011bc1fd907f84fbed1969c3240d66d70c7c724295ee3c9c2008271e8b788c302852406&token=276562139&lang=zh_CN&scene=21#wechat_redirect)我们主要讲了 UniswapV2 整体分为了哪些项目，并重点讲解了 **uniswap-v2-core** 的核心代码实现；[中篇](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494370&idx=1&sn=f825dfd0c71e09c7a86d5caab18df139&chksm=8b685032bc1fd9246e52293c9916771f524ede972347678e691cdb1fb847eff7e91b8ef3a920&token=855586650&lang=zh_CN&scene=21#wechat_redirect)主要对 **uniswap-v2-periphery** 的路由合约实现进行了剖析；现在剩下 V2 系列的最后一篇，我会介绍剩下的一些内容，主要包括：**TWAP、FlashSwap、质押挖矿**。

TWAP
----

**TWAP = Time-Weighted Average Price**，即**时间加权平均价格**，可用来创建有效防止价格操纵的**链上价格预言机**。

TWAP 的实现机制其实很简单。首先，在配对合约里会存储三个相关变量：

*   **price0CumulativeLast**
    
*   **price1CumulativeLast**
    
*   **blockTimestampLast**
    

前两个变量是两个 token 的累加价格，最后一个变量则用来记录更新的区块时间。我们可以直接来看看其代码实现：

![](https://storage.googleapis.com/papyrus_images/ad7af93e53c29574b002a08699cd7f52bfc7e4fa0405682e44ea77d35006790e.png)

这是 **UniswapV2Pair** 合约的 _\_update_ 函数，每次 _mint_、_burn_、_swap_、_sync_ 时都会触发更新。实现逻辑很容易理解，主要就以下几步：

1.  读取当前的区块时间 blockTimestamp
    
2.  计算出与上一次更新的区块时间之间的时间差 timeElapsed
    
3.  如果 timeElapsed > 0 且两个 token 的 reserve 都不为 0，则更新两个累加价格
    
4.  更新两个 reserve 和区块时间 blockTimestampLast
    

有些人可能还是不太理解累加价格的意义，要把它理解透彻，先从当前时刻的价格说起，即 token0 和 token1 的当前价格，其实可以根据以下公式计算所得：

    price0 = reserve1 / reserve0
    price1 = reserve0 / reserve1
    

比如，假设两个 token 分别为 WETH 和 USDT，当前储备量分别为 10 WETH 和 40000 USDT，那么 WETH 和 USDT 的价格分别为：

    price0 = 40000/10 = 4000 USDT
    price1 = 10/40000 = 0.00025 WETH
    

现在，再加上时间维度来考虑。比如，当前区块时间相比上一次更新的区块时间，过去了 5 秒，那就可以算出这 5 秒时间的累加价格：

    price0Cumulative = reserve1 / reserve0 * timeElapsed = 40000/10*5 = 20000 USDT
    price1Cumulative = reserve0 / reserve1 * timeElapsed = 10/40000*5 = 0.00125 WETH
    

假设之后再过了 6 秒，最新的 reserve 分别变成了 12 WETH 和 32000 USDT，则最新的累加价格变成了：

    price0CumulativeLast = price0Cumulative + reserve1 / reserve0 * timeElapsed = 20000 + 32000/12*6 = 36000 USDT
    price1CumulativeLast = price1Cumulative + reserve0 / reserve1 * timeElapsed = 0.00125 + 12/32000*6 = 0.0035 WETH
    

这就是合约里所记录的累加价格了。

另外，每次计算时因为有 timeElapsed 的判断，所以其实每次计算的是每个区块的第一笔交易。而且，计算累加价格时所用的 reserve 是更新前的储备量，所以，实际上所计算的价格是之前区块的，因此，想要操控价格的难度也就进一步加大了。

![](https://storage.googleapis.com/papyrus_images/098c91c8bfe461bc3a4625cfdf5e7d72397fc92fc9fb72a4fadd612670dc1033.png)

有了前面的基础，接下来就可以计算 TWAP 即时间加权平均价格了。计算公式也很简单，如下图：

![](https://storage.googleapis.com/papyrus_images/1e30aaf27e2673eda3b92c1b3d46a76918ebc03118483fbec5371bd5ed49e480.png)

代入我们的例子，为了简化，我们将前面 5 秒时间的时刻记为 T1，累加价格记为 priceT1，而 6 秒时间后的时刻记为 T2，累加价格记为 priceT2。如此，可以计算出，在后面 6 秒时间里的平均价格：

    twap = (priceT2 - priceT1)/(T2 - T1) = (36000 - 20000)/6 = 2666.66
    

在实际应用中，一般有两种计算方案，一是固定时间窗口的 TWAP，二是移动时间窗口的 TWAP。在 uniswap-v2-periphery 项目中，examples 目录下提供了这两种方案的示例代码，分为是 **ExampleOracleSimple.sol** 和 **ExampleSlidingWindowOracle.sol**，具体代码就不展开讲解了。

现在，Uniswap TWAP 已经被广泛应用于很多 DeFi 协议，很多时候会结合 Chainlink 一起使用。比如 Compound 就使用 Chainlink 进行喂价并加入 Uniswap TWAP 进行边界校验，防止价格波动太大。

FlashSwap
---------

FlashSwap，翻译过来就是**闪电兑换**，和**闪电贷（FlashLoan）** 有点类似。

从代码层面来说，闪电兑换的触发在 **UniswapV2Pair** 合约的 **swap** 函数里的，该函数里有这么一行代码：

    if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
    

这行代码主要说明了三个信息：

1.  **to** 地址是一个合约地址
    
2.  **to** 地址的合约实现了 **IUniswapV2Callee** 接口
    
3.  可以在 **uniswapV2Call** 函数里执行 **to** 合约自己的逻辑
    

一般情况下的兑换流程，是先支付 _tokenA_，再得到 _tokenB_。但闪电兑换却可以先得到 tokenB，最后再支付 tokenA。如下图：

![](https://storage.googleapis.com/papyrus_images/a7458ae2dcb9c9cb735c8d565038844ada908f4d85367cd75aa08aef7de96d27.png)

即是说，通过闪电兑换，可以实现无前置成本的套利。

比如，在 Uniswap 上可以用 3000 DAI 兑换出 1 ETH，而在 Sushi 上可以将 1 ETH 兑换成 3100 DAI，这就存在 100 DAI 的套利空间了。但是，如果用户钱包里没有 DAI 的话，该怎么套利呢？通过 Uniswap 的闪电兑换，就可以先获得 ETH，再将 ETH 在 Sushi 卖出得到 DAI，最后支付 DAI 给到 Uniswap，这样就实现了无需前置资金成本的套利了。

理论上，只要利润空间能覆盖两边的交易手续费和 GAS，就值得执行套利。这种套利行为能使得不同 DEX 之间的价格趋于一致。

闪电兑换还可以应用于另一种场景。假设用户想在 Compound 抵押 ETH 借出 DAI，再用借出的 DAI 到 Uniswap 兑换成 ETH，再抵押到 Compound 借出更多 DAI，如此重复操作，从而提高做多 ETH 的杠杆率。这么做的效率非常低。而使用闪电兑换，可以大大提高交易效率：

1.  先从 Uniswap 得到 ETH
    
2.  将用户的 ETH 和从 Uniswap 得到的 ETH 抵押进 Compound
    
3.  从 Compound 借出 DAI
    
4.  在 Uniswap 支付 DAI
    

上述步骤也不需要重复执行，一次流程就实现了用户想要的杠杆率，相比之下，明显高效很多。

在 uniswap-v2-periphery 项目中，examples 目录下有个 **ExampleFlashSwap.sol**，就是实现闪电兑换的一个示例，实现的是在 UniswapV1 和 UniswapV2 之间套利。

质押挖矿
----

质押挖矿项目也同样很小，这是项目的 github 地址：

*   [https://github.com/Uniswap/liquidity-staker](https://github.com/Uniswap/liquidity-staker)
    

总共只有四个 sol 文件：

*   **IStakingRewards.sol**
    
*   **RewardsDistributionRecipient.sol**
    
*   **StakingRewards.sol**
    
*   **StakingRewardsFactory.sol**
    

**IStakingRewards.sol** 是一个接口文件，定义了质押合约 **StakingRewards** 需要实现的一些函数，其中，Mutative 函数只有四个：

*   **stake**：充值，即质押
    
*   **withdraw**：提现，即解质押
    
*   **getReward**：提取奖励
    
*   **exit**：退出
    

剩下的则都是 View 函数：

*   **lastTimeRewardApplicable**：有奖励的最近区块数
    
*   **rewardPerToken**：每单位 Token 奖励数量
    
*   **earned**：用户已赚但未提取的奖励数量
    
*   **getRewardForDuration**：挖矿奖励总量
    
*   **totalSupply**：总质押量
    
*   **balanceOf**：用户的质押余额
    

**RewardsDistributionRecipient.sol** 则是一个抽象合约，跟常用的 Ownable 合约类似，我们可以直接看看其代码实现：

![](https://storage.googleapis.com/papyrus_images/c92c71cd453508163c1f832354d339b815ba57c6f9abbe51913d4930dda6915d.png)

总共就 12 行代码，rewardsDistribution 其实就是管理员地址，还有一个 onlyRewardsDistribution 的 modifier，这不就是和我们熟知的 Ownable 一样的功能嘛。另外，还定义了一个抽象函数 **notifyRewardAmount**，所以实际上这就是一个抽象合约。而继承了该合约的是 **StakingRewards** 合约，后面再细说。

StakingRewards.sol 留到最后再说，先来看看 **StakingRewardsFactory.sol**，这是一个工厂合约，主要就是用来部署 StakingRewards 合约的。

### StakingRewardsFactory

工厂合约里定义了四个变量：

*   **rewardsToken**：用作奖励的代币，其实就是 UNI 代币
    
*   **stakingRewardsGenesis**：质押挖矿开始的时间
    
*   **stakingTokens**：用来质押的代币数组，一般就是各交易对的 LPToken
    
*   **stakingRewardsInfoByStakingToken**：一个 mapping，用来保存质押代币和质押合约信息之间的映射
    

质押合约信息则是一个数据结构：

    struct StakingRewardsInfo {
        address stakingRewards;
        uint rewardAmount;
    }
    

其中，stakingRewards 其实就是 StakingRewards 合约（即质押合约）地址，rewardAmount 则是该质押合约每周期的奖励总量。

rewardsToken 和 stakingRewardsGenesis 在工厂合约的构造函数里就初始化的。除了构造函数，工厂合约还有三个函数：

*   **deploy**
    
*   **notifyRewardAmounts**
    
*   **notifyRewardAmount**
    

deploy 就是部署 StakingRewards 合约的函数，其代码实现如下：

    function deploy(address stakingToken, uint rewardAmount) public onlyOwner {
        StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken];
        require(info.stakingRewards == address(0), 'StakingRewardsFactory::deploy: already deployed');
    
        info.stakingRewards = address(new StakingRewards(address(this), rewardsToken, stakingToken));
        info.rewardAmount = rewardAmount;
        stakingTokens.push(stakingToken);
    }
    

两个入参，stakingToken 就是质押代币，一般为 LPToken；rewardAmount 则是奖励数量。

实现逻辑，先从 mapping 中读取出 info，如果 info 的 stakingRewards 不为零地址说明该质押代币的质押合约已经部署过了，不能重复部署。接着，用 new 的方式创建了 StakeingRewards 合约，并将合约地址赋值给 info.stakingRewards，将合约地址保存起来。之后，再保存 rewardAmount。最后，将 stakingToken 加到质押代币数组里。至此，质押合约的部署工作就完成了。

部署合约之后，下一步应该将用来挖矿的代币转入到质押合约中，这就要通过 **notifyRewardAmount** 函数了，其代码实现如下：

    function notifyRewardAmount(address stakingToken) public {
        require(block.timestamp >= stakingRewardsGenesis, 'StakingRewardsFactory::notifyRewardAmount: not ready');
    
        StakingRewardsInfo storage info = stakingRewardsInfoByStakingToken[stakingToken];
        require(info.stakingRewards != address(0), 'StakingRewardsFactory::notifyRewardAmount: not deployed');
    
        if (info.rewardAmount > 0) {
            uint rewardAmount = info.rewardAmount;
            info.rewardAmount = 0;
    
            require(
            IERC20(rewardsToken).transfer(info.stakingRewards, rewardAmount),
            'StakingRewardsFactory::notifyRewardAmount: transfer failed'
            );
            StakingRewards(info.stakingRewards).notifyRewardAmount(rewardAmount);
        }
    }
    

调用该函数之前，其实还有一个前提条件要先完成，那就是**需要先将用来挖矿奖励的 UNI 代币数量先转入该工厂合约**。有个这个前提，工厂合约的该函数才能实现将 UNI 代币下发到质押合约中去。

代码逻辑就很简单了，先是判断当前区块的时间需大于等于质押挖矿的开始时间。然后读取出指定的质押代币 stakingToken 映射的质押合约 info，要求 info 的质押合约地址不能为零地址，否则说明还没部署。再判断 info.rewardAmount 是否大于零，如果为零也不用下发奖励。if 语句里面的逻辑主要就是调用 rewardsToken 的 transfer 函数将奖励代币转发给质押合约，再调用质押合约的 notifyRewardAmount 函数触发其内部处理逻辑。另外，将 info.rewardAmount 重置为 0，可以避免向质押合约重复下发奖励代币。

而 **notifyRewardAmounts** 函数，则是遍历整个质押代币数组，对每个代币再调用 **notifyRewardAmount**，实现逻辑非常简单。

至此，工厂合约的代码逻辑就讲完了。下面，就来看看 StakingRewards 合约了。

### StakingRewards

**StakingRewards** 合约会继承 **RewardsDistributionRecipient** 合约和 **IStakingRewards** 接口。

StakingRewards 存储的变量则比较多，除了继承自 **RewardsDistributionRecipient** 抽象合约里的 rewardsDistribution 变量之外，还有 11 个变量：

*   **rewardsToken**：奖励代币，即 UNI 代币
    
*   **stakingToken**：质押代币，即 LPToken
    
*   **periodFinish**：质押挖矿结束的时间，默认时为 0
    
*   **rewardRate**：挖矿速率，即每秒挖矿奖励的数量
    
*   **rewardsDuration**：挖矿时长，默认设置为 60 天
    
*   **lastUpdateTime**：最近一次更新时间
    
*   **rewardPerTokenStored**：每单位 token 奖励数量
    
*   **userRewardPerTokenPaid**：用户的每单位 token 奖励数量
    
*   **rewards**：用户的奖励数量
    
*   **\_totalSupply**：私有变量，总质押量
    
*   **\_balances**：私有变量，用户质押余额
    

前面讲工厂合约的 notifyRewardAmount 函数时，提到最后其实会调用到 StakingRewards 合约的 notifyRewardAmount 函数，我们就来看看这个函数是如何实现的：

    function notifyRewardAmount(uint256 reward) external onlyRewardsDistribution updateReward(address(0)) {
        if (block.timestamp >= periodFinish) {
             rewardRate = reward.div(rewardsDuration);
        } else {
            uint256 remaining = periodFinish.sub(block.timestamp);
            uint256 leftover = remaining.mul(rewardRate);
            rewardRate = reward.add(leftover).div(rewardsDuration);
        }
    
        // Ensure the provided reward amount is not more than the balance in the contract.
        // This keeps the reward rate in the right range, preventing overflows due to
        // very high values of rewardRate in the earned and rewardsPerToken functions;
        // Reward + leftover must be less than 2^256 / 10^18 to avoid overflow.
        uint balance = rewardsToken.balanceOf(address(this));
        require(rewardRate <= balance.div(rewardsDuration), "Provided reward too high");
    
        lastUpdateTime = block.timestamp;
        periodFinish = block.timestamp.add(rewardsDuration);
        emit RewardAdded(reward);
    }
    

该函数由工厂合约触发执行，而且根据工厂合约的代码逻辑，该函数也只会被触发一次。

由于 **periodFinish** 默认值为 0 且只会在该函数中更新值，所以只会执行 **block.timestamp >= periodFinish** 的分支逻辑，将从工厂合约转过来的挖矿奖励总量除以挖矿奖励时长，得到挖矿速率 **rewardRate**，即每秒的挖矿数量。理论上，else 分支是执行不到的，除非以后工厂合约升级为可以多次触发执行该函数。之后，读取 balance 并校验下 rewardRate，可以保证收取到的挖矿奖励余额也是充足的，rewardRate 就不会虚高。最后，更新 **lastUpdateTime** 和 **periodFinish**。periodFinish 就是在当前区块时间上加上挖矿时长，就得到了挖矿结束的时间。

接着，再来看看几个核心业务函数的实现，包括 stake、withdraw、getReward。

**stake** 就是质押代币的函数，实现代码如下：

    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Cannot stake 0");
        _totalSupply = _totalSupply.add(amount);
        _balances[msg.sender] = _balances[msg.sender].add(amount);
        stakingToken.safeTransferFrom(msg.sender, address(this), amount);
        emit Staked(msg.sender, amount);
    }
    

函数体内的代码逻辑很简单，将用户指定的质押量 amount 增加到 \_totalSupply（总质押量）和 \_balances（用户的质押余额），最后调用 stakingToken 的 safeTransferFrom 将代币从用户地址转入当前合约地址。

**withdraw** 则是用来提取质押代币的，代码实现也同样很简单，\_totalSupply 和 \_balances 都减掉提取数量，且将代币从当前合约地址转到用户地址：

    function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
        require(amount > 0, "Cannot withdraw 0");
        _totalSupply = _totalSupply.sub(amount);
        _balances[msg.sender] = _balances[msg.sender].sub(amount);
        stakingToken.safeTransfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }
    

**getReward** 是领取挖矿奖励的函数，内部逻辑主要就是从 rewards 中读取出用户有多少奖励并清零和转账给到用户：

    function getReward() public nonReentrant updateReward(msg.sender) {
        uint256 reward = rewards[msg.sender];
        if (reward > 0) {
            rewards[msg.sender] = 0;
            rewardsToken.safeTransfer(msg.sender, reward);
            emit RewardPaid(msg.sender, reward);
        }
    }
    

这几个核心业务函数体内的逻辑都非常好理解，值得一说的其实是每个函数声明最后的 **updateReward(msg.sender)**，这是一个更新挖矿奖励的 modifer，我们来看其代码：

    modifier updateReward(address account) {
        rewardPerTokenStored = rewardPerToken();
        lastUpdateTime = lastTimeRewardApplicable();
        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }
    

主要逻辑就是更新几个字段，包括 rewardPerTokenStored、lastUpdateTime 和用户的奖励相关的 rewards\[account\] 和 userRewardPerTokenPaid\[account\]。

其中，还调用到其他三个函数：rewardPerToken()、lastTimeRewardApplicable()、earned(account)。先来看看这三个函数的实现。最简单的就是 lastTimeRewardApplicable：

    function lastTimeRewardApplicable() public view returns (uint256) {
        return Math.min(block.timestamp, periodFinish);
    }
    

其逻辑就是从**当前区块时间**和**挖矿结束时间**两者中返回最小值。因此，当挖矿未结束时返回的就是当前区块时间，而挖矿结束后则返回挖矿结束时间。也因此，挖矿结束后，lastUpdateTime 也会一直等于挖矿结束时间，这点很关键。

rewardPerToken 函数则是获取每单位质押代币的奖励数量，其实现代码如下：

    function rewardPerToken() public view returns (uint256) {
        if (_totalSupply == 0) {
            return rewardPerTokenStored;
        }
        return
            rewardPerTokenStored.add(
                lastTimeRewardApplicable().sub(lastUpdateTime).mul(rewardRate).mul(1e18).div(_totalSupply)
            );
    }
    

这其实就是用累加计算的方式存储到 rewardPerTokenStored 变量中。当挖矿结束后，则不会再产生增量，rewardPerTokenStored 就不会再增加了。

earned 函数则是计算用户当前的挖矿奖励，代码实现也只有一行代码：

    function earned(address account) public view returns (uint256) {
        return _balances[account].mul(rewardPerToken().sub(userRewardPerTokenPaid[account])).div(1e18).add(rewards[account]);
    }
    

其逻辑也是计算出增量的每单位质押代币的挖矿奖励，再乘以用户的质押余额得到增量的总挖矿奖励，再加上之前已存储的挖矿奖励，就得到当前总的挖矿奖励。

至此，StakingRewards 合约的主要实现逻辑也都讲解完了。

总结
--

至此，所有 UniswapV2 的合约项目就都讲解完了。虽然分为了好几个小项目，但从架构设计上来说，能够大大减低不同模块之间的耦合性，不同项目也可以由不同的小团队单独维护，而且项目小而简单，那出 BUG 的概率也会更低。所以，这样的架构设计其实更适合 Dapp。

* * *

文章首发于「Keegan小钢」公众号：

[https://mp.weixin.qq.com/s?\_\_biz=MzA5OTI1NDE0Mw==&mid=2652494379&idx=1&sn=6bbb9569665666fb2ef15e6de15337e0&chksm=8b68503bbc1fd92d9169f5fc9d401c4c44f30ef3f75e30a73dfe62523768b0429ffa5b6fed03&cur\_album\_id=1900659726451834889&scene=189#wechat\_redirect](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494379&idx=1&sn=6bbb9569665666fb2ef15e6de15337e0&chksm=8b68503bbc1fd92d9169f5fc9d401c4c44f30ef3f75e30a73dfe62523768b0429ffa5b6fed03&cur_album_id=1900659726451834889&scene=189#wechat_redirect)

---

*Originally published on [Keegan小钢](https://paragraph.com/@keeganlee/defi-uniswap-v2-3)*
