MasterChef 与 Synthetix 质押算法深度解析
目录
- 核心问题:如何公平分配固定奖励池
- 为什么不能每区块发送交易
- “追赶”奖励机制
- 核心不变性:无交易则余额不变
- 遍历所有质押者Gas消耗大
- 核心机制:累计每代币奖励计数器
- MasterChef 算法伪代码与实现
- Synthetix 质押算法
- MasterChef 与 Synthetix 的关键差异
- 综合DEMO:构建一个质押奖励系统
1. 核心问题:如何公平分配固定奖励池
假设有一个 100,000 REWARD 的固定奖励池,计划在区块1到100之间公平分配给质押者。每个区块分配 1000 REWARD,按质押比例分配。
示例:单个区块的分配
假设在某区块中,质押余额如下:
| 质押者 | 质押量 | 池占比 |
|---|---|---|
| Alice | 100 | 25% |
| Bob | 100 | 25% |
| Chad | 200 | 50% |
该区块分配 1000 REWARD:
| 质押者 | 质押量 | 占比 | 获得奖励 |
|---|---|---|---|
| Alice | 100 | 25% | 250 |
| Bob | 100 | 25% | 250 |
| Chad | 200 | 50% | 500 |
这个计算看似简单,但如何在链上高效、公平地实现,才是真正的挑战。
2. 为什么不能每区块发送交易
最朴素的想法是:用链下机器人每个区块发送一笔交易,读取每个质押者的余额,按比例铸造 REWARD。
但这种方案存在严重问题:
- 无法保证每个区块都被包含:在以太坊上,没有可靠的方法确保交易在每个区块都被打包。如果机器人错过某个区块,某些用户获得的奖励就会少于预期。
- Gas 费用极其高昂:每一个区块发送一笔交易并遍历所有质押者,消耗巨大。
- MEV 竞争:机器人之间会为了抢跑而产生 Gas 竞拍。
结论:链下触发模型不可行。必须让合约自己”知道”上次分配以来经过了多少区块。
3. “追赶”奖励机制
维护一个变量 lastUpdateBlockNumber,记录上次发放奖励的时间。当用户实际与合约交互时,计算自上次奖励以来的区块数:
block.number - lastUpdateBlockNumber
核心思路:不需要在每个区块都分配奖励。可以跳过一些区块,在实际发放奖励时进行”追赶”(catch-up),一次性把中间错过的奖励补上。
但这只解决了**“何时分配”**的问题,还面临两个关键挑战:
- 如何高效地将新铸造的奖励按比例分发给所有质押者?
- 如何防止用户在得知即将分配奖励时突击存入?——比如,如果 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 消耗会极其昂贵(质押者越多越贵)。
高效方案的约束条件:
- ✅ 只有发起交易的用户才获得奖励转移——其他用户的奖励被延迟
- ✅ 只能更新与发起交易账户相关的变量
- ✅ 只能更新一个全局变量来跟踪所有人的累计奖励分配
这就引出了核心算法。
6. 核心机制:累计每代币奖励计数器
6.1 追踪单一代币的累积奖励
核心思想:如果能够精确追踪 “自合约启动以来,一个代币累积了多少奖励”,那么任意账户应得的奖励就等于:
账户应得奖励 = 质押代币数量 × 单代币累计奖励
类比:这就像银行说”自开业以来,存入一美元赚了0.40美元利息。如果你在开业时就开户且从未存取,你的利息就是本金的40%。”
这引出两个问题:
- 如何追踪单一代币自开始以来的奖励累积?
- 如果用户并非从一开始就质押,只是最近才存入,该怎么办?
6.2 单一代币奖励累积机制详解
每区块发放固定数量奖励(假设1000),质押者越多,每个质押者分到的份额越少。关键在于合约中质押代币的总供应量。
详细数值示例:
| 区间 | 每区块奖励 | 质押总量 | 每代币每区块奖励 | 区间区块数 | 区间总奖励 | 累计每代币奖励 |
|---|---|---|---|---|---|---|
| block 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 |
计算过程详解:
- 区间 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
每次发生状态变更交易时:
- 回顾经过了多少区块
- 乘以每区块奖励
- 除以总质押供应量
- 加到全局累加器中
核心结论:从始至终质押的一个代币,累积了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;
}
关键设计要点
- update() 总是最先被调用:确保全局累加器是最新的
- 发放奖励在更新余额之前:用户按旧余额获得奖励,新余额不会”蹭”到之前的奖励
- rewardDebt 在余额变更后重新计算:用新余额 × 当前累加器,保证后续奖励只计算新余额的份额
- 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 的关键差异
| 维度 | MasterChef | Synthetix |
|---|---|---|
| 函数命名 | deposit() / withdraw() | stake() / withdraw() / getReward() |
| 时间单位 | 区块号 | 时间戳 |
| 奖励来源 | 合约自己铸造 | 管理员提前转入合约 |
| 奖励周期 | 从 startBlock 到 lastRewardBlock | 管理员启动后 一周 |
| 奖励发放时机 | 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 两种算法思想。
核心功能
- 质押代币(stake)
- 提取代币(withdraw)
- 查看待发奖励(pendingReward)
- 提取奖励(claimReward)
- 支持多个质押池(不同代币权重不同)
架构设计
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位小数)
第二步:实现核心质押合约
关键逻辑:
-
updateReward(address account)修饰器/内部函数:- 更新时间差
- 更新全局
rewardPerTokenStored - 更新
lastUpdateTime - 计算并累积用户的
rewards - 更新用户的
userRewardPerTokenPaid
-
stake(uint256 amount):- 调用
updateReward(msg.sender) - 从用户转账 STK 到合约
- 更新
balance和totalSupply
- 调用
-
withdraw(uint256 amount):- 调用
updateReward(msg.sender) - 更新
balance和totalSupply - 转账 STK 回用户
- 调用
-
getReward():- 调用
updateReward(msg.sender) - 转账用户的
rewards中的 RWD - 将
rewards清零
- 调用
第三步:实现奖励通知机制
notifyRewardAmount(uint256 reward):
- 只能由奖励分发者调用
- 传入一笔 RWD 作为本周期奖励
- 计算
rewardRate = reward / duration(duration 设为 7 天) - 注意:如果当前周期未结束,需要将剩余奖励加到新的奖励中
第四步:安全措施
- 使用
SafeERC20进行代币转账 - 使用 OpenZeppelin 的
ReentrancyGuard防止重入 - 使用 OpenZeppelin 的
Ownable管理权限 - 添加事件(
Staked、Withdrawn、RewardPaid、RewardNotified)
第五步:测试用例
- 单用户质押全程——验证从开始质押的奖励计算
- 多用户不同时间质押——验证 rewardDebt/快照机制
- 部分提取后的奖励计算
- 奖励周期结束后的正确停止
- 多周期奖励叠加
- 零质押时的边界情况
进阶挑战
- 多池支持:扩展为 MasterChef 风格的多池系统,每个池有独立的
allocPoint(权重),总奖励按权重分配 - 锁定机制:添加时间锁,质押时间越长,获得额外奖励倍率
- 罚没机制:提前提取扣除部分奖励
测试网部署
- 使用 Remix 或 Hardhat 部署到 Sepolia
- 铸造测试代币并分发到测试账户
- 设置初始奖励率并转入奖励代币
- 模拟多用户质押和提取
- 验证奖励计算的准确性