2026-03-16·14 min read

The ERC-4626 donation attack: first depositor exploit explained and tested

The ERC-4626 Donation Attack: First Depositor Exploit Explained and Tested

By antonio — Security Researcher at Recon

If you're building a vault — any vault that holds assets and issues shares, you need to understand this attack. The ERC-4626 donation attack (also called the "first depositor" or "share inflation" attack) is one of the most common vulnerabilities in DeFi vault implementations. It's elegant in its simplicity, devastating in its impact, and frustratingly easy to miss in code review.

I'm going to walk you through exactly how it works, show you real code, explain every mitigation strategy I've seen, and give you the invariant properties that catch it. Let's get into it.

How share-based vaults work

ERC-4626 vaults follow a simple model: you deposit assets, you get shares. The exchange rate between assets and shares determines how much you can withdraw later.

// Standard ERC-4626 share calculation
function convertToShares(uint256 assets) public view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0
        ? assets  // First deposit: 1:1 ratio
        : assets * supply / totalAssets();
}

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

The key formula: shares = assets * totalSupply / totalAssets

This works fine when there are many shares and many assets. But when the vault is near-empty? That's where things get ugly.

The attack: step by step

Here's the complete flow. I'll use concrete numbers so you can follow the math.

Setup

  • A new ERC-4626 vault just deployed. Zero deposits.
  • Alice is an honest user about to deposit 10,000 USDC.
  • Eve (the attacker) sees Alice's transaction in the mempool.

Step 1: Eve front-runs with minimal deposit

Eve deposits 1 wei (0.000000000000000001 USDC).

Vault state:
  totalAssets = 1 wei
  totalSupply = 1 share (1 wei worth of shares)
  Eve's shares = 1

Step 2: Eve donates directly to the vault

Eve transfers 10,000 USDC directly to the vault contract, not through deposit(), just a raw ERC-20 transfer().

Vault state:
  totalAssets = 10,000 USDC + 1 wei  (direct transfer inflated this)
  totalSupply = 1 share               (unchanged -- no deposit() call)
  Eve's shares = 1
  Exchange rate: 1 share = ~10,000 USDC

Step 3: Alice's deposit executes

Alice deposits her 10,000 USDC through deposit().

shares = assets * totalSupply / totalAssets
shares = 10,000 * 1 / 10,000.000000000000000001
shares = 0  (rounds down to zero!)

Alice gets zero shares for her 10,000 USDC. The integer division rounds down because the exchange rate is so inflated.

Step 4: Eve withdraws

Eve redeems her 1 share.

assets = shares * totalAssets / totalSupply
assets = 1 * 20,000 / 1
assets = 20,000 USDC

Eve walks away with 20,000 USDC: her original 10,000 plus Alice's 10,000.

The math that kills

The core issue is integer division rounding. When totalAssets is massive relative to totalSupply, new deposits convert to zero shares:

10,000 * 1 / 10,001 = 0.9999... → rounds to 0

Even if the victim gets 1 share instead of 0, they still lose most of their deposit. The attacker just needs the donation to be large enough relative to incoming deposits.

The vulnerable code

Here's a minimal vulnerable vault:

contract VulnerableVault is ERC20 {
    IERC20 public asset;

    function deposit(uint256 assets, address receiver)
        external
        returns (uint256 shares)
    {
        shares = totalSupply() == 0
            ? assets
            : assets * totalSupply() / totalAssets();

        require(shares > 0, "zero shares");

        asset.transferFrom(msg.sender, address(this), assets);
        _mint(receiver, shares);
    }

    function totalAssets() public view returns (uint256) {
        // This reads the actual token balance --
        // includes direct transfers (donations)
        return asset.balanceOf(address(this));
    }
}

The problem is totalAssets() using balanceOf. Direct transfers inflate it without minting shares.

Real exploits

This isn't theoretical. Several protocols got hit:

  • Yearn V1 vaults had this vector before they added protections.
  • Multiple ERC-4626 wrappers deployed in 2023-2024 were vulnerable.
  • The attack was demonstrated against early Morpho vault implementations.
  • Various yield aggregators on L2s were drained using this exact pattern.

Even OpenZeppelin's initial ERC-4626 implementation needed a security advisory about this. It's that common.

Mitigation strategies

There are four main approaches, each with different tradeoffs.

1. Virtual shares and assets (recommended)

Add a virtual offset to both totalSupply and totalAssets so the vault is never "empty":

contract SafeVault is ERC20 {
    IERC20 public asset;
    uint256 internal constant VIRTUAL_SHARES = 1e6;
    uint256 internal constant VIRTUAL_ASSETS = 1;

    function convertToShares(uint256 assets) public view returns (uint256) {
        return assets * (totalSupply() + VIRTUAL_SHARES)
            / (totalAssets() + VIRTUAL_ASSETS);
    }

    function convertToAssets(uint256 shares) public view returns (uint256) {
        return shares * (totalAssets() + VIRTUAL_ASSETS)
            / (totalSupply() + VIRTUAL_SHARES);
    }
}

With virtual shares of 1e6, the attacker would need to donate 1e6 times more to achieve the same rounding effect. OpenZeppelin's latest ERC-4626 uses this approach with a configurable offset.

Tradeoff: Tiny rounding loss on all deposits (usually < 1 wei). It's practically free.

2. Dead shares (burn on first deposit)

On the first deposit, mint a minimum number of shares to a dead address:

function deposit(uint256 assets, address receiver)
    external
    returns (uint256 shares)
{
    if (totalSupply() == 0) {
        uint256 deadShares = 1000;
        shares = assets - deadShares;
        _mint(address(0xdead), deadShares);
        _mint(receiver, shares);
    } else {
        shares = assets * totalSupply() / totalAssets();
        _mint(receiver, shares);
    }
    asset.transferFrom(msg.sender, address(this), assets);
}

Those dead shares can never be redeemed, so totalSupply always stays above 1000. An attacker would need to donate enough to make each of those 1000 shares worth more than the victim's entire deposit.

Tradeoff: First depositor loses a tiny amount. Fair enough.

3. Minimum deposit enforcement

Require a minimum first deposit:

uint256 constant MIN_FIRST_DEPOSIT = 1e6; // 1 USDC for 6-decimal tokens

function deposit(uint256 assets, address receiver) external {
    if (totalSupply() == 0) {
        require(assets >= MIN_FIRST_DEPOSIT, "Below minimum");
    }
    // ...
}

This makes the attack more expensive but doesn't eliminate it. The attacker just needs a proportionally larger donation.

Tradeoff: Not a complete fix. Use alongside other methods.

4. Internal accounting (don't use balanceOf)

Track assets internally instead of reading balanceOf:

contract InternalAccountingVault is ERC20 {
    uint256 internal _totalAssets;

    function deposit(uint256 assets, address receiver) external {
        // ... mint shares ...
        asset.transferFrom(msg.sender, address(this), assets);
        _totalAssets += assets;
    }

    function totalAssets() public view returns (uint256) {
        return _totalAssets; // Donations don't affect this
    }
}

Direct token transfers still land in the contract, but they don't affect the exchange rate because totalAssets is tracked separately.

Tradeoff: You need to handle yield accrual differently. If the vault earns yield through rebasing or airdrops, internal accounting won't capture it automatically.

The invariant properties that catch it

Here's what matters most, the properties that break when this attack happens. These are what you'd write for a fuzzing campaign.

Property 1: no depositor gets zero shares

// If you deposit a non-trivial amount, you MUST get shares
function invariant_noZeroShareDeposits() public returns (bool) {
    // After every deposit action, check:
    // if deposited amount > MIN_MEANINGFUL_AMOUNT,
    // shares received > 0
    for (uint256 i = 0; i < actors.length; i++) {
        uint256 deposited = vault.totalDepositedBy(actors[i]);
        uint256 shares = vault.balanceOf(actors[i]);

        if (deposited > MIN_MEANINGFUL_AMOUNT && shares == 0) {
            return false;
        }
    }
    return true;
}

Property 2: withdrawal value approximates deposit value

// What you can withdraw should be close to what you deposited
// (minus fees, plus yield, within tolerance)
function invariant_depositWithdrawSymmetry() public returns (bool) {
    for (uint256 i = 0; i < actors.length; i++) {
        uint256 shares = vault.balanceOf(actors[i]);
        if (shares == 0) continue;

        uint256 redeemable = vault.convertToAssets(shares);
        uint256 deposited = ghost_deposited[actors[i]];

        // Should get back at least 99% of deposit
        // (allowing for small rounding)
        if (redeemable < deposited * 99 / 100) {
            return false;
        }
    }
    return true;
}

Property 3: exchange rate bounded change

// Exchange rate should not change dramatically in one transaction
function invariant_exchangeRateBounded() public returns (bool) {
    uint256 currentRate = vault.totalSupply() > 0
        ? vault.totalAssets() * 1e18 / vault.totalSupply()
        : 1e18;

    if (ghost_lastExchangeRate > 0) {
        uint256 change = currentRate > ghost_lastExchangeRate
            ? currentRate - ghost_lastExchangeRate
            : ghost_lastExchangeRate - currentRate;

        // Rate shouldn't jump more than 10% in any single operation
        if (change > ghost_lastExchangeRate / 10) {
            return false;
        }
    }

    ghost_lastExchangeRate = currentRate;
    return true;
}

Property 4: no value extraction beyond deposits

// No user should withdraw more than they deposited (ignoring yield)
// This catches the attacker profiting from the attack
function invariant_noFreeValue() public returns (bool) {
    for (uint256 i = 0; i < actors.length; i++) {
        uint256 totalIn = ghost_totalDeposited[actors[i]];
        uint256 totalOut = ghost_totalWithdrawn[actors[i]];

        // In a vault with no yield source active,
        // no one should extract more than they put in
        if (totalOut > totalIn + DUST_THRESHOLD) {
            return false;
        }
    }
    return true;
}

Property 5: share inflation detection

// totalAssets and totalSupply should grow proportionally
function invariant_noShareInflation() public returns (bool) {
    uint256 supply = vault.totalSupply();
    uint256 assets = vault.totalAssets();

    if (supply == 0) return true;

    // Price per share shouldn't be astronomical
    // (sign of donation attack)
    uint256 pricePerShare = assets * 1e18 / supply;

    // If one share is worth more than 1M tokens, something's wrong
    return pricePerShare < 1e24; // 1M * 1e18
}

Running the fuzzing campaign

Here's how I'd set up the full invariant testing harness:

contract DonationAttackTest is Test {
    Vault vault;
    MockERC20 token;

    address attacker = address(0xBAD);
    address victim = address(0xBEEF);

    function setUp() public {
        token = new MockERC20("USDC", "USDC", 6);
        vault = new Vault(address(token));

        // Fund actors
        token.mint(attacker, 1_000_000e6);
        token.mint(victim, 100_000e6);

        vm.prank(attacker);
        token.approve(address(vault), type(uint256).max);
        vm.prank(victim);
        token.approve(address(vault), type(uint256).max);
    }

    // Handler: fuzzer can deposit any amount as any actor
    function handler_deposit(uint256 amount, bool isAttacker) external {
        address actor = isAttacker ? attacker : victim;
        amount = bound(amount, 1, token.balanceOf(actor));

        vm.prank(actor);
        vault.deposit(amount, actor);
    }

    // Handler: fuzzer can donate (direct transfer)
    function handler_donate(uint256 amount) external {
        amount = bound(amount, 1, token.balanceOf(attacker));

        vm.prank(attacker);
        token.transfer(address(vault), amount);
    }

    // Handler: fuzzer can withdraw
    function handler_withdraw(uint256 shares, bool isAttacker) external {
        address actor = isAttacker ? attacker : victim;
        shares = bound(shares, 1, vault.balanceOf(actor));
        if (shares == 0) return;

        vm.prank(actor);
        vault.redeem(shares, actor, actor);
    }

    // Invariants
    function invariant_noStolenValue() public {
        uint256 attackerNet = ghost_withdrawn[attacker];
        uint256 attackerDeposited = ghost_deposited[attacker];
        uint256 attackerDonated = ghost_donated[attacker];

        // Attacker shouldn't profit net of donations
        assertLe(
            attackerNet,
            attackerDeposited + attackerDonated + 1,
            "Attacker extracted value"
        );
    }
}

The fuzzer will naturally discover the deposit-1-wei → donate → wait-for-victim → withdraw sequence. It doesn't need to know about the attack. It just tries random actions and checks the properties.

That's the entire point of invariant testing. You don't encode the attack. You encode what should always be true, and the fuzzer finds the attack for you.

Which mitigation should you use?

My recommendation: virtual shares + internal accounting. Together they make the attack economically impractical and structurally impossible.

Virtual shares with an offset of 1e6 mean the attacker needs to donate 1e6 tokens per 1 token of victim loss. Internal accounting means donations don't affect the exchange rate at all. Belt and suspenders.

If you're using OpenZeppelin's ERC-4626, they've already got the virtual offset built in since v5. Just make sure you're using a recent version and haven't accidentally overridden _decimalsOffset() to return 0.

The bottom line

The donation attack is a textbook example of why you can't just review code, you have to test it. The vulnerable code looks correct. The math is right for normal conditions. It only breaks under a specific sequence of operations that a human reviewer might not think to check.

Invariant testing catches it because it doesn't care about the sequence. It checks the result: did anyone get free money? Did any depositor lose funds? Did the exchange rate jump suspiciously?

Want to test your vault against donation attacks and other ERC-4626 edge cases? Try Recon Pro to run fuzzing campaigns with built-in vault property suites. Or request an audit and we'll write custom invariants for your specific vault design.

Further reading

Related Posts

Related Glossary Terms

Secure your ERC-4626 vault