Sign in

ERC-4626 tokenized vaults

Why standardize vaults

Every yield protocol does roughly the same thing. Users deposit some token. The protocol does something with the token (lends it out, stakes it, runs an investment strategy, whatever). Profits get reflected somehow in what users get back when they withdraw.

Before 2022, every protocol invented its own interface for this. Yearn called it deposit/withdraw. Compound called it mint/redeem and used a cToken. Aave called it deposit/withdraw but minted an aToken. Each had different function signatures, different events, different decimals handling, different edge cases. A wallet that wanted to display "your positions across DeFi" had to write a separate integration for each one.

ERC-4626 solves this by defining the interface: a vault must expose certain function names, with certain signatures, with certain behavior. If a contract follows the standard, any tool that knows ERC-4626 can talk to it.

The standard does not say what the vault does internally. It does not say how the vault generates yield, or where the assets get invested, or whether they're invested at all. It only standardizes the deposit-and-withdraw interface around two tokens: an asset (whatever ERC-20 you put in) and a share (the ERC-20 receipt you get back).

The mental model

The clearest way to think about ERC-4626 is as a bank account that issues transferable receipts.

A vault swaps assets for shares, and back again later User holds assets (e.g. USDC) Vault totalAssets USDC in vault totalSupply shares issued share price = totalAssets / totalSupply user deposits assets vault mints shares back to the user user burns shares vault sends assets back to the user The shares are themselves an ERC-20 token A share is a transferable receipt. Holding 10% of the share supply means a claim on 10% of the vault's assets. If the vault earns yield, totalAssets grows while totalSupply stays flat, and each share becomes worth more underlying assets.

Two pieces of state matter for the math:

  • totalAssets() — how much of the underlying asset the vault is currently managing. For a USDC vault, this is the USDC balance of the vault contract, plus any USDC the vault has loaned out or staked elsewhere that still belongs to it.
  • totalSupply() — how many shares have been issued (inherited from the ERC-20 implementation, since shares are themselves an ERC-20).

The ratio between these two values is the share price. If the vault holds 1,000 USDC and has minted 100 shares total, each share is worth 10 USDC. If the vault later generates 100 USDC of yield without any new deposits, totalAssets becomes 1,100 and shares stay at 100, so each share is now worth 11 USDC. Same number of shares, more underlying value per share. This is exactly the same pattern as Uniswap V2 LP tokens.

Four operations, paired by which side you specify

A user might want to specify the deposit two different ways: "I have exactly 100 USDC, take it and give me whatever shares that's worth" or "I want exactly 10 shares of this vault, take whatever USDC that costs from me." The standard supports both. Same for withdrawals.

Four operations, paired by "which side you specify" deposit(assets, receiver) You say: "I want to deposit 100 USDC." Vault computes: shares = 100 × supply / assets Use when you have a specific amount of assets to spend. mint(shares, receiver) You say: "I want exactly 10 shares." Vault computes: assets = 10 × assets / supply Use when you need a specific number of shares to end up with. withdraw(assets, receiver, owner) You say: "I want to withdraw 100 USDC." Vault computes: shares to burn = 100 × supply / assets Use when you need a specific amount of assets back. redeem(shares, receiver, owner) You say: "Burn 10 shares of mine." Vault computes: assets sent = 10 × assets / supply Use when you want to cash out a specific number of shares. going in: assets → shares going out: shares → assets

The math is symmetric. deposit and mint produce the same net result if the inputs correspond to each other. The same is true for withdraw and redeem. The standard gives you both because different callers find different sides easier to reason about. A frontend showing "deposit $X" calls deposit. A portfolio rebalancer that wants a clean number of shares calls mint. Same outcome.

The owner parameter on withdraw and redeem exists because someone can let a third party burn their shares on their behalf, the same way ERC-20 transferFrom works. The caller needs allowance from the owner first. For self-withdrawals, owner equals msg.sender and the allowance check is skipped.

Preview functions

For every operation the standard provides a matching preview function that returns what the result would be without actually executing:

  • previewDeposit(assets) returns the shares you would receive
  • previewMint(shares) returns the assets you would pay
  • previewWithdraw(assets) returns the shares that would be burned
  • previewRedeem(shares) returns the assets you would receive

These are pure view functions. Frontends use them to show "you will receive ~X shares" before the user signs the transaction. They also let other contracts integrate without having to duplicate the conversion math.

There are also maxDeposit, maxMint, maxWithdraw, and maxRedeem functions that return per-user limits. A vault implementation can use these to enforce caps (no deposits over 1,000 USDC, no withdrawals if paused, and so on). By default they return the maximum representable value, meaning no limit, but vault authors can override.

The conversion math

Two helpers, convertToShares(assets) and convertToAssets(shares), do the core arithmetic. They're public view functions that anyone can call, useful for off-chain calculations or other contracts.

The formula for converting assets to shares is:

undefinedshares = (assets × totalSupply) / totalAssets

And the reverse:

undefinedassets = (shares × totalAssets) / totalSupply

Both are integer arithmetic in Solidity, which means the result is truncated (rounded down) at the division. The direction of rounding matters for security. The standard's general principle: round in the direction that favors the vault, not the user. A user depositing assets gets shares rounded down, so they never get more shares than their assets are worth. A user redeeming shares gets assets rounded down, so they never extract more than their shares are worth. Rounding the other direction would let users systematically extract tiny amounts each operation.

OpenZeppelin's reference implementation uses a Math.mulDiv helper with an explicit Rounding argument so the direction is clear at every call site.

The inflation attack

The math above has a problem when the vault is freshly deployed and nearly empty. The very first depositor sets the initial share price by being the first one to mint. If they're malicious, they can rig the price so that the second depositor gets cheated.

The inflation attack: how a first depositor can drain the second Attacker Vault freshly deployed Victim honest user, later 1. deposit(1 wei) totalAssets = 1 totalSupply = 1 share 2. transfer 10,000 USDC direct ERC-20 transfer, NOT a deposit totalAssets = 10,001 totalSupply = 1 share (!) share price is now 10,001 USDC per share 3. deposit(5,000 USDC) 5000 × 1 / 10001 = 0.4999... → 0 shares solidity rounds down, victim receives ZERO shares their 5,000 USDC is now in the vault but they have no claim totalAssets = 15,001 totalSupply = 1 share 4. redeem(1 share) 15,001 USDC sent back attacker profit: 5,000 USDC (deposited 10,001, walked away with 15,001)

The four steps:

  1. The attacker is the first to deposit. They put in 1 wei of the asset and receive 1 share. The vault now has totalAssets = 1, totalSupply = 1.
  2. The attacker transfers 10,000 USDC directly to the vault's address using a plain ERC-20 transfer, not a deposit call. The vault's contract has no idea this happened from the standpoint of share accounting. Its totalSupply stays at 1, but its balance (and therefore totalAssets) jumps to 10,001.
  3. The share price is now 10,001 USDC per share. A victim deposits 5,000 USDC and the contract calculates shares = 5,000 × 1 / 10,001 ≈ 0.4999. Solidity rounds down. The victim receives zero shares. Their 5,000 USDC is sitting in the vault, but they have no on-chain claim to it.
  4. The attacker redeems their 1 share. The vault now holds 15,001 USDC and has 1 share outstanding. The attacker gets all 15,001 USDC back. They invested 10,001. They walked away with 15,001. Net profit: 5,000 USDC, taken directly from the victim.

This isn't theoretical. Variants of this attack have hit real protocols, including early Aave V2 markets, Hundred Finance, and others. The pattern is general: any time a share-issuing system has a totalSupply much smaller than its assets, rounding can wipe out the next depositor.

The defense: virtual shares

The OpenZeppelin reference implementation fixes the problem with a technique called virtual shares (sometimes called decimals offset). The idea is to pretend there's always some baseline number of shares and assets in the vault that no one owns and no one can withdraw. These virtual amounts shift the math so the rounding-to-zero attack stops working.

The conversion formula becomes:

undefinedshares = (assets × (totalSupply + 10^offset)) / (totalAssets + 1)

The + 1 and + 10^offset are the virtual amounts. They're tiny in normal use, so they don't measurably affect honest depositors. But when the vault is nearly empty, they prevent share price from being inflated by a small direct transfer.

To see why, redo the attack with the virtual amounts using offset = 0:

  • Attacker deposits 1 wei. Vault has totalAssets = 1 + virtual 1 = 2 effective, totalSupply = 1 + virtual 1 = 2 effective. The 1 share they get is now worth ~half the vault, not the whole thing.
  • Attacker transfers 10,000 USDC directly. Effective totalAssets = 10,002, effective totalSupply still = 2 share-equivalents.
  • Victim deposits 5,000. Shares received = 5,000 × 2 / 10,002 = ~0.9998, still rounds to 0.

So offset = 0 isn't quite enough. Setting offset to 6 (a common choice) means the virtual share supply is 10^6 = 1,000,000. The victim's calculation becomes 5,000 × 1,000,001 / 10,001 ≈ 499,950 shares. Now they actually receive shares, and the attacker can't drain them by donating.

The offset is a parameter the vault implementer chooses. Higher values give stronger protection against the attack, at the cost of producing very small share amounts that may look strange in user interfaces. OpenZeppelin's default _decimalsOffset() returns 0, which the standard recommends increasing for any vault that holds value.

A simpler alternative that also defends against this attack: have the deployer make an initial deposit during construction, large enough that the inflation math doesn't work out economically. This is called "burning the first share" because the shares are usually sent to address(0). It's less robust than virtual shares but is sometimes the right choice for permissioned vaults where you control the deployment.

Decimals handling

A vault's share token has its own decimals(), which by convention should match the underlying asset's decimals. If USDC has 6 decimals, the share token should also have 6, so amounts look consistent in the UI.

The catch is that the original ERC-20 standard doesn't require decimals() to exist. It's part of the optional IERC20Metadata extension. A vault deployed against an exotic asset might not be able to read its decimals at all.

OpenZeppelin's solution is a helper that tries to call decimals() on the asset using a low-level staticcall, falls back to 18 if the call reverts or returns garbage, and then optionally adds the offset before exposing the vault's own decimals. The pattern looks like this:

solidity
function _tryGetAssetDecimals(IERC20 asset_)
    internal view returns (bool, uint8)
{
    (bool success, bytes memory data) = address(asset_).staticcall(
        abi.encodeWithSelector(IERC20Metadata.decimals.selector)
    );
    if (success && data.length >= 32) {
        uint256 raw = abi.decode(data, (uint256));
        if (raw <= type(uint8).max) {
            return (true, uint8(raw));
        }
    }
    return (false, 0);
}

The use of staticcall here is intentional. The vault is asking another contract a question, and staticcall guarantees that the call cannot modify state, so it's safe to make from a constructor or view function.

When to use ERC-4626

A protocol should expose an ERC-4626 interface when it manages an ERC-20 on behalf of users and tracks their share of the pool. That covers:

  • Yield aggregators (Yearn-style strategies that rotate between protocols)
  • Lending markets where deposits earn interest (Aave's aTokens, Compound's cTokens are spiritually ERC-4626)
  • Staking pools that issue derivative tokens
  • Liquid staking and restaking protocols (Lido's stETH is not formally ERC-4626 but plays the same role)
  • Single-asset vaults around any productive on-chain strategy

It's the wrong choice when:

  • The pool holds multiple assets, not one (an AMM pool holds two assets, so ERC-4626 doesn't fit; Uniswap V2 LP tokens are similar in spirit but use their own interface)
  • The user's claim isn't fungible (a lending position with a custom interest rate isn't a clean share of a pool)
  • The shares are non-transferable by design (governance staking with a lockup isn't naturally ERC-4626)

The standard is also intentionally minimal. It says nothing about how the vault generates yield, how fees are charged, how the strategy is upgraded, or how rebalancing happens. All of those are decisions the vault implementer has to make on top of the base interface.