前置介绍
继 Uniswap 大火之后,发现一个叫 SushiSwap 的有意思项目,它在 2020 年 8 月发布了一个叫 MasterChef 的合约。开始研究一下这个合约,它的主要资料如下:
- SushiSwap MasterChef 合约地址: 0xc2EdaD668740f1aA35E4D8f227fB8E17dcA888Cd
- SushiSwap Github 代码: MasterChef.sol
合约介绍
先看 contract MasterChef
,从下面 5 个方面了解
- 只读方法
- 构造函数 (部署时候需要的配置)
- 核心方法
- 辅助方法
- 管理方法
- 合约事件
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
: 用户债务。这个rewardDebt
和accSushiPerShare
一定要理解,这是计算收益的核心。并且每次修改收益相关的数据,都需要操作这两个值,以保证收益的准确性,否则容易丢失块,导致收益不符合预期。收益计算公式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.accSushiPerShare
和 lastRewardBlock
强关联。所以无论 deposit 还是 withdraw,在计算开始的时候,都需要触发updatePool
,将accSushiPerShare
更为为最新块的对应值。在更新accSushiPerShare
之后,结合当前user.amount
和user.rewardDebt
计算需要发放的 SUSHI 奖励,并且马上发给用户的地址。
这样只要合约有用户交互就可以源源不断的触发 pool.accSushiPerShare
的更新.
核心逻辑四步:
- 处理池子之前的数据: 把池子的信息更新为当前块
- 处理用户之前的数据: 奖励发掉
- 处理用户当前的数据: 存款时按照先收钱再记账的逻辑
- pool 的代币处理(收钱)
- user 的账单记录
- 抛出事件
✅ Anbang 注: 一些个人认为可以改进的地方
- 增加判断
require(_pid < poolInfo.length, 'Invalid PID');
,尽量告诉客户端当前出错的原因。
withdraw 取款
这个方法和 deposit 内部的逻辑是完全相同的,核心都是保证 user.amount
user.rewardDebt
pool.accSushiPerShare
的数据匹配。
如果只想提取收益,而并不想提取本金。那么传入的 _amount
使用 0 即可。这样就达到了目的。
总结:
首先判断用户在 LP 内的余额是否大于等于要提取的金额,如果余额不足则报错。
核心逻辑四步:
- 处理池子之前的数据: 奖励发掉,并把池子的信息更新为当前块
- 处理用户之前的数据: 奖励发掉
- 处理用户当前的数据: 取款时按照先记账再付钱的逻辑。
- user 的账单记录
- pool 的代币处理(发钱)
- 抛出事件
✅ Anbang 注: 一些个人认为可以改进的地方
- 用户存款时按照先收钱再记账的逻辑
- 用户取款时按照先记账再付钱的逻辑,这里 SUSHI 当前逻辑是先付钱再记账,这里处理的方式不推荐。
emergencyWithdraw 紧急取款
这种是在合约出现问题,或者 SUSHI 发放收益出现问题的时候才使用。这里实现的逻辑是所有金额全部赎回,user.amount
和 user.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% 的量销毁,这些币如果不做特殊处理,很容易被人攻击,导致资金损失。