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 ERC-4626 inflation attack is a vulnerability in tokenized vaults where an attacker front-runs the first depositor, mints 1 share for 1 wei, then donates a large amount of underlying tokens directly to the vault. This inflates the share price so the victim's deposit rounds down to zero shares, letting the attacker withdraw both balances. Fixes include virtual shares/assets, dead-share minting, or seeding the vault at deployment.
··7 min read

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.

One Solidity tip + 1 case study per month