ERC-4626 Vault Token Development

We design and develop full-cycle blockchain solutions: from smart contract architecture to launching DeFi protocols, NFT marketplaces and crypto exchanges. Security audits, tokenomics, integration with existing infrastructure.
Showing 1 of 1 servicesAll 1306 services
ERC-4626 Vault Token Development
Medium
~3-5 business days
FAQ
Blockchain Development Services
Blockchain Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1214
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_logo-advance_0.png
    B2B Advance company logo design
    561
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    823

ERC-4626 Token (Vault) Development

Before ERC-4626, each yield vault implemented its own interface. Yearn V2 had pricePerShare(). Compound provided exchangeRate(). Aave worked through aToken with rebasing. Writing an aggregator that works with multiple vaults simultaneously meant maintaining a zoo of adapters. ERC-4626 standardized this: one interface for all tokenized vaults.

Today ERC-4626 is used by: Yearn V3, Morpho Blue, most liquid staking protocols, all major lending aggregators. The de facto standard for yield-bearing tokens.

What is ERC-4626 and why it matters for integrations

ERC-4626 is an ERC-20 extension that adds standard methods for a vault: deposit/withdraw assets (underlying asset), mint/redeem shares (vault token), conversion between assets and shares.

assets (underlying, for example USDC)
    ↕  convertToShares / convertToAssets
shares (vault token, for example yvUSDC)

Key point: vault token (shares) is a regular ERC-20 that trades and transfers. The share price grows as yield accumulates. This is fundamentally different from rebasing (stETH), where the number of tokens changes while price stays constant.

Vault mathematics: price per share

Share price in ERC-4626 is determined by totalAssets() / totalSupply():

pricePerShare = totalAssets / totalShares

On deposit, the user receives shares:

sharesToMint = assets * totalShares / totalAssets

On the first deposit (totalShares = 0), a problem arises: any formula with division by zero is invalid. OpenZeppelin solves this through virtual shares: initialize totalShares = 10^decimals, totalAssets = 10^decimals, giving an initial pricePerShare = 1.

Inflation attack on vault

This is a real vulnerability that allows the first depositor to profit at the expense of subsequent ones. Scenario:

  1. Attacker deposits 1 wei of asset, receives 1 share
  2. Attacker donates (direct transfer, bypassing deposit) a large amount of asset to the vault
  3. pricePerShare spikes dramatically: 1 share now worth much more
  4. Next user deposits 1000 USDC, but due to rounding receives 0 shares (rounding down)
  5. Their assets go to the attacker through redemption

OpenZeppelin ERC4626 protects against this through virtual shares (ERC4626 v5.0+):

function _decimalsOffset() internal view virtual returns (uint8) {
    return 0; // Increase to 3 for additional protection
}

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

With _decimalsOffset() = 3, the virtual supply is 10^(3+decimals) shares against 10^decimals assets, making the attack economically infeasible — the attacker would need to deposit a huge sum for minimal gain.

Basic ERC-4626 vault implementation

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

import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract SimpleYieldVault is ERC4626, Ownable {
    address public strategy;
    uint256 public performanceFee; // in basis points (500 = 5%)
    
    constructor(
        IERC20 asset_,
        string memory name_,
        string memory symbol_
    ) ERC4626(asset_) ERC20(name_, symbol_) Ownable(msg.sender) {}
    
    // Override totalAssets: account for not just vault balance,
    // but also assets deployed in strategy
    function totalAssets() public view virtual override returns (uint256) {
        uint256 vaultBalance = IERC20(asset()).balanceOf(address(this));
        uint256 strategyBalance = strategy != address(0) 
            ? IStrategy(strategy).totalAssets() 
            : 0;
        return vaultBalance + strategyBalance;
    }
    
    // Hook after deposit — deploy to strategy
    function _afterDeposit(uint256 assets, uint256) internal virtual {
        if (strategy != address(0)) {
            IERC20(asset()).approve(strategy, assets);
            IStrategy(strategy).invest(assets);
        }
    }
    
    // Hook before withdraw — withdraw from strategy
    function _beforeWithdraw(uint256 assets, uint256) internal virtual {
        uint256 vaultBalance = IERC20(asset()).balanceOf(address(this));
        if (assets > vaultBalance && strategy != address(0)) {
            IStrategy(strategy).divest(assets - vaultBalance);
        }
    }
    
    // Custom slippage checks
    function deposit(uint256 assets, address receiver) 
        public 
        virtual 
        override 
        returns (uint256 shares) 
    {
        uint256 maxDeposit = maxDeposit(receiver);
        require(assets <= maxDeposit, "ERC4626: deposit more than max");
        
        shares = previewDeposit(assets);
        require(shares > 0, "Zero shares");
        
        _deposit(_msgSender(), receiver, assets, shares);
        
        _afterDeposit(assets, shares);
        
        return shares;
    }
}

Important edge cases in ERC-4626

Fee-on-transfer underlying asset

If the underlying asset is a fee-on-transfer token (some DeFi tokens with burn mechanics), the vault receives less than specified in deposit(). Correct implementation measures actual balance:

function _deposit(address caller, address receiver, uint256 assets, uint256 shares) 
    internal override 
{
    uint256 balanceBefore = IERC20(asset()).balanceOf(address(this));
    SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets);
    uint256 actualReceived = IERC20(asset()).balanceOf(address(this)) - balanceBefore;
    
    // Recalculate shares based on actually received amount
    shares = convertToShares(actualReceived);
    _mint(receiver, shares);
    
    emit Deposit(caller, receiver, actualReceived, shares);
}

Maximal extractable value through preview

Functions previewDeposit() and previewWithdraw() must return exact amounts without fees (EIP-4626 requirement). But if vault takes performance fee, this changes the balance between rounds. Important not to include fee in preview functions, otherwise integrators get wrong UI data.

Rounding direction

ERC-4626 explicitly specifies rounding direction:

  • convertToShares → floor (down, in vault's favor)
  • convertToAssets → floor (down, in vault's favor)
  • previewDeposit → floor (user gets no more than calculated)
  • previewWithdraw → ceil (vault takes no less than needed)
  • previewRedeem → floor

Violating these rules is an audit finding. Rounding must always favor the vault, otherwise drainage is possible through many small operations.

Integration with yield strategies

A full-featured ERC-4626 vault typically has a separate Strategy contract:

Component Responsibility
Vault (ERC-4626) Account for shares, deposit/withdraw, access control
Strategy Deploy assets to protocols (Aave, Curve, Convex)
Harvester Collect rewards, swap to underlying, reinvest
Fee Manager Calculate and distribute performance fees

Separation of concerns is important for audits: strategy can be replaced without changing vault. Users hold vault shares, while strategy can change through governance.

Testing and auditing

ERC-4626 has an official property test suite: a16z ERC4626 Properties. Run it mandatory — it covers all roundtrip properties and standard invariants.

Foundry fuzz tests on key invariants:

function testFuzz_DepositRedeem(uint256 assets) public {
    assets = bound(assets, 1, 1e30);
    vm.assume(assets <= token.balanceOf(user));
    
    uint256 shares = vault.deposit(assets, user);
    uint256 assetsBack = vault.redeem(shares, user, user);
    
    // May have rounding losses, but no more than 1 wei
    assertApproxEqAbs(assetsBack, assets, 1);
}

Developing an ERC-4626 vault with basic strategy: 3-5 working days. Full-featured vault with harvester, fee mechanism, and multiple strategies: 2-3 weeks. Cost is calculated individually.