DeFi 质押算法深度学习指导
—— 基于 SushiSwap MasterChef 与 Synthetix 协议解析
参考原文:The Staking Algorithm of SushiSwap MasterChef and Synthetix
文档日期:2025-06-09
目录
- 学习背景与目标
- 核心问题:如何公平分配奖励?
- 朴素方案及其缺陷
- 核心设计思路:累积器(Accumulator)
- 奖励债务(Reward Debt)机制
- MasterChef 完整算法详解
- Synthetix 实现方式详解
- 两种协议的对比分析
- 精度问题与工程细节
- 安全性设计原则
- 完整 Solidity 实现示例
- 常见面试题与考点
- 延伸阅读与实践建议
1. 学习背景与目标
1.1 什么是质押(Staking)?
质押是 DeFi 协议中最基础的激励机制之一:用户将代币锁入合约,协议按比例和时间向用户发放奖励代币。
核心场景举例:
- Uniswap/SushiSwap 的流动性挖矿:提供流动性 → 获得 SUSHI 代币奖励
- Synthetix 的质押挖矿:质押 SNX → 获得 sUSD 交易手续费
- 通用质押矿池:质押任意 ERC20 → 按比例获得奖励
1.2 学习目标
读完本文后,你应该能够:
- 理解累积器(Accumulator)模式的数学原理
- 解释奖励债务(Reward Debt)为何必要及如何计算
- 区分 MasterChef 和 Synthetix 两种实现的差异
- 自己从零实现一个安全的质押合约
- 识别质押合约中常见的精度问题和安全漏洞
1.3 预备知识
- Solidity 基础(mapping、struct、事件)
- ERC20 标准接口(transfer、balanceOf)
- 区块链基本概念(区块号、时间戳)
- 整数运算(注意 Solidity 中没有小数)
2. 核心问题:如何公平分配奖励?
2.1 问题定义
给定条件:
- 协议每个区块发放固定数量的奖励代币(例如 1,000 REWARD/block)
- 多个用户在不同时间存入不同数量的代币
- 总质押量随时间变化
- 需要公平地按”时间 × 数量”比例分配奖励
公平分配的直觉理解:
一个人质押了 100 个代币持续 10 个区块,应该获得与另一个人质押 200 个代币持续 5 个区块相同的奖励(都是 1000 代币·区块的贡献)。
2.2 核心挑战
| 挑战 | 说明 |
|---|---|
| 动态质押量 | 不同时间段,总质押量不同,每人的份额也不同 |
| Gas 效率 | 不能每个区块都循环遍历所有用户(O(n) 太贵) |
| 防止追溯奖励 | 新用户加入不应该拿走以前的奖励 |
| 延迟结算 | 用户不交互时不消耗 Gas,但奖励需要正确累计 |
3. 朴素方案及其缺陷
3.1 最简单的想法(每区块结算)
每个区块,对每个用户:
用户奖励 += (用户质押量 / 总质押量) × 每块奖励
为什么不行?
假设有 1000 个用户,每个区块都要执行 1000 次计算和写操作 → Gas 费用极高,在以太坊上不可行。
3.2 另一个想法(用户提取时再算)
记录用户的存入时间和金额,提取时一次性计算所有奖励。
问题:
- 总质押量是动态变化的,你不知道每个历史区块的总量
- 需要存储全量历史数据,成本高昂
3.3 正确方向:全局累积器
核心洞察:不追踪每个用户的历史,而是维护一个全局变量,记录”从合约部署到现在,每个质押代币累积了多少奖励”。
4. 核心设计思路:累积器(Accumulator)
4.1 累积器的定义
累积器(accRewardPerToken) = 从合约创建到现在,每质押 1 个代币理论上能获得的总奖励
4.2 累积器如何更新?
每次有用户交互时,更新累积器:
accRewardPerToken += (经过的区块数 × 每块奖励) / 当前总质押量
重要: 不是每个区块都更新,只在用户交互(存款/取款)时更新。
4.3 详细数值示例
场景设定:
- 每区块奖励:1,000 REWARD
- 初始状态:合约为空
时间线:
区块 1:Alice 存入 100 个代币
区块 5:Bob 存入 100 个代币(此时累积器需要更新)
区块 10:Carol 存入 100 个代币
区块 15:所有人提取
区块 1 → 区块 5(Bob 存入前):
- 总质押量 = 100(只有 Alice)
- 经过区块数 = 4(区块1到区块4,即4个区块产生奖励)
- 本阶段新增累积 = (4 × 1,000) / 100 = 40
- 累积器 = 0 + 40 = 40
区块 5 → 区块 10(Carol 存入前):
- 总质押量 = 200(Alice + Bob)
- 经过区块数 = 5
- 本阶段新增累积 = (5 × 1,000) / 200 = 25
- 累积器 = 40 + 25 = 65
区块 10 → 区块 15(提取时):
- 总质押量 = 300(Alice + Bob + Carol)
- 经过区块数 = 5
- 本阶段新增累积 = (5 × 1,000) / 300 ≈ 16.67
- 累积器 = 65 + 16.67 = 81.67
4.4 用累积器计算用户奖励(初步版本)
直觉上,Alice 的奖励 = Alice的质押量 × 当前累积器值
但这有问题!如果 Alice 是区块 1 就存入的,而 Bob 在区块 5 才存入,此时累积器已经是 40 了。如果用 质押量 × 当前累积器,Bob 会错误地获得区块 1-5 的奖励!
这就引出了奖励债务机制。
5. 奖励债务(Reward Debt)机制
5.1 核心思想
奖励债务记录的是:“如果这个用户从合约创建之初就存了这么多代币,他’本应该’已经拿到多少奖励”。
实际奖励 = (当前质押量 × 当前累积器) - 奖励债务
5.2 为什么叫”债务”?
想象一下:Bob 在区块 5 存入 100 个代币,此时累积器已经是 40 了。
- 如果 Bob 从区块 1 就存了,他的”假设收益”应该是
100 × 40 = 4,000 - 但这 4,000 根本不属于 Bob,因为区块 1-5 的奖励已经全给 Alice 了
- 所以 Bob 的债务就是 4,000,意思是”你欠协议 4,000 的奖励,要先还掉这个缺口”
5.3 完整数值示例(Alice 和 Bob)
场景:
- 每区块 1,000 奖励
- 区块 1:Alice 存入 100 代币
- 区块 10:Bob 存入 100 代币
- 区块 20:两人都提取
Alice 的过程:
区块 1,Alice 存入 100:
当前累积器 = 0(合约刚开始)
Alice 的 rewardDebt = 100 × 0 = 0
区块 10,Bob 存入前,更新累积器:
新增 = (10 × 1000) / 100 = 100
累积器 = 0 + 100 = 100
Bob 存入 100:
Bob 的 rewardDebt = 100 × 100 = 10,000
区块 20,提取前,更新累积器:
新增 = (10 × 1000) / 200 = 50
累积器 = 100 + 50 = 150
Alice 的奖励 = (100 × 150) - 0 = 15,000 ✓
Bob 的奖励 = (100 × 150) - 10,000 = 5,000 ✓
验证:总奖励 = 20区块 × 1000 = 20,000
Alice 15,000 + Bob 5,000 = 20,000 ✓
比例验证:
- Alice:前 10 个区块独占全部 10,000 奖励 + 后 10 个区块各 50% = 5,000,共 15,000
- Bob:后 10 个区块各 50% = 5,000
5.4 奖励债务的存款/取款操作
存款时:
1. 更新全局累积器
2. 如果用户有待领奖励,先发放
3. 增加用户质押余额
4. 更新用户 rewardDebt = 新余额 × 当前累积器
取款时:
1. 更新全局累积器
2. 计算奖励 = 余额 × 累积器 - rewardDebt
3. 发放奖励
4. 减少用户质押余额
5. 更新用户 rewardDebt = 新余额 × 当前累积器
5.5 追加存款时的奖励债务更新
关键细节: 用户追加存款时,必须先结算旧奖励,再更新债务。
示例:Alice 在区块 1 存了 100 代币,区块 10 再存 50 代币
区块 10 时,累积器 = 100(假设)
追加存款前,结算已有奖励:
待领奖励 = 100 × 100 - 0 = 10,000(先发给 Alice)
追加后:
Alice 总余额 = 150
Alice 新的 rewardDebt = 150 × 100 = 15,000
之后计算奖励时:
奖励 = 150 × 新累积器 - 15,000(正确,只计算新存款后的奖励)
6. MasterChef 完整算法详解
6.1 MasterChef 的设计特点
SushiSwap 的 MasterChef 合约是最经典的多池质押实现,特点:
- 多个质押池(LP1、LP2、LP3…)
- 权重分配(每个池有 allocPoint,决定获得总奖励的比例)
- 按区块号计时
- 自动发放(存/取时自动发放奖励,无需额外调用)
6.2 核心数据结构
// 每个质押池的信息
struct PoolInfo {
IERC20 lpToken; // 质押的代币合约
uint256 allocPoint; // 该池的权重(占总权重的比例)
uint256 lastRewardBlock; // 上次更新累积器的区块号
uint256 accSushiPerShare; // 累积器(每份质押的累计奖励),精度放大 1e12
}
// 每个用户在每个池的信息
struct UserInfo {
uint256 amount; // 用户质押的 LP 代币数量
uint256 rewardDebt; // 奖励债务
}
// 全局状态
PoolInfo[] public poolInfo;
mapping(uint256 => mapping(address => UserInfo)) public userInfo;
uint256 public totalAllocPoint; // 所有池的权重之和
uint256 public sushiPerBlock; // 每区块总奖励
6.3 多池权重分配原理
示例:
- Pool 0(ETH/USDC LP):allocPoint = 200
- Pool 1(ETH/WBTC LP):allocPoint = 100
- Pool 2(SUSHI/ETH LP):allocPoint = 100
- totalAllocPoint = 400
每个区块,Pool 0 获得 1000 × 200/400 = 500 个 SUSHI 奖励,Pool 1 和 2 各获得 250 个。
6.4 updatePool 函数详解
function updatePool(uint256 _pid) public {
PoolInfo storage pool = poolInfo[_pid];
// 如果当前区块已经更新过,直接返回
if (block.number <= pool.lastRewardBlock) {
return;
}
uint256 lpSupply = pool.lpToken.balanceOf(address(this));
// 如果没有人质押,只更新区块号,不计算奖励
if (lpSupply == 0) {
pool.lastRewardBlock = block.number;
return;
}
// 计算经过的区块数
uint256 multiplier = block.number - pool.lastRewardBlock;
// 计算该池本阶段获得的 SUSHI 数量
uint256 sushiReward = multiplier
* sushiPerBlock
* pool.allocPoint
/ totalAllocPoint;
// 铸造 SUSHI 到合约(给用户)和 devaddr(给开发者,通常 10%)
sushi.mint(devaddr, sushiReward / 10);
sushi.mint(address(this), sushiReward);
// 更新累积器(× 1e12 是为了避免整数除法精度损失)
pool.accSushiPerShare += sushiReward * 1e12 / lpSupply;
// 记录最后更新区块
pool.lastRewardBlock = block.number;
}
关键点解析:
if (block.number <= pool.lastRewardBlock)— 防止同一区块重复计算if (lpSupply == 0)— 没有质押时不分配奖励(奖励直接跳过该阶段)× 1e12— 精度放大因子,避免整数除法截断(后面会详细讲)
6.5 deposit 函数详解
function deposit(uint256 _pid, uint256 _amount) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
// 第一步:先更新累积器
updatePool(_pid);
// 第二步:如果用户有质押,先结算待领奖励
if (user.amount > 0) {
uint256 pending = user.amount * pool.accSushiPerShare / 1e12
- user.rewardDebt;
if (pending > 0) {
safeSushiTransfer(msg.sender, pending);
}
}
// 第三步:转入 LP 代币
if (_amount > 0) {
pool.lpToken.safeTransferFrom(msg.sender, address(this), _amount);
user.amount += _amount;
}
// 第四步:更新奖励债务(基于新余额)
user.rewardDebt = user.amount * pool.accSushiPerShare / 1e12;
emit Deposit(msg.sender, _pid, _amount);
}
操作顺序至关重要:
✅ 正确顺序:
1. 更新累积器
2. 结算旧奖励
3. 修改余额
4. 更新债务
❌ 错误顺序:
如果先修改余额再结算奖励 → 奖励计算会基于新余额,导致计算错误
6.6 withdraw 函数详解
function withdraw(uint256 _pid, uint256 _amount) public {
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][msg.sender];
require(user.amount >= _amount, "withdraw: insufficient balance");
// 更新累积器
updatePool(_pid);
// 计算并发放待领奖励
uint256 pending = user.amount * pool.accSushiPerShare / 1e12
- user.rewardDebt;
if (pending > 0) {
safeSushiTransfer(msg.sender, pending);
}
// 减少质押余额
if (_amount > 0) {
user.amount -= _amount;
pool.lpToken.safeTransfer(msg.sender, _amount);
}
// 更新奖励债务(基于新余额)
user.rewardDebt = user.amount * pool.accSushiPerShare / 1e12;
emit Withdraw(msg.sender, _pid, _amount);
}
6.7 pending 查询函数
// 查询用户可领取的奖励(不修改状态,只读)
function pendingSushi(uint256 _pid, address _user)
external view returns (uint256)
{
PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][_user];
uint256 accSushiPerShare = pool.accSushiPerShare;
uint256 lpSupply = pool.lpToken.balanceOf(address(this));
// 模拟 updatePool 的计算,但不写状态
if (block.number > pool.lastRewardBlock && lpSupply != 0) {
uint256 multiplier = block.number - pool.lastRewardBlock;
uint256 sushiReward = multiplier
* sushiPerBlock
* pool.allocPoint
/ totalAllocPoint;
accSushiPerShare += sushiReward * 1e12 / lpSupply;
}
return user.amount * accSushiPerShare / 1e12 - user.rewardDebt;
}
7. Synthetix 实现方式详解
7.1 Synthetix 的设计特点
Synthetix 的 StakingRewards 合约是另一个经典实现,特点:
- 单个质押池
- 按时间戳计时(而非区块号)
- 奖励预存(admin 提前转入,而非动态铸造)
- 明确调用领奖(需要单独调用
getReward()) - 存储累积器快照(而非奖励债务)
7.2 Synthetix 的数据结构
contract StakingRewards {
IERC20 public rewardsToken; // 奖励代币
IERC20 public stakingToken; // 质押代币
uint256 public rewardRate; // 每秒发放的奖励数量
uint256 public rewardsDuration; // 奖励周期(秒)
uint256 public periodFinish; // 本轮奖励结束时间戳
uint256 public lastUpdateTime; // 上次更新累积器的时间戳
// 累积器(全局):每质押 1 个代币累积的总奖励
uint256 public rewardPerTokenStored;
// 每个用户的累积器快照(用户上次交互时的累积器值)
mapping(address => uint256) public userRewardPerTokenPaid;
// 每个用户的待领取奖励
mapping(address => uint256) public rewards;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
}
7.3 Synthetix 累积器更新
// 计算到当前时刻,每个代币累积的奖励
function rewardPerToken() public view returns (uint256) {
if (_totalSupply == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
(lastTimeRewardApplicable() - lastUpdateTime)
* rewardRate
* 1e18 // 精度因子(Synthetix 用 1e18)
/ _totalSupply
);
}
// 返回当前有效的时间(不超过奖励结束时间)
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish
? block.timestamp
: periodFinish;
}
7.4 Synthetix 的 earned() 函数
// 计算用户当前可领取的奖励
function earned(address account) public view returns (uint256) {
return (
_balances[account]
* (rewardPerToken() - userRewardPerTokenPaid[account])
/ 1e18
) + rewards[account];
}
与 MasterChef 的关键区别:
| MasterChef | Synthetix | |
|---|---|---|
| 防追溯方式 | rewardDebt 存的是绝对值 | userRewardPerTokenPaid 存的是快照 |
| 计算方式 | 余额 × 当前累积器 - rewardDebt | 余额 × (当前累积器 - 快照) |
| 本质 | 相同,只是形式不同 |
数值示例说明:
Bob 在累积器 = 100 时存入 100 代币
MasterChef 方式:
rewardDebt = 100 × 100 = 10,000
之后奖励 = 100 × 150 - 10,000 = 5,000
Synthetix 方式:
userRewardPerTokenPaid[Bob] = 100(快照)
之后奖励 = 100 × (150 - 100) = 5,000
结果完全一致!
7.5 Synthetix 的 updateReward 修饰器
modifier updateReward(address account) {
// 更新全局累积器
rewardPerTokenStored = rewardPerToken();
lastUpdateTime = lastTimeRewardApplicable();
// 更新用户待领奖励和快照
if (account != address(0)) {
rewards[account] = earned(account);
userRewardPerTokenPaid[account] = rewardPerTokenStored;
}
_;
}
// 存款
function stake(uint256 amount) external updateReward(msg.sender) {
_totalSupply += amount;
_balances[msg.sender] += amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
// 取款
function withdraw(uint256 amount) public updateReward(msg.sender) {
_totalSupply -= amount;
_balances[msg.sender] -= amount;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
// 领取奖励
function getReward() public updateReward(msg.sender) {
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
rewardsToken.safeTransfer(msg.sender, reward);
emit RewardPaid(msg.sender, reward);
}
}
7.6 Synthetix 的奖励初始化
// 由管理员调用,启动新的奖励周期
function notifyRewardAmount(uint256 reward)
external
onlyRewardsDistribution
updateReward(address(0))
{
if (block.timestamp >= periodFinish) {
// 新周期
rewardRate = reward / rewardsDuration;
} else {
// 当前周期还未结束,将剩余奖励合并计算
uint256 remaining = periodFinish - block.timestamp;
uint256 leftover = remaining * rewardRate;
rewardRate = (reward + leftover) / rewardsDuration;
}
lastUpdateTime = block.timestamp;
periodFinish = block.timestamp + rewardsDuration;
emit RewardAdded(reward);
}
8. 两种协议的对比分析
8.1 全面对比表
| 特性 | MasterChef(SushiSwap) | StakingRewards(Synthetix) |
|---|---|---|
| 时间单位 | 区块号(block.number) | 时间戳(block.timestamp) |
| 奖励来源 | 合约自动铸造 ERC20 | 管理员预存奖励代币 |
| 池数量 | 多个池(每池有权重) | 单个池 |
| 防追溯机制 | rewardDebt(存绝对值) | userRewardPerTokenPaid(存快照) |
| 精度因子 | 1e12 | 1e18 |
| 领奖方式 | 存款/取款时自动发放 | 需要显式调用 getReward() |
| 奖励调度 | 无上限,按块持续铸造 | 有时间窗口(periodFinish) |
| 代码复杂度 | 较高(多池管理) | 较低(单池) |
8.2 适用场景选择
选择 MasterChef 风格:
- 需要支持多种资产质押
- 需要灵活调整各池奖励比例
- 奖励代币由协议控制铸造
选择 Synthetix 风格:
- 单一质押场景
- 奖励代币已经存在(不需要铸造权限)
- 需要定期重置奖励周期
8.3 为什么时间单位选择不同?
区块号(MasterChef):
- 优点:在同一链上更精确,攻击者无法操控
- 缺点:跨链时区块时间不一致,难以标准化
时间戳(Synthetix):
- 优点:直观,跨链通用
- 缺点:矿工可以在小范围内操控时间戳(±15秒),但对质押影响极小
9. 精度问题与工程细节
9.1 为什么需要精度放大因子?
问题场景:
totalStaked = 1,000,000 代币
rewardPerBlock = 1 代币
accRewardPerToken += 1 / 1,000,000 = 0.000001
但 Solidity 没有小数!0.000001 会被截断为 0!
解决方案:乘以精度因子
// 存储时放大
accRewardPerToken += reward * 1e12 / totalStaked;
// 使用时缩小
userReward = userBalance * accRewardPerToken / 1e12 - rewardDebt;
这样,1 * 1e12 / 1,000,000 = 1,000,000,保留了精度。
9.2 精度因子的选择
| 协议 | 精度因子 | 原因 |
|---|---|---|
| MasterChef | 1e12 | LP 代币通常 18 位小数,1e12 足够 |
| Synthetix | 1e18 | 更高精度,适应更极端的数值比例 |
经验规则: 精度因子 × 奖励代币精度 ≤ uint256 最大值(避免溢出)
9.3 Reward Debt 也需要同等精度
注意:rewardDebt 存储时也是放大后的值:
// 存入时
user.rewardDebt = user.amount * pool.accSushiPerShare / 1e12;
// ↑ 这里不能漏掉除以 1e12!
9.4 整数除法截断误差
每次累积器更新都会有轻微的截断误差:
实际增量 = 1,000 / 300 = 3.333...
存储增量 = 3(截断)
误差 = 0.333 代币/次更新
这是不可避免的,但误差非常小,协议通常接受这种微小损失。更严格的实现会用 (remainder × 1e18) 存储余数,但多数项目不这样做。
9.5 overflow 风险与处理
在 Solidity 0.8+ 中,默认有溢出保护(会 revert)。但在老版本(0.6/0.7)中需要使用 SafeMath。
要特别注意:
// 可能溢出的计算
sushiReward * 1e12 / lpSupply
// 如果 sushiReward 很大,sushiReward * 1e12 可能超出 uint256
// 需要确保: sushiReward < type(uint256).max / 1e12
10. 安全性设计原则
10.1 关键不变量:状态变更函数间无余额变化
在两次 updatePool 调用之间,总质押量不会改变。
这是整个算法正确性的基础。如果用户的存款/取款只能通过合约函数发生,那么在 updatePool 被调用后、下次调用前,totalStaked 是固定的。
违背这个不变量的例子(漏洞):
- 如果允许直接向合约转账 LP 代币而不调用
deposit(),totalStaked就会错误
10.2 检查-效果-交互模式(CEI Pattern)
function withdraw(uint256 amount) external {
// ✅ 检查(Checks)
require(userInfo[msg.sender].amount >= amount);
// ✅ 效果(Effects):先修改状态
userInfo[msg.sender].amount -= amount;
userInfo[msg.sender].rewardDebt = ...;
// ✅ 交互(Interactions):最后才转账
lpToken.safeTransfer(msg.sender, amount);
sushi.transfer(msg.sender, pending);
}
这防止了重入攻击(Reentrancy Attack)。
10.3 防止 Flash Loan 攻击
Flash Loan 允许用户在单个交易中借入大量资金。如果攻击者:
- 借入 1,000,000 代币
- 存入质押合约
- 立即取款并领奖励
- 归还闪贷
为什么这里不奏效?
因为 updatePool 只在交互时更新,而存款到取款在同一区块内,block.number - lastRewardBlock = 0,所以没有新奖励产生。攻击者无利可图。
10.4 奖励代币的 safeSushiTransfer
MasterChef 有一个 safeSushiTransfer 而非直接 transfer:
function safeSushiTransfer(address _to, uint256 _amount) internal {
uint256 sushiBal = sushi.balanceOf(address(this));
// 如果余额不足,只发送实际余额(防止因精度误差导致 revert)
if (_amount > sushiBal) {
sushi.transfer(_to, sushiBal);
} else {
sushi.transfer(_to, _amount);
}
}
11. 完整 Solidity 实现示例
11.1 简化版单池质押合约(含详细注释)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SimpleStaking {
using SafeERC20 for IERC20;
// --- 状态变量 ---
IERC20 public immutable stakingToken; // 质押代币
IERC20 public immutable rewardToken; // 奖励代币
uint256 public rewardPerBlock; // 每区块发放的奖励
uint256 public lastRewardBlock; // 上次更新的区块号
uint256 public accRewardPerShare; // 累积器(放大 1e12)
uint256 public totalStaked; // 总质押量
struct UserInfo {
uint256 amount; // 质押数量
uint256 rewardDebt; // 奖励债务(放大 1e12)
}
mapping(address => UserInfo) public userInfo;
uint256 private constant PRECISION = 1e12;
// --- 事件 ---
event Deposit(address indexed user, uint256 amount);
event Withdraw(address indexed user, uint256 amount);
event Harvest(address indexed user, uint256 amount);
// --- 构造函数 ---
constructor(
address _stakingToken,
address _rewardToken,
uint256 _rewardPerBlock
) {
stakingToken = IERC20(_stakingToken);
rewardToken = IERC20(_rewardToken);
rewardPerBlock = _rewardPerBlock;
lastRewardBlock = block.number;
}
// --- 核心逻辑 ---
/**
* @dev 更新累积器
* 在任何状态变更前必须先调用此函数
*/
function updatePool() public {
if (block.number <= lastRewardBlock) return;
if (totalStaked == 0) {
lastRewardBlock = block.number;
return;
}
uint256 blocks = block.number - lastRewardBlock;
uint256 reward = blocks * rewardPerBlock;
// 更新累积器,× PRECISION 避免精度损失
accRewardPerShare += reward * PRECISION / totalStaked;
lastRewardBlock = block.number;
}
/**
* @dev 计算用户待领奖励(只读)
*/
function pendingReward(address _user) external view returns (uint256) {
UserInfo storage user = userInfo[_user];
uint256 acc = accRewardPerShare;
// 模拟最新累积器,不写状态
if (block.number > lastRewardBlock && totalStaked > 0) {
uint256 blocks = block.number - lastRewardBlock;
acc += blocks * rewardPerBlock * PRECISION / totalStaked;
}
return user.amount * acc / PRECISION - user.rewardDebt;
}
/**
* @dev 存款
*/
function deposit(uint256 _amount) external {
UserInfo storage user = userInfo[msg.sender];
// 1. 先更新累积器
updatePool();
// 2. 结算待领奖励
if (user.amount > 0) {
uint256 pending = user.amount * accRewardPerShare / PRECISION
- user.rewardDebt;
if (pending > 0) {
rewardToken.safeTransfer(msg.sender, pending);
emit Harvest(msg.sender, pending);
}
}
// 3. 更新质押数量
if (_amount > 0) {
stakingToken.safeTransferFrom(msg.sender, address(this), _amount);
user.amount += _amount;
totalStaked += _amount;
}
// 4. 更新奖励债务(基于新余额)
user.rewardDebt = user.amount * accRewardPerShare / PRECISION;
emit Deposit(msg.sender, _amount);
}
/**
* @dev 取款
*/
function withdraw(uint256 _amount) external {
UserInfo storage user = userInfo[msg.sender];
require(user.amount >= _amount, "insufficient balance");
// 1. 更新累积器
updatePool();
// 2. 结算奖励
uint256 pending = user.amount * accRewardPerShare / PRECISION
- user.rewardDebt;
if (pending > 0) {
rewardToken.safeTransfer(msg.sender, pending);
emit Harvest(msg.sender, pending);
}
// 3. 更新质押数量
if (_amount > 0) {
user.amount -= _amount;
totalStaked -= _amount;
stakingToken.safeTransfer(msg.sender, _amount);
}
// 4. 更新奖励债务
user.rewardDebt = user.amount * accRewardPerShare / PRECISION;
emit Withdraw(msg.sender, _amount);
}
/**
* @dev 紧急取款(放弃奖励,仅取回质押代币)
*/
function emergencyWithdraw() external {
UserInfo storage user = userInfo[msg.sender];
uint256 amount = user.amount;
user.amount = 0;
user.rewardDebt = 0;
totalStaked -= amount;
stakingToken.safeTransfer(msg.sender, amount);
}
}
12. 常见面试题与考点
12.1 为什么需要 rewardDebt(奖励债务)?
答: 防止新质押者获取他们存款之前产生的历史奖励。当用户在累积器值为 X 时存入,债务 = 存款量 × X,这部分”理论奖励”被视为不存在,从而只计算存款后产生的增量奖励。
12.2 如果 totalStaked = 0,这期间的奖励去哪了?
答: 这期间的奖励被丢弃(不计入任何人)。updatePool 在检测到 totalStaked = 0 时,只更新 lastRewardBlock 而不增加 accRewardPerShare。这部分原本要铸造的代币不会被铸造,对于预存奖励的协议(如 Synthetix)来说,这部分奖励留在合约中。
12.3 同一个区块内,先存款还是先取款,奖励有差异吗?
答: 没有差异。因为 updatePool 中 blocks = block.number - lastRewardBlock,如果在同一区块内,blocks = 0,不产生新奖励。执行顺序不影响结果。
12.4 精度因子 1e12 vs 1e18,有什么影响?
答:
- 更大的精度因子 → 减少截断误差,但增加溢出风险
- 需要确保
reward * PRECISION不超过 uint256 最大值 - 一般经验:精度因子选择要保证
maxReward * precision / minStake不溢出
12.5 MasterChef 的多池奖励权重是如何工作的?
答: 每个池有 allocPoint,代表其权重。每个区块的奖励为:
池奖励 = 全局奖励/块 × (池的allocPoint / totalAllocPoint)
例如权重 200/400 = 50%,表示该池获得全局奖励的一半。
12.6 Synthetix 和 MasterChef 的防追溯机制本质相同吗?
答: 本质相同,形式不同:
- MasterChef:
debt = balance × currentAcc,计算奖励 =balance × currentAcc - debt - Synthetix:
snapshot = currentAcc,计算奖励 =balance × (currentAcc - snapshot) - 展开 Synthetix:
balance × currentAcc - balance × snapshot,与 MasterChef 完全等价,其中balance × snapshot = debt
13. 延伸阅读与实践建议
13.1 学习路径建议
基础阶段:
1. 阅读 ERC20 标准(OpenZeppelin 实现)
2. 理解本文的累积器算法
3. 手写一个简单质押合约并部署到测试网
进阶阶段:
4. 阅读 SushiSwap MasterChef 完整源码
https://github.com/sushiswap/sushiswap/blob/master/contracts/MasterChef.sol
5. 阅读 Synthetix StakingRewards 完整源码
https://github.com/Synthetixio/synthetix/blob/master/contracts/StakingRewards.sol
6. 研究 Curve 的 gauge 奖励机制(更复杂的扩展)
安全阶段:
7. 学习重入攻击及防御(Reentrancy Guard)
8. 研究历史上质押合约的安全事故
9. 学习 Slither/Echidna 等安全分析工具
13.2 关键概念速查
| 概念 | 核心公式 |
|---|---|
| 累积器更新 | acc += (blocks × rewardPerBlock × PRECISION) / totalStaked |
| 待领奖励 | pending = balance × acc / PRECISION - rewardDebt |
| 存款时更新债务 | rewardDebt = newBalance × acc / PRECISION |
| 多池分配 | poolReward = globalReward × allocPoint / totalAllocPoint |
| Synthetix earned | earned = balance × (currentAcc - userSnapshot) / PRECISION + savedRewards |
13.3 实践练习
-
基础练习: 在纸上或电子表格中,模拟 3 个用户在 20 个区块内的存取款,手动计算每人的奖励,然后对照合约逻辑验证。
-
代码练习: 用 Foundry 或 Hardhat 写测试用例,覆盖以下场景:
- 单用户全程质押
- 多用户不同时间加入
- 期间 totalStaked = 0 的情况
- 追加存款的奖励计算
-
安全练习: 尝试构造一个攻击场景,然后思考合约是如何防御的。
13.4 相关协议参考
- Compound Finance:类似机制用于借贷利息分发
- Curve Finance Gauge:支持多种奖励代币的扩展版本
- Convex Finance:在 Curve Gauge 之上的二次质押封装
- Uniswap v3 Staker:基于 NFT 仓位的质押,更复杂的实现
附录:关键术语中英对照
| 中文 | 英文 | 说明 |
|---|---|---|
| 累积器 | Accumulator / accRewardPerShare | 全局累计奖励计数器 |
| 奖励债务 | Reward Debt | 防追溯的债务记录 |
| 权重分配点 | Allocation Point (allocPoint) | 多池权重 |
| 待领奖励 | Pending Reward | 未领取的奖励 |
| 质押 | Staking / Deposit | 锁入代币 |
| 快照 | Snapshot (userRewardPerTokenPaid) | Synthetix 中的累积器快照 |
| 精度因子 | Precision Factor | 避免整数除法截断 |
| 奖励周期 | Reward Duration / Period | Synthetix 的奖励有效期 |
本文档基于 RareSkills 原文整理,添加了大量数值示例和工程实践细节,适合作为 DeFi 开发学习参考。