The incredible math in Ethereum staking contract (and how to implement it in solidity)

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.

Example of a normal staking event

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.

  1. How many GG token is being distributed per block/second? This is known as rewardRate
  2. How many USDC token is currently staked by you and other users? This is known as balanceOf other users

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 Math (Part 1)

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

  • For loop in summation is a very gas-expensive operation in solidity.
  • Si is included in the summation. It makes each item in the summation unique to a specific user at a specific time. If we have 100 users and 500 seconds in the staking event, it will result in 500,000 times of calculation.

Therefore we need to improve and refactor the formula.

The Math (Part 2)

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.

Mapping the math into solidity code

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.

  • notifyRewardAmount(): When the owner of contract set the reward per second
  • stake(): When user deposits token for staking
  • withdraw(): When user withdraws token to end their staking

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;  
        }          
    _;     
}
  • _account represents the current user interacting with the contract.
  • The rewardPerTokenStored is then updated to the latest block/second.
  • earned() function will fetch the last userRewardPerTokenPaid of the user, and calculate earned reward from the last userRewardPerTokenPaid timestamp (k) to the latest block (n).

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.

  • Since the earned() function is calculated up to time n, the userRewardPerTokenPaid will be updated to time n as well. This saves the progress of calculating user rewards to time n, and the contract doesn't need to calculate it again in the future.

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.

Matching the solidity code into our example

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.

Ending word

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

Subscribe to NakeyJakey
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.