π 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.
My journey from Web Dev to Web 3.0 Dev - Part 1
Mark Kop γ» Mar 30 '22
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);
}
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.
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
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
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
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
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
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
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)
And using the following formula that represents the staker share as their tokens divided by the total tokens in the pool
StakerAShareOnNtoM = StakerATokensOnNtoM / TotalTokensOnNtoM
We have this
StakerARewards =
(BlockRewardsOn0to10 * StakerATokensOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokensOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokensOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokensOn25to30 / TotalTokensOn25to30)
But, in this case, the staker had the same amount of tokens deposited at all ranges
StakerATokensOn0to10 =
StakerATokensOn10to15 =
StakerATokensOn15to25 =
StakerATokensOn25to30 =
StakerATokens
Then we can simplify our StakerARewards
formula
StakerARewards =
(BlockRewardsOn0to10 * StakerATokens / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokens / TotalTokensOn25to30)
And by putting StakerATokens
on evidence we have this
StakerARewards = StakerATokens * (
(BlockRewardsOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)
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)
)
StakerARewards = 14
Which matches with we were expecting
Let's do the same for staker B
StakerBRewards =
(BlockRewardsOn10to15 * StakerBTokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerBTokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerBTokens / TotalTokensOn25to30)
StakerBRewards = StakerBTokens * (
(BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)
StakerBRewards = 400 * (
(5 / 500) +
(10 / 500) +
(5 / 500)
)
StakerBRewards = 16
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)
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)
And instead of accSushiPerShare
we will call their sum AccumulatedRewardsPerShare
AccumulatedRewardsPerShare =
RewardsPerShareOn0to10 +
RewardsPerShareOn10to15 +
RewardsPerShareOn15to25 +
RewardsPerShareOn25to30
Then we can say that StakerARewards
is the multiplcation of StakerATokens
by AccumulatedRewardsPerShare
StakerARewards = StakerATokens *
AccumulatedRewardsPerShare
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)
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
If we isolate StakerARewardsOn15to30
in the first formula and replace its StakerATokens
with the second one
StakerARewardsOn15to30 = StakerARewards - StakerARewardsOn0to15
StakerARewards = StakerATokens * AccumulatedRewardsPerShare
we get
StakerARewardsOn15to30 = StakerATokens *
AccumulatedRewardsPerShare - StakerARewardsOn0to15
Now we can use the following formula for blocks 0to15
StakerARewardsOn0to15 = StakerATokens *
AccumulatedRewardsPerShareOn0to15
And replace StakerARewardsOn0to15
in the previous one
StakerARewardsOn15to30 =
StakerATokens * AccumulatedRewardsPerShare -
StakerATokens * AccumulatedRewardsPerShareOn0to15
Now you might have noticed that we can isolate StakerATokens
again
StakerARewardsOn15to30 = StakerATokens *
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)
And that's very similar to the formula we got for StakerBRewards
previously
StakerBRewards = StakerBTokens *
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)
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
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);
});
With hardhat-gas-reporter we can see how much expensive each implementation is.
For the first one (loop over all stakers):
For the last one (use AccumulatedRewardsPerShare):
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.
Top comments (9)
I'm so grateful for this article. It helped me understand the MasterChef contract. Thank you!
this is very helpful, thank you so much!
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
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?
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.
great article,
there's little typo after "We can also replace some values to check if it actually works", second
AccumulatedRewardsPerShare
shouldAccumulatedRewardsPerShareOn0to15
Wow, It is perfect. thank you for your help.
102 line: "amount" instead of "staker.amount", right? Otherwise it's already 0!
Wonderful. I want to know MasterChefV2 contract is fixed or flexible staking, main difference between fixed and flexible staking.
Thank you