Back to Blog 

DeFiHacksWeb3 SecuritySolidityTesting
ERC-4626 inflation attack: how it works and how to stop it
20 min
A vault opens for business. The first person to interact with it deposits one wei. The second person deposits twenty thousand dollars and receives, in exchange, exactly zero shares. The first person then withdraws everything, including the twenty thousand.
Nobody reentered a function. Nobody broke access control. No integer overflowed. Every line executed exactly as written. This is the ERC-4626 inflation attack, and the reason it keeps working, four years after it was first documented, is that it isn't a bug in the way auditors are trained to hunt bugs. It's arithmetic. It lives in the empty-vault edge case, and it only shows itself when someone interleaves the calls in a hostile order. Unit tests almost never do that. Invariant tests do it by construction.
This post walks the mechanics with real numbers, traces the on-chain body count from Cream in 2021 to Resupply in 2025, compares the defenses honestly (including where the "just use OpenZeppelin" advice quietly fails), and shows the specific invariants that turn this from a live threat into a caught test case.
ERC-4626 in ninety seconds
ERC-4626 is the tokenized-vault standard. You deposit an underlying asset, you receive shares. Later you burn shares to redeem the asset. The whole standard rests on one ratio: your shares represent your proportional claim on the vault's total assets.
Two internal conversions do the accounting. Written the way OpenZeppelin's implementation actually computes them, with the rounding made explicit:
1// deposit path: how many shares for these assets?2convertToShares(assets) = assets * (totalSupply + 10**offset) / (totalAssets + 1); // rounds DOWN34// redeem path: how many assets for these shares?5convertToAssets(shares) = shares * (totalAssets + 1) / (totalSupply + 10**offset); // rounds DOWN
Set
offset = 0 and this reduces to the classic formula everyone recognizes:1shares = assets * totalSupply / totalAssets;
Here
totalSupply is the number of vault shares in circulation and totalAssets is the amount of underlying the vault believes it holds. The rounding direction is not an accident. Deposits round shares down, withdrawals round assets down, always in the vault's favor. That's the correct, conservative choice: it stops anyone minting value from nothing. The inflation attack works by taking that vault-favoring rounding and pointing it at a depositor.The attack, step by step

Assume a fresh vault over a six-decimal stablecoin.
totalAssets and totalSupply both start at zero.Step 1 — become the first depositor. The attacker back-runs vault creation (submits their transaction immediately after it) and deposits one wei, the smallest indivisible unit of the token:
1vault.deposit(1, attacker); // totalAssets = 1, totalSupply = 1
They now hold the only share in existence.
Step 2 — donate, don't deposit. A victim broadcasts a transaction to deposit 20,000 USDT. The attacker front-runs it (pays a higher gas price so their transaction lands first), but not with another
deposit. With a raw transfer:1asset.transfer(address(vault), 20_000e6); // totalAssets = 20_000e6 + 1, totalSupply = 1
This is the move the whole attack turns on. A direct ERC-20 transfer lands in the vault's balance and inflates
totalAssets, but it goes nowhere near the deposit function, so no shares are minted. The ratio is now wildly distorted: one share is nominally backed by the entire donated balance.Step 3 — the victim mints zero. The victim's transaction executes:
1shares = 20_000e6 * 1 / (20_000e6 + 1) = 0; // rounds down
Twenty thousand dollars in, zero shares out. The victim's money is now in the vault, but they hold no claim on it.
Step 4 — collect. The attacker still holds the only share. They redeem it and withdraw the whole pool: their donation plus the victim's deposit. The victim is left with nothing.
It isn't only the zero-share corner
You might think the fix is just "revert if a deposit would mint zero shares." It helps, but the attack degrades gracefully. Suppose the attacker donates only 10,000 against the same 20,000 victim deposit:
1shares = 20_000e6 * 1 / (10_000e6 + 1) = 1; // victim gets 1 share
Now attacker and victim each hold one share, splitting a ~30,000e6 pool. The attacker redeems for ~15,000e6 against the 10,001 they put in: a +5,000 profit, about 25% of the victim's deposit, and the victim silently ate the loss. The general condition for full dilution to zero is:
1victim_deposit < (1 + attacker_donation / attacker_deposit)
A zero-share revert closes the worst case. It does not close the attack.
Why it happens

Three things have to be true at once, and every ERC-4626 vault has to answer for all three.
totalAssets() reads the raw balance. This is the dominant root cause. The default reflex is:1function totalAssets() public view returns (uint256) {2 return asset.balanceOf(address(this)); // anyone can inflate this3}
balanceOf returns the vault's live token balance. If the denominator of the share math is that live balance, then anyone who can transfer the underlying can move the exchange rate. The donation isn't exploiting a special function; it's exploiting the fact that the vault trusts its own balance.Integer division rounds down. Solidity has no decimals; every division floors to a whole number. At healthy
totalSupply this costs a depositor a fraction of a fraction of a wei. At totalSupply = 1 it costs them everything below the donation line.The empty-vault edge case. With
totalSupply = 0, the first depositor sets the exchange rate arbitrarily. Whoever owns the only share owns the pricing function. This is the same class of first-depositor and rounding hazard we cover across AMM security foundations — the vault is just an AMM with one asset and a much simpler curve.The flash-loan amplifier
The naive objection to this attack is capital: donating enough to dilute a real depositor means fronting real money. Flash loans delete that objection. "You need 35 million dollars of VELO to attack this market" becomes "borrow 35 million dollars of VELO from any AMM, run the attack, repay in the same transaction," and the only real cost is gas. This is exactly how the empty-market donation family was executed on Compound-V2 forks, and we walk the broader mechanics in our flash loan attacks breakdown. The inflation attack and flash loans are natural partners: one needs temporary capital, the other rents it atomically.
It is still happening

This is documented, four-years-old, first-checklist-item knowledge. It is also still draining protocols, as our 2025 exploit lessons roundup keeps confirming. The lineage is one mechanic — donate to inflate price-per-share — wearing different plumbing.
Cream Finance, ~$130M, October 2021. Not a first-deposit, but the same primitive through a lending oracle. The attacker directly transferred yield-bearing tokens into a Yearn vault to double
yUSD pricePerShare atomically, then borrowed against the inflated collateral value. PeckShield put the loss near $117M; Cream stated $130M. The root cause, in PeckShield's own diagnosis, was a directly transferred donation inflating price-per-share.Sonne Finance, ~$20M, May 2024. A Compound-V2 fork on Optimism. Sonne's own post-mortem called it "the known donation attack." The attacker flash-loaned and directly transferred about 35.5 million VELO into a freshly created, empty market so that wei of the market token was valued at the entire donation, then exploited redemption rounding. A whitehat rescued much of the remainder for the cost of $100 in gas. The market-creation step was permissionlessly executable through the timelock on Optimism, which is what made it reachable.
Resupply, $9.56M, June 2025. The textbook case, and the cleanest one to study. A crvUSD-wstUSR pair was deployed with a $10M debt limit while the underlying CurveLend vault held zero deposits. It went live at 00:18:47 UTC and was drained at 01:53:59 UTC — about an hour and a half later. The attacker donated 2,000e18 crvUSD to the vault's controller, deposited 2e18 to mint one wei of shares, and drove the oracle price so high that Resupply's
1e36 / oracle.getPrices() floored to zero. The solvency check read that zero as "well within LTV" and let the attacker borrow the entire $10M limit against one wei of collateral. Loss figures range from $9.56M (Ackee) to about $9.8M (Rekt).One correction, because roundups keep getting it wrong. The ~$80M Rari Capital / Fei hack of April 2022 was a reentrancy exploit of Compound-fork Fuse pools. It was not an inflation attack. If you see it cited as a donation-attack case study, that citation is wrong, and the distinction matters when you're building a mental model of which bug class you're defending against.
Defending, honestly

There are five defenses in common use. None is a silver bullet, and the most-recommended one has a default setting that provides no protection at all.
| Defense | How it works | What it costs you |
|---|---|---|
| Virtual shares + decimal offset (OpenZeppelin, Contracts v4.9+) | Adds +1 virtual asset and +10**offset virtual shares to conversions, so the empty-vault rate is well-defined and inflating it costs orders of magnitude more than it can return | The default _decimalsOffset() returns 0. You must override it. Virtual shares skim a sliver of yield; can distort exits under losses |
| Dead shares / minimum initial mint | First deposit mints a minimum share quantity to the zero address, permanently locking value (Uniswap-V2 style; Aave's Stata wrapper uses a variation) | Reduces but does not fully eliminate profitability; burns real value |
| Deployer seeding | Deployer makes a non-trivial first deposit inside the deployment transaction | Must be atomic with deployment or it's front-runnable; capital is locked; donation-griefing still possible |
| Router with slippage check | A wrapper reverts if minted shares fall below a minShares bound (Fei's ERC4626Router; Yearn's approach) | Extra gas; the router becomes a trusted surface |
| Internal balance tracking | totalAssets is computed from recorded positions, not balanceOf, so donations don't move the rate | More state to maintain |
The virtual-offset defense deserves a closer look, because the standard advice — "just inherit OpenZeppelin's ERC4626 and you're safe" — is only true if you change a default.
1// You MUST override this. The base implementation returns 0.2function _decimalsOffset() internal view virtual override returns (uint8) {3 return 6; // pick per asset; 3–6+ is the usual range4}
Leave
_decimalsOffset() at its default of 0 and you inherit the interface of the defense with none of the protection. This is the single most common way a vault ships "using OpenZeppelin" and is still vulnerable.And even with the virtual asset in place, don't overstate what you've bought. OpenZeppelin's own issue #5223 demonstrates a parameterization where an attacker, facing only the
+1 virtual asset, deposits 1, donates 10,000e18, watches a victim deposit 5,000e18 three times for zero shares each, and exits their single share at a profit. The correct framing is that a sufficient decimal offset makes the attack economically irrational, not that the +1 alone makes it mathematically impossible. If you carry one nuance out of this section, carry that one.The minimum bar for a vault shipping today: virtual offset and internal tracking and a zero-share revert. Any vault doing fewer than those three is a launch you should block. It's the vault-specific instance of the layered posture we describe in our defense-in-depth workflow.
Get funded for your audit
Core grants cover up to $32k. Growth and Builder tiers available. Rolling applications.
No spam. Unsubscribe anytime.
Why unit tests miss it and invariants catch it

Here is the uncomfortable part. A team can have excellent test coverage and still ship this bug, because the attack does not live on any path a well-intentioned test author naturally writes. Nobody's happy-path test seeds a vault with one wei, donates via raw transfer, and then has a second user deposit. The bug lives in the interleaving, and the interleaving is hostile.
Invariant testing inverts the problem. Instead of asserting that a specific sequence produces a specific result, you assert rules that must hold across any sequence the fuzzer can generate, and let it search for the sequence that breaks them. (For the broader mechanics, see how fuzz testing strengthens smart contract security.) For ERC-4626, the rules that catch this attack are short and unambiguous:
- No zero-share deposits. A nonzero deposit must mint more than zero shares. The attack falsifies this directly.
- Share price is not manipulable by transfer. Price-per-share may change only through
depositandwithdraw. A raw donation that moves the rate breaks this. - Round-trips can't profit. Minting N shares and immediately withdrawing must burn at least N shares' worth. (a16z's suite found real mainnet vaults failing this by a few satoshi.)
- Solvency holds.
totalSupplyis at least the sum of user shares; the sum of individually redeemable assets never exceedstotalAssets. - Previews never over-promise.
previewDeposit/previewRedeemmust not exceed what the real call delivers.
The catch — and this is where most in-house invariant suites quietly fail to catch the bug they were written for — is that the harness has to be able to reach the vulnerable state. Two things have to be true:
1contract VaultHandler is Test {2 // 1. The invariant campaign must start from an EMPTY vault.3 // If setUp() seeds the vault, the fuzzer never sees totalSupply == 1.45 // 2. The handler MUST expose a raw-donation action.6 // Without this, the fuzzer can only deposit/withdraw and will7 // never discover the deposit -> donate -> deposit sequence.8 function donate(uint256 amount) external {9 amount = bound(amount, 0, asset.balanceOf(address(this)));10 asset.transfer(address(vault), amount); // bypasses deposit()11 }1213 function deposit(uint256 assets, uint256 actorSeed) external {14 // ...bounded deposit from a fuzzed actor...15 }16}1718// The invariant the donation is trying to break:19function invariant_nonzeroDepositMintsShares() external {20 if (lastDepositAssets > 0) {21 assertGt(lastMintedShares, 0, "deposit rounded to zero shares");22 }23}
Omit the
donate() action and your suite is testing a vault that no attacker would ever face. This is the most common failure mode we see in invariant suites that were written specifically to defend against inflation: the property is correct, the handler can't reach the counterexample. When you want a bounded proof rather than a probabilistic search, this is where formal verification takes over.The tooling that already ships this
You do not have to write these properties from scratch. Several batteries encode the ERC-4626 invariants directly:
- a16z's
erc4626-tests— Foundry property tests for standard conformance and round-trip behavior; already integrated by OpenZeppelin and Solmate. - Trail of Bits'
crytic/properties— 37 ERC-4626 properties for Echidna and Medusa, including a dedicated share-inflation security property and a rounding-direction detector, run as stateful fuzzing campaigns. - Cyfrin's 4626 property suite — the closest thing to a standard compliance battery.
- Echidna / Medusa — coverage-guided stateful fuzzers with counterexample shrinking, for when you want the fuzzer to hand you the exact minimal attack sequence.
- Halmos — symbolic execution when you want a bounded proof rather than a probabilistic search.
Wire one of these in, make sure the handler starts empty and can donate, and the
deposit → donate → deposit sequence stops being a thing an attacker discovers in production and becomes a thing your CI discovers on a pull request.Where Krait fits
Krait is Zealynx's open-source AI auditor, delivered as Claude Code skills and run locally. For this bug class specifically, two of its capabilities are relevant, and it's worth being precise about them. (The engineering behind it is written up in the Krait engine deep dive.)
First, pattern detection front-runs the fuzzing. Krait's detection coverage lists first-depositor inflation and ERC-4626 vault accounting explicitly, and ships an
erc4626-vault-deep module. Before any fuzzing runs, that module flags the static signatures of this vulnerability: a totalAssets() that reads balanceOf, a missing _decimalsOffset() override, share math with no minimum-liquidity or virtual-offset protection. First-depositor is also one of Krait's triage "Kill Gates," which is what keeps the finding from drowning in false positives.Second, harness generation exercises the edge case unit tests skip. Krait's
/krait-fuzz command runs an invariant-extraction → Foundry-test-generation → run-and-fix loop through a Foundry gateway. In practice that means it extracts the "nonzero deposit mints nonzero shares" and "price-per-share is transfer-invariant" properties and scaffolds the harness to exercise them — the mechanical work of getting from "I know the invariant" to "I have a running test that tries to break it."Two honest scoping notes.
/krait-fuzz is LLM-driven invariant extraction plus Foundry harness generation; it is not a formal-verification engine, and Krait's standalone invariant-testing and formal-verification work is a human Zealynx engagement, not a tool feature. Treat Krait here as a fast pre-audit pass that leaves a re-runnable check behind, not as a replacement for the audit.Which is the real lesson. A traditional audit catches what one reviewer noticed on one read of the code. Resupply's pair contract had been reviewed; what killed it was a freshly deployed market that nobody re-checked, going from live to drained in an hour and a half. Encoding the exchange-rate invariant as a re-runnable fuzz property is the difference between a finding that expires the moment the next market ships and a check that runs every time. That's the entire argument for treating this bug class as an invariant, not a line item.
The launch gate
If you ship or integrate ERC-4626 vaults, the takeaways compress to this:
- Never deploy an empty vault to production. Seed it atomically in the deployment transaction, mint dead shares, or override
_decimalsOffset()to at least 3 (6 for high-value assets). Confirm the default 0 is not silently in effect. - Compute
totalAssets()from internal accounting where you can, so a donation cannot move the rate. - Revert deposits that would mint zero shares.
- Run an invariant suite whose handler starts from an empty vault and includes a raw-donation action. A suite without both is testing a vault no attacker faces.
- Re-run that suite on every new market or vault deployment, not just the first audit. New markets are new attack surface even when the contract behind them was audited yesterday.
Fold these into your pre-audit checklist so they're settled before an auditor's clock starts, and treat re-deployment as its own event in the audit process, week by week.
The inflation attack has been public knowledge since 2022 and is still landing in 2026. It persists not because it's subtle but because it hides in a state — the empty vault under a hostile call order — that ordinary testing never visits. Send a fuzzer there on every deploy and the attack stops being news.
Partner with Zealynx
At Zealynx, we treat the exchange-rate invariant as a re-runnable property, not a one-time line item in a report. If you ship an ERC-4626 vault, a lending market, or anything that prices an asset on-chain, we build the invariant suite that starts empty, donates, and tries to break your share math — then hand it back so it runs on every deploy. That's the difference between a finding that expires and a check that keeps working, and it's why we price vault work the way we do.
A scope review takes one call.
FAQ: ERC-4626 inflation attacks
1. What is the ERC-4626 inflation attack in one sentence?
It's an attack where the first depositor into a tokenized vault mints one share, directly transfers a large "donation" of the underlying token into the vault to inflate the share price, and thereby forces the next depositor's shares to round down to zero — letting the attacker redeem the whole pool. It works because the vault prices shares off its raw token balance and Solidity division always rounds down.
2. What is a "donation" and why doesn't it mint shares?
A donation here is a plain ERC-20
transfer that sends tokens straight to the vault's address. Shares are only ever minted inside the vault's deposit or mint functions; a raw transfer never calls those functions, so it raises the vault's token balance (totalAssets) without creating any new shares. That mismatch — more assets, same number of shares — is exactly what inflates the price per share.3. What do "wei," "rounds down," and "first depositor" actually mean here?
A "wei" is the smallest indivisible unit of a token (for an 18-decimal token, one wei is 10⁻¹⁸ of a token). "Rounds down" means Solidity has no fractions:
20_000e6 * 1 / (20_000e6 + 1) mathematically equals 0.99995, but integer division discards the fraction and returns 0. The "first depositor" is whoever interacts with the vault while totalSupply == 0 — that person sets the initial share price arbitrarily, which is the corner the attack lives in.4. Does inheriting OpenZeppelin's ERC4626 make me safe?
Not by default. OpenZeppelin adds virtual shares and a decimal offset, but
_decimalsOffset() returns 0 unless you override it, and offset 0 provides no meaningful protection. You must override it (3 is a floor, 6 for high-value assets). Even then, a sufficient offset makes the attack economically irrational rather than mathematically impossible — the recommended bar is virtual offset and internal balance tracking and a zero-share revert together.5. How is an invariant test different from a unit test, and why does it catch this bug?
A unit test asserts that one specific input produces one specific output — it walks a path the author chose. An invariant test states a rule ("a nonzero deposit must mint more than zero shares") and lets a fuzzer generate thousands of random call sequences trying to break it. Because the attack only appears when
deposit → donate → deposit are interleaved in a hostile order, no hand-written unit test naturally visits it, but a fuzzer searching call orderings does — provided the test harness starts from an empty vault and exposes a raw-donation action.6. Do flash loans make the attack easier?
Yes. The only real barrier to donating a large amount is having the capital. A flash loan lets an attacker borrow that capital from an AMM, run the deposit-donate-drain sequence, and repay the loan all within a single atomic transaction, so their only out-of-pocket cost is gas. This is how the empty-market donation family was executed on Compound-V2 forks like Sonne Finance.
Glossary
| Term | Definition |
|---|---|
| Virtual Shares | Phantom shares and assets added to ERC-4626 conversion math to make the empty-vault exchange rate well-defined and inflation uneconomical. |
| Decimal Offset | The _decimalsOffset() value in OpenZeppelin's ERC4626 that scales virtual shares; defaults to 0 (no protection) and must be overridden. |
| Dead Shares | A minimum quantity of shares minted to the zero address on first deposit to permanently lock value and blunt first-depositor manipulation. |
| Price Per Share | A vault's exchange rate between one share and the underlying asset; the value an inflation attack manipulates via donation. |
| Round-Trip Invariant | A property test asserting that minting shares and immediately redeeming them can never return more assets than were put in. |
Get funded for your audit
Core grants cover up to $32k. Growth and Builder tiers available. Rolling applications.
No spam. Unsubscribe anytime.
