Defi application has gone way too deep into the rabbit hole that smart contract developers sometimes have no way to catch up with the math behind the function but simply copy it and take it for granted.
A solidity program often starts with a readable but very (gas) inefficient implementation. With a mixture of good math and programming techniques in algorithm and data structure, the program will become more efficient, but at the same time more complicated to interpret. A simple concept like staking token is a really good example for developers to truly understand the principle of efficient solidity code.
This article is structured by first walking through an example of a token staking event, converting it into math, and finally translating the math components into solidity code blocks.
Imagine there is a “GG” token launched on a defi project. By staking your USDC token, you will receive “GG” token over time from the staking contract.
Let’s say the block time (t) is updated every second. You are Satoshi-san, and start to stake 200 USDC at t = 4. You start to earn some GG tokens, then withdraw the USDC and reward at t = 6. How many GG tokens would you receive? It depends on the following factors.
Let's say GG token founder decided to emit 200 tokens per block. And there is 600 USDC token staked by other users at t = 4. Then you will expect to receive 200 / (200+600) ~=50 GG tokens per block.
This is just a simple distribution based on the portion of the user’s stake. Let’s take a step further and create a table expressing a complete staking event.
Several operations happen in this timeline.
At t = 2, the GG founder has started the emission of GG tokens at 200 tokens per block.
At t = 2, other users started to deposit USDC tokens into the contract, receiving the full 200 token emission
At t = 4, you (Satoshi-san) started to deposit 200 USDC tokens into the contract, proportionally receiving 25% of the emission per block.
The next question would be: Can we use a formula to describe the user reward up to time t?
The answer would be yes and it is also super simple to define. We establish that there is a function r(u, k, n) = rewards earned by user u from k to n seconds:
where
Si = amount staked by user u at time = i
Ti = total balance of token staked at time = i (Assume Ti > 0)
R = reward rate per second (total rewards / duration)
This is easy to understand, calculate, and implemented in any programming language. Unfortunately, this is not the case in solidity, due to two reasons
Therefore we need to improve and refactor the formula.
Let's improve the formula by assuming that
S is always constant from time k to n-1.
It means S(i) is equal to S(i+1). This is often true in real-world staking event since most users won't change their staking amount often. And even if they do we can simply treat them as a new staking event, still resulting in fewer times of calculations involved.
If S is a constant, we can decouple it from the summation function.
Also, the sum from time k to n-1 is equal to the sum from time 0 to n-1, minus the sum from time 0 to k. So the function can be further re-write as followed.
This simple refactor represents the final form of the staking function. Let's define and name each component into smart contract variables.
Let’s use the GG example above. Assume that you are Satoshi-san at t = 5
R is the rewardRate variable: 200
Ti is the totalSupply variable**:** 800 (200 from Satoshi, and 600 from other users)
n is the current block time: 5
k is the staking start block time: 4
The left summation is assigned to a variable called “rewardPerToken“, which represents the cumulative reward per token staked up to time n. It is important to understand that this item should increase over time. Even when more users stake USDC into the contract over time, it would only make the next increment of the summation smaller. This summation number will still go up.
The right summation is assigned to a variable called “userRewardPerTokenPaid”, which represents the cumulative reward per token staked up to time k. Notice that this value technically is not unique for each user, but unique for each block time. Although users will deposit at different times (k is different), the summation up to time k is the same for every user. The value is stored in a map (dictionary) with the user address as the key.
It is also important to reiterate that this function does not represent the total reward earned by the user, but only the user reward from time k to n-1. This is great as we can cache (save) the previous snapshot of user reward, and use the exact same function to update the cumulative reward in the next snapshot.
Let’s look at the minimal example of a contract that rewards users for staking their token on solidity-by-example.org.
There are 3 main functions that affect the reward calculation for the users.
The trick here is that no matter which function is called, a modifier function updateReward() is called before running those functions to update the rewardPerToken as well as userRewardPerTokenPaid (the two summations we see on the formula), as well as to calculate and cache the latest earning for each user.
modifier updateReward(address _account) {
rewardPerTokenStored = rewardPerToken();
updatedAt = lastTimeRewardApplicable();
if (_account != address(0)) {
rewards[_account] = earned(_account);
userRewardPerTokenPaid[_account] = rewardPerTokenStored;
}
_;
}
For example, when the user first deposits their fund to the contract, the rewardPerTokenStored will be immediately calculated and saved as the first userRewardPerTokenPaid for that user. When the user withdraws his fund 10 seconds later, earned() function will calculate the difference of RewardPerTokenPaid between these 10 seconds, multiplied by the user staking balance. The result will be the total reward earned.
In summary, updateReward() modifier handles all the calculations, notifyRewardAmount(), stake(), and withdraw() function change the value of the components used in the formula and handle the transfer of funds between the contract and the user.
Let’s revisit the table from the example we have and verify the solidity/math achieves what we expected
At time 2, notifyRewardAmount() is called as the emission started, rewardRate is updated to 200.
At time 4, Satoshi deposited 200 USDC to mine the GG token, rewardPerTokenStored is refreshed and saved to Satoshi’s userRewardPerTokenPaid.
rewardPerTokenStored at time 4 is equal to (200 * 2) / 600 = 0.666
where Ti = 600, n = 2, R = 200
At time 6, Satoshi decided to withdraw his money. rewardPerTokenStored is again updated to time 6, which is equal to 0.6666 + (200 * 3) / 800 = 1.416
where Ti = 800, n = 6, k = 4, R = 200
Satoshi total earning = (1.416 — 0.666) * 200 = 150
where left summation = rewardPerTokenStored = 1.416, and right summation = userRewardPerTokenPaid = 0.666, S = 200
The result matches our manual derivation earlier and proves the formula is working as intended.
And this wraps up the normal practice in staking contract. To check out the full contract, I would recommend reading the synthetic stakingrewards.sol example at https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol.
Reference:
Staking rewards example contract: https://solidity-by-example.org/defi/staking-rewards/
Smart Contract Programmer: https://www.youtube.com/channel/UCJWh7F3AFyQ_x01VKzr9eyA