ERC-4626 inflation attacks: how they work and how to prevent them
How the first-depositor inflation attack steals from ERC-4626 vault users, with worked numbers, and how virtual shares and internal accounting stop it.
· 4 min read
- ERC-4626
- DeFi security
- Solidity
ERC-4626 gave Ethereum a standard interface for tokenised vaults: deposit assets, receive shares, redeem shares for assets later. It is now the default building block for yield vaults, lending pools and staking wrappers. It also comes with a well-known trap. A vault that converts between assets and shares naively can be drained by its very first depositor.
This article walks through the inflation attack with real numbers, then covers the defences and how to test that they work.
How a vault prices shares
A vault tracks two numbers: the total assets it holds and the total supply of shares. When you deposit, you receive shares in proportion to your share of the assets:
shares = assets * totalSupply / totalAssets;
Solidity integer division rounds down. ERC-4626 asks vaults to round in the vault’s favour, so a depositor receiving shares is rounded down, and a redeemer receiving assets is rounded down too. That is correct, but it is also where the attack lives.
The attack, step by step
Take a new USDC vault (6 decimals) whose totalAssets() is simply its token balance.
- The attacker deposits 1 unit (0.000001 USDC) into the empty vault and receives 1 share.
- The attacker donates 10,000 USDC by transferring it straight to the vault, without calling
deposit. No new shares are minted. The vault now holds 10,000.000001 USDC against 1 share. - A victim deposits 10,000 USDC. Their shares are
10_000e6 * 1 / (10_000e6 + 1), which rounds down to zero. The victim’s USDC enters the vault and they receive nothing. - The attacker redeems their single share for the whole vault: about 20,000 USDC.
The attacker spent 10,000 USDC on the donation and walked away with 20,000. In practice they front-run the victim’s deposit transaction, so the victim never sees the manipulated state before signing.
The attack doesn’t need the victim to round all the way to zero. If the victim had deposited 19,999 USDC they would receive one share, the same as the attacker, and the attacker would still take a large slice of their deposit. Any rounding loss on a share price the attacker can inflate is extractable.
Why it works
Three things combine:
- Share price can be inflated by donation. If
totalAssets()reads the token balance, anyone can raise it without minting shares. - Share supply starts tiny. With one share outstanding, a single unit of rounding is enormous.
- Rounding is in the vault’s favour, and the attacker owns the vault.
Remove any one of these and the attack becomes unprofitable.
Defence 1: virtual shares and assets
OpenZeppelin’s ERC-4626 implementation adds a virtual offset to both sides of the conversion:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}
The vault behaves as if it always had some extra shares and one extra unit of assets that nobody owns. A donation now accrues partly to those virtual shares, so the attacker loses part of every donation. Increasing _decimalsOffset() multiplies the cost of the attack: with an offset of 6, the attacker must donate roughly a million times more to cause the same rounding loss. For most vaults, an offset that makes the attack cost far more than it could ever return is the simplest fix.
Defence 2: dead shares
Mint a small number of shares to an address nobody controls when the vault is created, as Uniswap V2 does with its minimum liquidity. The vault never starts empty, so the attacker can’t own 100% of the supply. This works well when the deployer seeds the vault in the same transaction as deployment.
Defence 3: internal accounting
Track assets in a storage variable that only changes through deposit, withdraw, and the vault’s own yield logic, instead of reading balanceOf(address(this)). Donations then don’t change the share price at all. They are simply ignored (or swept by governance). This removes the root cause, at the cost of slightly more accounting code.
Defence 4: revert on zero shares, and slippage limits
Two cheap checks belong in every vault:
shares = previewDeposit(assets);
require(shares != 0, ZeroShares());
Rejecting deposits that mint zero shares stops the most blatant version of the attack. It doesn’t stop partial losses, so integrators should also pass a minimum number of shares they will accept. ERC-4626 itself has no slippage parameter, so this usually lives in a router or the calling contract.
Which defences to use
| Defence | Stops donation inflation | Cost |
|---|---|---|
| Virtual shares (offset) | Makes it unprofitable | Tiny rounding loss to virtual shares |
| Dead shares | Makes it unprofitable | Seed deposit at launch |
| Internal accounting | Yes | More code, donated tokens need handling |
| Zero-share revert | Only the worst case | None |
| Slippage limits | Protects the caller | Integration work |
We usually combine internal accounting or a virtual offset with a zero-share revert, then add slippage checks in any contract that deposits on a user’s behalf.
Testing it
Don’t rely on reading the code. Write the attack as a test:
function test_inflationAttackIsUnprofitable() public {
vm.prank(attacker);
vault.deposit(1, attacker);
vm.prank(attacker);
usdc.transfer(address(vault), 10_000e6);
vm.prank(victim);
vault.deposit(10_000e6, victim);
vm.prank(attacker);
uint256 out = vault.redeem(vault.balanceOf(attacker), attacker, attacker);
assertLt(out, 10_000e6 + 1, "attacker profited");
}
Then go further with a fuzz test over donation and deposit sizes, and an invariant that no depositor can redeem less than they put in, minus a bounded rounding allowance, when no losses have occurred.
Summary
The ERC-4626 inflation attack is old, well documented and still turns up in new code, usually in vaults that read their token balance and launch empty. Use a virtual offset or internal accounting, reject zero-share deposits, protect callers with slippage limits, and prove it with tests.
If you’re building or reviewing a vault, we’re happy to help: talk to us about your protocol.
Try it
Run the attack yourself
The attacker deposits 0.000001 USDC first, donates, then the victim deposits. Change the numbers and the defence.
- Victim's shares
- Victim can withdraw
- Attacker's profit