← Back to Blog

ERC-4626 Vault Security: Inflation Attacks, Share Price Manipulation, and Rounding Errors

2026-04-18 erc-4626 vault inflation attack share price defi rounding solidity security

ERC-4626 brought long-overdue standardization to DeFi vaults. Before the standard, every lending protocol, yield aggregator, and liquidity vault implemented its own share-accounting math. Integration was fragile and audits were repetitive. The standard fixed the interface — but the share-to-asset conversion math it mandates has created a predictable, well-documented attack surface that still catches developers off-guard in 2026.

The core abstraction is deceptively simple: a vault holds assets, mints shares proportional to deposits, and redeems shares for assets on exit. The ratio between shares and assets is the "share price." When that ratio can be manipulated — or when the arithmetic that computes it rounds in an attacker-friendly direction — funds are at risk. This post covers six vulnerability classes every Solidity developer and DeFi security auditor must understand before deploying or reviewing an ERC-4626 vault.


1. Vault Inflation Attack (First Depositor Front-Run)

The inflation attack is the canonical ERC-4626 exploit. It works by manipulating the share price before a victim deposits, causing integer division to round the victim's shares down to zero.

The attack sequence:

  1. Attacker monitors the mempool and sees a victim about to deposit 10 ETH.
  2. Attacker front-runs with a deposit of 1 wei, receiving 1 share (totalSupply = 1).
  3. Attacker donates 10 ETH directly to the vault contract (not through deposit), inflating totalAssets without minting new shares.
  4. Victim's 10 ETH deposit arrives. _convertToShares calculates shares as:
// Vulnerable: no virtual offset, pure balanceOf accounting
function _convertToShares(uint256 assets) internal view returns (uint256) {
    uint256 supply = totalSupply();
    return supply == 0
        ? assets
        : assets.mulDiv(supply, totalAssets());
}

With supply = 1 and totalAssets = 10 ETH + 10 ETH + 1 wei ≈ 20 ETH, the victim's 10 ETH yields 10e18 * 1 / 20e18 = 0 shares due to integer truncation.

  1. Attacker redeems their 1 share for the entire vault balance (~20 ETH), profiting ~10 ETH.

The fix — virtual offset (dead shares):

OpenZeppelin's ERC-4626 implementation uses a virtual share offset to make this attack economically infeasible. A constant _decimalsOffset() is added so the initial share price is never 1:1.

// Safe: virtual offset inflates initial supply/assets
function _convertToShares(uint256 assets, Math.Rounding rounding)
    internal view virtual returns (uint256)
{
    return assets.mulDiv(
        totalSupply() + 10 ** _decimalsOffset(),
        totalAssets() + 1,
        rounding
    );
}

With an offset of 3 (1000x), the attacker would need to donate 1000x more than the victim's deposit to cause a zero-share outcome — making the attack unprofitable. Alternatively, the vault can mint "dead shares" to address(0) at deployment time to seed a non-zero supply.


2. Share Price Manipulation via Direct Token Transfer

Even without a first-depositor scenario, any established vault is vulnerable if totalAssets() is implemented naively using balanceOf.

Vulnerable implementation:

// Dangerous: anyone can inflate totalAssets by sending tokens
function totalAssets() public view override returns (uint256) {
    return asset.balanceOf(address(this));
}

Because ERC-20 transfer has no access control, any holder of the underlying asset can donate tokens directly to the vault contract. This inflates totalAssets() without minting new shares, raising the share price. An attacker who holds shares purchased before the donation extracts profit on redemption at the inflated price.

This is not a theoretical concern. Multiple production vaults have required emergency patches due to donation-based price manipulation. The attack is particularly effective against vaults with low TVL, where a modest donation creates a large relative price impact.

Safe internal accounting (Morpho-style):

// Safe: track deposits explicitly, ignore stray transfers
uint256 private _totalDeposited;

function deposit(uint256 assets, address receiver)
    public override returns (uint256 shares)
{
    shares = previewDeposit(assets);
    _totalDeposited += assets;
    SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, assets, shares);
}

function totalAssets() public view override returns (uint256) {
    return _totalDeposited; // never reads balanceOf
}

Yearn v3 and Morpho vaults both maintain internal totalAssets state that accumulates from actual deposit/withdraw calls and accrued yield, never from raw balanceOf. Stray tokens sent to the contract are effectively ignored and cannot be redeemed, but they also cannot manipulate the share price.


3. Rounding Direction Bug (Always Round Against Attacker)

The ERC-4626 specification is explicit about rounding direction, and violating it opens a subtle but real extraction vector.

The standard's requirements:
- convertToShares (used in deposit/mint previews): round DOWN — depositors get fewer shares
- convertToAssets (used in withdraw/redeem previews): round UP — withdrawers pay more assets

When a vault always truncates (floors) regardless of direction, withdrawers can profit from rounding errors through repeated small operations.

Vulnerable — always floor:

// Bug: rounds down in both directions
function previewWithdraw(uint256 assets) public view returns (uint256 shares) {
    uint256 supply = totalSupply();
    return supply == 0 ? assets : assets.mulDiv(supply, totalAssets()); // floors
}

Attack via repeated small withdrawals: If the share price is 1.5 assets per share, a withdrawer requesting 1 asset should pay ceil(1 / 1.5) = 1 share. With floor division they pay floor(1 / 1.5) = 0 shares in degenerate cases, or accumulate fractional advantages across many small withdrawals.

Fixed — explicit rounding direction per operation:

// Correct: deposit rounds down (caller gets fewer shares)
function previewDeposit(uint256 assets) public view returns (uint256) {
    return _convertToShares(assets, Math.Rounding.Floor);
}

// Correct: withdraw rounds up (caller burns more shares)
function previewWithdraw(uint256 assets) public view returns (uint256) {
    return _convertToShares(assets, Math.Rounding.Ceil);
}

function _convertToShares(uint256 assets, Math.Rounding rounding)
    internal view returns (uint256)
{
    return assets.mulDiv(totalSupply() + 1, totalAssets() + 1, rounding);
}

OpenZeppelin's Math.mulDiv accepts a Rounding enum. Always pass Rounding.Floor for deposit-side math and Rounding.Ceil for withdrawal-side math. Auditors should verify this for every path through the accounting code.


4. Fee-on-Transfer Token Incompatibility

ERC-4626's deposit function is specified to accept an assets parameter representing the exact amount transferred. Fee-on-transfer (FoT) tokens break this assumption because the vault receives less than assets after the transfer fee is deducted.

Vulnerable deposit — assumes exact delivery:

function deposit(uint256 assets, address receiver)
    public override returns (uint256 shares)
{
    // assets = 100, but FoT token delivers only 98 (2% fee)
    shares = previewDeposit(assets); // overestimates: calculates shares for 100
    SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    _mint(receiver, shares); // mints shares worth 100, but vault only has 98
    emit Deposit(msg.sender, receiver, assets, shares);
}

The vault mints shares calculated against 100 tokens but only holds 98. The depositor received shares representing value that does not exist in the vault. Over time this dilutes all existing shareholders.

Fixed — balance-delta pattern:

function deposit(uint256 assets, address receiver)
    public override returns (uint256 shares)
{
    uint256 balanceBefore = asset.balanceOf(address(this));
    SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    uint256 actualReceived = asset.balanceOf(address(this)) - balanceBefore;

    // mint shares based on what was actually received, not what was requested
    shares = previewDeposit(actualReceived);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, actualReceived, shares);
}

The balanceBefore/balanceAfter pattern measures actual delivery. If actualReceived < assets, the user is credited correctly. Many protocols instead explicitly document that FoT tokens are not supported — that is also acceptable, but must be enforced with a require statement or documented clearly in the contract NatSpec.


5. Sandwich Attack on Large Deposit

MEV bots routinely sandwich large AMM trades, but ERC-4626 vaults face the same class of attack when share price is manipulable between the time a transaction is submitted and when it lands on-chain.

Attack sequence:

  1. Victim submits a deposit(1000 ETH) transaction.
  2. Bot front-runs by manipulating vault state (e.g., a donation or a large deposit/withdrawal that shifts the share price).
  3. Victim's deposit lands at an unfavorable share price — they receive fewer shares than expected.
  4. Bot back-runs to restore the share price and extract the difference.

Vulnerable — no slippage check:

function deposit(uint256 assets, address receiver)
    public returns (uint256 shares)
{
    shares = _convertToShares(assets);
    // no minimum shares check — depositor gets whatever the current price dictates
    _mint(receiver, shares);
}

Fixed — minSharesOut parameter (analogous to AMM slippage):

function deposit(
    uint256 assets,
    address receiver,
    uint256 minSharesOut,  // caller specifies minimum acceptable shares
    uint256 deadline       // transaction expires after this timestamp
) public returns (uint256 shares)
{
    require(block.timestamp <= deadline, "Vault: expired");
    shares = _convertToShares(assets);
    require(shares >= minSharesOut, "Vault: insufficient shares out");
    SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, assets, shares);
}

This mirrors Uniswap's amountOutMin and deadline parameters. Frontends integrating with the vault should always compute expected shares off-chain and set minSharesOut with a reasonable slippage tolerance (e.g., 0.5%). The deadline prevents stale transactions from landing after conditions have changed significantly.


6. Vault Reentrancy via ERC-777 or ERC-1363 Base Token

ERC-777 tokens implement send/receive hooks — callback functions triggered on the sender and recipient during transfers. If a vault's underlying asset is an ERC-777 (or ERC-1363) token, the deposit function can be reentered before state updates complete.

Vulnerable — state updated after external call:

// Bug: ERC-777 tokensToSend hook fires before _mint, enabling reentry
function deposit(uint256 assets, address receiver)
    public returns (uint256 shares)
{
    shares = previewDeposit(assets);
    // ERC-777 transferFrom triggers tokensToSend on msg.sender
    // attacker reenters here — totalSupply is stale, shares are minted again
    IERC777(asset).operatorSend(msg.sender, address(this), assets, "", "");
    _mint(receiver, shares); // state update happens AFTER external call
}

A malicious token holder can implement tokensToSend to call deposit again before _mint executes. Each reentrant call sees the same totalSupply, minting shares at the same price. The attacker ends up with double (or more) shares backed by a single deposit.

Fixed — nonReentrant guard + Checks-Effects-Interactions:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

function deposit(uint256 assets, address receiver)
    public nonReentrant returns (uint256 shares)
{
    // Checks
    require(assets > 0, "Vault: zero assets");

    // Effects — update state BEFORE external calls
    shares = previewDeposit(assets);
    _mint(receiver, shares);

    // Interactions — external call last
    SafeERC20.safeTransferFrom(asset, msg.sender, address(this), assets);

    emit Deposit(msg.sender, receiver, assets, shares);
}

The nonReentrant modifier from OpenZeppelin's ReentrancyGuard blocks recursive calls at the contract level. Applying the Checks-Effects-Interactions (CEI) pattern independently ensures that even if nonReentrant were absent, the state would already be committed before the external call fires. Both defenses should be present; relying on only one leaves a gap if the other is incorrectly applied elsewhere in the contract.


What ContractScan Detects

ContractScan analyzes ERC-4626 vaults for all six vulnerability classes described above. The AI-powered scanner combines static analysis, data-flow tracing, and semantic pattern matching to surface real exploitable issues — not just stylistic warnings.

Vulnerability Detection Method Severity
Vault Inflation Attack Checks for missing virtual offset or dead-shares seeding when totalSupply == 0 path exists Critical
Share Price Manipulation via Donation Flags totalAssets() implementations that read balanceOf(address(this)) without internal accounting High
Rounding Direction Bug Traces mulDiv rounding mode through all deposit and withdrawal code paths High
Fee-on-Transfer Incompatibility Detects absence of balance-delta pattern in deposit/mint when no FoT exclusion comment is present Medium
Sandwich Attack / No Slippage Guard Flags public deposit functions lacking a minSharesOut or equivalent slippage parameter Medium
Reentrancy via ERC-777/ERC-1363 Identifies CEI violations and missing nonReentrant guards in vault entry points Critical

Scans complete in under 60 seconds and produce a prioritized report with line-level findings, exploit scenarios, and suggested fixes. Run your vault at contractscan.io before deployment.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →