剖析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 函数,每次 mint、burn、swap、sync 时都会触发更新。实现逻辑很容易理解,主要就以下几步:
读取当前的区块时间 blockTimestamp 计算出与上一次更新的区块时间之间的时间差 timeElapsed 如果 timeElapsed > 0 且两个 token 的 reserve 都不为 0,则更新两个累加价格 更新两个 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);
这行代码主要说明了三个信息:
to 地址是一个合约地址 to 地址的合约实现了 IUniswapV2Callee 接口 可以在 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 的杠杆率。这么做的效率非常低。而使用闪电兑换,可以大大提高交易效率:
先从 Uniswap 得到 ETH 将用户的 ETH 和从 Uniswap 得到的 ETH 抵押进 Compound 从 Compound 借出 DAI 在 Uniswap 支付 DAI
上述步骤也不需要重复执行,一次流程就实现了用户想要的杠杆率,相比之下,明显高效很多。
在 uniswap-v2-periphery 项目中,examples 目录下有个 ExampleFlashSwap.sol,就是实现闪电兑换的一个示例,实现的是在 UniswapV1 和 UniswapV2 之间套利。
质押挖矿
质押挖矿项目也同样很小,这是项目的 github 地址:
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 合约类似,我们可以直接看看其代码实现:
总共就 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。