JavaScript 不可用。

我们检测到浏览器禁用了 JavaScript。请启用 JavaScript 或改用支持的浏览器来继续访问

SushiSwap MasterChef 智能合约源码解读

作者:Anban Chu

发表日期:2020年09月15日

所属目录:合约分析

前置介绍

继 Uniswap 大火之后,发现一个叫 SushiSwap 的有意思项目,它在 2020 年 8 月发布了一个叫 MasterChef 的合约。开始研究一下这个合约,它的主要资料如下:

合约介绍

先看 contract MasterChef,从下面 5 个方面了解

  1. 只读方法
  2. 构造函数 (部署时候需要的配置)
  3. 核心方法
  4. 辅助方法
  5. 管理方法
  6. 合约事件

1 只读方法

  • sushi: Sushi 币的合约地址。从部署记录里看使用的是 0x6B3595068778DD592e39A122f4f5a5cF09C90fE2,部署后不可修改。
  • devaddr: 开发者地址。额外增发质押池的 10% 作为收益,部署后可以转让给其他地址
  • sushiPerBlock: 每块产出的 SUSHI 币数量
  • BONUS_MULTIPLIER: 10,奖励膨胀系数
  • bonusEndBlock: 10 倍奖金的结束区块号,在 bonusEndBlock 到达前,每块收益是 sushiPerBlock * BONUS_MULTIPLIER
    • startBlock 用的是 10750000
    • bonusEndBlock 用的是 10850000,相当于奖金在 100000 个块内都是 10 倍收益
  • migrator: 迁移合约地址,用于处理 LP 的迁移
  • poolInfo(pid): 每一个质押池的信息,返回的是 PoolInfo 结构,如下:
    • lpToken: LP 合约地址
    • allocPoint: 分配点数。用于决定该质押池占全局收益中的比重
    • lastRewardBlock: 最后一次分配 SUSHI 币的区块号,在添加的时候设置,每次更新质押池的时候更新。
    • accSushiPerShare: 1wei SUSHI 从最开始质押到现在为止的加权收益。因为收益是持续增加的,不可能减少,所以计算用户收益的时候,需要引入债务的概念。这样才能计算出用户入场和出场之间的收益。
  • userInfo(pid,userAddress): 获取指定质押池中指定地址的信息,返回的是 userInfo 结构
    • amount: 用户质押的金额
    • rewardDebt: 用户债务。这个rewardDebtaccSushiPerShare 一定要理解,这是计算收益的核心。并且每次修改收益相关的数据,都需要操作这两个值,以保证收益的准确性,否则容易丢失块,导致收益不符合预期。收益计算公式 pending reward = (user.amount * pool.accSushiPerShare) - user.rewardDebt
    • rewardDebt 需要明确是"用户的奖励债务"。比如用户存款 20,然后取款 5;第一次计算债务的时候会按照 20 进行操作。用户离开了 5,还剩下 15,此时会按照 13 做债务。下次再发奖励的时候,需要剪掉之前已经发掉的 15 所属奖励债务,在计算的时候,一定要记得减去这部分债务。同理,如果用户此时取款时 20,而不是 5,此时 amount 为 0 了,则债务为 0;因为用户已经没有剩余金额需要取出了。
  • totalAllocPoint:全局分配点数。某个质押池的占当前收益的比例通过poolInfo.allocPoint / totalAllocPoint 得到。所以必须保证这个值和所有池的分配点总和完全相同。
  • startBlock: 开始产出 SUSHI 币的块高。部署时设置,后续不可修改
  • poolLength: 获取当前质押池的总数量
  • getMultiplier: 获取 from 到 to 的块高。(有膨胀系数的参与)
  • pendingSushi(pid,user): 获取当前未领取的 SUSHI 币数量。
    • 因为没有更新池子,所以需要计算上一次发放奖励的数量+上次更新到当前块之间的奖励。这两个数据加一起是真正的奖励预览。核心逻辑和存取一样。仅仅是只读方法。

计算收益的说明

  • accCakePerShare 添加时候到默认值都是 0,更新方法如下
  • 2 个区块之间的池子奖励是:
    • sushiReward = 单个块的 SUSHI 奖励 * (pool.allocPoint / totalAllocPoint) * 两个块之间的块数
  • 池子的总量是 lpSupply 个,则 1 wei LP 的在两个区块间的质押奖励是
    • (sushiReward / lpSupply)
  • 用户的资产是一直放在池子内的,除了当前两个区块之间的奖励,之前的块也是有奖励的,所以这里的 pool.accCakePerShare 需要累加。
    • pool.accCakePerShare = pool.accCakePerShare + (sushiReward * 1e12 / lpSupply)
    • 这里引入 1e12 是为了精度,所以计算用户奖励以及给用户发奖励的时候,可以通过除以 1e12 得到真实值;
    • Anbang注::引入精度时候,推荐使用1e18运算,而且定义为一个常量。比如可以使用常量ACC_SUSHI_PRECISION = 1e18做精度处理,每次需要用的时候使用变量即可。
    • 这里 SushiSwap 处理的不是很好。

Anbang 注: 一些个人认为可以改进的地方

  • sushi 使用的结构是 SushiToken,这个推荐使用 address 格式,使用的时候,使用 ISushiToken 这种 interface 来处理。
  • devaddr 在项目中比较推荐使用 fundAddress 这种变量。
  • startBlock 用来计录开始时间,如果想记录平台运行时间,则记录 startTime = block.timestamp ,然后前端用当前时间戳(秒为单位)减去 startTime 即可判断平台的运行时间。
  • 建议增加 userReward 变量,结构是 mapping(uint256 => mapping(address => uint256)) public userReward,用来记录用户收到的总金额。

2 构造函数 (部署时候需要的配置)

constructor(
    SushiToken _sushi,      // SUSHI 合约地址
    address _devaddr,       // 开发者地址
    uint256 _sushiPerBlock, // 每块产出的SUSHI币数量
    uint256 _startBlock,    // 开始区块
    uint256 _bonusEndBlock  // 奖励膨胀结束的区块链
) public {
    sushi = _sushi;
    devaddr = _devaddr;
    sushiPerBlock = _sushiPerBlock;
    bonusEndBlock = _bonusEndBlock;
    startBlock = _startBlock;
}

Anbang 注: 一些个人认为可以改进的地方

  • startBlock = _startBlock; 这里推荐使用 startBlock = block.number > _startBlock ? block.number : _startBlock;的风格,这样可以避免参数掺入的失误。当前方式失误的时候直接再次部署即可,但是这种冗余处理推荐在写合约的时候一定要养成。

3 核心方法

  • deposit(_pid,_amount)
  • withdraw(_pid,_amount)
  • emergencyWithdraw(_pid)

deposit 存款

这里的 _pid,取决于 poolInfo 中的索引。所以需要提供给外部 poolInfo.length 的值(使用的是 poolLength())。然后使用索引值通过 poolInfo(pid) 即可获取对应的数据信息。实际开发中服务端数据整合后以后给前端,前端使用服务端给的数据即可。

合约内部逻辑:因为 accSushiPerShare 是递增的,记录每 1wei token 从最开始到现在的收益。用户是中途进场的,不可能用户提取的时候从头到尾计算出来的收益都给他。所以这里引入了 user.rewardDebt 的概念。每次增加和减少 amount 后,都使用 userNewAmount * pool.accSushiPerShare 赋值给它,代表这个点的用户收益(因为用户没有真实收到那么多,所以叫债务),当计算收益的时候,每次更新 user.rewardDebt 前,使用 userOldAmount * pool.accSushiPerShare - user.rewardDebt 即可获得真实收益。

用户自己的 SUHI 收益需要使用user.amount user.rewardDebt pool.accSushiPerShare 计算得到,而 pool.accSushiPerSharelastRewardBlock 强关联。所以无论 deposit 还是 withdraw,在计算开始的时候,都需要触发updatePool,将accSushiPerShare更为为最新块的对应值。在更新accSushiPerShare之后,结合当前user.amountuser.rewardDebt计算需要发放的 SUSHI 奖励,并且马上发给用户的地址。

这样只要合约有用户交互就可以源源不断的触发 pool.accSushiPerShare 的更新.

核心逻辑四步:

  1. 处理池子之前的数据: 把池子的信息更新为当前块
  2. 处理用户之前的数据: 奖励发掉
  3. 处理用户当前的数据: 存款时按照先收钱再记账的逻辑
    1. pool 的代币处理(收钱)
    2. user 的账单记录
  4. 抛出事件

Anbang 注: 一些个人认为可以改进的地方

  • 增加判断 require(_pid < poolInfo.length, 'Invalid PID');,尽量告诉客户端当前出错的原因。

withdraw 取款

这个方法和 deposit 内部的逻辑是完全相同的,核心都是保证 user.amount user.rewardDebt pool.accSushiPerShare 的数据匹配。

如果只想提取收益,而并不想提取本金。那么传入的 _amount 使用 0 即可。这样就达到了目的。

总结:

首先判断用户在 LP 内的余额是否大于等于要提取的金额,如果余额不足则报错。

核心逻辑四步:

  1. 处理池子之前的数据: 奖励发掉,并把池子的信息更新为当前块
  2. 处理用户之前的数据: 奖励发掉
  3. 处理用户当前的数据: 取款时按照先记账再付钱的逻辑。
    1. user 的账单记录
    2. pool 的代币处理(发钱)
  4. 抛出事件

Anbang 注: 一些个人认为可以改进的地方


  • 用户存款时按照先收钱再记账的逻辑
  • 用户取款时按照先记账再付钱的逻辑,这里 SUSHI 当前逻辑是先付钱再记账,这里处理的方式不推荐。

emergencyWithdraw 紧急取款

这种是在合约出现问题,或者 SUSHI 发放收益出现问题的时候才使用。这里实现的逻辑是所有金额全部赎回,user.amountuser.rewardDebt 全部初始化。因为 SUSHI 发放可能出现问题,所以此时就不触发updatePool(pid)了。


Anbang 注: 一些个人认为可以改进的地方

当紧急提款的时候,用户一般都是千方百计的赎回本金。这里的紧急取款,如果是我写。我肯定会加入扣除本金的 1% 作为违约金给基金地址(这里是devaddr),只要做好说明和公示。这个钱赚的毫无压力,而且合理合法。用户也能接受。

4 辅助方法

  • updatePool
  • massUpdatePools
  • safeSushiTransfer

updatePool

这里是合约经济模型的基础,在每次更新合约的时候,产出 SUSHI 分别转入当前合约和开发者地址。当前合约内的奖励是供之后发给用户,开发者地址是用来做项目收益的。这次需要注意的是开发者地址收益是额外产出总产出的 10%,并不是占总产出的 10%,如果要瓜分总产出的 10%,这里的公式需要改为如下

sushi.mint(devaddr, sushiReward.div(10));
// 如果瓜分总产出的 10%,需要这样计算真实值
uint256 rewardValue = sushiReward.sub(sushiReward.div(10));
sushi.mint(address(this),rewardValue );
pool.accSushiPerShare = pool.accSushiPerShare.add(
    rewardValue.mul(1e12).div(lpSupply)
);

如果需要加入暂停产出控制,控制的时候需要更新 pool.lastRewardBlock = block.number;,这样最后区块一直更新,但是 accSushiPerShare 不变,当再次开启奖励的时候,就可以完美对接了。


Anbang 注: 需要注意地方

每次做奖励相关或者影响产出的操作时,一定要先做 updatePool,将这些数据更新为最新值后再做其他操作。比如增加新池,设置池的瓜分占比,甚至可能有的暂停产出等。

massUpdatePools

这个方法和 updatePool(_pid) 的超集,循环所有池子,并更新每一个池子。如果池子非常多,这会比较消耗 gas 费。这个方法没啥好说的。

需要注意的是,如果池子有失效,作废之类的状态,可以加个判断,只循环和触发活跃中的池子。

safeSushiTransfer

这是一个防止因为小数取整导致奖励无法发放的兼容处理。比如 SUSHI 产出全部被用户提取了,还剩一个用户没有提走,剩余 123456789 wei,但是用户账号上收益是 123456790,此时如果使用 sushi.transfer(_to, _amount); 进行转账会报余额不足的。这种兼容处理,在合约内部兼容一些精度问题是没问题的。但是如果在写 ERC20 代币的时候,转账和授权转账不要做类似处理。

5 管理方法 🔒

  • 🔒 add(_allocPoint,_lpToken,_withUpdate)
  • 🔒 set(_pid,_allocPoint,_withUpdate)
  • 🔒 dev(_devaddr)

提供了添加 LP 和修改 LP 的功能。

🔒 add

这里的添加逻辑不是很好。没有做防重复处理,只能手工去避免,这里可以引入 mapping(address=>bool) 这种变量,每次添加的时候判断是否为 false,为 false 再添加。添加后改为 true

这里的核心是,保证总分配点的值正确。

🔒 set

通过修改分配点的方式来动态调节每一个池的产出量。核心是,保证总分配点的值正确。公式 totalAllocPoint = totalAllocPoint - oldAllocPoint + newAllocPoint

🔒 dev

这里是基金地址,为了地址隔离,一般基金地址,国库地址这类拥有资产的资产,替换都是由老地址去执行。方法名推荐使用 transferDev 这种命名方式。


Anbang 注: 一些个人认为可以改进的地方

  • add / set / dev 这些新池子的增加和修改,以及状态变量的修改,按照规范应该抛出事件,但是该合约没有抛出。我们写代码的时候需要注意这个问题。

6 合约事件

  • event Deposit(user, pid, amount);
  • event Withdraw(user, pid, amount);
  • event EmergencyWithdraw(user, pid,amount);

正常状态变量的修改,除了mapping这种结构外,我都比较喜欢返回事件,让链下监听和搜索。这里 SUSHI 处理并不是很好。不推荐这种修改了变量,但是不抛事件的代码风格。

7 迁移 migrate

这里主要是将 LP 做迁移。需要注意这行代码 require(bal == newLpToken.balanceOf(address(this)), "migrate: bad");

他是确保代币转账符合预期的重要保证。这个逻辑在我们平时写转账相关操作的时候很常见。比如做 Token 质押,一般采用 transferFrom 方法。我们可以通过判断前后两次的余额来保证收到预期的钱。这种方式可以防止恶意的垃圾山寨 ERC20 代币。

比如有一个垃圾币,他们内部 transferFrom 使用的逻辑如下:

function transferFrom(address _from ,address _to, uint256 _amount) internal {
    // 其他代码 ....
    uint256 balanceOf = sushi.balanceOf(address(_from));
    if (_amount > balanceOf) {
        _amount = balanceOf
    }
    // 其他代码 ....
}

这种恶意的山寨币,如果没有上面说的判断,很容易导致合约没有收到足够的钱,但是记录了用户传入的金额。还有一些代币转账时候扣除 10% 的量销毁,这些币如果不做特殊处理,很容易被人攻击,导致资金损失。





以上就是本篇文章的全部内容了,希望对你有帮助。

>> 本站不提供评论服务,技术交流请在 Twitter 上找我 @anbang_account