MasterChef 和 Synthetix 质押算法根据质押者对池子的时间加权贡献,在他们之间分配一个固定的奖励池。为了节省 gas,这些算法使用代币级别奖励的累加计数器,并延迟奖励的分配。
假设我们有一个包含 100,000 个 REWARD 代币的固定池,我们希望在区块 1 到 100 之间公平地分配给质押者。
我们的目标是每个区块分配 1,000 个 REWARD,并根据质押者的质押份额在他们之间进行分配。
例如,如果在某个特定区块,合约中的质押余额如下:
| 质押数量 | 池占比 | |
|---|---|---|
| Alice | 100 | 25% |
| Bob | 100 | 25% |
| Chad | 200 | 50% |
那么该区块分配的 1,000 个 REWARD 将如下所示:
| 质押数量 | 池占比 | 分配的奖励 | |
|---|---|---|---|
| Alice | 100 | 25% | 250 |
| Bob | 100 | 25% | 250 |
| Chad | 200 | 50% | 500 |
代币(token)与奖励(reward)的术语
质押的代币和质押奖励可能是也可能不是同一种加密货币。为了清楚起见,我们将它们称为代币(token)和奖励(reward)——有时称为 TOKEN 和 REWARD。
每个区块发送一笔交易来分配奖励是不切实际的
最简单粗暴的解决方案是让一个链下机器人在每个区块发送交易,读取合约中每个质押者声明的 TOKEN 余额,并根据他们在池中的百分比为他们每个人铸造 REWARD。
然而,没有可靠的方法能确保交易被包含在每个区块中。如果机器人错过了一个区块,那么用户获得的奖励将少于他们的预期。
这种策略也会产生大量的交易费用。
没必要每个区块都发送交易,我们可以为未分配奖励的区块发放“追赶(catch up)”奖励
假设我们保留一个变量 lastUpdateBlockNumber 来记录我们上次发放奖励的时间。我们可以将自上次发放奖励以来的区块数计算为 block.number - lastUpdateBlockNumber。
然后我们可以跳过一些分配奖励的区块,并在实际分配奖励时进行“追赶”。
下图说明了这一点。

然而,我们仍然没有一个好方法能根据质押比例,将刚刚铸造的奖励分配给所有的质押者。
此外,我们不知道自上次奖励分配以来的时间段内,质押的代币余额是否保持不变。例如,如果 Chad 知道我们要在区块 100 测量余额,并在区块 99 存入大笔资金以获取更大份额的奖励怎么办?
事实证明这个问题很容易解决。
关键不变量:没有交易,就没有余额变化
与其让机器人每隔大约 20 个区块触发一次交易,我们可以直接等待用户通过如 deposit() 或 withdraw() 等状态改变函数与合约进行交互。
在调用这些函数之间,我们可以确定没有任何人的余额发生变化。
例如,如果在区块 10 到区块 15 之间,Alice 拥有 50% 的质押份额,Bob 拥有 50% 的质押份额,那么我们可以发行 5,000 个 REWARD(5 个区块乘以 1,000),并给他们每人 50%。
Chad 或 Bob 无法“半路杀出”并增加他们的余额,因为当他们调用 deposit() 时,会触发奖励分配。而奖励分配程序的设定是不包含他们最近的这笔存款的。
请看下面这张显示余额随时间变化的图表。发生这些变化的唯一途径是执行了智能合约的交易。

然而,这个解决方案无法扩展。
遍历所有质押者非常消耗 gas
每当有人调用 deposit() 或 withdraw() 时给每个质押者分配他们的 REWARD,如果质押者有几十个,那么 gas 成本将会非常高。转移 ERC-20 代币并不便宜,在一个循环中执行几十次是不可承受的。
为了高效地进行这种质押,只有当人们发起状态改变的交易时,才能将奖励转移给他们。对于那些没有领取奖励的人,他们的奖励会被延迟发放。奖励会留在合约中,等待他们来领取。
这将避免我们不得不执行一大堆 ERC-20 转账操作。
为了获得一个高效的解决方案:
- 我们只更新与发起交易的账户相关联的账户变量
- 我们只更新一个追踪其他所有人增加的奖励分配的单一全局变量,我们不能显式地更新每个账户
我们不追踪账户中累计的奖励,而是追踪单一质押代币的收益
假设我们能准确追踪单个质押代币“自始至终”(从合约开始分配奖励起)累计了多少奖励。
如果能做到这一点,那么追踪一个账户累计了多少奖励,只需将他们的代币余额乘以单个代币自始至终累计的奖励量即可。
假设我们知道一个代币从一开始质押到现在已经获得了 12 个奖励。如果 Alice 的质押量为 100,那么她应得 1,200 个奖励。
这有点像在说:“自从我们开办银行以来,存入我们银行的一美元已经赚取了 0.40 美元的利息。如果你在我们开办银行时开户,并且从那以后没有存取款,你就赚了 40% 的利息。”
这引出了两个问题:
-
如何追踪自始至终单一代币的奖励累计情况。
-
如果 Alice 不是从一开始就质押,而是最近才存款怎么办。
我们如何追踪自始至终单一代币的奖励累计
由于每个区块发放的奖励数量是固定的(在我们举的例子中为 1,000),质押者越多,他们赚取的固定 1,000 奖励的份额就越小。有多少人参与质押并不重要,重要的是合约中质押代币的总供应量。
想一下下面这个假设的例子。
| 每个区块发放的奖励 | 质押代币供应量 | 每个区块每个代币的奖励 | |
|---|---|---|---|
| 区块 1-5 | 1,000 | 100 | 10 |
| 区块 6-13 | 1,000 | 200 | 5 |
| 区块 14-15 | 1,000 | 100 | 10 |
| 区块 16-20 | 1,000 | 500 | 2 |
质押的代币越多,每个区块 每个代币 的奖励就越少。更多的质押量稀释了奖励,单个代币赚取的收益也因此变少。
该表格的可视化图表如下。 红色 曲线是质押代币的供应量。 紫色线 是该区块中单个代币累计的奖励量。X 轴上的区块向右递增。这两个变量之间的反比关系应该很清晰了。

关键在于
每次发生状态改变的交易时,我们回溯一下经过了多少个区块,将其乘以每个区块的奖励,然后除以总质押供应量。这就是一个代币在该时间段内累计的奖励量。然后我们将这个值加到一个全局累加器上,该累加器在最开始时为零。如果我们在每次交易到来时不断重复这个过程,我们就能知道从一开始到现在单一代币总共累计了多少奖励。
这是添加了累加器(accumulator)后的同一张图。

下面是显示相同数值的表格:
| 每个区块发放的奖励 | 质押代币供应量 | 每个区块每个代币的奖励 | 时间段内的区块数 | 时间段内发放的奖励 | 每个代币的累计奖励 | |
|---|---|---|---|---|---|---|
| 区块 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 区块 1-5 | 1,000 | 100 | 10 | 5 | 50 | 50 |
| 区块 6-13 | 1,000 | 200 | 5 | 8 | 40 | 90 |
| 区块 14-15 | 1,000 | 100 | 10 | 2 | 20 | 110 |
| 区块 16-20 | 1,000 | 500 | 2 | 5 | 10 | 120 |
也就是说,在我们的图表所示过程中,质押的单一代币已经累计了 120 个奖励。
测试用例:奖励从一开始就参与质押的 Alice
让我们来看一个非常简单的例子。我们再次设定每个区块发放 1,000 个奖励。在 20 个区块的过程中,将发放 20,000 个奖励。
以下是 Alice 和 Bob 的操作:
- Alice 从区块 1 到区块 20 质押了 100 个代币。
- 在区块 10,Bob 质押了 100 个代币。
- 在区块 20,Alice 应获得截至该时间点发放的所有奖励中的 75%,即 15,000 个奖励。
从视觉上看,每个区块质押池的份额将如下所示。

从区块 1 到区块 10,每个区块每个代币的奖励是 10(1,000 ÷ 100)。在这 10 个区块的间隔中,每个代币累计了 100 个奖励(每个区块每个代币 10 个奖励 x 10 个区块)。
但是当 Bob 在区块 10 存入时,每个区块每个代币的奖励被稀释到了 5(1,000 ÷ 200)。在接下来的 10 个区块间隔(区块 11 到 20)中,每个代币累计了 50 个奖励。
因此,从区块 1 到区块 10 一个代币累计的总值为 100,从 11 到 20 累计的值为 50。所以,一个代币累计的总值为 100 + 50 = 150。
因为 Alice 存入了 100 个代币,并且每个代币累计了 150 个奖励,所以她将获得 15,000 个奖励,这确实是总发放奖励的 75%。
如果有人不是从一开始就质押怎么办?
在上述例子中一个明显的边缘情况(这也是我们在前面章节中预测过的)是,如果 Bob 此时去领取奖励,他也会获得 15,000 个奖励,因为他在区块 20 的质押量是 100,和 Alice 一样。
为了解决这个问题,我们希望累加器只在 Bob 存入的那个时刻才开始为 Bob 计数。
直观的解决方案是记录下 Bob 存款的区块号,然后留待之后进行修正。
不过,更简单的做法是,直接计算他在区块 10 存入并立刻领取时,他 本应 获得的奖励数量。例如,在区块 10,每个代币累计的奖励是 100。既然 Bob 存入了 100 个代币,理论上他可以立即领取 10,000 个奖励。
为了防止这种情况发生,我们为 Bob 设置了一个变量,我们称之为“奖励负债(reward debt)”。在他存款的那一刻,我们将奖励负债设为存入余额乘以每个代币的奖励累加器数值。这能阻止他立即领取奖励,因为在那个时刻,他应得的奖励将为零(当前奖励减去奖励负债)。
我们为 Bob 专门设有一个称为“奖励负债”或“已发奖励(rewards already issued)”的变量,并将其赋值为那个假设的奖励金额。在区块 10,每个代币的累计奖励为 100,Bob 的存款为 100,因此他的奖励负债为 10,000。
如果 Bob 在区块 20 领取奖励,我们将 15,000 的奖励减去 10,000 的奖励负债。Bob 在区块 20 将只能领取 5,000 个奖励。
MasterChef 的伪代码
下面我们展示一个简化版的 MasterChef 算法。为了更加清晰,我们擅自更改了原合约中的一些变量名。我们还省略了事件和关于代币精度缩放的实现细节。

Synthetix 和 MasterChef 之间的区别
Synthetix 和 MasterChef 都使用相同的机制,根据质押数量来累计每个代币的奖励。主要的区别在于,Synthetix 不追踪奖励负债,而是在用户最后一次与合约交互时存储奖励累加器的快照。当前奖励累加器和该快照之间的差值,被用于计算发放给用户账户的奖励。
这个差额会被添加到每个用户的 rewards 映射(mapping)中,并一直在那里累加,直到用户调用 getRewards()。这种额外的记账操作使得 Synthetix 的算法效率略低一些。
剩下的区别相对次要:
- MasterChef 有
deposit()和withdraw()。- Synthetix 有
stake()、withdraw()和getReward()。
- Synthetix 有
- MasterChef 使用区块作为时间单位。
- Synthetix 使用时间戳。
- MasterChef 如前面章节所述为自己铸造奖励。
- Synthetix 假设管理员已经将奖励转移到了合约中,因此不铸造奖励。
- MasterChef 从一个可配置的
startBlock到lastRewardBlock分配奖励。- Synthetix 被硬编码为在管理员启动计时器后的一周内分配奖励。Synthetix 不一定会分配合约中全部的奖励余额,而是分配管理员指定的数量。
- 每当用户使用非零金额调用
deposit()或withdraw()时,MasterChef 就会将奖励转移给用户。- Synthetix 在一个名为 rewards 的映射中累计应付给用户的奖励,但不会将其转移给用户,除非他们显式地调用
getRewards()。
- Synthetix 在一个名为 rewards 的映射中累计应付给用户的奖励,但不会将其转移给用户,除非他们显式地调用
- MasterChef 在同一个合约中支持多个池,并按池的权重来划分奖励。
- Synthetix 只有一个池。
感兴趣的读者可以查阅 SushiSwap MasterChef 质押的代码。
Synthetix 的伪代码
下图展示了 Synthetix 在执行 deposit()、withdraw() 或 getRewards() 期间调用的记账子程序。具体来说,它是在 deposit 或 withdraw 中的余额更新,或者奖励分配之前完成的。
在下图中,lastUpdateTime 是任意用户最后一次调用这三个函数之一的时间。在下面的例子中,领取奖励的用户与之前和合约交互的用户不是同一个。撇号(')标记表示子程序完成后该变量的值。

感兴趣的读者可以 自行查阅 Synthetix 的质押代码。
在 RareSkills 了解更多
请查看我们的 区块链训练营 以学习更高级的 web3 技术主题。
最初发布于 2023 年 11 月 21 日