ERC-4626 Inflation Attack
Smart contract security audit by TierZero · 2023-07-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. This is a design review of a publicly known attack class and its standard mitigations, not a finding against any specific deployed contract.
What it is
ERC-4626 is the tokenized vault standard: users deposit an underlying asset and receive shares representing a proportional claim on the vault's holdings. The share price is derived from totalAssets() / totalSupply(). The inflation attack — first documented publicly around the standard's adoption in 2022–2023 and folded into OpenZeppelin's ERC4626 implementation notes — targets the moment a vault has zero or near-zero supply. An attacker who controls the first deposit can manipulate the share price before a real user's deposit is priced, causing that user's shares to round down to zero while the attacker redeems the difference.
The bug is not a coding mistake in the arithmetic. It's a rounding-direction and initial-state problem inherent to any share-ratio accounting system that lets an external actor set the ratio before anyone else has skin in it.
Threat model
Attacker capability required: ability to be the first depositor (or one of the first) in a freshly deployed vault, and ability to transfer the underlying asset directly to the vault contract (a plain transfer, not through deposit).
Sequence:
- Attacker deposits 1 wei of the underlying asset, receiving 1 share.
totalAssets = 1,totalSupply = 1. - Attacker front-runs (or simply precedes) the victim's deposit by transferring, say, 10,000 tokens directly to the vault via ERC-20
transfer, bypassingdeposit().totalAssetsjumps to 10,001 whiletotalSupplystays 1. - Victim calls
deposit(9,999, ...), expecting a proportional share. Naive share math computes:
shares = assets * totalSupply / totalAssets
= 9,999 * 1 / 10,001
= 0 // rounds down
- Victim receives 0 shares for a real deposit. Attacker then redeems their 1 share for close to the full vault balance, extracting the victim's funds.
This is a griefing/theft primitive that only pays off against the first depositor(s) of a given vault instance, which is exactly why factory-deployed vaults (one per market, one per strategy) are the highest-risk surface — every new market recreates the zero-supply window.
Why the design holds (key invariants & mitigations)
The invariant a safe implementation needs: no depositor can be forced to zero shares while contributing non-trivial assets, regardless of what a prior actor donated to the contract. Three complementary mechanisms get you there, and OpenZeppelin's ERC4626 (since the 4.9 line) ships the first two by default.
1. Virtual shares and virtual assets (decimals offset). Instead of computing the ratio against raw totalSupply and totalAssets, add a constant virtual offset to both sides:
shares = assets.mulDiv(
totalSupply() + 10 ** decimalsOffset(),
totalAssets() + 1,
Rounding.Down
);
A non-zero decimalsOffset (OpenZeppelin defaults to 0 but recommends 3–8 for vaults where this attack is a concern) makes the attacker's donation-to-inflate ratio prohibitively expensive — they'd need to donate assets on the order of 10^offset times the victim's deposit to force a zero-share rounding, which is economically self-defeating for any realistic offset.
2. Rounding direction discipline. Deposits round shares down (in the protocol's favor), withdrawals round assets down (also in the protocol's favor). Getting this backwards anywhere in the four conversion functions (convertToShares, convertToAssets, previewDeposit, previewRedeem) reintroduces value leakage even with virtual shares in place.
3. Dead shares / seeded liquidity. An older but still valid mitigation, borrowed from Uniswap V2's minimum-liquidity burn: mint a small first tranche of shares to address(0) or a protocol-owned sink at deployment, so the vault never sits at literal zero supply when a real user arrives.
None of these mechanisms require trusting balanceOf(address(this)) for internal accounting — which brings up the most common way implementers undo the protection.
Where implementers get it wrong
- Tracking
totalAssets()via raw token balance instead of internal accounting. IftotalAssets()returnsIERC20(asset).balanceOf(address(this)), a donation attack works regardless of virtual shares, because the denominator is still attacker-controlled. Vaults that hold assets elsewhere (lending pools, strategy contracts) need internal ledgers that donations can't touch. - Zero decimals offset "because gas." Shipping OpenZeppelin's ERC4626 with default offset (0) provides no inflation protection by itself — the offset has to be explicitly set above zero, and teams frequently miss this because the base contract compiles and passes tests either way.
- Custom vaults that reimplement
convertToSharesfor a "cleaner" formula and drop the+1/+virtualSharesterms in the process, usually while optimizing for gas or readability. - Fee-on-transfer or rebasing underlying assets, which make
totalAssets()drift independent ofdeposit()calls, reopening a version of the same rounding gap even with virtual shares present.
This category of bug is why static line-by-line diffing against OpenZeppelin's reference isn't enough — you have to verify the specific accounting source and the offset value for each new vault deployment, not just the once-audited factory code. It's the kind of accounting-source-versus-token-balance distinction we look for line by line in every smart contract audit we run, and it parallels account-model confusion we've written about on the Solana side in our review of Anchor account discriminator and type confusion attacks.
Takeaways
- The vulnerability is a rounding/initial-state property of share-ratio accounting, not a missing check — you can't "add a require" and fix it.
- Virtual shares (non-zero decimals offset) plus internal asset accounting is the current standard mitigation; dead-share seeding is a valid supplementary layer, not a replacement.
- The highest-risk deployments are factories that spin up a fresh vault per market — each new instance recreates the zero-supply window an attacker needs.
- Verify this per-deployment, not per-codebase: the same audited base contract can be safe or exploitable depending on one constructor argument.
If you're shipping a vault factory or a custom ERC-4626 variant, this is a narrow, well-understood surface worth getting checked before mainnet — our smart contract audit engagement covers exactly this class of accounting-invariant review.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit