1/5
## Completing Your Rebase Token Smart Contract Welcome! In this lesson, we'll finalize the implementation of our custom `RebaseToken.sol` smart contract. This token is designed to accrue interest for its holders over time. We'll be building upon OpenZeppelin's standard `ERC20.sol` contract, overriding specific functions to incorporate our unique rebase logic, and adding necessary helper functions. By the end, our token will be functionally complete, and we'll identify the next critical step: implementing robust access control. Our `RebaseToken` operates on a core principle: a user's actual balance is their initial *principal* (the amount minted to them) plus any *accrued interest* since their last interaction with the protocol. This requires careful consideration of how standard ERC20 functions behave and how they need to be modified. ## Reviewing Standard ERC20 Functions: What to Keep First, let's look at OpenZeppelin's `ERC20.sol` (typically found in `lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol`) to identify which standard functions we can use as-is and which need custom logic for our rebase token. * **`name()` and `symbol()`:** These are standard getter functions. Their return values are set in the constructor of our `RebaseToken` contract. No override is needed here. * **`decimals()`:** This function also behaves as standard. OpenZeppelin's `ERC20.sol` defaults to 18 decimals. While it's possible to override this (for example, USDC uses 6 decimals), we'll stick with the default 18 for our `RebaseToken`. Thus, no override is necessary. ## The `totalSupply()` Dilemma: Accuracy vs. Gas Costs The `totalSupply()` function in a standard ERC20 contract typically returns a state variable (`_totalSupply`) that tracks the sum of all tokens ever minted, minus any tokens burned. For our `RebaseToken`, an *accurate* total supply would need to reflect all minted principal tokens *plus* all accrued (but not yet minted) interest across *all* token holders. To calculate this on-the-fly, we would theoretically need to: 1. Iterate through every address that holds the token. 2. For each address, calculate their `balanceOf(address)` (which, as we'll see, includes their accrued interest). 3. Sum these balances. This approach presents a significant challenge: the loop is potentially unbounded. If a large number of users hold the token, iterating through all of them could easily exceed the block gas limit. This would make the `totalSupply()` function unusable and could even be exploited as a Denial of Service (DoS) vector. **Decision:** We will **not override `totalSupply()`**. The function will therefore return the sum of *principal* balances only (i.e., tokens that have been explicitly minted via the `_mint` function). This means the `totalSupply()` will not represent the true economic supply of the token, which includes unmaterialized interest. This is a deliberate design trade-off to avoid excessive gas costs and potential DoS vulnerabilities. This "inaccuracy" is a known characteristic of this specific protocol implementation. ## Overriding `balanceOf(address account)` for Real-Time Interest The `balanceOf(address account)` function in the base ERC20 contract returns the balance stored for the `account` in the `_balances` mapping. For our `RebaseToken`, this function *must* be overridden to reflect the user's principal plus their accrued interest. Our `RebaseToken.sol` already includes this override: ```solidity function balanceOf(address _user) public view override returns (uint256) { // get the current principle balance of the user (the number of tokens that have actually been minted to the user) // multiply the principle balance by the interest that has accumulated in the time since the balance was last updated return super.balanceOf(_user) + _calculateUserAccumulatedInterestSinceLastUpdate(_user) / PRECISION_FACTOR; } ``` Let's break down this logic: 1. `super.balanceOf(_user)`: This calls the `balanceOf` function from the parent `ERC20.sol` contract, retrieving the user's *principal* balance (the amount of tokens actually minted to them and currently recorded in the `_balances` mapping). 2. `_calculateUserAccumulatedInterestSinceLastUpdate(_user)`: This is a crucial internal helper function (not detailed here but assumed to be implemented) that calculates the amount of interest the `_user` has earned since their last interaction with the contract (e.g., a transfer, mint, or burn). 3. `PRECISION_FACTOR`: This constant is used to adjust the interest calculation, likely to handle fixed-point arithmetic for interest rates. 4. The function returns the sum of the principal balance and the calculated accrued interest (adjusted for precision), giving the user's current *effective* balance. ## Customizing `transfer(address _recipient, uint256 _amount)` for Interest-Aware Transactions The standard `transfer` function needs to be overridden to correctly handle interest accrual for both the sender and the recipient, and to manage how interest rates are applied, especially for new recipients. **Key Considerations for `transfer` Override:** 1. **Interest Accrual:** Before any transfer occurs, any pending interest for both the sender and the recipient must be calculated and effectively minted to their principal balances. This ensures that balances are up-to-date. 2. **Recipient's Interest Rate:** * **New Recipient:** If the `_recipient` has a balance of zero *before* this transfer, they should inherit an interest rate. The chosen logic is for the recipient to inherit the sender's current user-specific interest rate (`s_userInterestRate[msg.sender]`). This handles cases like user-to-user transfers or users consolidating funds into a new wallet they control. * **Existing Recipient:** If the `_recipient` already holds tokens (i.e., their balance is non-zero), their existing `s_userInterestRate` should *not* be altered by an incoming transfer. This prevents a potential attack where someone could send a tiny amount of tokens to another user to forcibly change (and potentially lower) their interest rate. 3. **Max Transfer:** Allow users to easily transfer their entire balance, including accrued interest. **Implementation of Overridden `transfer`:** ```solidity /** * @notice Transfers tokens from the caller to a recipient. * Accrued interest for both sender and recipient is minted before the transfer. * If the recipient is new, they inherit the sender's interest rate. * @param _recipient The address to transfer tokens to. * @param _amount The amount of tokens to transfer. Can be type(uint256).max to transfer full balance. * @return A boolean indicating whether the operation succeeded. */ function transfer(address _recipient, uint256 _amount) public override returns (bool) { // 1. Mint accrued interest for both sender and recipient _mintAccruedInterest(msg.sender); _mintAccruedInterest(_recipient); // 2. Handle request to transfer maximum balance if (_amount == type(uint256).max) { _amount = balanceOf(msg.sender); // Use the interest-inclusive balance } // 3. Set recipient's interest rate if they are new (balance is checked *before* super.transfer) // We use balanceOf here to check the effective balance including any just-minted interest. // If _mintAccruedInterest made their balance non-zero, but they had 0 principle, this still means they are "new" for rate setting. // A more robust check for "newness" for rate setting might be super.balanceOf(_recipient) == 0 before any interest minting for the recipient. // However, the current logic is: if their *effective* balance is 0 before the main transfer part, they get the sender's rate. if (balanceOf(_recipient) == 0 && _amount > 0) { // Ensure _amount > 0 to avoid setting rate on 0-value initial transfer s_userInterestRate[_recipient] = s_userInterestRate[msg.sender]; } // 4. Execute the base ERC20 transfer return super.transfer(_recipient, _amount); } ``` *(Note: The helper function `_mintAccruedInterest(address)` is responsible for calculating and adding any due interest to the user's principal balance, effectively "minting" it.)* Adding NatSpec documentation (as shown above with `@notice`, `@param`, `@return`) is crucial for explaining the function's behavior, especially since it deviates from the standard ERC20 `transfer`. ## Adapting `transferFrom(address _sender, address _recipient, uint256 _amount)` for Delegated Rebase Transfers Similar to `transfer`, the `transferFrom` function also needs to be overridden. This function is used when an allowance mechanism is in play (e.g., `approve` has been called), allowing a third party (often `msg.sender`) to move tokens on behalf of the `_sender`. The logic is analogous to the `transfer` override: 1. Mint accrued interest for both the `_sender` and the `_recipient`. 2. Handle the `type(uint256).max` case for transferring the full balance of the `_sender`. 3. If the `_recipient` is new (balance is zero before the transfer), they inherit the `_sender`'s interest rate. 4. Call `super.transferFrom` to execute the core transfer logic. **Implementation of Overridden `transferFrom`:** ```solidity /** * @notice Transfers tokens from one address to another, on behalf of the sender, * provided an allowance is in place. * Accrued interest for both sender and recipient is minted before the transfer. * If the recipient is new, they inherit the sender's interest rate. * @param _sender The address to transfer tokens from. * @param _recipient The address to transfer tokens to. * @param _amount The amount of tokens to transfer. Can be type(uint256).max to transfer full balance. * @return A boolean indicating whether the operation succeeded. */ function transferFrom(address _sender, address _recipient, uint256 _amount) public override returns (bool) { _mintAccruedInterest(_sender); _mintAccruedInterest(_recipient); if (_amount == type(uint256).max) { _amount = balanceOf(_sender); // Use the interest-inclusive balance of the _sender } // Set recipient's interest rate if they are new if (balanceOf(_recipient) == 0 && _amount > 0) { s_userInterestRate[_recipient] = s_userInterestRate[_sender]; } return super.transferFrom(_sender, _recipient, _amount); } ``` Again, clear NatSpec documentation is important. ## Standard ERC20 `allowance()` and `approve()`: No Override Needed The `allowance(address owner, address spender)` and `approve(address spender, uint256 amount)` functions are standard ERC20 features that manage spending allowances. The rebase logic of our token does not directly affect how allowances are set or queried. **Decision:** We will **not override `allowance()` or `approve()`**. They are essential for interoperability with other smart contracts and protocols (e.g., decentralized exchanges, cross-chain protocols like CCIP) and can be used as-is from the base OpenZeppelin `ERC20.sol` contract. ## Leveraging OpenZeppelin's Internal ERC20 Logic OpenZeppelin's `ERC20.sol` contains several internal functions (usually prefixed with an underscore) like `_transfer`, `_update`, `_mint`, `_burn`, `_approve`, and `_spendAllowance`. These functions implement the core mechanics of the token. **Decision:** We will **not override these internal functions**. Our custom rebase logic is built *around* them by: 1. Overriding the `public virtual` functions (like `transfer`, `balanceOf`) that eventually call these internal functions. 2. Adding pre-processing or post-processing steps within our overridden public functions (e.g., calling `_mintAccruedInterest` before `super.transfer`). This approach allows us to leverage OpenZeppelin's battle-tested and audited internal logic while layering our specific rebase features on top. ## Adding Utility: `principleBalanceOf()` and `getInterestRate()` To provide more transparency and utility, we'll add a couple of new helper functions: 1. **`principleBalanceOf(address _user)`:** * **Purpose:** This function allows external callers (and other contracts) to see a user's balance *excluding* any accrued but unminted interest. It returns only the principal amount of tokens that have been explicitly minted to the user. * **Implementation:** It simply calls the base contract's `balanceOf` function, which directly queries the `_balances` mapping. ```solidity /** * @notice Gets the principle balance of a user (tokens actually minted to them), excluding any accrued interest. * @param _user The address of the user. * @return The principle balance of the user. */ function principleBalanceOf(address _user) external view returns (uint256) { return super.balanceOf(_user); // Calls ERC20.balanceOf, which returns _balances[_user] } ``` 2. **`getInterestRate()`:** * **Purpose:** This function allows external callers to view the current *global* interest rate of the token, which is stored in the `s_interestRate` state variable. * **Implementation:** It returns the value of the `s_interestRate` state variable. ```solidity /** * @notice Gets the current global interest rate for the token. * @return The current global interest rate. */ function getInterestRate() external view returns (uint256) { return s_interestRate; } ``` NatSpec documentation clarifies the distinct purpose of `principleBalanceOf` compared to the overridden `balanceOf`. ## A Note on Existing Custom Functions: `mint`, `burn`, `setInterestRate` Our `RebaseToken.sol` contract likely already includes custom functions such as: * `mint(address _to, uint256 _amount)`: To create new tokens and assign them to an address, potentially also setting an initial user-specific interest rate. * `burn(address _from, uint256 _amount)`: To destroy tokens from an address, which should also first mint any accrued interest for that user. * `setInterestRate(uint256 _newInterestRate)`: To adjust the global interest rate (often with safeguards, like only allowing it to decrease or within certain bounds). While these functions are crucial for the token's operation, their current implementation (as reviewed so far) leads us directly to a critical security consideration. ## Security First: The Imperative of Access Control With the rebase mechanics and ERC20 overrides in place, our token is functionally complete. However, there's a major missing piece: **Access Control**. **Identified Vulnerabilities (Without Access Control):** * **Unauthorized Minting:** As it stands, *anyone* could potentially call the `mint` function and create new tokens for themselves or any other address, devaluing the token for legitimate holders. * **Unauthorized Burning:** Similarly, *anyone* could call the `burn` function and destroy tokens belonging to *any* address (`_from`), leading to loss of funds. * **Unauthorized Rate Setting:** *Anyone* could call `setInterestRate` and arbitrarily change the global interest rate, potentially harming the token's economic model (e.g., setting it to zero). **Required Action:** To secure our `RebaseToken`, we *must* implement access control mechanisms. This typically involves restricting who can call sensitive functions like `mint`, `burn`, and `setInterestRate`. Common patterns include: * **`Ownable`:** A simple pattern from OpenZeppelin where only a designated "owner" address can call restricted functions. * **Role-Based Access Control (RBAC):** A more flexible system where different roles (e.g., MINTER_ROLE, BURNER_ROLE, RATE_SETTER_ROLE) can be assigned to different addresses, granting them specific permissions. Implementing proper access control is the essential next step before this `RebaseToken` contract can be considered safe for any real-world use. This will be addressed in a subsequent lesson.
An in-depth lesson to Completing Your Rebase Token Smart Contract - Finalize your custom RebaseToken by overriding OpenZeppelin ERC20 functions like `balanceOf` and `transfer` for accurate interest accrual. Explore the `totalSupply` trade-off, add new utility functions, and identify the next vital step: implementing robust access control.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025