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

流动性挖矿应该是上个牛市最火热的内容,基本上整个 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 都是运用了这个原理。建议大家好好理解一下这一块,看懂之后再看市面上的其他流动性挖矿就会发现基本上大同小异了。英语好的朋友建议也看看下面的视频,讲解得也很透彻。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
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.