ERC-4626 Explained: A Vault That Counts in Two Units
The tokenised vault standard, read from the real contract. Assets versus shares, why four functions cover two operations, what preview promises that convert does not, and where the share price actually lives.
TL;DR
- A vault holds two units of account at once, and every function in the standard converts between them. Assets are what you put in. Shares are what you hold.
- The vault is itself an ERC-20 token. Its shares are its own balances, so
balanceOfis a share count, not an asset amount. - Four functions cover two operations, because the caller can pin down either side of the conversion: assets, or shares.
previewtells you what a real call hands back now.convertreports an idealised rate, ignoring fees and limits.- No share price is stored anywhere. It is
totalAssets()overtotalSupply(), recomputed on every call.
What is ERC-4626?
ERC-4626 is a standard interface for a vault: a contract that takes in one token and gives back a claim on a growing pool of it. The vault holds two units of account at once. Assets are what you put in, shares are what you hold, and every function in the standard is a conversion between them.
The Academy has covered what a vault is as a product, in vaults, aggregators and intents. This is the other half: what the interface underneath it computes.
Two words carry the whole file. Assets are the underlying token, the thing you hand over and eventually get back. Shares are the vault's own accounting unit, your claim on a fraction of everything it holds. Deposit assets, receive shares. Return shares, receive assets. When the pool grows your share count does not move; what each share is worth does.
Before the standard, every vault invented its own vocabulary, so integrating with ten meant ten adapters. The implementation quoted throughout is OpenZeppelin's.
Is an ERC-4626 vault itself an ERC-20 token?
Yes, and this is the single most missed fact about the standard. The vault's shares are its own ERC-20 balances, not a separate token contract alongside it. The implementation inherits ERC20 directly, so balanceOf, transfer and totalSupply on the vault are all statements about shares.
The inheritance list says it in one line, and the state underneath is two variables:
abstract contract ERC4626 is ERC20, IERC4626 {
using Math for uint256;
IERC20 private immutable _asset;
uint8 private immutable _underlyingDecimals;
The vault does not own a share token; the vault is the share token. balanceOf(you) is your share count, totalSupply() is every share in existence, and transfer moves a claim on the pool without any assets moving. Shares compose like any other ERC-20, the standard covered in ERC-20 vs ERC-721.
Note the first word too. abstract means this file is never deployed alone; you meet it as the parent of a concrete vault, which is why almost every function is virtual. See interface versus abstract contract. And note the state: two immutable variables. Everything else is computed.
What does asset() return, and can a vault change it?
asset() returns the address of the token the vault accepts. It is set once in the constructor and stored in an immutable variable, so a deployed vault can never be pointed at another token. totalAssets() then reports how much of it the vault currently holds.
constructor(IERC20 asset_) {
(bool success, uint8 assetDecimals) = SafeERC20.tryGetDecimals(asset_);
_underlyingDecimals = success ? assetDecimals : 18;
_asset = asset_;
}
function asset() public view virtual returns (address) {
return address(_asset);
}
function totalAssets() public view virtual returns (uint256) {
return IERC20(asset()).balanceOf(address(this));
}
The constructor does one careful thing before storing the address: it asks the token how many decimals it uses, falling back to 18 if it does not answer. That cached number matters later, because the vault's decimals derive from it.
totalAssets() here is the plainest implementation, the vault's balance of the asset token. It is virtual, and a strategy vault usually overrides it, because assets lent out elsewhere are still assets the vault owns. On a live vault, open this one first: every conversion divides by it.
Why does ERC-4626 have four functions for two operations?
Because each operation can be pinned down from either side of the conversion. deposit and mint both put assets in. withdraw and redeem both take assets out. The difference is which number the caller fixes, an amount of assets or an amount of shares, and the vault calculates the other one.
| Function | You specify | Vault calculates | Direction |
|---|---|---|---|
deposit | assets paid in | shares you receive | in |
mint | shares you receive | assets you must pay | in |
withdraw | assets you receive | shares burned | out |
redeem | shares burned | assets you receive | out |
The two deposit-side functions are adjacent in the file, and the symmetry is the point:
function deposit(uint256 assets, address receiver) public virtual returns (uint256) {
uint256 maxAssets = maxDeposit(receiver);
if (assets > maxAssets) {
revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets);
}
uint256 shares = previewDeposit(assets);
_deposit(_msgSender(), receiver, assets, shares);
return shares;
}
function mint(uint256 shares, address receiver) public virtual returns (uint256) {
uint256 maxShares = maxMint(receiver);
if (shares > maxShares) {
revert ERC4626ExceededMaxMint(receiver, shares, maxShares);
}
uint256 assets = previewMint(shares);
_deposit(_msgSender(), receiver, assets, shares);
return assets;
}
deposit takes assets and derives shares. mint takes shares and derives assets. Both hand the same four values to the same internal _deposit, and the exit pair has the identical shape around _withdraw.
Which you want depends on the number you cannot be flexible about. A user emptying a wallet takes whatever 500 units buys, and calls deposit. A contract needing exactly 1,000 shares calls mint and pays what they cost.
What is the difference between preview and convert in ERC-4626?
convertToShares and convertToAssets report an idealised exchange rate: given this many of one unit, how many of the other. The four preview functions promise something stronger. Each returns what its matching call would actually hand back if you made it in the same transaction, including whatever fees and rounding the implementation applies.
function convertToShares(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor);
}
function convertToAssets(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor);
}
function previewDeposit(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor);
}
function previewMint(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Ceil);
}
function previewWithdraw(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Ceil);
}
function previewRedeem(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor);
}
Here previewDeposit and convertToShares are literally the same line, which makes the distinction look academic. It is not: the two families are allowed to diverge. convert* stays a clean rate quote; preview* tracks the implementation, so a child vault charging an entry fee subtracts it in previewDeposit and leaves convertToShares alone. They already separate here, with previewMint and previewWithdraw rounding Ceil where the convert pair rounds Floor.
The previews also ignore maxDeposit, maxMint, maxWithdraw and maxRedeem, so one will happily quote a number the call then rejects.
How does an ERC-4626 vault convert assets to shares?
Both directions are a single multiply-then-divide. Shares are assets times total supply over total assets. Assets are shares times total assets over total supply. Two constant terms sit inside those expressions, + 1 and 10 ** _decimalsOffset(), and their job is to keep the arithmetic defined when the vault is empty.
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual returns (uint256) {
return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}
/**
* @dev Internal conversion function (from shares to assets) with support for rounding direction.
*/
function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) {
return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding);
}
mulDiv multiplies first and divides second in full precision, so the intermediate product cannot overflow. Strip the constants and what remains is the ratio you would write on paper.
Now those constants. On a brand new vault totalAssets() and totalSupply() are both 0, so without the extra terms both expressions divide by zero. + 1 and 10 ** _decimalsOffset() keep each denominator at least 1. The offset has a default:
function _decimalsOffset() internal view virtual returns (uint8) {
return 0;
}
10 ** 0 is 1, so a default vault starts at one share per asset unit: a first deposit of X units mints X shares. Raising the offset changes both that ratio and the share token's decimal scale, because the two are wired together:
function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) {
return _underlyingDecimals + _decimalsOffset();
}
That leaves Math.Rounding, the third argument threaded through both conversions. Integer division discards the remainder, and there is no fractional share unit to hold it. The remainder has to go somewhere, so the standard chooses consistently which side of the trade it lands on:
previewDeposit: you fix the assets, it computes shares out, rounded down.previewRedeem: you fix the shares, it computes assets out, rounded down.previewMint: you fix the shares wanted, it computes what you owe, rounded up.previewWithdraw: you fix the assets wanted, it computes shares to burn, rounded up.
In all four the leftover stays with the vault. That uniformity is the design choice, and in any vault overriding these functions it is the first thing to check: which way does it round, and who keeps the remainder.
Where is the share price stored in an ERC-4626 vault?
Nowhere. No variable holds an exchange rate and no function updates one. The price is derived on every call from totalAssets() over totalSupply(), both read at that moment. A vault's shares become more valuable because its asset balance grew, not because anything wrote a new number.
Search for a sharePrice variable and you will not find one. The vault has no need to track its own worth: ask convertToAssets for the value of one whole share and you get today's price, from the same two totals every other function uses. The rate is an output, never a stored input, so it cannot drift out of sync with its balances.
That draws a clean line through what this file can and cannot tell you. From the source alone you know the shape: two units of account, four entry points that pair off, an asset fixed at deployment, a share token that is the vault itself, remainders landing on the vault's side.
What it cannot give you is any number. What totalAssets() returns, what _decimalsOffset() returns, whether a fee sits between convertToShares and previewDeposit: all of that depends on the child contract and on balances right now, and you have to go and fetch it. Sorting a contract into those two piles is what how to read a smart contract drills.
Delete the line and ask what breaks
- Delete
ERC20from the inheritance list and nothing compiles. Shares have nowhere to live:_mint,_burnandtotalSupply()all come from there, and_convertToSharescallstotalSupply()in its body. - Change
Math.Rounding.CeiltoFloorinpreviewMintand it still compiles and runs. The caller now pays one unit less for the same shares, and nothing announces it. - Remove
+ 1from the denominator in_convertToSharesand an empty vault divides by zero on the first deposit. That term is arithmetic, not decoration. - Delete
immutablefrom_assetand the compiler accepts it happily. The token address stops being fixed at deployment, and whether it changes now depends on the child.
Related questions
What is ERC-4626? A standard interface for tokenised vaults. It fixes the names, arguments and meaning of deposit, withdrawal and quoting functions, so one piece of code can talk to any compliant vault.
What is the difference between assets and shares in ERC-4626? Assets are the underlying token the vault accepts and returns. Shares are the vault's own token, a claim on a fraction of everything it holds.
Is an ERC-4626 vault an ERC-20 token?
Yes. The vault inherits ERC-20 and its shares are its own balances, so balanceOf returns a share count and shares transfer like any other token.
Why does ERC-4626 have both deposit and mint?
Because each fixes a different side of the conversion. deposit takes assets and computes the shares you get. mint takes shares and computes the assets you owe.
What is the difference between previewDeposit and convertToShares?
convertToShares reports an idealised rate. previewDeposit reports what an actual deposit in the same transaction would return, fees and rounding included. They diverge once a vault charges.
How is the share price of an ERC-4626 vault calculated?
It is not stored. Every conversion derives it from totalAssets() divided by totalSupply() at the moment of the call.
Where to go next
The whole standard fits in one sentence: a vault counts in two units, and every function converts between them. Hold that, and the four entry points stop looking redundant, the rounding arguments stop looking arbitrary, and the absent share price stops looking like an omission.
For what these vaults do with your money once it is inside, vaults, aggregators and intents picks up where this leaves off. To keep reading contracts, the checkpoint below walks this file top to bottom.
Tagged