DEV Community

Cover image for Understanding SushiSwap's MasterChef staking rewards
Mark Kop
Mark Kop

Posted on

Understanding SushiSwap's MasterChef staking rewards

πŸ‘‹ Introduction

As part of my goal to migrate from Web2 to Web3 development, I'm building a DeFi application from scratch to learn and practice solidity.

I've started with the staking implementation and used as reference a smart contract from a DeFi that I've been using lately.

Turns out that most staking contracts are a copy from SushiSwap's MasterChef contract.

While reading the contract, I could understand how the staking rewards were actually calculated.

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);
}
Enter fullscreen mode Exit fullscreen mode

I mean, it's easy to see that tokens are minted for each block and distributed between all stakers according to their participation in the pool.

However it's not clear what role the variables accSushiPerShare and rewardDebt play in this calculation.

In this blog post I want to share how I managed to understand the logic behind this MasterChef contract and explain why it's written this way.

Let's start by first figuring out ourselves what would be a fair reward for stakers.

🧠 Simple Rewards Simulation

Let's assume that

RewardsPerBlock = $1
On block 0, Staker A deposits $100
On block 10, Staker B deposits $400
On block 15, Staker A harvests all rewards
On block 25, Staker B harvests all rewards
On block 30, both stakers harvests all rewards.
Enter fullscreen mode Exit fullscreen mode

Staker A deposits $100 on block 0 and ten blocks later Staker B deposits $400.
For the first ten blocks, Staker A had 100% of their rewards, which is $10.

From block 0 to 10:
BlocksPassed: 10
BlockRewards = BlocksPassed * RewardsPerBlock 
BlockRewards = $10
StakerATokens: $100
TotalTokens: $100

StakerAShare = StakerATokens / TotalTokens
StakerAShare = 1
StakerAAccumulatedRewards = BlockRewards * StakerAShare
StakerAAccumulatedRewards = $10
Enter fullscreen mode Exit fullscreen mode

On block 10, Staker B deposits $400.
Now on block 15 Staker A is harvesting its rewards.
While they got 100% rewards from blocks 0 to 10, from 10 to 15 they are only getting 20% (1/5)

From Block 10 to 15:
BlocksPassed: 5
BlockRewards = BlocksPassed * RewardsPerBlock 
BlockRewards = $5
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500

StakerAShare = StakerATokens / TotalTokens
StakerAShare = 1/5
StakerAAccumulatedRewards = (BlockRewards * StakerAShare) + StakerAAccumulatedRewards
StakerAAccumulatedRewards = $1 + $10

StakerBShare = StakerBTokens / TotalTokens
StakerBShare = 4/5
StakerBAccumulatedRewards = BlockRewards * StakerBShare
StakerBAccumulatedRewards = $4
Enter fullscreen mode Exit fullscreen mode

Staker A harvests $11 and StakerAAccumulatedRewards resets to 0.
Staker B has accumulated $4 for these last 5 blocks.
Then 10 more blocks pass and B decides to harvest as well.

From Block 15 to 25:
BlocksPassed: 10
BlockRewards: $10
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500
StakerAAccumulatedRewards: $2
StakerBAccumulatedRewards: $8 + $4
Enter fullscreen mode Exit fullscreen mode

Staker B harvests $12 and StakerBAccumulatedRewards resets to 0.
Finally, both staker harvest their rewards on block 30.

From Block 25 to 30:
BlocksPassed: 5
BlockRewards: $5
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500
StakerAAccumulatedRewards: $1 + $2
StakerBAccumulatedRewards: $4
Enter fullscreen mode Exit fullscreen mode

Staker A harvests $3 and B harvests $4.
Staker has harvested in total $14 and B $16

πŸ“ The implementation

This way, for each action (Deposit or Harvest) we had to go through all stakers and calculate their accumulated rewards.

Here's a simple staking contract with this implementation:

The updateStakersRewards is responsible to loop over all staker and update their accumulated rewards every time someone deposits, withdraws or harvests their earnings.

But what if we could avoid this loop?

πŸ“ Applying some math manipulation

If we see Staker A rewards as a sum of their rewards on each group of blocks

StakerARewards = 
StakerA0to10Rewards + 
StakerA10to15Rewards + 
StakerA15to25Rewards + 
StakerA25to30Rewards
Enter fullscreen mode Exit fullscreen mode

And if we see their rewards from the block N to M as the multiplication between the rewards that were distributed in the same range by their share in the same range

StakerANtoMRewards = BlockRewardsOnNtoM * StakerAShareOnNtoM
Enter fullscreen mode Exit fullscreen mode

Then we get the staker rewards as the sum of the multiplication between the rewards and their share for each range up to the end

StakerARewards = 
(BlockRewardsOn0to10 * StakerAShareOn0to10) + 
(BlockRewardsOn10to15 * StakerAShareOn10to15) + 
(BlockRewardsOn15to25 * StakerAShareOn15to25) + 
(BlockRewardsOn25to30 * StakerAShareOn25to30)
Enter fullscreen mode Exit fullscreen mode

And using the following formula that represents the staker share as their tokens divided by the total tokens in the pool

StakerAShareOnNtoM = StakerATokensOnNtoM / TotalTokensOnNtoM
Enter fullscreen mode Exit fullscreen mode

We have this

StakerARewards = 
(BlockRewardsOn0to10 * StakerATokensOn0to10 / TotalTokensOn0to10) + 
(BlockRewardsOn10to15 * StakerATokensOn10to15 / TotalTokensOn10to15) + 
(BlockRewardsOn15to25 * StakerATokensOn15to25 / TotalTokensOn15to25) + 
(BlockRewardsOn25to30 * StakerATokensOn25to30 / TotalTokensOn25to30)
Enter fullscreen mode Exit fullscreen mode

But, in this case, the staker had the same amount of tokens deposited at all ranges

StakerATokensOn0to10 = 
StakerATokensOn10to15 = 
StakerATokensOn15to25 = 
StakerATokensOn25to30 = 
StakerATokens
Enter fullscreen mode Exit fullscreen mode

Then we can simplify our StakerARewards formula

StakerARewards = 
(BlockRewardsOn0to10 * StakerATokens / TotalTokensOn0to10) + 
(BlockRewardsOn10to15 * StakerATokens / TotalTokensOn10to15) + 
(BlockRewardsOn15to25 * StakerATokens / TotalTokensOn15to25) + 
(BlockRewardsOn25to30 * StakerATokens / TotalTokensOn25to30)
Enter fullscreen mode Exit fullscreen mode

And by putting StakerATokens on evidence we have this

StakerARewards = StakerATokens * (
  (BlockRewardsOn0to10 / TotalTokensOn0to10) + 
  (BlockRewardsOn10to15 / TotalTokensOn10to15) + 
  (BlockRewardsOn15to25 / TotalTokensOn15to25) + 
  (BlockRewardsOn25to30 / TotalTokensOn25to30)
)
Enter fullscreen mode Exit fullscreen mode

We can make sure that it works with our scenario by replacing these big words with numbers and getting the total rewards for Staker A

StakerARewards = 100 * (
  (10 / 100) + 
  (5  / 500) + 
  (10 / 500) + 
  (5  / 500)
)
Enter fullscreen mode Exit fullscreen mode
StakerARewards = 14
Enter fullscreen mode Exit fullscreen mode

Which matches with we were expecting

Let's do the same for staker B

StakerBRewards = 
(BlockRewardsOn10to15 * StakerBTokens / TotalTokensOn10to15) + 
(BlockRewardsOn15to25 * StakerBTokens / TotalTokensOn15to25) + 
(BlockRewardsOn25to30 * StakerBTokens / TotalTokensOn25to30)
Enter fullscreen mode Exit fullscreen mode
StakerBRewards = StakerBTokens * (
  (BlockRewardsOn10to15 / TotalTokensOn10to15) + 
  (BlockRewardsOn15to25 / TotalTokensOn15to25) + 
  (BlockRewardsOn25to30 / TotalTokensOn25to30)
)
Enter fullscreen mode Exit fullscreen mode
StakerBRewards = 400 * (
  (5  / 500) + 
  (10 / 500) + 
  (5  / 500)
)
Enter fullscreen mode Exit fullscreen mode
StakerBRewards = 16
Enter fullscreen mode Exit fullscreen mode

Now that both stakers rewards are matching with what we've seen before, let's check what we can reuse in both rewards calculation.

As you can see, both stakers rewards formulas have a common sum of divisions

(5 / 500) + (10 / 500) + (5 / 500)
Enter fullscreen mode Exit fullscreen mode

The SushiSwap's contract call this sum accSushiPerShare, so let's call each division as RewardsPerShare

RewardsPerShareOn0to10  = (10 / 100)
RewardsPerShareOn10to15 = (5  / 500)
RewardsPerShareOn15to25 = (10 / 500)
RewardsPerShareOn25to30 = (5  / 500)
Enter fullscreen mode Exit fullscreen mode

And instead of accSushiPerShare we will call their sum AccumulatedRewardsPerShare

AccumulatedRewardsPerShare = 
RewardsPerShareOn0to10 + 
RewardsPerShareOn10to15 + 
RewardsPerShareOn15to25 + 
RewardsPerShareOn25to30
Enter fullscreen mode Exit fullscreen mode

Then we can say that StakerARewards is the multiplcation of StakerATokens by AccumulatedRewardsPerShare

StakerARewards = StakerATokens * 
AccumulatedRewardsPerShare
Enter fullscreen mode Exit fullscreen mode

Since AccumulatedRewardsPerShare is the same for all stakers, we can say that StakerBRewards is that value minus the rewards they didn't get from blocks 0to10

StakerBRewards = StakerBTokens * 
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)
Enter fullscreen mode Exit fullscreen mode

This is important, because even though we can use AccumulatedRewardsPerShare for every staker rewards calculation, we have to subtract the RewardsPerShare that happened before their Deposit/Harvest action.

Let's find out how much the Staker A has harvested on their first harvest using what we discovered out so far.

πŸ’Έ Finding out rewardDebt

We know that the rewards that Staker A got is the sum of their first and last harvest, that is from blocks 0to15 and 15to30.
Also, we know that we can get the same value with the StakerARewards formula we just used above

StakerARewards = StakerARewardsOn0to15 + StakerARewardsOn15to30
StakerARewards = StakerATokens * AccumulatedRewardsPerShare
Enter fullscreen mode Exit fullscreen mode

If we isolate StakerARewardsOn15to30 in the first formula and replace its StakerATokens with the second one

StakerARewardsOn15to30 = StakerARewards - StakerARewardsOn0to15
StakerARewards = StakerATokens * AccumulatedRewardsPerShare
Enter fullscreen mode Exit fullscreen mode

we get

StakerARewardsOn15to30 = StakerATokens * 
AccumulatedRewardsPerShare - StakerARewardsOn0to15
Enter fullscreen mode Exit fullscreen mode

Now we can use the following formula for blocks 0to15

StakerARewardsOn0to15 = StakerATokens * 
AccumulatedRewardsPerShareOn0to15
Enter fullscreen mode Exit fullscreen mode

And replace StakerARewardsOn0to15 in the previous one

StakerARewardsOn15to30 = 
StakerATokens * AccumulatedRewardsPerShare -
StakerATokens * AccumulatedRewardsPerShareOn0to15
Enter fullscreen mode Exit fullscreen mode

Now you might have noticed that we can isolate StakerATokens again

StakerARewardsOn15to30 = StakerATokens * 
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)
Enter fullscreen mode Exit fullscreen mode

And that's very similar to the formula we got for StakerBRewards previously

StakerBRewards = StakerBTokens * 
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)
Enter fullscreen mode Exit fullscreen mode

We can also replace some values to check if it actually works

StakerATokens = 100
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500) + (10 / 500) + (5 / 500)
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500)

StakerARewardsOn15to30 = StakerATokens * 
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)
StakerARewardsOn15to30 = 100 * ((10 / 500) + (5 / 500))
StakerARewardsOn15to30 = 3
Enter fullscreen mode Exit fullscreen mode

So yeah, it works.

This means that if we save the AccumulatedRewardsPerShare value multiplied by the staker tokens amount each time their deposits or withdraws we can use this value to simply subtract it from their total rewards.

This is called rewardDebt on the MasterChef's contract.

It's like calculating a staker total rewards since block 0, but removing the rewards they already harvested or the rewards their were not eligibly to claim because they weren't staking yet.

πŸ“ The AccumulatedRewardsPerShare implementation

Using the previous contract as base, we can simply calculate accumulatedRewardsPerShare on updatePoolRewards function (renamed from updateStakersRewards) and get the staker rewardsDebt each time they perform an action.

You can see the diff code on this commit.

β›½ Gas Saving

The reason we are avoiding a loop is mainly to save gas. As you can imagine, the more stakers we have, the more expensive the updateStakersRewards function gets.

We can compare both gas spending with a hardhat test:

it.only("Harvest rewards according with the staker pool's share", async function () {
  // Arrange Pool
  const stakeToken = rewardToken;
  await stakeToken.transfer(
    account2.address,
    ethers.utils.parseEther("200000") // 200.000
  );
  await createStakingPool(stakingManager, stakeToken.address);
  const amount1 = ethers.utils.parseEther("80");
  const amount2 = ethers.utils.parseEther("20");

  // Arrange Account1 staking
  await stakeToken.approve(stakingManager.address, amount1);
  await stakingManager.deposit(0, amount1);

  // Arrange Account 2 staking
  await stakeToken.connect(account2).approve(stakingManager.address, amount2);
  await stakingManager.connect(account2).deposit(0, amount2);

  // Act
  const acc1HarvestTransaction = await stakingManager.harvestRewards(0);
  const acc2HarvestTransaction = await stakingManager
    .connect(account2)
    .harvestRewards(0);

  // Assert

  // 2 blocks with 100% participation = 4 reward tokens * 2 blocks = 8
  // 1 block with 80% participation = 3.2 reward tokens * 1 block = 3.2
  // Account1 Total = 8 + 3.2 = 11.2 reward tokens
  const expectedAccount1Rewards = ethers.utils.parseEther("11.2");
  await expect(acc1HarvestTransaction)
    .to.emit(stakingManager, "HarvestRewards")
    .withArgs(account1.address, 0, expectedAccount1Rewards);

  // 2 block with 20% participation = 0.8 reward tokens * 2 block
  // Account 1 Total = 1.6 reward tokens
  const expectedAccount2Rewards = ethers.utils.parseEther("1.6");
  await expect(acc2HarvestTransaction)
    .to.emit(stakingManager, "HarvestRewards")
    .withArgs(account2.address, 0, expectedAccount2Rewards);
});
Enter fullscreen mode Exit fullscreen mode

With hardhat-gas-reporter we can see how much expensive each implementation is.

For the first one (loop over all stakers):
Simple Staking Rewards implementation gas spent

For the last one (use AccumulatedRewardsPerShare):
Efficient Staking Rewards implementation gas spent

That's a whole 20% gas saving, even with only two stakers.

That's why SushiSwap's MasterChef contract is similar to the last one I showed.
In fact, is even more efficient because it doesn't have a harvestRewards function. The harvesting happens when the deposit function is called with amount 0.

❓ What about the 1e12 mul and div?

Since accSushiPerShare can be a number with decimals and Solidity doesn't handle float numbers, they multiply sushiReward by a big number like 1e12 when calculating it and then divide it by the same number when using it.

🏁 Conclusion

I couldn't move on with my project without understanding how most DeFis were calculting their rewards and spent my latest days figuring out how the SushiSwap's contract worked.

I could only understand the meaning of some MasterChef variables (specially accSushiPerShare and rewardDept) after implementing and manipulating the math in the rewards system myself.

While I've found some material explaining the contract, all of them were too superficial. So I decided to explain it myself.

I hope this can be helpful for anyone who is also studying DeFi in more depth.

Oldest comments (7)

Collapse
 
amoweolubusayo profile image
amoweolubusayo

I'm so grateful for this article. It helped me understand the MasterChef contract. Thank you!

Collapse
 
danielcawley profile image
DanielCawley

this is very helpful, thank you so much!

Collapse
 
ismajlramadani profile image
Ismajl Ramadani

This is really helpful blog, and great explanation of the math behind reward distribution. A question though, how can you implement a method that gets the accumulated rewards without modifying the state.
i.e. If you want to implement something similar to pendingSushi that displays the accumulated rewards for a user to the front-end. I see that MasterChef implementation includes "bonusEndBlock", but I'm not sure what meaning does it have and how to set it properly. Any idea on that?

Collapse
 
elpinguinofrio profile image
elpinguinofrio

gm, Ismail, were you able to implement it? it's also interesting to have proof of exact errors, because in gas optimized implementation we naturally want to use smallest size numbers, i.e. 64 bits, so it might be very useful.

Collapse
 
johnstonez profile image
JohnStonez

Wow, It is perfect. thank you for your help.

Collapse
 
lebed2045 profile image
Alex Lebed 🐧

great article,
there's little typo after "We can also replace some values to check if it actually works", second AccumulatedRewardsPerShare should AccumulatedRewardsPerShareOn0to15

Collapse
 
willab07 profile image
William Chandra • Edited

Hi, its awesome, but may I know what rewardsPerBlock that you've been set in your unit test is?

Because I want to match with my unit test to make sure the logic is same what you've explained. @heymarkkop