ERC-4626: Extending ERC-20 for Interest Management
How the newly finalized standard works and can help you with Defi
Many Defi projects have an ERC-20 token which represents ownership over an interest generating asset. This is for example the case for lending/borrowing platforms (money markets) like Compound and Aave. As a lender you will receive aDAI or cDAI. And since lenders receive interest payments for borrowers, the exchange rate from aDAI/cDAI back to DAI changes all the time. The longer you wait, the more DAI you will receive.
Or you have an asset like xSushi which we explained here, that will generate income from the trading fees on SushiSwap. The xSushi will represent ownership over all the Sushi tokens currently in the contract. This is very similar to the previous lending platform.
And then you also have aggregators like Yearn or Rari Vaults. Those are services that you can put your funds in and that have some mechanism to put those funds into various other projects that generate yield. And the ownership over the funds of such so-called vaults are also handled by an ERC-20 token. And since the vault funds will increase over time, you again have a similar situation in as on a lending platform.
So now that we have all these services implementing something similar, it only makes sense to standardize it. Rather than adhering to dozens of different interfaces, an aggregator has to implement only one. Or let's say you want to implement additional features for such tokens, you also benefit from having a single standardized interface. This is ERC-4626. So if you want to implement your own interest generating token, an aggregator or just want to learn more about Defi, this post is for you.
Overview of ERC-4626
The standard describes a vault which generates interests in the form of a single ERC-20 token. The vault itself is an ERC-20 standard extension. In theory vaults may generate interest over various tokens. But the first and most important standard is just for a single ERC-20 token. In the future one could imagine more advanced standards for multiple tokens and possibly not only ERC-20. For now it's kept as simple as possible.
On a high-level there's functionality to deposit and redeem the vault ownership token for the underlying asset. There are two functions each, one for using the amounts of the underlying as input and one for using the amounts of the ownership token (shares) as input. And then you have some additional helper functions for conversions, receiving maximum amounts and previews. Let's get into the details!
Below infographic was provided by MidasCapital, thank you!
1. The Execution Functions
You can call deposit
to pass an amount of the underlying asset, e.g. DAI. The asset will be transferred and in return you will receive shares, e.g. cDAI, in the normal case determined by the current conversion rate.
function deposit(uint256 assets, address receiver)
external
returns (uint256 shares);
Similarly to deposit
, you can also use mint
. Here instead of passing an amount of the underlying asset, you will pass the amount of shares. And the amount of the underlying being used and transferred will be determined when executing the call.
function mint(uint256 shares, address receiver)
external
returns (uint256 assets);
You can then call redeem
to convert shares back into the underlying asset. In redeem you will pass the amount of shares to be burnt and the amount of the underlying asset will be determined when executing the call.
function redeem(
uint256 shares,
address receiver,
address owner
) external returns (uint256 assets);
Like for the deposit
/mint
, you also have a second function here where instead you can pass the amount of assets you'd like to withdraw
. And the amount of shares that have to be burnt will be determined when executing the call.
function withdraw(
uint256 assets,
address receiver,
address owner
) external returns (uint256 shares);
2. Max Functions
Then you will have a bunch of view functions to read the maximally allowed inputs for each previous function.
function maxDeposit(address receiver) external view returns (uint256);
Returns the maximally allowed amount of the underlying asset that can be deposited and passed as input to the deposit function.
function maxMint(address receiver) external view returns (uint256);
Returns the maximally allowed amount of the shares that can be minted and passed as input to the mint function.
function maxWithdraw(address owner) external view returns (uint256);
Returns the maximally allowed amount of the underlying asset that can be withdrawn for the owner. It implies the owner may call the withdraw function with a value up to that amount.
function maxRedeem(address owner) external view returns (uint256);
Returns the maximally allowed amount of shares that can be burnt for the owner. It implies the owner may call the redeem function with a value up to that amount.
3. Assets
This view function will simply return the address of the ERC-20 token contract used as the underlying asset, e.g. the DAI token address.
function asset() external view returns (address);
This view function will return the total amount of the underlying asset that is managed by the current vault.
function totalAssets() external view returns (uint256);
4. Converters
There are also two conversion view functions. Rather than having an exchange rate to return those two functions can directly be used to tell you how much current assets would be in shares and vice versa.
To convert an amount of the underlying asset into shares, you can use:
function convertToShares(uint256 assets) external view returns (uint256);
To convert an amount of shares into the underlying asset, you can use:
function convertToAssets(uint256 shares) external view returns (uint256);
5. Previews
Lastly, to allow for simulating the effects of execution functions at the current block, given current on-chain conditions, there are (pre-)view functions available:
function previewDeposit(uint256 assets) external view returns (uint256);
function previewMint(uint256 shares) external view returns (uint256);
function previewWithdraw(uint256 assets) external view returns (uint256);
function previewRedeem(uint256 shares) external view returns (uint256);
Implementation Example
You can find an opinionated example implementation here.
And here's a version that's slightly easier to understand for educational purposes:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import {ERC20, IERC4626} from "./interfaces/IERC4626.sol";
contract ERC4626 is IERC4626, ERC20 {
event Deposit(
address indexed caller,
address indexed owner,
uint256 assets,
uint256 shares
);
event Withdraw(
address indexed caller,
address indexed receiver,
address indexed owner,
uint256 assets,
uint256 shares
);
ERC20 public immutable asset;
constructor(
ERC20 _asset,
string memory _name,
string memory _symbol
) ERC20(_name, _symbol, _asset.decimals()) {
asset = _asset;
}
function deposit(uint256 assets, address receiver) external returns (uint256) {
uint256 shares = previewDeposit(assets);
require(shares != 0, "ZERO_SHARES");
asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
return shares;
}
function mint(uint256 shares, address receiver) external returns (uint256) {
uint256 assets = previewMint(shares);
asset.safeTransferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
emit Deposit(msg.sender, receiver, assets, shares);
return shares;
}
function withdraw(
uint256 assets,
address receiver,
address owner
) external returns (uint256) {
uint256 shares = previewWithdraw(assets);
if (msg.sender != owner) {
allowance[owner][msg.sender] -= shares;
}
_burn(owner, shares);
asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
return shares;
}
function redeem(
uint256 shares,
address receiver,
address owner
) external returns (uint256) {
if (msg.sender != owner) {
allowance[owner][msg.sender] -= shares;
}
uint256 assets = previewRedeem(shares);
require(assets != 0, "ZERO_ASSETS");
_burn(owner, shares);
asset.safeTransfer(receiver, assets);
emit Withdraw(msg.sender, receiver, owner, assets, shares);
return assets;
}
function totalAssets() public view returns (uint256) {
return asset.balanceOf(address(this));
}
function maxDeposit(address) external view returns (uint256) {
return type(uint256).max;
}
function maxMint(address) external view returns (uint256) {
return type(uint256).max;
}
function maxWithdraw(address owner) external view returns (uint256) {
return convertToAssets(balanceOf[owner]);
}
function maxRedeem(address owner) external view returns (uint256) {
return balanceOf[owner];
}
function convertToShares(uint256 assets) public view returns (uint256) {
if (totalSupply == 0) {
return assets;
}
return (assets * totalSupply) / totalAssets();
}
function convertToAssets(uint256) public view returns (uint256) {
if (totalSupply == 0) {
return shares;
}
return (shares * totalAssets()) / totalSupply;
}
function previewDeposit(uint256 assets) public view returns (uint256) {
return convertToShares(assets);
}
function previewMint(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
}
function previewWithdraw(uint256 assets) public view returns (uint256) {
return convertToShares(assets);
}
function previewRedeem(uint256 shares) public view returns (uint256) {
return convertToAssets(shares);
}
}
Solidity Developer