返回 SC 笔记
SC Day 34

Solidity - StakingRewards 合约 - 质押/解质押/奖励计算

### 一、为什么需要 StakingRewards?

2026-05-04
第二阶段:框架实战
SolidityStakingRewardsDeFiSynthetix

日期: 2026-05-04 方向: Solidity 阶段: 第二阶段:框架实战 标签: #Solidity #Staking #Rewards #DeFi #Synthetix


今日目标

  1. 深入理解 Synthetix StakingRewards 模式的数学原理
  2. 掌握 rewardPerToken 累加器的工作机制
  3. 实现完整的 StakingRewards 合约(stake/withdraw/getReward)
  4. 用数值例子推导奖励分配过程,确保数学理解透彻

核心概念

一、为什么需要 StakingRewards?

在 DeFi 中,质押奖励分配是最核心的机制之一。它的应用场景极为广泛:

场景说明
流动性挖矿LP Token 质押获得治理代币奖励
质押治理代币锁定 Token 获得协议收入分成
Gauge VotingCurve/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)极高精确不可用
快照 MerkleO(1)精确一次性空投
累加器模式O(1)近似精确持续奖励分配

精度处理

// 为什么要乘以 1e18?

// 错误写法(会丢失精度):
rewardPerToken += rewardRate * deltaTime / totalSupply;
// 如果 rewardRate * deltaTime < totalSupply → 结果为 0!

// 正确写法:
rewardPerToken += rewardRate * deltaTime * 1e18 / totalSupply;
// 放大 1e18 后再除,保留小数精度
// earned 计算时再除以 1e18 还原

常见误区

  1. 误区:奖励周期结束后继续质押还会有奖励

    • 事实:lastTimeRewardApplicable() 返回 min(now, finishAt),周期结束后 rewardPerToken 不再增长
  2. 误区:取出质押会丢失未领取的奖励

    • 事实:updateReward modifier 在操作前会把当前 earned 保存到 rewards[user]
  3. 误区:rewardPerToken 是一个比例

    • 事实:它是一个累积值,只增不减。用户的奖励是通过差值计算的
  4. 误区:notifyRewardAmount 会重置所有人的奖励

    • 事实:它通过 updateReward(address(0)) 先更新全局状态,然后只修改 rewardRate 和周期时间
  5. 误区:精度问题不重要

    • 事实:Solidity 没有浮点数,整数除法会截断。不乘以 1e18 放大会导致严重精度丢失

面试关联

Q1: 解释 Synthetix StakingRewards 的奖励计算原理

简短回答:通过维护一个全局累加器 rewardPerTokenStored(记录每单位质押代币的历史累计奖励),结合每个用户的快照值,用 O(1) 的复杂度计算任意用户在任意时刻的待领取奖励。

详细回答

  1. 每次任何用户操作时,先更新全局 rewardPerTokenStored += rewardRate * Δt / totalSupply
  2. 用户的 earned = balance * (rewardPerToken - userPaid) + rewards[user]
  3. 更新后把当前 rewardPerToken 保存为用户的 userRewardPerTokenPaid
  4. 这样无论多少用户,每次操作都是 O(1),非常 Gas 高效

Q2: 如何设计一个支持多种奖励代币的 StakingRewards?

思路

  • 为每种奖励代币维护独立的 rewardPerTokenStoredrewardRate
  • updateReward 中遍历所有奖励代币更新累加器
  • Curve Gauge 就是这种多代币奖励的实际案例
  • 需要注意的 trade-off:奖励代币种类越多,每次操作的 Gas 越高

Q3: StakingRewards 有什么安全风险?

答案要点

  • 奖励代币不足notifyRewardAmount 时必须确保合约有足够代币
  • 精度丢失:如果 totalSupply 极大且 rewardRate 极小,分配不精确
  • Flash Loan 攻击:在同一笔交易中 stake → 等一个 block → withdraw,获取奖励(通过 lastTimeRewardApplicable 和 block.timestamp 缓解)
  • Reentrancy:使用 nonReentrant guard 和 CEI 模式防御

参考资源

  1. Synthetix StakingRewards 源码 — 原始实现
  2. Solidity by Example - Staking Rewards — 简化教程
  3. Curve Gauge 实现 — 多代币奖励参考
  4. Scalable Reward Distribution — 数学原理论文
  5. OpenZeppelin Staking 指南 — 最佳实践