MasterChef与Synthetix质押算法深度解析

详解SushiSwap MasterChef和Synthetix的质押奖励分配算法,包括累计每代币奖励计数器、奖励债务机制及Gas优化策略

13 分钟阅读
MasterChef与Synthetix质押算法深度解析

MasterChef 与 Synthetix 质押算法深度解析

目录

  1. 核心问题:如何公平分配固定奖励池
  2. 为什么不能每区块发送交易
  3. “追赶”奖励机制
  4. 核心不变性:无交易则余额不变
  5. 遍历所有质押者Gas消耗大
  6. 核心机制:累计每代币奖励计数器
  7. MasterChef 算法伪代码与实现
  8. Synthetix 质押算法
  9. MasterChef 与 Synthetix 的关键差异
  10. 综合DEMO:构建一个质押奖励系统

1. 核心问题:如何公平分配固定奖励池

假设有一个 100,000 REWARD 的固定奖励池,计划在区块1到100之间公平分配给质押者。每个区块分配 1000 REWARD,按质押比例分配。

示例:单个区块的分配

假设在某区块中,质押余额如下:

质押者质押量池占比
Alice10025%
Bob10025%
Chad20050%

该区块分配 1000 REWARD:

质押者质押量占比获得奖励
Alice10025%250
Bob10025%250
Chad20050%500

这个计算看似简单,但如何在链上高效、公平地实现,才是真正的挑战。


2. 为什么不能每区块发送交易

最朴素的想法是:用链下机器人每个区块发送一笔交易,读取每个质押者的余额,按比例铸造 REWARD

但这种方案存在严重问题:

  • 无法保证每个区块都被包含:在以太坊上,没有可靠的方法确保交易在每个区块都被打包。如果机器人错过某个区块,某些用户获得的奖励就会少于预期。
  • Gas 费用极其高昂:每一个区块发送一笔交易并遍历所有质押者,消耗巨大。
  • MEV 竞争:机器人之间会为了抢跑而产生 Gas 竞拍。

结论:链下触发模型不可行。必须让合约自己”知道”上次分配以来经过了多少区块。


3. “追赶”奖励机制

维护一个变量 lastUpdateBlockNumber,记录上次发放奖励的时间。当用户实际与合约交互时,计算自上次奖励以来的区块数:

block.number - lastUpdateBlockNumber

核心思路:不需要在每个区块都分配奖励。可以跳过一些区块,在实际发放奖励时进行”追赶”(catch-up),一次性把中间错过的奖励补上。

但这只解决了**“何时分配”**的问题,还面临两个关键挑战:

  1. 如何高效地将新铸造的奖励按比例分发给所有质押者?
  2. 如何防止用户在得知即将分配奖励时突击存入?——比如,如果 Chad 知道将在区块100测量余额,他可以在区块99大额存入以获取更大份额,这会产生公平性问题。

4. 核心不变性:无交易则余额不变

不需要机器人在每 N 个区块发送交易。相反,我们等待用户自己通过状态变更函数来与合约交互,这些函数包括:

  • deposit() —— 存入质押代币
  • withdraw() —— 提取质押代币

关键洞察:在这些函数调用之间,我们可以确信没有任何人的余额发生变化。

举个例子:

  • 在区块10到15之间,Alice 占50%份额,Bob占50%份额
  • 可以发放 5000 REWARD(5个区块 × 1000),每人各得2500
  • Chad 或 Bob 无法”跳入”增加余额,因为当他们调用 deposit() 时,该函数会被编程为排除他们刚存入的金额

5. 遍历所有质押者 Gas 消耗大

每当有人调用 deposit()withdraw() 时,如果遍历分发每个质押者的 REWARD,Gas 消耗会极其昂贵(质押者越多越贵)。

高效方案的约束条件

  1. ✅ 只有发起交易的用户才获得奖励转移——其他用户的奖励被延迟
  2. ✅ 只能更新与发起交易账户相关的变量
  3. 只能更新一个全局变量来跟踪所有人的累计奖励分配

这就引出了核心算法。


6. 核心机制:累计每代币奖励计数器

6.1 追踪单一代币的累积奖励

核心思想:如果能够精确追踪 “自合约启动以来,一个代币累积了多少奖励”,那么任意账户应得的奖励就等于:

账户应得奖励 = 质押代币数量 × 单代币累计奖励

类比:这就像银行说”自开业以来,存入一美元赚了0.40美元利息。如果你在开业时就开户且从未存取,你的利息就是本金的40%。”

这引出两个问题:

  1. 如何追踪单一代币自开始以来的奖励累积?
  2. 如果用户并非从一开始就质押,只是最近才存入,该怎么办?

6.2 单一代币奖励累积机制详解

每区块发放固定数量奖励(假设1000),质押者越多,每个质押者分到的份额越少。关键在于合约中质押代币的总供应量

详细数值示例

区间每区块奖励质押总量每代币每区块奖励区间区块数区间总奖励累计每代币奖励
block 0000000
1-51,0001001055050
6-131,000200584090
14-151,00010010220110
16-201,0005002510120

计算过程详解

  • 区间 1-5:质押总量100,每代币每区块奖励 = 1000 ÷ 100 = 10。5个区块后,累计 = 10 × 5 = 50
  • 区间 6-13:Bob 在区块5存入100,总量变为200。每代币每区块奖励 = 1000 ÷ 200 = 5。8个区块后,累计 = 50 + 5 × 8 = 90
  • 区间 14-15:Bob 在区块13提取100,总量变回100。每代币每区块奖励 = 1000 ÷ 100 = 10。2个区块后,累计 = 90 + 10 × 2 = 110
  • 区间 16-20:Chad 在区块15存入400,总量变为500。每代币每区块奖励 = 1000 ÷ 500 = 2。5个区块后,累计 = 110 + 2 × 5 = 120

更新公式

delta = (block.number - lastUpdateBlock) × rewardPerBlock / totalStaked
accRewardPerToken += delta
lastUpdateBlock = block.number

每次发生状态变更交易时:

  1. 回顾经过了多少区块
  2. 乘以每区块奖励
  3. 除以总质押供应量
  4. 加到全局累加器中

核心结论:从始至终质押的一个代币,累积了120奖励。

6.3 从开始质押的测试案例

场景

  • 每区块发放1000奖励,共20个区块 = 总计 20000 奖励
  • Alice 从区块1到20质押100个代币
  • Bob 在区块10质押100个代币

在区块20的预期结果:Alice 应得所有奖励的 75%,即 15000 奖励。

逐步计算

阶段1(区块1-10)

  • 每代币每区块奖励 = 1000 ÷ 100 = 10
  • 10个区块后,每个代币累积 = 10 × 10 = 100

阶段2(区块11-20)

  • Bob 存入后,总量 = 200
  • 每代币每区块奖励 = 1000 ÷ 200 = 5
  • 10个区块后,每个代币累积 = 5 × 10 = 50

单一代币总累积:100 + 50 = 150

Alice 应得:100个代币 × 150 = 15000 奖励 ✅(等于总奖励的75%)

6.4 非从开始质押的情况——奖励债务

如果 Bob 在区块20也申领奖励,他的100质押量 × 150 = 15000 奖励,但这显然不对——Bob 只质押了10个区块!

解决方案:Reward Debt(奖励债务)

Bob 存入时(区块10),当时累计每代币奖励为 100。我们将他的 rewardDebt 设置为:

rewardDebt = 存入余额 × 当时的累计每代币奖励
rewardDebt = 100 × 100 = 10000

当 Bob 在区块20申领奖励时:

应得奖励 = (质押量 × 当前累计每代币奖励) - rewardDebt
应得奖励 = (100 × 150) - 10000 = 15000 - 10000 = 5000

Bob 只得到 5000 奖励,这恰好对应他质押的10个区块(区块11-20)应得的份额!

奖励债务的本质:当用户存入代币时,将当时的全局累计奖励”快照”下来。后续申领时,只需减掉这个快照值,就能精确计算出该用户实际质押期间应得的奖励。


7. MasterChef 算法伪代码与实现

全局变量

accRewardPerToken   // 累计每代币奖励(全局累加器)
lastUpdateBlock     // 上次更新区块号
totalStaked         // 总质押量
rewardPerBlock      // 每区块分配的奖励量(常量)

用户变量(每个用户一个结构体)

amount       // 用户质押量
rewardDebt   // 用户奖励债务

核心更新函数

function update() internal {
    if (totalStaked == 0) {
        // 没有人质押,只需更新区块号
        lastUpdateBlock = block.number;
        return;
    }
    
    uint256 blocks = block.number - lastUpdateBlock;
    uint256 reward = blocks * rewardPerBlock;
    accRewardPerToken += reward / totalStaked;  // 注意:整数除法向下取整
    lastUpdateBlock = block.number;
}

deposit 函数逻辑

function deposit(uint256 _amount) external {
    update();
    
    // 计算待发奖励
    uint256 pending = (user.amount * accRewardPerToken) - user.rewardDebt;
    if (pending > 0) {
        rewardToken.transfer(msg.sender, pending);
    }
    
    // 更新状态
    user.amount += _amount;
    totalStaked += _amount;
    user.rewardDebt = user.amount * accRewardPerToken;  // 新余额 × 新累加器
}

withdraw 函数逻辑

function withdraw(uint256 _amount) external {
    update();
    
    // 计算待发奖励
    uint256 pending = (user.amount * accRewardPerToken) - user.rewardDebt;
    if (pending > 0) {
        rewardToken.transfer(msg.sender, pending);
    }
    
    // 更新状态
    user.amount -= _amount;
    totalStaked -= _amount;
    user.rewardDebt = user.amount * accRewardPerToken;
}

关键设计要点

  1. update() 总是最先被调用:确保全局累加器是最新的
  2. 发放奖励在更新余额之前:用户按旧余额获得奖励,新余额不会”蹭”到之前的奖励
  3. rewardDebt 在余额变更后重新计算:用新余额 × 当前累加器,保证后续奖励只计算新余额的份额
  4. totalStaked = 0 时的处理:直接跳过累加器更新,因为除以零会出错

8. Synthetix 质押算法

Synthetix 使用相同的核心机制,但簿记方式不同。

Synthetix 的关键差异

Synthetix 存储用户上次交互时的奖励累加器快照,而不是追踪”奖励债务”。

全局变量

rewardPerTokenStored   // 全局累计每代币奖励
lastUpdateTime         // 上次更新时间戳
totalSupply            // 总质押量
rewardRate             // 每秒奖励率

用户变量

userRewardPerTokenPaid  // 用户上次更新时的快照值
rewards                 // 用户累积但未提取的奖励
balance                 // 用户质押量

核心子程序逻辑

stake()withdraw()getReward() 被调用时:

// 1. 更新时间差
uint256 currentTime = block.timestamp;
uint256 timeDiff = currentTime - lastUpdateTime;

// 2. 如果总质押量 > 0,更新累加器
if (totalSupply > 0) {
    uint256 reward = timeDiff * rewardRate;
    rewardPerTokenStored += reward / totalSupply;
}
lastUpdateTime = currentTime;

// 3. 计算用户应得奖励
uint256 userEarned = user.balance * 
    (rewardPerTokenStored - user.userRewardPerTokenPaid);

// 4. 累加到用户奖励映射
user.rewards += userEarned;

// 5. 更新用户快照
user.userRewardPerTokenPaid = rewardPerTokenStored;

关键理解

  • rewardPerTokenStored - userRewardPerTokenPaid 计算出自用户上次交互以来每个代币累积的新奖励
  • 结果累加到 user.rewards 映射中,而不是立即发放
  • 用户需要显式调用 getReward() 来提取累积的奖励

9. MasterChef 与 Synthetix 的关键差异

维度MasterChefSynthetix
函数命名deposit() / withdraw()stake() / withdraw() / getReward()
时间单位区块号时间戳
奖励来源合约自己铸造管理员提前转入合约
奖励周期startBlocklastRewardBlock管理员启动后 一周
奖励发放时机deposit()withdraw() 时自动转移累计在映射中,需显式调用 getReward()
多池支持✅ 支持多个池并按权重分配❌ 只有一个池
簿记方式rewardDebt(奖励债务)userRewardPerTokenPaid(快照值)

两种簿记方式的等价性

// MasterChef 的奖励债务方式
pending = user.amount × currentAcc - user.rewardDebt

// Synthetix 的快照方式
user.rewards += user.balance × (currentAcc - user.userRewardPerTokenPaid)

两者在数学上是等价的。但 Synthetix 的额外簿记(维护 rewards 映射和 userRewardPerTokenPaid)使其略微更消耗 Gas。


10. 综合DEMO:构建一个质押奖励系统

项目目标

在 Sepolia 测试网上部署一个完整的质押奖励系统,综合应用 MasterChef 和 Synthetix 两种算法思想。

核心功能

  1. 质押代币(stake)
  2. 提取代币(withdraw)
  3. 查看待发奖励(pendingReward)
  4. 提取奖励(claimReward)
  5. 支持多个质押池(不同代币权重不同)

架构设计

StakingRewards
├── 全局状态
│   ├── rewardPerTokenStored(累计每代币奖励)
│   ├── lastUpdateTime(上次更新时间)
│   ├── totalSupply(总质押量)
│   └── rewardRate(每秒奖励率,由 owner 设置)

├── 用户状态
│   ├── balance(质押量)
│   ├── userRewardPerTokenPaid(快照)
│   └── rewards(累积奖励)

├── 核心方法
│   ├── stake(uint256 amount)
│   ├── withdraw(uint256 amount)
│   ├── getReward()
│   ├── earned(address account) view
│   └── updateReward(address account) internal

└── 管理方法
    ├── setRewardRate(uint256 rate)
    ├── notifyRewardAmount(uint256 amount)
    └── recoverERC20(address token)

实现步骤建议

第一步:创建质押代币和奖励代币

部署两个 ERC20 代币:

  • StakingToken (STK):用户用于质押的代币(18位小数)
  • RewardToken (RWD):作为奖励发放的代币(18位小数)

第二步:实现核心质押合约

关键逻辑:

  1. updateReward(address account) 修饰器/内部函数:

    • 更新时间差
    • 更新全局 rewardPerTokenStored
    • 更新 lastUpdateTime
    • 计算并累积用户的 rewards
    • 更新用户的 userRewardPerTokenPaid
  2. stake(uint256 amount)

    • 调用 updateReward(msg.sender)
    • 从用户转账 STK 到合约
    • 更新 balancetotalSupply
  3. withdraw(uint256 amount)

    • 调用 updateReward(msg.sender)
    • 更新 balancetotalSupply
    • 转账 STK 回用户
  4. getReward()

    • 调用 updateReward(msg.sender)
    • 转账用户的 rewards 中的 RWD
    • rewards 清零

第三步:实现奖励通知机制

notifyRewardAmount(uint256 reward)

  • 只能由奖励分发者调用
  • 传入一笔 RWD 作为本周期奖励
  • 计算 rewardRate = reward / duration(duration 设为 7 天)
  • 注意:如果当前周期未结束,需要将剩余奖励加到新的奖励中

第四步:安全措施

  • 使用 SafeERC20 进行代币转账
  • 使用 OpenZeppelin 的 ReentrancyGuard 防止重入
  • 使用 OpenZeppelin 的 Ownable 管理权限
  • 添加事件(StakedWithdrawnRewardPaidRewardNotified

第五步:测试用例

  1. 单用户质押全程——验证从开始质押的奖励计算
  2. 多用户不同时间质押——验证 rewardDebt/快照机制
  3. 部分提取后的奖励计算
  4. 奖励周期结束后的正确停止
  5. 多周期奖励叠加
  6. 零质押时的边界情况

进阶挑战

  1. 多池支持:扩展为 MasterChef 风格的多池系统,每个池有独立的 allocPoint(权重),总奖励按权重分配
  2. 锁定机制:添加时间锁,质押时间越长,获得额外奖励倍率
  3. 罚没机制:提前提取扣除部分奖励

测试网部署

  1. 使用 Remix 或 Hardhat 部署到 Sepolia
  2. 铸造测试代币并分发到测试账户
  3. 设置初始奖励率并转入奖励代币
  4. 模拟多用户质押和提取
  5. 验证奖励计算的准确性

💬 评论