流动性挖矿-合约原理详解
流动性挖矿应该是上个牛市最火热的内容,基本上整个 DeFi 都是在围绕着流动性挖矿展开的,今天我们就来看看它到底是什么以及合约代码层面是怎么实现的。
流动性挖矿简介
首先我们先从用户的角度来理解一下流动性挖矿是什么,实际上就是用户通过在合约中质押一个 token 从而赚取另一个 token 的过程。例如,SushiSwap 最初推出的 DEX 流动性挖矿,用户可以通过将 SushiSwap 的 LP token 质押到合约中赚取 Sushi token。那么这个奖励具体是怎么发放以及如何实现的呢?我们今天就来研究一下这部分内容。
先来看几个例子:
一:假设有一个流动性挖矿的合约,可以质押 A token 赚取 B token。它在 0 秒时开始活动,每秒奖励 R
个 B token。此时有用户 Alice 在第 3 秒时质押了 2 个 A token,并且之后没有其他人参与,在第 8 秒时取出 token,图示:
那么他在此时获得的收益就是:
5R = (2 / 2) * (8 - 3) * R
其中,第一个 2
是用户 A 质押的数量,第二个 2
是合约中质押的总量,(8-3)
是用户 Alice 质押的时间,R
是每秒发放的奖励。
二:同样是上面的合约,用户 Alice 在第 3 秒时质押了 2 个 A token,在第 6 秒时,用户 Bob 也质押了 2 个 A token。Alice 在第 8 秒时离场,Bob 在第 10 秒时离场。图示:
那么此时 Alice 可以获得的奖励是:
4R = (2 / 2) * (6 - 3) * R + (2 / 4) * (8 - 6) * R
Bob 可以获得的奖励是:
3R = (2 / 4) * (8 - 6) * R + (2 / 2) * (10 - 8) * R
对于 Alice,3~6 秒独占所有奖励,6~8 由于有 Bob 参与,因此需要计算自己在整个池子中的占比,再去计算奖励总额。
Bob 同理,6~8 秒与 Alice 分享,8~10 秒独享奖励。
同时,两个人的奖励总和是 7R
,是这 7 秒时间的奖励发放总量。
我们思考一下这部分计算逻辑在代码中如何实现。首先,用户自己的本金数量在一段时间内(下次增加,减少质押数量之前)是不会变化的,但是由于有其它用户的参与,池子中质押的总数量是一直在变化的,而我们计算最终奖励的时候是需要用到池子总数量的,因此需要在代码中一直维护这个变量。
在上面的例子中,我们可以看到,3~6 秒中总量是 2
,6~8 秒中总量是 4
。这个简单的例子中,由于总量变化了一次,因此可以分解为两部分,但是如果说在 Alice 质押的这段时间,有一万个用户参与。那么总量变化次数将难以估计,这种情况下,如果我们要计算 Alice 可以获得的奖励,就需要知道每一个小区间的质押总量,然后再计算 Alice 的质押占比,再乘上各个小区间的时间长度,最后加起来,便是 Alice 的可以获得的奖励数量。这个计算量在普通的后端计算中也许可以实现,但是在合约中是不可能的,仅仅在 gas 消耗这方面就否定了这个方案。
注:这里的区间是指时间轴上每两个最近的时间点组成的时间段
那么有没有办法在合约中计算出奖励数量呢?答案是可以的。
优化数学原理
我们换一个思路,对于用户来说,他把 token 质押进来这段时间内(下次增加,减少质押数量之前),他所占的份额可能会发生变化,但是数量是没有变的。如果我们知道了每一单位质押 token 在每个区间内可以获得的奖励数量,那么把这些区间内的所有单位奖励都加起来,最后再乘上用户质押的数量,就是最终的奖励,即:
其中,k
是用户质押的数量,At
是每一单位在整个池子中的占比,Tt
是每个区间的时间长度,R * At * Tt
是每个区间每单位质押 token 可以获得的奖励。我们以区间将时间轴进行划分,假设用户在第 a
个区间存入,在第 b
个区间后取出,因此上面公式的跨度是从 a
到 b
。
这个公式对于我们在合约中实现似乎没有什么帮助,因为需要计算该用户在每个区间内的质押占比,仍然是一笔不小的工作量。不过我们可以将上面公式稍微转化一下:
可以消去常量,即:
对于 0~b
与 0~(a-1)
这部分,由于它们是从 0
区间开始累加的,因此是一个不断递增的变量。我们思考一下,对于用户在任何时刻的操作,此时的时间轴都是由 N 个区间构成的,且当前时刻是最后一个区间的右端点(因为区间就是这么定义的)。对于任何用户的操作,我们可以记录下以当前时刻点为右端点的区间的 At
,并累加。这部分是不难计算的,因为 At
是每一单位在整个池子中的占比,所以容易算出:
At = 1 / Lt
其中 Lt
是当前区间质押总量,这个是已知的。Tt
是当前区间的时间长度,也是已知的。
对于 Alice 单一用户而言,在他对池子有操作的时候(增加,减少质押数量),在用户个人维度上记录一下当前的单位数量奖励累加值,再在用户下一次操作的时候,用最新的累加值减去上一次用户个人维度记录的累加值,就是这段时间内用户个人单位数量可以获得的单位奖励,再乘上之前的 kR
,就可以算出这段时间内用户获得奖励数量。
实践
我们再来看看前面的例子验证一下:
第 3 秒时,Alice 入场,此刻左边的区间,总质押量为 0
,因此单位数量获得的单位奖励为
s = 0
同时将 Alice 的单位累加值记为 0
第 6 秒时,Bob 入场,此刻左边的区间,总质押量为 2
,因此单位数量获得的奖励为:
s = s + 1 / 2 * (6 - 3) = 1.5
同时将 Bob 的单位累加值记为 1.5
第 9 秒时,Alice 离场,此刻之前的区间,总质押量为 4
,因此单位数量获得的奖励为:
s = s + 1 / 4 * (8 - 6) = 2
同时将 Alice 的单位累加值记为 2
,此时 Alice 可以获得的奖励为:
4R = (2 - 0) * 2 * R
其中,第一个 2
是最新累加值,0
是 Alice 的上次累加值,第二个2
是 Alice 的质押数量。
第 10 秒时,Bob 离场,此刻之前的区间,总质押量为 2
,因此单位数量获得的奖励为:
s = s + 1 / 2 * (10 - 8) = 3
同时将 Bob 的单位累加值记为 3
,此时 Bob 可以获得的奖励为:
3R = (3 - 1.5) * 2 * R
与我们上面最原始的方法得出的答案相同,验证成功。
代码实现
接下来我们看看代码怎么写。
首先定义几个变量:
// 质押奖励的发放速率
uint256 public rewardRate = 0;
// 每次有用户操作时,更新为当前时间
uint256 public lastUpdateTime;
// 我们前面说到的每单位数量获得奖励的累加值,这里是乘上奖励发放速率后的值
uint256 public rewardPerTokenStored;
// 在单个用户维度上,为每个用户记录每次操作的累加值,同样也是乘上奖励发放速率后的值
mapping(address => uint256) public userRewardPerTokenPaid;
// 用户到当前时刻可领取的奖励数量
mapping(address => uint256) public rewards;
// 池子中质押总量
uint256 private _totalSupply;
// 用户的余额
mapping(address => uint256) private _balances;
接着按照前面讲解的数学原理实现代码:
// 计算当前时刻的累加值
function rewardPerToken() public view returns (uint256) {
// 如果池子里的数量为0,说明上一个区间内没有必要发放奖励,因此累加值不变
if (_totalSupply == 0) {
return rewardPerTokenStored;
}
// 计算累加值,上一个累加值加上最近一个区间的单位数量可获得的奖励数量
return
rewardPerTokenStored.add(
lastTimeRewardApplicable().sub(lastUpdateTime).mul(rewardRate)
.mul(1e18).div(_totalSupply)
);
}
// 获取当前有效时间,如果活动结束了,就用结束时间,否则就用当前时间
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish ? block.timestamp : periodFinish;
}
// 计算用户可以领取的奖励数量
// 质押数量 * (当前累加值 - 用户上次操作时的累加值)+ 上次更新的奖励数量
function earned(address account) public view returns (uint256) {
return
_balances[account].mul(rewardPerToken().sub(userRewardPerTokenPaid[account]))
.div(1e18).add(rewards[account]);
}
modifier updateReward(address account) {
// 更新累加值
rewardPerTokenStored = rewardPerToken();
// 更新最新有效时间戳
lastUpdateTime = lastTimeRewardApplicable();
if (account != address(0)) {
// 更新奖励数量
rewards[account] = earned(account);
// 更新用户的累加值
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
上面的代码,实现了我们前面讲过的原理,同时将所有逻辑包装成了一个 modifier
,这样与最基本的 stake
,withdraw
逻辑抽离,使整个合约逻辑代码更清晰。
最后,实现 stake
,withdraw
的逻辑,并用 updateReward
修饰:
function stake(uint256 amount) external nonReentrant notPaused 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);
}
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);
}
这段代码来自 Synthetic
的 StakingRewards
的合约(代码地址),我们这里只截取了最核心的逻辑部分,建议大家在理解了上面代码之后去看看完整的代码。
总结
今天我们简单聊了聊流动性挖矿的原理,其实它本身是运用了一个很巧妙的数学原理来实现的。目前 DeFi 中比较流行的两个流动性挖矿合约 StakingRewards
和 MasterChef
都是运用了这个原理。建议大家好好理解一下这一块,看懂之后再看市面上的其他流动性挖矿就会发现基本上大同小异了。英语好的朋友建议也看看下面的视频,讲解得也很透彻。
关于我
欢迎和我交流
参考
https://www.youtube.com/watch?v=6ZO5aYg1GI8
https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol
https://etherscan.io/address/0xc2edad668740f1aa35e4d8f227fb8e17dca888cd