xyyme.eth

Posted on Aug 30, 2022Read on Mirror.xyz

流动性挖矿-合约原理详解

流动性挖矿应该是上个牛市最火热的内容,基本上整个 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 个区间后取出,因此上面公式的跨度是从 ab

这个公式对于我们在合约中实现似乎没有什么帮助,因为需要计算该用户在每个区间内的质押占比,仍然是一笔不小的工作量。不过我们可以将上面公式稍微转化一下:

可以消去常量,即:

对于 0~b0~(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,这样与最基本的 stakewithdraw 逻辑抽离,使整个合约逻辑代码更清晰。

最后,实现 stakewithdraw 的逻辑,并用 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);
}

这段代码来自 SyntheticStakingRewards 的合约(代码地址),我们这里只截取了最核心的逻辑部分,建议大家在理解了上面代码之后去看看完整的代码。

总结

今天我们简单聊了聊流动性挖矿的原理,其实它本身是运用了一个很巧妙的数学原理来实现的。目前 DeFi 中比较流行的两个流动性挖矿合约 StakingRewardsMasterChef 都是运用了这个原理。建议大家好好理解一下这一块,看懂之后再看市面上的其他流动性挖矿就会发现基本上大同小异了。英语好的朋友建议也看看下面的视频,讲解得也很透彻。

关于我

欢迎和我交流

参考

https://www.youtube.com/watch?v=6ZO5aYg1GI8

https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol

https://etherscan.io/address/0xc2edad668740f1aa35e4d8f227fb8e17dca888cd