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

By [xyyme.eth](https://paragraph.com/@xyyme) · 2022-08-30

---

流动性挖矿应该是上个牛市最火热的内容，基本上整个 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，图示：

![](https://storage.googleapis.com/papyrus_images/cd660641f2845aa22f25eb789fdbd2ff6b6e1ad63cab4f91d51819e5e5fabcef.png)

那么他在此时获得的收益就是：

    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 秒时离场。图示：

![](https://storage.googleapis.com/papyrus_images/62bf0ec3b84f670d47b3cc3427672c7b1ecd56efd7ab32cd0b09598890790638.png)

那么此时 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 在每个区间内可以获得的奖励数量，那么把这些区间内的所有单位奖励都加起来，最后再乘上用户质押的数量，就是最终的奖励，即：

![](https://storage.googleapis.com/papyrus_images/20118a72441bf4add84160e9582d2ea33f750f65fec79ea4cb5877691bc9d194.png)

其中，`k` 是用户质押的数量，`At` 是每一单位在整个池子中的占比，`Tt` 是每个区间的时间长度，`R * At * Tt` 是每个区间每单位质押 token 可以获得的奖励。我们以区间将时间轴进行划分，假设用户在第 `a` 个区间存入，在第 `b` 个区间后取出，因此上面公式的跨度是从 `a` 到 `b`。

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

![](https://storage.googleapis.com/papyrus_images/05131d8f72929d8054f42463fe2da4f14f330c067bc2e1a370cc839676196d3e.png)

可以消去常量，即：

![](https://storage.googleapis.com/papyrus_images/6cdb37f9c424a62591eb19a7148146327f04d3fd29c28ab05572c2e69a9ce64b.png)

对于 `0~b` 与 `0~(a-1)`这部分，由于它们是从 `0` 区间开始累加的，因此是一个不断递增的变量。我们思考一下，对于用户在任何时刻的操作，此时的时间轴都是由 N 个区间构成的，且当前时刻是最后一个区间的右端点（因为区间就是这么定义的）。对于任何用户的操作，我们可以记录下以当前时刻点为右端点的区间的 `At`，并累加。这部分是不难计算的，因为 `At` 是每一单位在整个池子中的占比，所以容易算出：

> At = 1 / Lt

其中 `Lt` 是当前区间质押总量，这个是已知的。`Tt` 是当前区间的时间长度，也是已知的。

对于 Alice 单一用户而言，在他对池子有操作的时候（增加，减少质押数量），在用户个人维度上记录一下当前的单位数量奖励累加值，再在用户下一次操作的时候，用最新的累加值减去上一次用户个人维度记录的累加值，就是这段时间内用户个人单位数量可以获得的单位奖励，再乘上之前的 `kR`，就可以算出这段时间内用户获得奖励数量。

实践
--

我们再来看看前面的例子验证一下：

![](https://storage.googleapis.com/papyrus_images/25d39a11fa4709537873742f87cf43e747954033a26366c65dca8cffd2a3f209.png)

第 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` 的合约（[代码地址](https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol)），我们这里只截取了最核心的逻辑部分，建议大家在理解了上面代码之后去看看完整的代码。

总结
--

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

关于我
---

欢迎[和我交流](https://linktr.ee/xyymeeth)

参考
--

[![]({{DOMAIN}}/editor/youtube/play.png)](https://www.youtube.com/watch?v=6ZO5aYg1GI8)

[

synthetix/contracts/StakingRewards.sol at develop · Synthetixio/synthetix
-------------------------------------------------------------------------

Synthetix Solidity smart contracts. Contribute to Synthetixio/synthetix development by creating an account on GitHub.

https://github.com

![](https://storage.googleapis.com/papyrus_images/3590fc60ddd281e9dd252f9b49bb9d036962034f95ff4050d16294e873d93e03.png)

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

[

SushiSwap: MasterChef LP Staking Pool | Address: 0xc2edad66...7dca888cd | Etherscan
-----------------------------------------------------------------------------------

Contract: Verified | Balance: $23,021,510.77 across 3 Chains | Transactions: 464,780 | As at Oct-24-2025 10:42:54 PM (UTC)

https://etherscan.io

![](https://storage.googleapis.com/papyrus_images/c3ceb1ec55f2d6c2b4eeb2df764e4bc8936caf048a981c893b5f220aa9d207a0.jpg)

](https://etherscan.io/address/0xc2edad668740f1aa35e4d8f227fb8e17dca888cd)

---

*Originally published on [xyyme.eth](https://paragraph.com/@xyyme/PBEbOKmAq1Jgs9MNn5Xk)*
