前言

上篇我们主要讲了 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 地址:

  • 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;}returnrewardPerTokenStored.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。

【区块链 | Uniswap】3.剖析DeFi交易产品之Uniswap:V2下篇相关推荐

  1. 【区块链 | Compound】2.剖析DeFi借贷产品之Compound:合约篇

    理解了Compound合约才能真正理解Compound的业务 剖析DeFi借贷产品之Compound:概述篇 前言 概述篇 简单介绍了 DeFi 和借贷的一些现状,以及 Compound 的一些核心概 ...

  2. 互联网 vs 区块链革命:早期成功的产品

    点击上方"Unitimes" 可以订阅哦! unitimes.io 全球视角,独到见解 作者 | Remi Gai 翻译 | 黄非红 马克·吐温曾经说过,"历史不会重演, ...

  3. 区块链学习笔记16——ETH交易树和收据树

    区块链学习笔记16--ETH交易树和收据树 学习视频:北京大学肖臻老师<区块链技术与应用> 笔记参考:北京大学肖臻老师<区块链技术与应用>公开课系列笔记--目录导航页 交易树和 ...

  4. 阿里+法大大,全球首个基于区块链技术的邮箱存证产品登陆云市场

    法大大存证邮专注为阿里邮箱用户提供在线电子存证服务:通过电子存证,可以安全有效的存储证据,确保证据的真实性.公正性和有效性,现在只要16元/账户 ·月! 法大大存证邮 有没有想过,企业商务沟通中应用最 ...

  5. 【区块链 | Compound】1.剖析DeFi借贷产品之Compound:概述篇

    前言 我前段时间一直在研究 Compound,走过一点弯路,也趟过一些坑,最终把它啃了下来.最近有些小伙伴也在咨询我相关的一些问题,那我本着乐善好施的优良传统,决定将我所学的知识整理成文字分享出来.我 ...

  6. 【区块链 | Compound】4.剖析DeFi借贷产品之Compound:清算篇

    清算机制 因为数字资产存在价格波动,若用户的所借资产上涨或抵押资产下跌,导致用户的债务价值超过抵押资产的安全门槛时,就可以被清算.我们用具体的场景来说明. 假如,用户存入了 1 个 ETH,价值 22 ...

  7. 【区块链】深入剖析免费赚钱app的本质

    你对免费赚钱软件好奇吗? 前言 一.揭开"免费赚钱app"神秘面纱 1.常见的赚钱app 2.app真的在做慈善吗? 3.羊毛党的价值 4.真正的游戏规则 二.区块链 1.哈希算法 ...

  8. 写给CTO的主流区块链架构横向剖析

    关注微信公众号 区块链大本营,获取更多区块链开发技能 时常听人们谈起区块链,从2009年比特币诞生至今,各式各样的区块链系统或基于区块链的应用不断被开发出来,并被应用到大量的场景中,而区块链技术本身也 ...

  9. 【区块链】从一笔交易看区块链运作流程

    出处 一笔交易从产生到完成的流程 搞懂区块链运作原理,可先区分出交易(Transaction)与区块(Block)两个部分,这里我们分别从区块链中一笔交易产生到完成验证的流程,以及图解一个区块,来了解 ...

最新文章

  1. 【LoadRunner】OSGI性能测试实例
  2. android按钮最底,Android:点击按钮后布局上的动画,最低SDK版本为14
  3. CountDownLatch应用实战
  4. 基于51的串行通讯原理及协议详解(uart)
  5. 8万行的insert数据,Ctrl+c、Ctrl+v后心态崩了(如何在Linux下对MySQL数据库执行sql文件)...
  6. 报名倒计时 | TeaTalk 深圳站邀您共话安全云世界
  7. 表likp新增第一次过账输入日期字段,vl02n/vl01n/vl03n/vl06o的增强
  8. Spring事件发布
  9. ps-色彩饱和度的设计
  10. 地理探测器“运行时系统找不到指定文件”报错
  11. Can't open ACPI ATK0100 kernel mode driver解决方法
  12. 图解PROFINET——PROFINET IO设备类型
  13. 适配器模式的三种形式
  14. [异常] Encountered a duplicated sql alias [name] during auto-discovery of a native-sql query;
  15. 模型会忘了你是谁吗?两篇Machine Unlearning顶会论文告诉你什么是模型遗忘
  16. 南通车管所的网址更新啦
  17. Modbus 的RTU、ASCII、TCP解析
  18. Wayland协议了解
  19. Spring中报Could not resolve placeholder的解决方案
  20. STM8L101系列单片机串口配置详解(基于IAR自带库)

热门文章

  1. C#使用ADO操作Excel
  2. Python统计文件字母出现次数(字典形式返回结果)
  3. 企业级闪存盘的结构和特征
  4. 美团技术分享:深度解密美团的分布式ID生成算法
  5. GBase 8c 分布式高可用
  6. 记录一下使用DSFD中demo.py测试其他图片的过程
  7. 1-1 云南省2020年普通高校专升本院校招生计划
  8. smbclient 使用方法
  9. 百度飞桨第1课|让人拍案叫绝的创意都是如何诞生的
  10. 百度OCR文字识别、证卡识别、票据识别原生插件