At a glance
- The setting
- A vault where depositors receive shares, and each share is worth a slice of the pooled assets. Standardised as ERC-4626
- The bug
- When the vault is empty, the first depositor can set the exchange rate, then donate assets directly to inflate the price of a single share
- The theft
- The next depositor's stake, divided by an inflated share price and rounded down, mints zero shares, and their assets are absorbed by the attacker's one share
- The fix
- Virtual shares and assets in the maths, and internal accounting that ignores donations, so the rate cannot be set from empty or moved by a transfer
- The habit
- Never derive accounting from a live token balance, and never trust the very first deposit into an empty pool
How vault shares are supposed to work
A vault pools everyone's assets and gives each depositor shares that represent their fraction of the pool. Deposit into an empty vault and you might get one share per asset. As the vault earns yield the assets grow while the shares do not, so each share is now worth more, and that is the point: your shares appreciate. The standard formula converts assets to shares by the current ratio.
// shares you receive = assets you deposit * total shares / total assets
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
if (supply == 0) return assets; // first depositor: 1:1
return assets * supply / totalAssets(); // integer division: rounds DOWN
}
Two ordinary facts about this formula are, together, the vulnerability. The supply == 0 branch lets the first depositor set the starting rate. And integer division rounds down, so a deposit that computes to less than one share yields zero shares. Neither is a mistake on its own. Combined, they let the first depositor rob the second.
The first-depositor inflation attack
The attacker moves in four steps, all before any honest user arrives.
- Deposit the smallest possible amount, 1 wei of the asset, into the empty vault. They receive 1 share. Total supply is now 1.
- Donate a large amount, say 10 tokens, by transferring it straight to the vault's address. This does not mint shares; it just raises the vault's balance. Now 1 share is backed by roughly 10 tokens.
- An honest user deposits 5 tokens. Their shares are
5e18 * 1 / 10e18, which is 0 after rounding down. They receive nothing, but their 5 tokens are now in the vault. - The attacker still holds the only share, now backed by roughly 15 tokens. They redeem it and walk away with the victim's deposit.
The engine of the theft is that the vault measured itself by totalAssets(), and that number came from its token balance, which anyone can inflate with a plain transfer. The attacker never broke a rule. They used rounding and a donation, both legitimate, to make an honest deposit round to zero.
The fix: virtual offsets and honest accounting
The robust and now-standard defence is virtual shares and virtual assets: the conversion pretends there is a tiny amount of pre-existing supply and backing that no one owns. That single change means the first depositor cannot set a 1:1 rate from truly empty, and a donation is diluted against the virtual offset so heavily that inflating a share past the rounding threshold costs the attacker far more than they could ever steal. OpenZeppelin's ERC4626 implements this with a configurable decimals offset.
uint256 constant OFFSET = 1e3; // virtual shares and assets
function convertToShares(uint256 assets) public view returns (uint256) {
// Add the offset to both sides: never divides from a truly empty pool,
// and a direct donation is diluted against the virtual backing.
return assets * (totalSupply() + OFFSET) / (totalAssets() + 1);
}
Two more guards are worth combining with it. Track balances in an internal accounting variable that only deposit and withdraw update, so a raw transfer into the contract changes nothing the maths reads. And where a vault is deployed for a known launch, seed it with a minimum initial deposit or a small permanent "dead shares" position, so it is never in the empty state an attacker needs. Prefer the audited implementation over your own: this bug has a long history of being reintroduced by hand.
Rounding is never neutral, so choose its direction. A vault should round in its own favour: fewer shares out on deposit, fewer assets out on withdrawal, so the tiny remainder accrues to the pool rather than to a caller who engineered it. Auditors read every division in a financial contract twice: once for the value, and once for which way the dust falls.
The short version. A vault mints shares in proportion to assets, and two innocent facts, a 1:1 rate from empty and division that rounds down, let the first depositor inflate a single share with a direct donation until the next deposit rounds to zero and is absorbed. Defend with virtual shares and assets so the rate cannot be set from empty or moved by a transfer, keep accounting in an internal variable rather than a live balance, seed the vault so it is never empty, and always round in the pool's favour. Use a reviewed ERC-4626, not a fresh one.
References & further reading
- Ethereum Improvement Proposals, EIP-4626: Tokenized Vaults. The standard the shares maths comes from.
- OpenZeppelin, ERC4626 and the inflation attack. The virtual-offset defence, explained and implemented.
- OpenZeppelin, Rounding direction in vaults. Which way each conversion should round, and why.
- Ethereum community, Smart contract security. The broader accounting-safety context.