SC Day 34
Solidity - StakingRewards 合约 - 质押/解质押/奖励计算
### 一、为什么需要 StakingRewards?
2026-05-04
第二阶段:框架实战SolidityStakingRewardsDeFiSynthetix
日期: 2026-05-04 方向: Solidity 阶段: 第二阶段:框架实战 标签: #Solidity #Staking #Rewards #DeFi #Synthetix
今日目标
- 深入理解 Synthetix StakingRewards 模式的数学原理
- 掌握 rewardPerToken 累加器的工作机制
- 实现完整的 StakingRewards 合约(stake/withdraw/getReward)
- 用数值例子推导奖励分配过程,确保数学理解透彻
核心概念
一、为什么需要 StakingRewards?
在 DeFi 中,质押奖励分配是最核心的机制之一。它的应用场景极为广泛:
| 场景 | 说明 |
|---|---|
| 流动性挖矿 | LP Token 质押获得治理代币奖励 |
| 质押治理代币 | 锁定 Token 获得协议收入分成 |
| Gauge Voting | Curve/Balancer 的投票激励 |
| 保险基金 | 质押 Token 作为协议保险池,获得奖励 |
核心挑战:如何在用户随时进出、余额不断变化的情况下,公平地分配奖励?
二、朴素方案的问题
朴素方案: 每秒遍历所有质押者,按比例分配奖励
问题:
1. Gas 消耗与质押者数量成正比 → 无法扩展
2. 1000 个质押者 → 每次更新需要 1000 次写操作
3. 这在链上是不可能的!
三、Synthetix 方案:rewardPerToken 累加器
这是一个极其精巧的数学设计:不需要遍历用户,每次操作只更新一个全局变量 + 当前用户的状态。
核心思想
关键洞察: 不记录"每个用户获得了多少奖励",
而是记录"每单位质押代币在历史上总共产生了多少奖励"
全局变量: rewardPerTokenStored
= 从开始到现在,每 1 个质押代币总共产生了多少奖励
用户变量: userRewardPerTokenPaid[user]
= 用户上次操作时的 rewardPerTokenStored
用户待领取奖励:
earned = balance * (rewardPerTokenStored - userRewardPerTokenPaid)
数学推导
设:
R= 每秒奖励总量(rewardRate)S= 当前总质押量(totalSupply)Δt= 经过的时间
在 Δt 时间内:
总奖励 = R × Δt
每单位质押代币的奖励 = R × Δt / S
rewardPerToken 的更新:
rewardPerToken += R × Δt / S
用户 i 的奖励:
earned_i = balance_i × (rewardPerToken_current - rewardPerToken_at_last_action_i)
数值例子
假设: rewardRate = 100 tokens/sec
=== t=0: Alice 质押 100 tokens ===
totalSupply = 100
rewardPerTokenStored = 0
Alice.rewardPerTokenPaid = 0
=== t=10: Bob 质押 100 tokens ===
先更新 rewardPerTokenStored:
Δt = 10, totalSupply = 100 (更新前)
rewardPerToken += 100 * 10 / 100 = 10
rewardPerTokenStored = 10
Alice 的待领取奖励:
earned = 100 * (10 - 0) = 1000 tokens ✓
(10 秒内只有 Alice,她获得全部奖励)
Bob 加入:
totalSupply = 200
Bob.rewardPerTokenPaid = 10
=== t=20: Charlie 想看看大家赚了多少 ===
先更新 rewardPerTokenStored:
Δt = 10, totalSupply = 200
rewardPerToken += 100 * 10 / 200 = 5
rewardPerTokenStored = 15
Alice 的待领取奖励:
earned = 100 * (15 - 0) = 1500 tokens
(前 10 秒赚 1000 + 后 10 秒赚 500)
Bob 的待领取奖励:
earned = 100 * (15 - 10) = 500 tokens
(后 10 秒赚 500)
验证: 1500 + 500 = 2000 = 100 * 20 ✓ 总奖励分配正确!
=== t=25: Alice 取走 50 tokens ===
先更新 rewardPerTokenStored:
Δt = 5, totalSupply = 200
rewardPerToken += 100 * 5 / 200 = 2.5
rewardPerTokenStored = 17.5
Alice 领取前的累计:
earned = 100 * (17.5 - 0) = 1750 tokens
→ 保存到 rewards[Alice] = 1750
→ Alice.rewardPerTokenPaid = 17.5
→ Alice 的质押余额变为 50
=== t=35: Alice 来领奖励 ===
先更新 rewardPerTokenStored:
Δt = 10, totalSupply = 150 (Alice 50 + Bob 100)
rewardPerToken += 100 * 10 / 150 = 6.667
rewardPerTokenStored = 24.167
Alice 的待领取奖励:
earned = rewards[Alice] + balance * (rewardPerToken - paid)
= 1750 + 50 * (24.167 - 17.5)
= 1750 + 333.35
= 2083.35 tokens
四、奖励周期(Duration)
rewardsDuration = 7 days (例如)
notifyRewardAmount(700_000 tokens):
rewardRate = 700_000 / 7 days = 100_000 / day ≈ 1.157 / sec
如果在周期中间追加奖励:
remaining = periodFinish - block.timestamp
leftover = remaining * rewardRate // 剩余未分配的奖励
rewardRate = (reward + leftover) / rewardsDuration
periodFinish = block.timestamp + rewardsDuration
代码实战
完整的 StakingRewards 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/**
* @title StakingRewards
* @notice 基于 Synthetix StakingRewards 模式的质押奖励合约
* @dev 使用 rewardPerToken 累加器实现 O(1) 奖励分配
*/
contract StakingRewards is ReentrancyGuard {
using SafeERC20 for IERC20;
// ===== 状态变量 =====
IERC20 public immutable stakingToken; // 质押代币
IERC20 public immutable rewardsToken; // 奖励代币
address public owner; // 管理员
// 奖励参数
uint256 public duration; // 奖励周期长度(秒)
uint256 public finishAt; // 当前周期结束时间
uint256 public updatedAt; // 上次更新时间
uint256 public rewardRate; // 每秒奖励量
// 核心累加器
uint256 public rewardPerTokenStored; // 全局: 每单位质押代币的累计奖励
// 用户状态
mapping(address => uint256) public userRewardPerTokenPaid; // 用户上次的 rewardPerToken
mapping(address => uint256) public rewards; // 用户待领取的奖励
// 质押状态
uint256 public totalSupply; // 总质押量
mapping(address => uint256) public balanceOf; // 用户质押余额
// ===== 事件 =====
event Staked(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
event RewardPaid(address indexed user, uint256 reward);
event RewardsDurationUpdated(uint256 newDuration);
event RewardAdded(uint256 reward);
// ===== 错误 =====
error ZeroAmount();
error NotOwner();
error RewardPeriodNotFinished();
error RewardRateTooHigh();
// ===== 修饰符 =====
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
/**
* @dev 核心修饰符:在每次用户操作前更新奖励状态
*/
modifier updateReward(address _account) {
rewardPerTokenStored = rewardPerToken();
updatedAt = lastTimeRewardApplicable();
if (_account != address(0)) {
rewards[_account] = earned(_account);
userRewardPerTokenPaid[_account] = rewardPerTokenStored;
}
_;
}
constructor(
address _stakingToken,
address _rewardsToken
) {
owner = msg.sender;
stakingToken = IERC20(_stakingToken);
rewardsToken = IERC20(_rewardsToken);
}
// ===== 核心视图函数 =====
/**
* @notice 最后一次可获得奖励的时间
* @dev min(block.timestamp, finishAt) —— 周期结束后不再产生奖励
*/
function lastTimeRewardApplicable() public view returns (uint256) {
return _min(finishAt, block.timestamp);
}
/**
* @notice 计算每单位质押代币的累计奖励
* @dev 这是整个算法的核心
*/
function rewardPerToken() public view returns (uint256) {
if (totalSupply == 0) {
return rewardPerTokenStored;
}
return rewardPerTokenStored + (
rewardRate
* (lastTimeRewardApplicable() - updatedAt)
* 1e18 // 精度放大
/ totalSupply
);
}
/**
* @notice 计算用户待领取的奖励
* @param _account 用户地址
*/
function earned(address _account) public view returns (uint256) {
return (
balanceOf[_account]
* (rewardPerToken() - userRewardPerTokenPaid[_account])
/ 1e18 // 除以精度因子
) + rewards[_account];
}
// ===== 用户操作 =====
/**
* @notice 质押代币
* @param _amount 质押数量
*/
function stake(uint256 _amount) external nonReentrant updateReward(msg.sender) {
if (_amount == 0) revert ZeroAmount();
stakingToken.safeTransferFrom(msg.sender, address(this), _amount);
balanceOf[msg.sender] += _amount;
totalSupply += _amount;
emit Staked(msg.sender, _amount);
}
/**
* @notice 解除质押
* @param _amount 解除质押的数量
*/
function withdraw(uint256 _amount) external nonReentrant updateReward(msg.sender) {
if (_amount == 0) revert ZeroAmount();
require(balanceOf[msg.sender] >= _amount, "Insufficient balance");
balanceOf[msg.sender] -= _amount;
totalSupply -= _amount;
stakingToken.safeTransfer(msg.sender, _amount);
emit Withdrawn(msg.sender, _amount);
}
/**
* @notice 领取奖励
*/
function getReward() external nonReentrant 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);
}
}
/**
* @notice 解除质押 + 领取奖励(组合操作,省 Gas)
*/
function exit() external {
// 注意:withdraw 和 getReward 各自有 updateReward
// 这里直接调用内部逻辑避免重复更新
uint256 stakedAmount = balanceOf[msg.sender];
if (stakedAmount > 0) {
// 手动触发 updateReward
rewardPerTokenStored = rewardPerToken();
updatedAt = lastTimeRewardApplicable();
rewards[msg.sender] = earned(msg.sender);
userRewardPerTokenPaid[msg.sender] = rewardPerTokenStored;
// withdraw
balanceOf[msg.sender] = 0;
totalSupply -= stakedAmount;
stakingToken.safeTransfer(msg.sender, stakedAmount);
emit Withdrawn(msg.sender, stakedAmount);
// getReward
uint256 reward = rewards[msg.sender];
if (reward > 0) {
rewards[msg.sender] = 0;
rewardsToken.safeTransfer(msg.sender, reward);
emit RewardPaid(msg.sender, reward);
}
}
}
// ===== 管理员操作 =====
/**
* @notice 设置奖励周期长度
*/
function setRewardsDuration(uint256 _duration) external onlyOwner {
if (finishAt > block.timestamp) revert RewardPeriodNotFinished();
duration = _duration;
emit RewardsDurationUpdated(_duration);
}
/**
* @notice 注入奖励并开始/延续奖励周期
* @param _amount 新注入的奖励数量
*/
function notifyRewardAmount(uint256 _amount)
external
onlyOwner
updateReward(address(0))
{
if (block.timestamp >= finishAt) {
// 新周期
rewardRate = _amount / duration;
} else {
// 周期内追加:将剩余奖励 + 新奖励一起分配
uint256 remainingRewards = (finishAt - block.timestamp) * rewardRate;
rewardRate = (_amount + remainingRewards) / duration;
}
// 安全检查:确保合约有足够的奖励代币
if (rewardRate == 0) revert ZeroAmount();
if (rewardRate * duration > rewardsToken.balanceOf(address(this))) {
revert RewardRateTooHigh();
}
finishAt = block.timestamp + duration;
updatedAt = block.timestamp;
emit RewardAdded(_amount);
}
// ===== 内部函数 =====
function _min(uint256 a, uint256 b) private pure returns (uint256) {
return a < b ? a : b;
}
}
完整测试
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/StakingRewards.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {
_mint(msg.sender, 10_000_000e18);
}
}
contract StakingRewardsTest is Test {
StakingRewards public staking;
MockERC20 public stakingToken;
MockERC20 public rewardsToken;
address public owner = makeAddr("owner");
address public alice = makeAddr("alice");
address public bob = makeAddr("bob");
uint256 public constant DURATION = 7 days;
uint256 public constant REWARD_AMOUNT = 700_000e18;
function setUp() public {
vm.startPrank(owner);
stakingToken = new MockERC20("Staking Token", "STK");
rewardsToken = new MockERC20("Reward Token", "RWD");
staking = new StakingRewards(address(stakingToken), address(rewardsToken));
// 设置奖励周期
staking.setRewardsDuration(DURATION);
// 转移代币给用户
stakingToken.transfer(alice, 100_000e18);
stakingToken.transfer(bob, 100_000e18);
// 转移奖励代币给合约
rewardsToken.transfer(address(staking), REWARD_AMOUNT);
// 启动奖励
staking.notifyRewardAmount(REWARD_AMOUNT);
vm.stopPrank();
}
function test_SingleStaker_FullDuration() public {
// Alice 质押,持续整个周期
vm.startPrank(alice);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
// 快进到周期结束
vm.warp(block.timestamp + DURATION);
// Alice 应该获得几乎全部奖励
uint256 earned = staking.earned(alice);
// 可能有微小的舍入误差
assertApproxEqRel(earned, REWARD_AMOUNT, 1e15); // 0.1% 误差范围
}
function test_TwoStakers_EqualShares() public {
// Alice 和 Bob 同时质押相同数量
vm.startPrank(alice);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
vm.startPrank(bob);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
// 快进到周期结束
vm.warp(block.timestamp + DURATION);
// 两人应该各获得约一半奖励
uint256 earnedAlice = staking.earned(alice);
uint256 earnedBob = staking.earned(bob);
assertApproxEqRel(earnedAlice, REWARD_AMOUNT / 2, 1e15);
assertApproxEqRel(earnedBob, REWARD_AMOUNT / 2, 1e15);
}
function test_LateStaker_GetsProportional() public {
// Alice 从头开始质押
vm.startPrank(alice);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
// Bob 在周期一半时加入
vm.warp(block.timestamp + DURATION / 2);
vm.startPrank(bob);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
// 快进到周期结束
vm.warp(block.timestamp + DURATION / 2);
uint256 earnedAlice = staking.earned(alice);
uint256 earnedBob = staking.earned(bob);
// Alice: 前半段 100% + 后半段 50% = 75%
// Bob: 后半段 50% = 25%
assertApproxEqRel(earnedAlice, REWARD_AMOUNT * 3 / 4, 1e15);
assertApproxEqRel(earnedBob, REWARD_AMOUNT / 4, 1e15);
}
function test_GetReward() public {
vm.startPrank(alice);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
vm.warp(block.timestamp + DURATION);
uint256 balanceBefore = rewardsToken.balanceOf(alice);
vm.prank(alice);
staking.getReward();
uint256 balanceAfter = rewardsToken.balanceOf(alice);
assertTrue(balanceAfter > balanceBefore, "Should receive rewards");
assertEq(staking.earned(alice), 0, "Earned should be 0 after claim");
}
function test_WithdrawDoesNotLoseRewards() public {
vm.startPrank(alice);
stakingToken.approve(address(staking), 1000e18);
staking.stake(1000e18);
vm.stopPrank();
vm.warp(block.timestamp + DURATION / 2);
// 记录已赚取的奖励
uint256 earnedBefore = staking.earned(alice);
assertTrue(earnedBefore > 0, "Should have earned something");
// 取出质押
vm.prank(alice);
staking.withdraw(1000e18);
// 奖励不应该丢失
uint256 earnedAfter = staking.earned(alice);
assertEq(earnedAfter, earnedBefore, "Rewards should be preserved");
// 可以领取奖励
vm.prank(alice);
staking.getReward();
assertEq(rewardsToken.balanceOf(alice), earnedBefore);
}
}
关键要点总结
rewardPerToken 累加器精髓
传统思路: 遍历所有用户分配奖励 → O(n) 每次操作
Synthetix: 维护全局累加器 + 用户快照 → O(1) 每次操作
核心公式:
rewardPerToken += rewardRate × Δt / totalSupply
earned(user) = balance × (rewardPerToken - userPaid) + rewards[user]
设计模式对比
| 方案 | 复杂度 | Gas | 精确度 | 使用场景 |
|---|---|---|---|---|
| 朴素遍历 | O(n) | 极高 | 精确 | 不可用 |
| 快照 Merkle | O(1) | 低 | 精确 | 一次性空投 |
| 累加器模式 | O(1) | 低 | 近似精确 | 持续奖励分配 |
精度处理
// 为什么要乘以 1e18?
// 错误写法(会丢失精度):
rewardPerToken += rewardRate * deltaTime / totalSupply;
// 如果 rewardRate * deltaTime < totalSupply → 结果为 0!
// 正确写法:
rewardPerToken += rewardRate * deltaTime * 1e18 / totalSupply;
// 放大 1e18 后再除,保留小数精度
// earned 计算时再除以 1e18 还原
常见误区
-
误区:奖励周期结束后继续质押还会有奖励
- 事实:
lastTimeRewardApplicable()返回min(now, finishAt),周期结束后 rewardPerToken 不再增长
- 事实:
-
误区:取出质押会丢失未领取的奖励
- 事实:
updateRewardmodifier 在操作前会把当前 earned 保存到rewards[user]
- 事实:
-
误区:rewardPerToken 是一个比例
- 事实:它是一个累积值,只增不减。用户的奖励是通过差值计算的
-
误区:notifyRewardAmount 会重置所有人的奖励
- 事实:它通过
updateReward(address(0))先更新全局状态,然后只修改 rewardRate 和周期时间
- 事实:它通过
-
误区:精度问题不重要
- 事实:Solidity 没有浮点数,整数除法会截断。不乘以 1e18 放大会导致严重精度丢失
面试关联
Q1: 解释 Synthetix StakingRewards 的奖励计算原理
简短回答:通过维护一个全局累加器 rewardPerTokenStored(记录每单位质押代币的历史累计奖励),结合每个用户的快照值,用 O(1) 的复杂度计算任意用户在任意时刻的待领取奖励。
详细回答:
- 每次任何用户操作时,先更新全局
rewardPerTokenStored += rewardRate * Δt / totalSupply - 用户的 earned = balance * (rewardPerToken - userPaid) + rewards[user]
- 更新后把当前 rewardPerToken 保存为用户的
userRewardPerTokenPaid - 这样无论多少用户,每次操作都是 O(1),非常 Gas 高效
Q2: 如何设计一个支持多种奖励代币的 StakingRewards?
思路:
- 为每种奖励代币维护独立的
rewardPerTokenStored和rewardRate updateReward中遍历所有奖励代币更新累加器- Curve Gauge 就是这种多代币奖励的实际案例
- 需要注意的 trade-off:奖励代币种类越多,每次操作的 Gas 越高
Q3: StakingRewards 有什么安全风险?
答案要点:
- 奖励代币不足:
notifyRewardAmount时必须确保合约有足够代币 - 精度丢失:如果 totalSupply 极大且 rewardRate 极小,分配不精确
- Flash Loan 攻击:在同一笔交易中 stake → 等一个 block → withdraw,获取奖励(通过
lastTimeRewardApplicable和 block.timestamp 缓解) - Reentrancy:使用 nonReentrant guard 和 CEI 模式防御
参考资源
- Synthetix StakingRewards 源码 — 原始实现
- Solidity by Example - Staking Rewards — 简化教程
- Curve Gauge 实现 — 多代币奖励参考
- Scalable Reward Distribution — 数学原理论文
- OpenZeppelin Staking 指南 — 最佳实践