Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome to the fourth lesson in our "Fungible Tokens & DeFi Primitives" module.
Introduction
In our last lesson, we built the core logic of an Automated Market Maker (AMM), a fundamental DeFi protocol that allows for permissionless token trading. We saw how users deposit tokens into a contract (the liquidity pool) to facilitate these trades.
Today, we'll explore another cornerstone of DeFi: staking. Staking allows token holders to "lock up" their assets in a smart contract to earn rewards. This is a common mechanism for projects to incentivize long-term holding, secure a network, or distribute protocol revenue.
Our goal is to build a contract that handles this process. Specifically, we will focus on distributing rewards based not just on how much a user stakes, but also for how long.
Learning Outcome: By the end of this lesson, you will be able to build a staking contract for an ERC-20 token with a time-based reward distribution mechanism.
We will explore two common models for implementing this, starting with a simple, intuitive approach and then moving to a more scalable pattern used in many major DeFi protocols.
1. Staking Fundamentals: The Core Functions
Regardless of the reward calculation model, a staking contract needs a few key functions:
stake(uint amount): Allows a user to deposit ERC-20 tokens into the contract.unstake(uint amount): Allows a user to withdraw their deposited tokens.claimRewards(): Allows a user to withdraw the rewards they have earned.
The central challenge is how to calculate the rewards owed to each user at any given time, efficiently and fairly.
2. Model 1: The Direct Calculation Approach
The most straightforward way to implement time-based rewards is to calculate them on the fly whenever a user interacts with the contract. The logic is simple:
rewards = amountStaked * duration * rewardRate
Let's explore a detailed implementation of this model.
Solidity Tutorial: Creating an ERC20 Staking Contract
The article 'Solidity Tutorial: Creating an ERC20 Staking Contract' provides an excellent, test-driven walkthrough of building a staking contract using this direct calculation model. It covers not just the basic functions but also compounding and a critical bug fix.
Please read through the article from the beginning down to the 'Bug fix' section. As you read, focus on understanding the implementation of these key functions and concepts: State Variables: How balanceOf, totalStaked, and lastUpdated are used to track user state. deposit: How a user's balance and the total staked amount are updated. rewards: The formula used to calculate accrued rewards based on block.timestamp. claim: How rewards are transferred and the user's lastUpdated timestamp is reset. compound: The difference between claiming and compounding (restaking rewards). withdraw: How rewards are handled before a withdrawal. The Bug Fix: Pay close attention to why rewards need to be compounded before a new deposit.
Key Concepts from the Direct Calculation Model
As you saw in the article, this model relies on tracking each user's staked balance and the timestamp of their last interaction.
balanceOf[user]: Stores the amount of tokens a user has staked.lastUpdated[user]: Stores theblock.timestampof the user's last action (stake, claim, withdraw, etc.). This is crucial for calculating theduration.
The reward calculation function is the heart of the contract:
function _rewards(address account) internal view returns (uint) {
// Time elapsed since last update
uint timeElapsed = block.timestamp - lastUpdated[account];
// Reward rate logic (simplified from article)
// Here, rewards are calculated per second based on a rate
uint rewards = timeElapsed * balanceOf[account] * REWARD_RATE / 1e18;
return rewards;
}
The Critical "Update" Step
The most important pattern in this model is that any function that changes a user's staked balance must first account for the rewards they've already earned.
As the "Bug fix" section in the article points out, if you simply add to a user's stake in the deposit function, you effectively reset their reward clock without paying out the rewards accrued on their previous balance.
The solution is to create an internal _compound() or _updateRewards() function that calculates, claims, and re-stakes the user's pending rewards. This function must be called at the beginning of stake(), unstake(), and claim().
function stake(uint amount) external {
// First, update and compound any existing rewards
_compound();
// Then, proceed with the new deposit
// ... transfer tokens, update balances ...
lastUpdated[msg.sender] = block.timestamp;
}
function unstake(uint amount) external {
// First, update and compound rewards
_compound();
// Then, proceed with the withdrawal
// ... check balance, transfer tokens, update balances ...
// Note: lastUpdated is already set by _compound()
}
This "update-then-act" pattern ensures reward calculations are always correct.
For a live demonstration of a similar, simplified contract, you can watch this video.
Solidity Tutorial: Fixed Rate Staking Contract
The video 'Solidity Tutorial: Fixed Rate Staking Contract' by EatTheBlocks shows a complete, simple implementation of this model in Remix, from writing the code to deploying and interacting with it.
Watch from 02:22 to 07:38. This covers the implementation of the stake, unstake, and claim functions and a final demonstration. Notice how the reward calculation is similar to the one in the article.
3. Model 2: The Scalable Reward-per-Token Approach
The direct calculation model is intuitive but has limitations. It works best when the reward rate is fixed and rewards are inflationary (minted on demand). What if rewards come from an external source, like protocol fees, and are added to the pool periodically? Calculating rewards for every user would be incredibly gas-intensive.
This is where a more advanced, scalable pattern comes in, famously used by Synthetix.
Time-Weighted Staking Rewards & Dynamic APY in Solidity
Before diving into the code, let's understand the concept. The guide 'Time-Weighted Staking Rewards & Dynamic APY in Solidity' explains the high-level idea behind this more scalable approach.
Read the sections '1. Time-Weighted Rewards - Making Loyalty Count!' and 'Solidity Snippets: Key Data for Time-Weighting'. Focus on the description of 'Share/Point Systems (Continuous & Fair - Think Synthetix StakingRewards.sol)'.
How the Reward-per-Token Model Works
Instead of calculating each user's rewards from scratch every time, this model tracks a global value: how many rewards have been distributed per staked token so far.
-
Global State:
rewardPerTokenStored: A global accumulator that tracks the cumulative rewards distributed per single token staked in the contract.lastUpdateTime: The timestamp whenrewardPerTokenStoredwas last updated.
-
User State:
userRewardPerTokenPaid[user]: A personal snapshot for each user, recording the value ofrewardPerTokenStoredthe last time they interacted.rewards[user]: The amount of rewards the user has earned but not yet claimed.
-
The Logic:
- When rewards are added to the pool, we update the global
rewardPerTokenStored.
rewardPerTokenStored += (newRewards / totalStaked) - When a user interacts (e.g., to claim), we first calculate the rewards they've earned since their last interaction:
earned = stakedAmount * (rewardPerTokenStored - userRewardPerTokenPaid[user]) - We add this
earnedamount to theirrewards[user]balance and update their personal snapshot:userRewardPerTokenPaid[user] = rewardPerTokenStored.
- When rewards are added to the pool, we update the global
This way, we only need to perform one storage write (rewardPerTokenStored) to distribute rewards to all stakers, making it extremely gas-efficient. The calculation of individual rewards is done "lazily" when each user chooses to interact with the contract.
Let's see this pattern in action.
Discrete Staking Rewards | Solidity 0.8
The video 'Discrete Staking Rewards' by Smart Contract Programmer provides a clear implementation of this reward-per-token model.
Watch from the beginning until 08:11. Pay close attention to these functions and variables: State Variables: rewardIndex (same as rewardPerTokenStored) and rewardIndexOf (same as userRewardPerTokenPaid). updateRewardIndex: How new rewards are added to the pool and the global index is updated. _calculateRewards: The core formula for calculating a user's pending rewards. updateReward: The internal function that is called before stake, unstake, or claim to update a user's earned balance and their personal index.
4. Comparing the Models
Both models achieve time-based rewards, but they are suited for different scenarios. Your experience in system design will help you appreciate these trade-offs.
| Feature | Model 1: Direct Calculation | Model 2: Reward-per-Token |
|---|---|---|
| Simplicity | High. The logic is intuitive and easy to follow. | Medium. Requires understanding the index/accumulator pattern. |
| Reward Source | Best for fixed-rate, inflationary rewards (mint-on-claim). | Excellent for variable rewards from external sources (e.g., fees). |
| Gas Efficiency | Good for user actions. Can be inefficient for the protocol to distribute rewards to all. | Excellent. Distributing rewards to all stakers is a single SSTORE. |
| Flexibility | Less flexible. Changing the reward rate can be complex. | Highly flexible. The reward rate can change dynamically. |
| Typical Use Case | Simple project tokens with a predictable emission schedule. | DeFi protocols where rewards are funded by protocol revenue. |
5. Security and Best Practices
Building any contract that holds user funds requires extreme care.
- Checks-Effects-Interactions: Always update state variables (like balances and timestamps) before making external calls (like
transfer). This helps prevent reentrancy attacks. - Reentrancy Guards: Use a
nonReentrantmodifier (e.g., from OpenZeppelin) on all functions that transfer tokens (stake,unstake,claim). - Safe Math: Use Solidity 0.8+ to get automatic overflow/underflow protection. Be extra careful with divisions to avoid precision loss or division-by-zero errors. In the reward-per-token model, a multiplier is often used to maintain precision, as seen in the video.
- Access Control: Functions that control crucial parameters (like adding rewards or changing rates) must be protected with proper access control (e.g.,
onlyOwneror a multi-sig).
Conclusion
You have now learned how to design and build a staking contract, a vital component of the DeFi ecosystem. You've explored two distinct models for distributing time-based rewards and understand the trade-offs between them.
Key Takeaways:
- Staking Contracts are DeFi primitives that allow users to lock tokens to earn yield.
- Direct Calculation Model: An intuitive approach where
rewards = amount * duration * rate. It requires updating a user's state on every interaction to prevent losing accrued rewards. - Reward-per-Token Model: A more scalable and gas-efficient pattern that uses a global reward index. It's ideal for protocols with dynamic rewards funded from external sources.
- Security is Paramount: Implementing patterns like Checks-Effects-Interactions and using reentrancy guards are non-negotiable when handling user funds.
Next Steps:
We have now covered trading (AMMs) and yield generation (staking). The next logical step in our exploration of DeFi primitives is lending and borrowing. In our next lesson, we will implement core lending pool functions for depositing collateral and borrowing assets, another fundamental building block of decentralized finance.