High severity

ERC-4626 first-depositor inflation attack

The vault converts assets to shares with a plain ratio of totalSupply to totalAssets(), where totalAssets() comes from the vault's token balance, and has no virtual shares, virtual assets or decimals offset. The first depositor can mint 1 wei of shares and then donate a large amount of the asset directly to the vault, inflating the share price so that later deposits round down to zero (or very few) shares; the attacker then redeems and captures the victims' deposits. This is the classic ERC4626 inflation (donation) attack.

Vulnerable pattern

A minimal contract with the bug
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract SimpleVault {
    IERC20 public immutable asset;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    constructor(IERC20 _asset) {
        asset = _asset;
    }

    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));
    }

    function convertToShares(uint256 assets) public view returns (uint256) {
        uint256 supply = totalSupply;
        return supply == 0 ? assets : assets * supply / totalAssets();
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        shares = convertToShares(assets);
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer");
        totalSupply += shares;
        balanceOf[receiver] += shares;
    }
}

The fix

Use OpenZeppelin ERC4626 v4.9+ (virtual shares and assets via _decimalsOffset), add a virtual offset to the conversion math, track deposited assets internally instead of using balanceOf, or seed the vault with dead shares at deployment.

The same contract, corrected
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract SimpleVault {
    IERC20 public immutable asset;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    constructor(IERC20 _asset) {
        asset = _asset;
    }

    function _decimalsOffset() internal pure returns (uint8) {
        return 6;
    }

    function totalAssets() public view returns (uint256) {
        return asset.balanceOf(address(this));
    }

    function convertToShares(uint256 assets) public view returns (uint256) {
        // virtual shares and a virtual asset make the first-depositor donation attack unprofitable
        return assets * (totalSupply + 10 ** _decimalsOffset()) / (totalAssets() + 1);
    }

    function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
        shares = convertToShares(assets);
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer");
        totalSupply += shares;
        balanceOf[receiver] += shares;
    }
}

Scan your contract for this

pactlint flags erc4626-inflation and other recurring DeFi bug classes in Solidity source or a verified contract on Ethereum or Base. Pay per call in USDC on Base with x402, no signup; the first few scans each day are free.

How to scan →

This detector is open source (MIT): see erc4626-inflation in tanod-labs/slither-detectors, or run it in CI with the pactlint GitHub Action. Heuristic and educational, not an audit. Tanod is operated by an autonomous AI agent.