Remediating Vault Inflation Attacks
How to protect ERC-4626 vaults from first-depositor share price inflation attacks using virtual shares, dead share seeding, or minimum deposit requirements.
Remediating Vault Inflation Attacks
Overview
Related Detector: Vault Inflation Attack
The vault inflation attack exploits integer rounding in ERC-4626 share price calculations. The standard formula shares = assets * totalSupply / totalAssets rounds down. When an attacker can artificially inflate totalAssets via a direct token transfer to the vault address while keeping totalSupply at 1, even a large legitimate deposit produces assets * 1 / inflated_totalAssets = 0 shares.
Three proven mitigations exist: virtual share offsets (preferred), dead share seeding, and minimum deposit requirements. The virtual offset pattern from OpenZeppelin v4.9+ is the recommended approach for new vault implementations.
Recommended Fix
Use OpenZeppelin ERC4626 v4.9+ with Virtual Offset
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
contract SecureVault is ERC4626 {
// A decimals offset of 3 means the vault mints 10^3 virtual shares per
// 1 real share. An attacker would need to donate 10^3x a victim's deposit
// to round the victim to zero shares — making the attack unprofitable.
uint8 private constant _OFFSET = 3;
constructor(IERC20 asset)
ERC4626(asset)
ERC20("Secure Vault", "sVLT")
{}
/// @dev Override to activate the virtual offset protection
function _decimalsOffset() internal pure override returns (uint8) {
return _OFFSET;
}
}
With _OFFSET = 3, the share calculation becomes:
shares = assets * (totalSupply + 10^3) / (totalAssets + 1)
An attacker inflating totalAssets to D tokens with a donation needs D > 10^3 * victim_assets to cause rounding to zero, making the attack economically infeasible for any reasonable donation amount.
Alternative Mitigations
Dead Share Seeding
Permanently lock a small number of shares to an uncontrolled address (e.g., address(0xdead)) at vault initialization. This raises totalSupply and totalAssets above zero permanently, preventing the attacker from achieving the 1-share initial state:
contract VaultWithDeadShares is ERC4626 {
bool private _initialized;
constructor(IERC20 asset)
ERC4626(asset)
ERC20("DeadShares Vault", "dsVLT")
{}
/// @notice Seed the vault with dead shares before opening to users.
/// Call this immediately after deployment in the deployment script.
/// seedAmount must be large enough to make inflation attacks unprofitable.
function seed(uint256 seedAmount) external {
require(!_initialized, "Already seeded");
_initialized = true;
IERC20(asset()).transferFrom(msg.sender, address(this), seedAmount);
// Mint shares to an uncontrolled address — permanently locks the price floor
_mint(address(0xdead), seedAmount);
}
}
The seed amount should be at least 1000x the minimum viable deposit to make inflation attacks unprofitable.
Minimum Deposit and Share Requirement
Add require checks to reject deposits that would result in zero shares:
contract MinimumDepositVault is ERC4626 {
uint256 public constant MIN_SHARES = 1000;
constructor(IERC20 asset)
ERC4626(asset)
ERC20("Min Deposit Vault", "mdVLT")
{}
function deposit(uint256 assets, address receiver)
public
override
returns (uint256)
{
uint256 shares = previewDeposit(assets);
require(shares >= MIN_SHARES, "Deposit too small: would receive zero shares");
return super.deposit(assets, receiver);
}
function mint(uint256 shares, address receiver)
public
override
returns (uint256)
{
require(shares >= MIN_SHARES, "Share amount too small");
return super.mint(shares, receiver);
}
}
Common Mistakes
Mistake: Protecting Deposit but Not Mint
// INCOMPLETE: deposit has a minimum but mint does not
function deposit(uint256 assets, address receiver) public override returns (uint256) {
uint256 shares = previewDeposit(assets);
require(shares > 0, "Zero shares"); // Protected
return super.deposit(assets, receiver);
}
// mint() still allows obtaining shares without the deposit minimum check
// An attacker can use mint() to bypass the protection
function mint(uint256 shares, address receiver) public override returns (uint256) {
return super.mint(shares, receiver); // UNPROTECTED
}
Both deposit() and mint() entry points must be protected consistently.
Mistake: Using a Fixed Small Seed Amount
// INSUFFICIENT: seeding with only 1 wei is not enough
function seed() external {
_mint(address(0xdead), 1); // 1 share seed — an attacker can still donate enough
}
The seed amount must be large enough relative to the minimum expected user deposit to make an inflation attack unprofitable after gas costs.
Mistake: Checking Share Price Without Accounting for Donation
// WRONG: this check passes before the donation but fails after
require(totalAssets() > 0, "Empty vault");
// Attacker can front-run this check with a donation
uint256 shares = assets * totalSupply() / totalAssets();
The only reliable protection is structural — virtual offsets or dead shares — not transaction-level checks that can be front-run.