At first glance, it’s hard to see why you should care about ERC4626.
How it works is you deposit one ERC20 token into the ERC4626 contract, let’s call it token A, and get another ERC20 token back, call it token B
At a later date, you can put token B back into the vault and get token A returned to you.
It may seem incredibly boring to create a specification around this, but let’s look over the interface documentation and code just to clarify everything just explained.
An ERC4626 contract is also an ERC20 token
When an ERC4626 contract gives you an ERC20 token for the initial deposit, it isn’t a separate contract. It’s the same contract. Therefore, ERC4626 supports all the functions you expect from ERC20:
balanceOf
transfer
transferFrom
approve
allowance
And so forth.
If you look at the OpenZeppelin implementation of ERC4626, you’ll see it extends ERC20.
This token is referred to as the shares in an ERC4626. This is the ERC4626 contract itself.
The more shares you own, the more rights you have to the underlying asset (a separate ERC20 token) that gets deposited into it.
Each ERC4626 contract only supports one asset. You cannot deposit multiple kinds of ERC20 tokens into the contract and get shares back.
ERC4626 Motivation
Let’s use a real example to motivate the design.
Let’s say we all own a company, or a liquidity pool, that earns a stablecoin DAI periodically. The stablecoin DAI is the asset in this case.
One inefficient way we could distribute the earnings is to push out DAI to each of the holders of the company on a pro-rata basis. But this would be extremely expensive gas wise.
Similarly, if we were to update everyone’s balance inside a smart contract, that would be expensive too.
Instead, this is how the workflow would work with ERC4626.
Let’s say you and ten friends get together and each deposit 10 DAI each into the ERC4626 vault. You get back one share.
So far so good. Now your company earns 10 more DAI, so the total DAI inside the vault is now 110 DAI.
When you trade your share back for your part of the DAI, you don’t get 10 DAI back, but 11.
Now there is 99 DAI in the vault, but 9 people to share it among. If they were to each withdraw, they would get 11 DAI each.
Note how efficient this is. When someone makes a trade, instead of updating everyones shares one-by-one, only the total supply of shares and the amount of assets in the contract changes.
ERC4626 does not have to be used in this manner. You can have an arbitrary mathematical formula that determines the relationship between shares and assets. For example, you could say ever time someone withdraws the asset, they also have to pay some sort of a tax that depends on the block timestamp or something like that.
The real point of ERC4626 is that this method of accounting is so common in DeFi because of its gas efficiency that ERC4626 was specified so that interoperation between DeFi protocols is more seamless.
ERC4626 Shares
Naturally, users want to know which asset the ERC4626 uses and how many are owned by the contract, so there are two solidity functions for that.
function asset() returns (address)
The asset function returns the address of the underlying token used for the Vault. If the underlying asset was say, DAI, then the function would return the ERC20 contract address of DAI 0x6b175474e89094c44da98b954eedeac495271d0f.
function totalAssets() returns (uint256)
Total amount of the underlying asset that is “managed” by Vault, I.e. the number of ERC20 tokens owned by the ERC4626 contract. The implementation is quite simple in
/** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual override returns (uint256) { return _asset.balanceOf(address(this)); }
There is of course no function to get the shares address, because that is just the address of the ERC4626 contract.
Giving assets, getting shares
Let’s copy and paste the two specifications for making this trade from the EIP.
function deposit(uint256 assets, address receiver) public virtual override returns (uint256)
EIP: Mints shares Vault shares to receiver by depositing exactly assets of underlying tokens.
function mint(uint256 shares, address receiver) public virtual override returns (uint256)
EIP: Mints exactly shares Vault shares to receiver by depositing assets of underlying tokens.
This description from the ERC spec seems a bit sparse: you’re trading in assets to get shares with both functions.
Here’s the difference:
With deposit(), you specify how many assets you want to put in, and the function will calculate how many shares to send to you.
With mint(), you specify how many shares you want, and the function will calculate how much of the ERC20 asset to transfer from you.
Of course, if you don’t have enough assets to transfer in to the contract, the transaction will revert.
The uint256 that gets returned to you is amount of shares you get back.
The following invariant should always be true
// remember, erc4626 is also an erc20 token uint256 sharesBalanceBefore = erc4626.balanceOf(address(this)); uint256 sharesReceived = erc4626.deposit(numAssets, address(this));
// strict equality checks in accounting are a big no no! assert(erc4626.balanceOf(address(this)) >= sharesBalanceBefore + sharesReceived);
Anticipating how many shares you will get
If you are using ethers.js, you can just issue a staticcall to deposit or mint to predict what will happen. If you are doing this on-chain however, you have the following two functions at your disposal:
previewDeposit
previewMint
Like their state changing counterparts, previewDeposit takes assets as an argument and previewMint takes shares as an argument.
Anticipating how many shares you will get under ideal conditions
Confusingly enough, there is also a view function called convertToShares which takes assets as an argument and returns the amount of shares you will get back under ideal conditions (no slippage or fees).
Why would you care about this ideal information that doesn’t reflect the trade you will execute?
The difference between ideal and actual tells you how much your trade is impacting the market and how the fee depends depend on trade size. A smart contract could do a binary search on the difference between convertToShares and to find the best trade size to execute.
Returning shares, getting assets back
The inverse of deposit and mint is withdraw and redeem respectively.
With deposit, you specify the assets you want to trade and the contract calculates how many shares you get.
With mint, you specify how many shares you want, and the contract calculates how many assets to take from you.
Similarly, withdraw lets you specify how many assets you want to take from the contract, and the contract calculates how many of your shares to burn.
With redeem, you specify how many shares you want to burn, and the contract calculates the amount of assets to give back.
Anticipating how many shares you will burn to get assets back
The view methods for withdraw and redeem are previewRedeem and previewWithdraw respectively.
The idealized analog of these functions is convertToAssets which takes shares as an argument and gives you how many assets you will get back, not including fees and slippage.
Summary of functions so far
What about the address argument?
The functions mint, deposit, redeem, and withdraw, have an second argument “receiver” for cases where the account receiving shares or assets from the ERC4626 is not msg.sender. This means I can deposit assets into the account and specify the ERC4626 contract give you the shares.
Redeem and withdraw have a third argument, “owner” which allows msg.sender to burn the shares of the “owner” while sending assets to the “receiver” (second argument) if they have allowance to do so.
maxDeposit, maxMint, maxWithdraw, maxRedeem
These functions take idendical arguments to their state-changing counterparts and return the largest trade they can execute. This can change per address (remember, we just discussed that these functions take addresses as arguments).
Events
ERC4626 has only two events: Deposit and Withdraw. These are emitted even if mint and redeem were called, because functionally the same thing happened: tokens were swapped.
ERC4626 inflation attack
Although ERC4626 is agnotic to the algorithm that translates prices to shares, most implementations use a linear relationship. If there are 10,000 assets, and 100 shares, then 100 assets should result in 1 share.
But what happens if someone sends 99 assets? It will round down to zero and they get zero shares.
Of course no-one would intentionaly throw away their money like this. However, an attacker can frontrun a trade by donating assets to the vault.
If an attack donates money to the vault, one share is suddenly worth more than it was initially. If there are 10,000 assets in the vault corresponding to 100 shares, and the attacker donates 20,000 assets, then one share is suddenly worth 200 assets instead of 100 assets. When the victim’s trade trades in assets to get back shares, they suddenly get a lot fewer shares — possibly zero.
Two defenses exist: one is for the deployer to initialize the contract with enough assets to make this attack not work. The second is to artifiically inflate the amount of assets in the vault such that when there are a lot of assets in the vault, this inflation is negligible, but when the assets are low, the attacker won’t be able to manipulate the market because they are donating to a pool that has been artificially increased in size.
Real life examples of share / asset accounting
Earlier versions of Compound minted what they called c-tokens to users who supplied liquidity. For example, if you deposited USDC, you would get a separate cUSDC (Compound USDC) back. When you decided to stop lending, you would send back your cUSDC to compound (where it would be burned) then get your pro-rata share of the USDC lending pool.
Uniswap used LP tokens as “shares” to represent how much liqudity someone had put into a pool, (and how much the could withdraw pro-rata when they redeemed the LP tokens for the underlying asset.
Learn More
Learn more advanced topics in our blockchain bootcamp.
Further Resources
Original EIP Author on Youtube
Openzeppelin’s implementation
Solmate implementation