ERC-4626: The Tokenized Vault Standard Explained

ERC-4626 is a Solidity interface standard for yield-bearing vaults. It defines a single, consistent way for protocols to accept deposits, issue shares, and return assets on withdrawal. This post explains how the standard works, why the math behaves the way it does, and what to watch out for when implementing or integrating with it.

The Problem It Solves

Before ERC-4626, every yield protocol built its own vault interface. Yearn issued yTokens. Compound issued cTokens. Aave issued aTokens. Each worked differently at the function level.

This meant every integration required custom code. An aggregator that wanted to support five yield protocols had to write five different adapters. A bug in one adapter did not apply to the others. Auditing was harder. Development was slower.

ERC-4626 gives every vault the same function signatures and the same mathematical guarantees. Deposit tokens, receive shares. Redeem shares, receive tokens. An aggregator written against ERC-4626 works with any compliant vault without modification.

How Shares and Assets Relate

A vault holds an underlying asset (for example, USDC). When you deposit, you receive shares in return. Shares represent your proportional ownership of everything inside the vault.

The relationship between shares and assets is defined by this ratio:

exchange rate = totalAssets() / totalSupply()

At launch, a vault typically starts at 1:1. As the vault earns yield and totalAssets() grows, each share becomes redeemable for more of the underlying asset.

Here is a concrete example:

State: 10,000 USDC in the vault, 8,000 shares outstanding
Exchange rate: 10,000 / 8,000 = 1.25 USDC per share

You deposit 1,000 USDC:
Shares minted = 1,000 / 1.25 = 800 shares

Later, the vault earns yield: now 11,000 USDC, 8,800 shares outstanding
You redeem your 800 shares:
Assets returned = 800 * (11,000 / 8,800) = 1,000 USDC * 1.04... โ‰ˆ 1,045 USDC

Your shares became worth more. You did not need to claim anything. The exchange rate carried the yield.

The Core Interface

Every ERC-4626 vault implements these functions:

// Returns the address of the underlying token
function asset() external view returns (address);

// Returns total underlying tokens managed by the vault
function totalAssets() external view returns (uint256);

// Deposits `assets` tokens, mints shares to `receiver`
function deposit(uint256 assets, address receiver) external returns (uint256 shares);

// Mints exactly `shares` shares, pulls required assets from caller
function mint(uint256 shares, address receiver) external returns (uint256 assets);

// Burns shares from `owner`, sends exactly `assets` tokens to `receiver`
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares);

// Burns exactly `shares` from `owner`, sends resulting tokens to `receiver`
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);

// Preview functions (view, no state changes)
function previewDeposit(uint256 assets) external view returns (uint256 shares);
function previewMint(uint256 shares) external view returns (uint256 assets);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function previewRedeem(uint256 shares) external view returns (uint256 assets);

// Limits
function maxDeposit(address receiver) external view returns (uint256);
function maxMint(address receiver) external view returns (uint256);
function maxWithdraw(address owner) external view returns (uint256);
function maxRedeem(address owner) external view returns (uint256);

Deposit vs Mint, Withdraw vs Redeem

The standard provides two entry paths and two exit paths. The difference is which side you fix.

OperationYou fixThe vault determines
depositHow many assets to put inHow many shares you receive
mintHow many shares you wantHow many assets to pull
withdrawHow many assets to get backHow many shares to burn
redeemHow many shares to burnHow many assets to return

Use deposit and redeem in most cases. Use mint and withdraw when the contract on the other side needs exact amounts, such as when fulfilling a specific share-denominated obligation.

A Minimal Implementation

The simplest compliant vault just holds tokens in place, with no yield strategy at all:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract BasicVault is ERC4626 {
    constructor(
        IERC20 _asset,
        string memory _name,
        string memory _symbol
    ) ERC4626(_asset) ERC20(_name, _symbol) {}

    function totalAssets() public view override returns (uint256) {
        return IERC20(asset()).balanceOf(address(this));
    }
}

Real vaults deploy yield strategies inside totalAssets() or through a separate harvest() function that compounds returns and updates the vault's balance.

The Inflation Attack

Early vault designs had a known exploit. Here is how it works:

  1. An attacker deposits 1 wei as the first depositor. They receive 1 share.
  2. The attacker transfers 1,000,000 tokens directly to the vault contract (not through deposit). This does not mint shares.
  3. The exchange rate is now 1,000,001 assets per share.
  4. A victim deposits 999,999 tokens. Integer division gives them 0 shares.
  5. The victim receives nothing. The attacker redeems their 1 share and takes everything.

Two approaches defend against this.

Virtual Offset

Add a virtual balance and virtual supply to the exchange rate calculation:

function _convertToShares(uint256 assets, Math.Rounding rounding)
    internal view override returns (uint256)
{
    return assets.mulDiv(
        totalSupply() + 10 ** _decimalsOffset(),  // virtual supply offset
        totalAssets() + 1,                         // virtual asset offset
        rounding
    );
}

OpenZeppelin's ERC4626 implementation uses a _decimalsOffset() hook for exactly this purpose. Setting it to a non-zero value (commonly 0 for most tokens, higher for tokens with few decimals) adds the offset automatically.

Dead Shares

Mint a small number of shares to address(0) in the constructor, permanently locking them:

constructor(IERC20 _asset) ERC4626(_asset) ERC20("Vault", "vTKN") {
    // Lock initial liquidity so the exchange rate cannot be manipulated cheaply
    _mint(address(0xdead), 1000);
}

This raises the cost of the attack significantly. The attacker would need to donate enough to overwhelm the dead shares.

If you deploy a vault without inflation protection and someone front-runs the first deposit, the vault can be permanently broken for honest users. Add protection before deployment, not after.

Fee Structures

Vaults typically charge fees through share dilution rather than direct asset transfers. A performance fee works like this:

function harvest() external {
    uint256 currentAssets = totalAssets();
    uint256 profit = currentAssets - lastRecordedAssets;

    if (profit > 0) {
        uint256 feeInAssets = (profit * performanceFee) / 10_000; // basis points
        uint256 feeShares = convertToShares(feeInAssets);

        // Minting shares to the protocol dilutes all other holders proportionally
        _mint(feeRecipient, feeShares);
        lastRecordedAssets = totalAssets();
    }
}

Minting shares to a recipient reduces the share price for everyone else by a small, proportional amount. No asset transfers happen. The fee is implicit in the dilution.

Integrating with ERC-4626 Vaults

Because every ERC-4626 vault exposes the same interface, aggregators and routers can interact with any compliant vault using the same code:

interface IERC4626 {
    function asset() external view returns (address);
    function deposit(uint256 assets, address receiver) external returns (uint256 shares);
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets);
    function previewDeposit(uint256 assets) external view returns (uint256 shares);
}

contract VaultRouter {
    function zapIntoVault(IERC4626 vault, uint256 amount) external {
        IERC20 underlying = IERC20(vault.asset());
        underlying.transferFrom(msg.sender, address(this), amount);
        underlying.approve(address(vault), amount);

        vault.deposit(amount, msg.sender);
    }
}

This router works for any vault, whether it is a Yearn strategy, a Beefy compounder, or a custom protocol, as long as the vault implements ERC-4626.

Real-World Considerations

Slippage Protection

Always check preview functions before transacting and enforce a minimum output:

uint256 expectedShares = vault.previewDeposit(amount);
uint256 minShares = (expectedShares * 99) / 100; // 1% slippage tolerance

uint256 actualShares = vault.deposit(amount, receiver);
require(actualShares >= minShares, "Slippage exceeded");

Some vaults charge entry or exit fees that the preview functions may not fully account for, depending on implementation. Read the vault documentation before assuming previews are exact.

Deposit and Withdrawal Limits

Vaults can cap total deposits or impose per-user limits. Always check before transacting:

uint256 depositLimit = vault.maxDeposit(msg.sender);
require(depositAmount <= depositLimit, "Exceeds vault capacity");

Withdrawal Delays

Some yield strategies lock assets in external protocols. The vault may not be able to return funds immediately. maxWithdraw() and maxRedeem() tell you how much is available right now:

uint256 availableNow = vault.maxWithdraw(msg.sender);
if (desiredAmount > availableNow) {
    // Handle partial withdrawal or enter a withdrawal queue
}

Rounding

Integer division always truncates. The standard specifies how to round: round in favor of the vault on deposits, round in favor of the user on withdrawals. This prevents users from extracting value through repeated small operations.

// Deposit: round shares DOWN (vault-favorable)
shares = assets.mulDiv(totalSupply(), totalAssets(), Math.Rounding.Floor);

// Withdraw: round shares UP (user-favorable, meaning fewer shares burned)
// Redeem: round assets DOWN (user-favorable, meaning fewer assets expected to be exact)

OpenZeppelin's implementation handles this correctly. If you are writing a custom vault, verify your rounding direction matches the spec.

Security Risks

Reentrancy. Deposit and withdrawal functions transfer tokens, which can trigger callbacks in ERC-777 or similar token contracts. Use the checks-effects-interactions pattern or a reentrancy guard on all external-facing vault functions.

Oracle manipulation. If totalAssets() reads a price from an AMM or external oracle, a flash loan can manipulate the exchange rate within a single transaction. Use time-weighted average prices or other manipulation-resistant methods if your vault's asset accounting depends on price feeds.

Direct token transfers. Tokens sent to the vault address without calling deposit() increase totalAssets() but do not mint shares. This is a donation that benefits all existing shareholders. It can also be used as part of an inflation attack if the vault is not protected.

Testing Fundamentals

Two properties must hold for any correct vault implementation.

Round-trip integrity: depositing and immediately withdrawing should return approximately the same amount, minus only legitimate fees and rounding:

function testRoundTrip() public {
    uint256 depositAmount = 1000e18;
    uint256 shares = vault.deposit(depositAmount, user);
    uint256 assetsBack = vault.redeem(shares, user, user);

    // Allow for rounding up to 2 wei
    assertApproxEqAbs(assetsBack, depositAmount, 2);
}

Inflation resistance: a direct token transfer to the vault should not allow an attacker to extract a later depositor's funds:

function testInflationAttack() public {
    // Attacker plants a small deposit
    vault.deposit(1, attacker);

    // Attacker donates directly to the vault contract
    token.transfer(address(vault), 1e18);

    // Victim deposits a large amount
    uint256 shares = vault.deposit(1000e18, victim);

    // Victim must receive meaningful shares
    assertGt(shares, 0);
}

If your vault fails the second test, you need inflation protection before deployment.

When ERC-4626 Fits and When It Does Not

The standard works well for:

  • Yield aggregators that pool user deposits into a single strategy
  • Single-asset staking with auto-compounding returns
  • Lending protocols where depositors earn interest over time
  • Treasury management systems that need a standardized vault interface

It is a poor fit for:

  • Multi-asset vaults. The standard assumes one underlying asset. Vaults that hold baskets of tokens require a different design.
  • NFT-based strategies. Shares are fungible ERC-20 tokens. You cannot represent fractional or unique positions within the standard.
  • Vaults with complex, conditional withdrawal rules. maxWithdraw() is a simple integer. If withdrawal eligibility depends on time locks, governance votes, or other state, the standard cannot express that fully.

Summary

ERC-4626 standardizes the deposit-share-redeem cycle so that any integrator can work with any compliant vault using the same code. The exchange rate rises as yield accumulates. Preview functions let callers simulate outcomes before transacting. The standard has known risks (inflation attacks, rounding, reentrancy) that have established mitigations.

If you are building a new yield vault, start from OpenZeppelin's ERC4626 base contract. It handles the exchange rate math, rounding direction, and inflation protection correctly. Add your yield strategy on top.