hazehaze

The ERC-4626 inflation attack, and how to make your vault immune

· 3 min read · by haze

ERC-4626 vaults are everywhere: yield vaults, staking wrappers, lending markets. The standard is simple and good. But a naive implementation has a well-known hole: when the vault is empty, the first depositor can steal most or all of the next person's deposit. No flash loan or oracle needed, just a token transfer.

How share math works

A vault turns assets into shares. When you deposit, you get shares in proportion to what's already there:

function convertToShares(uint256 assets) public view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0 ? assets : assets * supply / totalAssets();
}

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

Two details make the attack possible:

  • Division rounds down. If assets * supply is smaller than totalAssets(), you get zero shares.
  • totalAssets() reads the token balance. Anyone can raise it by sending tokens straight to the vault, without minting a single share.

The attack, step by step

  1. The vault is empty. The attacker deposits 1 wei and gets 1 share.
  2. The attacker transfers 10,000 tokens directly to the vault. No shares are minted, so there is still only 1 share, now backed by 10,000 tokens and 1 wei.
  3. A victim deposits 9,999 tokens. Their shares are 9,999e18 * 1 / (10,000e18 + 1), which rounds down to 0.
  4. The vault now holds about 20,000 tokens and the attacker owns the only share. They redeem it and walk away with the victim's deposit.

The attacker doesn't even need to be first by luck. They can watch the mempool for the first real deposit and front-run it with steps 1 and 2.

If the victim deposits more than the donation, they get 1 share instead of the roughly 2 they deserved, and the rounding loss still flows to the attacker. Reverting on zero shares (which some vaults do) blocks the worst case, but not this one.

A Foundry test that shows it

function test_inflationAttack() public {
    vm.startPrank(attacker);
    token.approve(address(vault), type(uint256).max);
    vault.deposit(1, attacker);                  // 1 share
    token.transfer(address(vault), 10_000e18);   // donation: no shares minted
    vm.stopPrank();

    vm.startPrank(victim);
    token.approve(address(vault), type(uint256).max);
    uint256 shares = vault.deposit(9_999e18, victim);
    vm.stopPrank();

    assertEq(shares, 0); // the whole deposit now belongs to the attacker's share
}

If this test passes against your vault, you have the bug.

Fix 1: virtual shares and assets

OpenZeppelin's ERC4626 (from v4.9) adds a virtual offset to the share math. Conversions behave as if there were always some extra shares and 1 extra unit of assets in the vault:

// inside OpenZeppelin's ERC4626
shares = assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);

The virtual shares soak up part of any donation, so the attacker loses more than they can take. That makes the attack unprofitable even with an offset of 0. A bigger offset also multiplies what it costs to grief depositors at all:

contract Vault is ERC4626 {
    constructor(IERC20 asset_) ERC4626(asset_) ERC20("Vault", "vTKN") {}

    function _decimalsOffset() internal pure override returns (uint8) {
        return 6;
    }
}

The trade-off: shares get more decimals than the asset, so check that your frontend and integrations display them correctly.

Fix 2: seed the vault at deploy

Deposit a meaningful amount when you deploy and send those shares to an address nobody controls (Uniswap V2 does the same with its minimum liquidity). With real assets and shares already in place, a donation has to be enormous to move the price. Do the deposit in the same deploy script, so there is never a block where the vault is empty.

Fix 3: count assets yourself

Keep totalAssets in storage and update it only on deposit, withdraw and harvest, instead of reading balanceOf. A direct transfer then changes nothing. This is the strongest fix, but it changes how yield gets in, so design the harvest path carefully.

Fix 4: give depositors slippage protection

Let users say how many shares they expect at minimum, and revert if they would get fewer. A router or a deposit(assets, receiver, minShares) variant (as in ERC-5143) protects them even if something else goes wrong with the share price.

Checklist for your vault

  • Run the test above against your vault, with the vault empty.
  • Use OpenZeppelin's ERC4626 with a _decimalsOffset() above 0, or seed the vault in the deploy script, or both.
  • Remember the vault can become empty again after everyone withdraws. The same attack works then.
  • Give depositors a minimum-shares check.
  • Add an invariant test: no deposit should ever mint zero shares while assets go in.
Building a vault? This is one of the first things the 48-hour pre-launch check looks at, along with the rest of the pre-launch checklist.

more notes