On this page
  1. The attack, step by step
  2. Why it is a rounding bug
  3. Defence one: virtual offsets
  4. Defence two: dead shares
  5. Defence three: seed the vault
  6. Reading the compiled vault
  7. References

Most descriptions of the vault inflation attack stop at the sentence “the attacker donates tokens to inflate the share price”. That is true and it is not enough to see why the victim loses everything rather than a little, or why one defence works and another only mostly works. So here is the whole thing with numbers.

The vault is a plain ERC-4626 implementation. Shares are minted on deposit as

shares = assets × totalSupply / totalAssets

with integer division, and totalAssets is the vault’s token balance. That is the naive formula and it is what a straightforward implementation compiles to.

The attack, step by step

Step 0. The vault is empty. totalSupply = 0, totalAssets = 0. Most implementations special-case this: the first deposit mints shares 1:1 with assets.

Step 1. The attacker deposits 1 wei of the underlying token. They receive 1 share. Now totalSupply = 1, totalAssets = 1. Share price: 1 wei per share.

Step 2. The attacker transfers 10,000 tokens (10,000 × 10¹⁸ wei) directly to the vault’s address, using the token’s ordinary transfer, not the vault’s deposit. The vault’s balance goes up; its share supply does not. totalSupply = 1, totalAssets = 10,000 × 10¹⁸ + 1. Share price: ten thousand tokens per share.

Step 3. A victim deposits 5,000 tokens. Shares minted:

5,000e18 × 1 / (10,000e18 + 1) = 0.4999…  →  0

Integer division. The victim receives zero shares. Their 5,000 tokens are now in the vault, and the vault has totalSupply = 1, totalAssets = 15,000 × 10¹⁸ + 1.

Step 4. The attacker redeems their single share for the entire balance: 15,000 tokens plus a wei. They put in 10,000 and a wei; they take out their 10,000, the victim’s 5,000, and the wei.

The attacker’s outlay is refundable and the profit is the victim’s whole deposit, as long as the deposit is smaller than the donation. A larger victim is not safe, only less exposed: a deposit of 20,000 mints 20,000e18 / (10,000e18 + 1) = 1.999… → 1 share, which is half of the 30,000 now in the vault, so the victim loses the rounded-off 5,000 and the attacker gains it. The attack takes everything when the donation exceeds the deposit and takes the rounding remainder otherwise, which is why it is aimed at fresh vaults where the attacker can wait for the first real deposit and size the donation to it in the same block.

Why it is a rounding bug

Look at step 3 again. Nothing in the vault’s logic is wrong. The formula is the standard one, the accounting is honest, the share price really is ten thousand tokens. The victim’s loss comes from one thing: 0.4999… rounded to 0. The vault discarded a fraction of a share, and because the share is worth ten thousand tokens, the discarded fraction was worth five thousand of them.

That is the whole insight. Share math is integer math, and the value of the rounding error scales with the share price. An attacker who can push the share price up arbitrarily can make the rounding error arbitrarily large. Every defence is a way of bounding one of those two quantities: either the price cannot be pushed that far, or the error cannot be that large.

Defence one: virtual offsets

OpenZeppelin’s ERC-4626 implementation, from version 4.9, computes conversions as if the vault always held a small phantom balance:

shares = assets × (totalSupply + 10^offset) / (totalAssets + 1)

Run the attack again with offset = 6 (the phantom supply is one million shares). Step 1: the attacker’s 1 wei deposit mints 1 × (0 + 10⁶) / (0 + 1) = 10⁶ shares. Step 2: the donation still lands, totalAssets = 10,000e18 + 1. Step 3: the victim’s 5,000 tokens mint

5,000e18 × (10⁶ + 10⁶) / (10,000e18 + 2) ≈ 10⁶

shares. The victim now holds half the real supply. On redemption each real share is worth its slice of the vault after the phantom shares take theirs, so the victim gets back roughly the 5,000 they deposited and the attacker gets back roughly 5,000 of the 10,000 they donated. The other 5,000 sits behind the phantom shares, which belong to nobody.

The offset does not make the attack impossible. It makes the donation required to zero out a victim of size D on the order of D × 10^offset, and it makes the attacker lose a large fraction of any donation to the phantom shares. At six decimals of offset, zeroing a 5,000-token deposit needs a donation in the region of ten billion tokens, most of which is forfeited. That is what “uneconomic” means here, and it is a precise claim with a number behind it.

The cost is a small permanent discount: because the phantom shares own a slice of every donation and of rounding dust, real holders are very slightly under-served. At any reasonable offset it is negligible.

Defence two: dead shares

The other common defence is to make the first deposit burn a fixed number of shares, say 1,000, by minting them to the zero address. Uniswap V2’s MINIMUM_LIQUIDITY is the well-known instance.

Rerun the attack. The attacker’s step 1 deposit must now be at least 1,000 wei, and 1,000 of the resulting shares are burned. To hold one share themselves they deposit 1,001 wei; totalSupply = 1,001. Step 2’s donation still inflates the price, but the price is now (10,000e18 + 1,001) / 1,001, a thousand times lower than before, and the victim’s step 3 mints 5,000e18 × 1,001 / (10,000e18 + 1,001) ≈ 500 shares. Not zero.

Dead shares bound the share price rather than the rounding error: the supply can never fall below the burned amount, so a donation of X moves the price by at most X / 1,000. It works. It is weaker than the offset for the same parameter because 1,000 dead shares is a bound of 10³ while offset = 6 is a bound of 10⁶, and because the burned shares are real value the first depositor loses outright rather than a phantom that costs nobody.

Defence three: seed the vault

The simplest defence is operational: the deployer makes a meaningful first deposit in the deployment transaction, before any attacker can. If the vault opens with 10,000 tokens and 10,000 shares already in it, the attacker’s donation has to compete with that, and the arithmetic is the dead-shares case with a much bigger number.

This works, and it is the one an auditor cannot verify from the code. A vault that relies on seeding is safe only if the seeding actually happened, in the same transaction as deployment or before the contract was reachable. A bytecode analysis will report such a vault as undefended, because the defence is not in the bytecode, and the report is correct: the protection is a property of how the contract was deployed, not of what it is.

Reading the compiled vault

From bytecode, the question is which of these shapes is present. The conversion is a multiply feeding a divide, with one operand from calldata and two from storage. The offset defence adds constants to both storage operands before the division, and the +1 and +10^n constants are visible in the instruction stream. Dead shares show up as a mint to the zero address on the empty-vault path. Seeding shows up as nothing at all, which is the honest answer.

The point of working the numbers is that “vulnerable” and “defended” are not labels; they are claims about how large a donation has to be to move a rounding error past a victim’s deposit. A vault with offset = 0 and no dead shares is broken by a donation the size of the deposit. A vault with offset = 6 is broken by a donation a million times larger, most of which is forfeited. Those are different things, and the arithmetic is what tells them apart.

References