内容说明:《Web3 小白学习笔记系列》是本人在 ETHChiangMai BootCamp 学习过程中的记录,目的在于 研究和分享技术理解,不构成任何投资建议。
我觉得 MasterChef 是一个很精妙的算法,它把交互过程中参与者进进出出的不同贡献量和参与时间计算大大简化,通过 acc 和 debt 实现一次性结算,在理解了这一点之后,我又收获到一个啊哈时刻。
MasterChef 是 Sushi Swap 中负责处理流动性挖矿奖励的核心合约(代码地址为 https://github.com/sushiswap/masterchef/blob/master/contracts/MasterChef.sol),它主要解决这么一个问题:如何在多人、可随时进出、权重可变的系统中,高效、准确地分配时间相关收益?
背景知识
所谓流动性挖矿,本质上是一种挖矿。用户通过付出流动性获取代币奖励。与 BTC 的算力挖矿(付出硬件计算)和 ETH 的质押挖矿(付出 Ether)一样。流动性,是指可用的资金,常说的现金流、周转等概念也是一种流动性。在中心化交易所(CEX)中,交易所机构自己有大钱包能够应付业务的资金进出,去中心化交易(DEX)所则通过用户们的存款构成的资质池保证,这便是 DEX 运行的基本逻辑。
在 Sushi Swap 之前出现的 Uniswap 通过恒定乘积的方式,解决了定价的问题,但是用户的收益仅仅来自于从所提供的流动性产生的手续费,流动性一旦撤走,也就没有收益了。而 Sushi Swap 除了给用户手续费以外,还会根据所提供的流动性发放代币奖励(额外价值),进一步激励了参与者热度,并靠着这套激励机制成功偷家 Uniswap。
给 DEX 提供流动性之后,系统会根据资金份额计算出 LP Token(简称 LP)数值,是衡量流动性大小的指标,提供了多少流动性等价于拥有多少 LP。LP 也是一个 ERC20 代币,可以转移,当需要提现时,根据 LP 计算赎回的数量和手续费收益。
流动性挖矿奖励计算
在 Sushi Swap 中,每个区块铸造固定数量的 SUSHI 作为奖励代币,按 Pool 的权重分配,用户按其所提供的流动性占池子比例领取。实际业务中,不同用户所添加的 LP 数额不一,进入的时间不同(只能从进入后产生的区块奖励中瓜分),如何给参与质押(提供流动性)的用户分配奖励成为关键的问题。
可以理解为有一个打开的水龙头一直在放水,很多人持续用不同大小的杯子接水,能接到多少和杯子大小以及接了多久有关。假设每一个区块总共会产生 1 个币,每个用户每次质押的数量为 1,有以下图示(纵向表示同一区块高度):
流动性挖矿奖励计算用户 A 在开始的 0 号区块最早加入。在区块 1 时刻计算上一轮奖励:之前区块 0 的奖励全部归 A 所有,A 方框中表示奖励总额为 1,此时 B 加入,B 方框奖励总额为 0。池子 LP 总数为 2,A 和 B 各占一半。
在区块 2 时刻计算上一轮奖励,之前区块 1 的奖励由 A 和 B 平分各得 0.5,此时 A 获得的奖励总额为 1.5,B 为 0.5,C 加入。后续的计算也同理,当有三名用户参与,每次一个人只能分到 0.3,奖励不断累加。在区块 4 的时候 A 退出,则拿走 1.83 个 SUSHI。
可以看出,这种积分的计算方式,需要在每一个区块计算每一个用户的奖励,计算量与用户数成正比。用户多起来就会十分占用量计算资源。要知道链上的计算与存储是昂贵的,需要进一步优化。
单位 LP 累计奖励
MasterChef 的解法是,每次出块后,计算一个单位 LP 累计奖励 accSushiPerShare(简称 acc),这个单位累计奖励的含义是,假定从有一个用户带着 1 LP 从头加入,那么到目前为止他能拿到多少奖励。实际上总 LP 不止 1,需要把每次奖励除以当前总 LP 再累加,就能算出单位 LP 截至目前的累计奖励。一次只需要计算一个数值,大大减少计算工作量和难度。
具体计算例子见下图:
单位 LP 累计奖励计算有 A、B 两个初始参与者,每人 1 LP,总 LP 为 2,要计算每个区块时刻对应的 acc,分三步走:
- 除以总 LP 2 得出此次单位 LP 奖励为 0.5
- 把之前的 acc 加上本次奖励 0.5 得到本轮 acc
在图中 acc 是以 0.5 递增的,因为总 LP 不变。如果过程中有不同的用户加入,总 LP 发生变化,每次 acc 递增的值将不同,例如用最开始的图片作为例子,计算 acc 如下:
记前一个区块产生总奖励为 preAwardPerBlock
- 时刻 0:A 已经加入,前一轮没有奖励产生,没有 LP,acc = 0
- 时刻 1:
preAwardPerBlock = 1,LP = 1,acc = 1/1 =1,B 加入 - 时刻 2:
preAwardPerBlock = 1,LP = 2,acc = acc + 1/2 = 1+0.5 = 1.5,C 加入 - 时刻 3:
preAwardPerBlock = 1,LP = 3,acc = acc + 1/3 = 1.5+0.33 = 1.83
以上计算过程对应的 MasterChef 合约代码 updatePool() 函数第 227 行:
pool.accSushiPerShare = pool.accSushiPerShare.add( sushiReward.mul(1e12).div(lpSupply));
每次添加以及移除流动性时,都要执行以上操作计算更新 acc。那么有了 acc 要如何计算每个用户所获得的奖励总和呢?
用户累计奖励计算
既然 acc 是从头开始累计的单位 LP 奖励总额,那么对于一开始就参与的用户,只需要在结算发放奖励的时候,把用户提供的 LP 份额乘上 acc,就是实际能拿到手的代币奖励了。用上面例子来说的话,最初加入的 A 投入了 1 LP,并且一直保留到区块 3,那么 A 最终的奖励就是 acc = 1.83,如果 A 投入了 2 LP,那么奖励就是 acc = 2*1.83 = 3.66。
那么问题来了,对于 B 和 C 这样后来加入的用户,该如何计算奖励?由于 acc 是包括从起始区块开始的奖励,假设直接也是用 B、C 的 LP 份额乘上 acc,就会给他们多发了前面缺席的区块奖励,也就是说所有人奖励都相等了,这显然是不对的。既然这样,办法也简单,就是计算完全程的奖励后,再减去他们之前还没加入时的那段时间累积的奖励就可以了。恰好这段多出来时间里的奖励就是他们加入时候的 LP*acc(假设全程参与多出来的部分)。这个多出来的部分在 MasterChef 中称为「负债」,使用 rewardDebt(简称 debt) 存储。debt 等于加入时的 acc 乘上各自 LP 份额,增加 debt 之后的图示如下:
用户累计奖励计算rewardDebt 的计算过程对应的 MasterChef 合约代码在 deposit() 函数第 251 行:
user.rewardDebt = user.amount.mul(pool.accSushiPerShare).div(1e12);
累计奖励的计算过程对应的 MasterChef 合约代码在 pendingSushi() 函数第 198 行:
return user.amount.mul(accSushiPerShare).div(1e12).sub(user.rewardDebt);
另外有个细节要注意的是,由于以上算法只能计算一段连续的质押奖励,如果同一个用户在不连续的两个以上时间段加入池子,那么最后一次结算时的 debt 就会把前面的 reward 也减掉,造成奖励丢失,所以在 MasterChef 中,用户每一次存款加入 LP 时,都要结算当前已经获得的未发放奖励,可以看作为了简化算法做出的妥协,我觉得是一个不完美的地方(毕竟我只是再次存钱却莫名其妙给我打上一次的奖励)。
那么看到到这里,你已经差不多理解了 MasterChef 的设计思想了。让我们再欣赏一下完整源代码吧。
完整源码
更新 accSushiPerShare 的 updatePool() 函数:
// Update reward variables of the given pool to be up-to-date.functionupdatePool(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 = getMultiplier(pool.lastRewardBlock, block.number); uint256 sushiReward = multiplier.mul(sushiPerBlock).mul(pool.allocPoint).div( totalAllocPoint ); sushi.mint(devaddr, sushiReward.div(10)); sushi.mint(address(this), sushiReward); pool.accSushiPerShare = pool.accSushiPerShare.add( sushiReward.mul(1e12).div(lpSupply) ); pool.lastRewardBlock = block.number;}
存款与取款函数(添加/移除流动性 LP):
// Deposit LP tokens to MasterChef for SUSHI allocation.functiondeposit(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.mul(pool.accSushiPerShare).div(1e12).sub( user.rewardDebt ); safeSushiTransfer(msg.sender, pending); } pool.lpToken.safeTransferFrom( address(msg.sender), address(this), _amount ); user.amount = user.amount.add(_amount); user.rewardDebt = user.amount.mul(pool.accSushiPerShare).div(1e12); emit Deposit(msg.sender, _pid, _amount); }// Withdraw LP tokens from MasterChef.functionwithdraw(uint256 _pid, uint256 _amount) public{ PoolInfo storage pool = poolInfo[_pid]; UserInfo storage user = userInfo[_pid][msg.sender];require(user.amount >= _amount, "withdraw: not good"); updatePool(_pid); uint256 pending = user.amount.mul(pool.accSushiPerShare).div(1e12).sub( user.rewardDebt ); safeSushiTransfer(msg.sender, pending); user.amount = user.amount.sub(_amount); user.rewardDebt = user.amount.mul(pool.accSushiPerShare).div(1e12); pool.lpToken.safeTransfer(address(msg.sender), _amount); emit Withdraw(msg.sender, _pid, _amount); }
获取未发放奖励 pendingSushi() 函数:
// View function to see pending SUSHIs on frontend.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));if (block.number > pool.lastRewardBlock && lpSupply != 0) { uint256 multiplier = getMultiplier(pool.lastRewardBlock, block.number); uint256 sushiReward = multiplier.mul(sushiPerBlock).mul(pool.allocPoint).div( totalAllocPoint ); accSushiPerShare = accSushiPerShare.add( sushiReward.mul(1e12).div(lpSupply) ); }return user.amount.mul(accSushiPerShare).div(1e12).sub(user.rewardDebt);}
总结
MasterChef 奖励机制把「参与者不断进出 + 不同时间贡献」的积分问题,压缩成了一个 O(1) 结算模型 acc + debt,把「时间」折叠进一个全局变量 accRewardPerShare,时间不再显示存在,而是只体现在 acc 的增加轨迹里,每个用户只记一个「快照」rewardDebt,用类比真实债务的方式记录一个锚点,真正的结算只发生在用户触发时。虽然解决的是一个老问题,但却称得上工程级的优雅。
如果你也对区块链 Web3 知识感兴趣,欢迎与我交流~