DeFi 质押算法深度学习指导

深入解析 DeFi 质押奖励分配算法,结合 SushiSwap MasterChef 与 Synthetix,系统讲解累积器、奖励债务、精度处理、安全设计及 Solidity 实现。

· ☕ 23 分钟阅读
DeFi 质押算法深度学习指导

DeFi 质押算法深度学习指导

—— 基于 SushiSwap MasterChef 与 Synthetix 协议解析

参考原文:The Staking Algorithm of SushiSwap MasterChef and Synthetix

文档日期:2025-06-09


目录

  1. 学习背景与目标
  2. 核心问题:如何公平分配奖励?
  3. 朴素方案及其缺陷
  4. 核心设计思路:累积器(Accumulator)
  5. 奖励债务(Reward Debt)机制
  6. MasterChef 完整算法详解
  7. Synthetix 实现方式详解
  8. 两种协议的对比分析
  9. 精度问题与工程细节
  10. 安全性设计原则
  11. 完整 Solidity 实现示例
  12. 常见面试题与考点
  13. 延伸阅读与实践建议

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;
}

关键点解析:

  1. if (block.number <= pool.lastRewardBlock) — 防止同一区块重复计算
  2. if (lpSupply == 0) — 没有质押时不分配奖励(奖励直接跳过该阶段)
  3. × 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 的关键区别:

MasterChefSynthetix
防追溯方式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(存快照)
精度因子1e121e18
领奖方式存款/取款时自动发放需要显式调用 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 精度因子的选择

协议精度因子原因
MasterChef1e12LP 代币通常 18 位小数,1e12 足够
Synthetix1e18更高精度,适应更极端的数值比例

经验规则: 精度因子 × 奖励代币精度 ≤ 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. 借入 1,000,000 代币
  2. 存入质押合约
  3. 立即取款并领奖励
  4. 归还闪贷

为什么这里不奏效?

因为 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 同一个区块内,先存款还是先取款,奖励有差异吗?

答: 没有差异。因为 updatePoolblocks = 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 earnedearned = balance × (currentAcc - userSnapshot) / PRECISION + savedRewards

13.3 实践练习

  1. 基础练习: 在纸上或电子表格中,模拟 3 个用户在 20 个区块内的存取款,手动计算每人的奖励,然后对照合约逻辑验证。

  2. 代码练习: 用 Foundry 或 Hardhat 写测试用例,覆盖以下场景:

    • 单用户全程质押
    • 多用户不同时间加入
    • 期间 totalStaked = 0 的情况
    • 追加存款的奖励计算
  3. 安全练习: 尝试构造一个攻击场景,然后思考合约是如何防御的。

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 / PeriodSynthetix 的奖励有效期

本文档基于 RareSkills 原文整理,添加了大量数值示例和工程实践细节,适合作为 DeFi 开发学习参考。

💬 评论