【收集】-剖析DeFi交易产品之Uniswap:V2下篇

前言

上篇我们主要讲了 UniswapV2 整体分为了哪些项目,并重点讲解了 uniswap-v2-core 的核心代码实现;中篇主要对 uniswap-v2-periphery 的路由合约实现进行了剖析;现在剩下 V2 系列的最后一篇,我会介绍剩下的一些内容,主要包括:TWAP、FlashSwap、质押挖矿。

TWAP

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

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

  • price0CumulativeLast

  • price1CumulativeLast

  • blockTimestampLast

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

这是 UniswapV2Pair 合约的 _update 函数,每次 mintburnswapsync 时都会触发更新。实现逻辑很容易理解,主要就以下几步:

  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 是更新前的储备量,所以,实际上所计算的价格是之前区块的,因此,想要操控价格的难度也就进一步加大了。

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

代入我们的例子,为了简化,我们将前面 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。如下图:

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

比如,在 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 地址:

总共只有四个 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 合约类似,我们可以直接看看其代码实现:

总共就 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。

Subscribe to 0x00pluto
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.