ERC-4626 Inflation Attack: How 1 Wei Drains a Vault
A single wei deposit followed by a direct token transfer can let an attacker steal the next depositor's entire principal. The ERC-4626 inflation attack — also called the donation attack — exploits rounding in the share-price formula. Hundred Finance lost $7M to a variant of this bug in 2023. Here's…
The Bug in One Sentence
ERC-4626's `convertToShares` divides by `totalSupply`, and when `totalSupply` is tiny but `totalAssets` is huge, the division rounds your deposit down to zero shares. Congratulations, you just donated to the attacker.
How the Attack Works
A naive ERC-4626 vault calculates shares like this:
```solidity
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
// shares = assets * totalSupply / totalAssets
shares = totalSupply() == 0
? assets
: (assets * totalSupply()) / totalAssets();
_mint(receiver, shares);
asset.transferFrom(msg.sender, address(this), assets);
}
```
Looks reasonable. It isn't. Here's the attack, step by step:
1. **Vault is freshly deployed.** `totalSupply == 0`, `totalAssets == 0`.
2. **Attacker front-runs Alice's pending deposit of 10 USDC.** They deposit `1 wei` of USDC and receive `1 share`. Now `totalSupply == 1`, `totalAssets == 1 wei`.
3. **Attacker `transfer`s 10 USDC directly to the vault address** (not via `deposit`). No shares minted. Now `totalSupply == 1`, `totalAssets == 10_000_001 wei`.
4. **Alice's tx lands.** Her shares: `10_000_000 * 1 / 10_000_001 = 0`. She gets zero shares. Her USDC is now backing the attacker's 1 share.
5. **Attacker redeems their 1 share** and walks away with their 1 wei + their 10 USDC donation + Alice's 10 USDC.
The attacker's only cost is gas plus 1 wei. The victim eats the entire principal.
Why Rounding Bites
Solidity integer division truncates. Any `convertToShares` formula of the form `assets * totalSupply / totalAssets` produces zero whenever `assets * totalSupply < totalAssets`. The attacker controls `totalAssets` cheaply (just `transfer`), and keeps `totalSupply` at 1. So they can size the donation to make any victim deposit round to zero.
This isn't theoretical. The pattern surfaced in Compound v2 forks years ago, and **Hundred Finance lost roughly $7M in April 2023** when an attacker exploited an empty market on the Gnosis Chain deployment — donating to inflate share price before a redemption-driven imbalance let them drain collateral. The same class of bug has hit Cream, multiple Compound forks, and a long tail of unaudited 4626 wrappers.
Fix #1: Virtual Shares and Virtual Assets (OpenZeppelin's Approach)
The cleanest fix is to pretend the vault always has some virtual liquidity that nobody owns. OpenZeppelin's ERC4626 implementation does exactly this:
```solidity
// Simplified from OpenZeppelin
uint256 private constant _DECIMALS_OFFSET = 0; // raise to harden
function _convertToShares(uint256 assets, Math.Rounding rounding)
internal view returns (uint256)
{
return assets.mulDiv(
totalSupply() + 10 ** _DECIMALS_OFFSET,
totalAssets() + 1,
rounding
);
}
function _convertToAssets(uint256 shares, Math.Rounding rounding)
internal view returns (uint256)
{
return shares.mulDiv(
totalAssets() + 1,
totalSupply() + 10 ** _DECIMALS_OFFSET,
rounding
);
}
```
The `+1` on assets and the `10 ** offset` on shares mean the first depositor faces an exchange rate that an attacker can only skew at exponential cost. With `_DECIMALS_OFFSET = 6` on an 18-decimal share token, an attacker needs to donate `~10^6` times the victim's deposit to cause meaningful loss — economically irrational for almost any vault.
If you're inheriting OZ's `ERC4626.sol`, override `_decimalsOffset()`:
```solidity
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
}
```
Fix #2: Dead Shares at Deployment
Mint a non-trivial amount of shares to a burn address during initialization. This guarantees `totalSupply` is never small enough to exploit:
```solidity
constructor(IERC20 _asset) ERC4626(_asset) {
// Seed with 1000 shares to address(0xdead)
uint256 seed = 10 ** decimals() * 1000;
_mint(address(0xdead), seed);
}
```
The deployer eats this cost once. Be aware: this only works if you can also seed `totalAssets` correspondingly, otherwise you've created a different rounding edge case. Most teams combine this with Fix #1.
Fix #3: Seed the Vault Before Public Use
The lowest-tech fix: the deployer deposits a meaningful amount themselves in the same transaction as deployment, via a factory. Atomic deployment + first deposit eliminates the front-run window entirely. Yearn V3 and Morpho Blue both rely on patterns like this.
```solidity
function createVault(IERC20 asset, uint256 seedAmount) external returns (Vault v) {
v = new Vault(asset);
asset.transferFrom(msg.sender, address(this), seedAmount);
asset.approve(address(v), seedAmount);
v.deposit(seedAmount, address(0xdead));
}
```
What Doesn't Work
**Checking `shares > 0` in `deposit`.** Reverting on zero shares stops Alice's tx, but it doesn't refund her — and an attacker can still grief by keeping the price inflated.
- **Using `balanceOf(this)` instead of an internal accounting variable.** Many devs assume tracking internal balances stops the donation attack. It half-helps, but breaks rebasing tokens and yield-bearing assets, and you still need rounding protection.
- **"Just audit it."** This bug has shipped past dozens of audits because the math looks fine in isolation.
Auditor's Checklist
When I review a 4626 vault, I look for:
1. Is `convertToShares` using virtual assets/shares (the `+1` and offset)?
2. Is there a seed mechanism at deployment, or are first depositors protected another way?
3. Does `totalAssets()` read from internal accounting or from `balanceOf(this)`? Each has tradeoffs.
4. Is rounding direction correct? Deposits should round shares **down**, withdrawals should round assets **down**, mints should round assets **up**, redeems should round assets **down**. Get this backward and you create a free-money glitch in the other direction.
5. Are there hooks (`_deposit`, `_withdraw`) that could be reentered before share accounting settles?
If you're shipping a vault and want a fast sanity check, run it through our [free AI audit](https://www.cryptohawking.com/audit) — it flags missing virtual-share protection and rounding-direction bugs in minutes. For production deployments, the [manual audit](https://www.cryptohawking.com/audit/manual) is $5,000 in ETH, SOL, or USDT and turns around in 3 business days, with hand-traced share-math invariants and economic-attack modeling.
TL;DR
ERC-4626 looks simple. It isn't. Inherit OpenZeppelin's reference implementation, override `_decimalsOffset()` to at least 6, seed the vault atomically at deployment, and never assume the first depositor is honest — assume they're the attacker. Because in a public mempool, eventually they are.
FAQ
Does using OpenZeppelin's ERC4626 fully prevent the inflation attack?
Mostly, but only if you override `_decimalsOffset()` to a non-zero value (6 is the common recommendation). The default offset is 0, which still leaves a small but non-trivial attack surface for low-decimal underlying assets like USDC (6 decimals). With an offset of 6, the attacker would need to donate roughly 10^6 times the victim's deposit to cause loss — making the attack uneconomic. Combine this with atomic seeding at deployment for defense in depth.
Why can't I just check that shares > 0 in the deposit function?
Because it doesn't refund the victim's transaction — it only reverts it. The attacker can keep the share price inflated indefinitely with a single 1-wei share outstanding, griefing the entire vault. Worse, in composable systems, a revert in one leg can lock funds in another protocol. The `require(shares > 0)` check is a useful defensive guard against silent loss, but it's not a fix. You still need virtual shares or seeding.
How did the Hundred Finance hack relate to this bug?
Hundred Finance's April 2023 exploit on Gnosis Chain (~$7M lost) leveraged a related rounding/donation pattern in a Compound v2-style cToken market. The attacker manipulated a market with very low total supply via donations and redemptions to skew the exchange rate, then borrowed against inflated collateral. While not a pure ERC-4626 vault, the underlying bug class — share-price manipulation via direct token transfer when supply is tiny — is identical. Any vault-like contract that derives price from `balanceOf(this) / totalSupply` is at risk.
Should totalAssets() use balanceOf(this) or internal accounting?
Tradeoff. `balanceOf(this)` is simpler and handles rebasing/yield naturally, but it makes you vulnerable to donation attacks and inflates with airdrops. Internal accounting (tracking deposits/withdrawals in a state variable) prevents donation manipulation but misses yield from rebasing tokens and requires explicit `skim`/`sync` logic. Most modern vaults use internal accounting plus virtual shares. If you must use `balanceOf`, the virtual shares/offset defense is mandatory, not optional.
Is the inflation attack still relevant in 2024+?
Yes. New 4626 vaults ship monthly, and a meaningful fraction still copy old templates without virtual-share protection. The attack has been weaponized by MEV bots that monitor new vault deployments and front-run the first depositor automatically. If your vault is deployed without a seed and without virtual shares, expect the first non-team deposit to be sandwiched within the same block. Treat any vault that doesn't explicitly document its inflation-attack mitigation as vulnerable until proven otherwise.