
Preventing ERC-4626 Share Inflation Attacks in Solidity
What is a share inflation attack?
An ERC-4626 vault mints shares in exchange for deposited assets. In a simple model, the share price is determined by:
- total assets held by the vault
- total shares already issued
If the vault starts empty or nearly empty, the first depositor can manipulate the exchange rate by making a tiny deposit, then donating assets directly to the vault without minting shares. Later depositors receive fewer shares than expected because the vault now appears more valuable per share.
Example attack flow
- Attacker deposits 1 wei and receives 1 share.
- Attacker transfers a large amount of assets directly to the vault contract.
- The vault’s asset balance increases, but total shares do not.
- A victim deposits assets and receives very few shares due to the inflated asset-to-share ratio.
- The attacker withdraws a disproportionate amount of value.
This is not a reentrancy issue or an approval issue. It is an accounting and initialization problem that appears when vault math assumes honest asset inflows.
Why ERC-4626 vaults are vulnerable
ERC-4626 standardizes deposit, mint, withdraw, and redeem, but it does not force a specific anti-manipulation strategy. Vulnerability usually comes from one or more of the following:
- Empty vault bootstrap: no initial liquidity or seed shares
- Direct asset transfers: assets can be sent to the vault without calling
deposit - Rounding down: small deposits may mint zero or near-zero shares
- Unprotected previews:
previewDepositandconvertToSharescan be misleading in manipulated states
The core issue is that the vault’s pricing function becomes attacker-controlled when the initial share supply is too small.
A minimal vulnerable pattern
The following simplified vault mints shares based on current assets and supply, but it does not defend against donation-based manipulation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract VulnerableVault is ERC4626 {
constructor(IERC20 asset) ERC20("Vault Share", "VSH") ERC4626(asset) {}
function deposit(uint256 assets, address receiver)
public
override
returns (uint256 shares)
{
shares = previewDeposit(assets);
require(shares > 0, "zero shares");
asset().transferFrom(msg.sender, address(this), assets);
_mint(receiver, shares);
}
function previewDeposit(uint256 assets)
public
view
override
returns (uint256)
{
uint256 supply = totalSupply();
uint256 totalAssets_ = totalAssets();
if (supply == 0) {
return assets;
}
return assets * supply / totalAssets_;
}
}At first glance this looks reasonable, but it is still vulnerable. If the vault has one share outstanding and a large amount of donated assets, totalAssets() rises while totalSupply() stays fixed, causing new deposits to mint too few shares.
Defensive design strategies
There is no single fix. Robust vaults usually combine several defenses.
| Defense | Purpose | Tradeoff |
|---|---|---|
| Seed liquidity | Prevents extreme initial exchange rates | Requires an initial capital contribution |
| Minimum share minting | Avoids zero-share deposits | May reject tiny deposits |
| Virtual assets and shares | Reduces the impact of donation attacks | Adds accounting complexity |
| Locked initial shares | Stabilizes early pricing | Requires careful governance |
| Deposit caps during bootstrap | Limits exposure while vault is young | Slower early growth |
The best choice depends on whether the vault is permissioned, public, or intended for high-volume DeFi integrations.
Use seed liquidity or locked initial shares
The simplest mitigation is to ensure the vault is never truly empty. A trusted initializer can deposit assets and mint shares to a burn address or a locked treasury address. This creates a non-zero supply and makes the initial exchange rate less manipulable.
Practical pattern
- Mint a small, fixed amount of shares on deployment
- Lock those shares forever or for a long period
- Ensure the initial asset amount is meaningful relative to expected deposits
This does not eliminate donation attacks entirely, but it makes them much more expensive.
Why it helps
If the vault starts with a meaningful asset base, an attacker must donate a much larger amount to distort pricing enough to exploit later users. The attack becomes capital-inefficient instead of trivial.
Add virtual assets and virtual shares
A more robust approach is to include “virtual” balances in conversion math. This technique is widely used to smooth the initial exchange rate and reduce the effect of donations.
Instead of computing shares from raw totalAssets() and totalSupply(), the vault adds constants:
virtualAssetsvirtualShares
This ensures the denominator and numerator never collapse to zero and prevents extreme price swings in the early lifecycle.
Example implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ProtectedVault is ERC4626 {
uint256 private constant VIRTUAL_ASSETS = 1e6;
uint256 private constant VIRTUAL_SHARES = 1e6;
constructor(IERC20 asset) ERC20("Protected Share", "PSH") ERC4626(asset) {}
function totalAssets() public view override returns (uint256) {
return asset().balanceOf(address(this));
}
function convertToShares(uint256 assets)
public
view
override
returns (uint256)
{
uint256 supply = totalSupply();
uint256 managedAssets = totalAssets();
return (assets * (supply + VIRTUAL_SHARES)) / (managedAssets + VIRTUAL_ASSETS);
}
function convertToAssets(uint256 shares)
public
view
override
returns (uint256)
{
uint256 supply = totalSupply();
uint256 managedAssets = totalAssets();
return (shares * (managedAssets + VIRTUAL_ASSETS)) / (supply + VIRTUAL_SHARES);
}
}Important notes
- The constants should be chosen carefully relative to the asset’s decimals and expected deposit sizes.
- Virtual balances should be consistent across all conversion functions.
- If you override one conversion function, override the related preview and mint/redeem logic as well, or inherit behavior that uses your conversion functions correctly.
Prevent zero-share deposits
A common failure mode is allowing a deposit that mints zero shares due to rounding. Even if the vault is not directly exploitable, zero-share deposits create a terrible user experience and can be abused to trap funds in edge cases.
Best practice
Reject deposits that would mint fewer than a minimum number of shares:
uint256 shares = previewDeposit(assets);
require(shares >= MIN_SHARES, "deposit too small");You can also enforce a minimum asset threshold:
require(assets >= MIN_ASSET_DEPOSIT, "deposit too small");This is especially useful when the vault handles tokens with low decimals or highly volatile exchange rates.
Be careful with direct token transfers
ERC-4626 vaults often assume that totalAssets() reflects assets deposited through the vault interface. But ERC-20 tokens can be transferred directly to the vault contract, bypassing deposit.
That means:
asset().balanceOf(address(this))may include donationstotalAssets()may rise without corresponding shares- conversion math may be skewed
Mitigation options
- Treat direct transfers as donations
- Accept that they increase vault value for all shareholders
- Ensure they do not disproportionately affect early pricing
- Use internal accounting
- Track assets managed through deposits and withdrawals
- Ignore unsolicited transfers in pricing logic
- Combine with virtual balances
- Makes unsolicited transfers less harmful
Internal accounting is the most precise option, but it requires careful reconciliation with strategy profits, losses, and external yield.
Use internal accounting for managed assets
If the vault deploys assets into an external strategy, asset().balanceOf(address(this)) may not equal the true managed balance. In that case, totalAssets() should reflect the sum of:
- idle assets held by the vault
- assets deployed in the strategy
- realized profits or losses
This is also the right place to defend against donation attacks, because pricing should depend on tracked accounting rather than raw token balance alone.
Pattern
- Maintain an internal
managedAssetsvariable - Update it only during authorized deposit, withdrawal, harvest, or loss events
- Do not let arbitrary transfers directly affect share pricing
This makes the vault’s economics explicit and auditable.
Test the attack path explicitly
Security for ERC-4626 vaults is not complete without adversarial tests. You should simulate:
- empty vault bootstrap
- tiny first deposit
- large donation to the vault
- victim deposit after donation
- withdrawal by attacker
Example test scenario
- Attacker deposits 1 token
- Attacker donates 1,000,000 tokens directly
- Victim deposits 100 tokens
- Verify victim receives a fair share amount or that the deposit is rejected
A good test suite should also cover:
- deposits near rounding boundaries
- tokens with 6 decimals and 18 decimals
- repeated donation and withdrawal cycles
- preview functions before and after manipulation
Recommended implementation checklist
Before deploying an ERC-4626 vault, verify the following:
totalAssets()reflects the intended accounting modelconvertToShares()andconvertToAssets()are resistant to empty-vault distortion- zero-share deposits are rejected
- bootstrap liquidity is seeded or virtual balances are used
- direct token transfers do not create exploitable pricing shifts
- preview functions match actual mint/burn behavior
- tests cover donation-based manipulation
When to use each mitigation
| Scenario | Recommended approach |
|---|---|
| Public vault with open deposits | Virtual assets and shares, plus minimum deposit checks |
| Permissioned vault with known initializer | Seed liquidity and locked initial shares |
| Strategy vault with off-chain accounting | Internal accounting plus donation tolerance |
| Low-value or experimental vault | Minimum deposit thresholds and strict bootstrap controls |
In practice, public vaults benefit most from layered defenses. Relying on only one mitigation is usually not enough.
Common mistakes to avoid
1. Assuming balanceOf is authoritative
Raw token balance is not always the same as managed assets.
2. Using naive first-deposit math
Returning assets when totalSupply() == 0 is convenient but often unsafe without bootstrap controls.
3. Ignoring rounding effects
Small deposits can mint zero shares or create disproportionate pricing changes.
4. Overlooking direct transfers
Attackers do not need to call your deposit function to influence vault balance.
5. Failing to test manipulated states
A vault that works in normal conditions may fail badly after a donation or a tiny bootstrap deposit.
Conclusion
ERC-4626 share inflation attacks are a pricing and initialization problem, not a low-level call problem. They occur when an attacker can distort the asset-to-share ratio before honest users deposit. The safest vaults combine bootstrap liquidity, virtual balances, minimum deposit checks, and explicit accounting for managed assets.
If you are building a public vault, assume that direct transfers, tiny deposits, and manipulated exchange rates will happen. Design your conversion logic so that early-state economics remain stable even under adversarial conditions.
