# Solidity Vault First Deposit Inflation

> Detect and remediate ERC-4626 and token vault share inflation attacks, donation-based exchange rate manipulation, division-to-zero share rounding, and virtual shares/offset mitigation strategies.

- Skill: `netvar1337/solidity-vault-first-deposit-inflation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-vault-first-deposit-inflation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-vault-first-deposit-inflation/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-vault-first-deposit-inflation

---


# ERC-4626 Vault Share Inflation & First-Deposit Donation Attacks

## Overview & Empirical Grounding
This security auditing skill is synthesized from **118 empirical audit contest findings** (16 Critical, 102 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **DeFi Vaults & Accounting** patterns.

### Key Trigger Keywords
`erc4626`, `vault inflation`, `first depositor`, `share inflation`, `donation attack`, `virtual shares`, `dead shares`, `shares to assets`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Pools configured with a malicious hook can bypass the `BunniHub` re-entrancy guard to drain all raw balances and vault reserves of legitimate pools
- **Severity**: High (Dataset Weight: 1.00, ID: #25)
- **Vulnerability Mechanism**:
** The `BunniHub` is the main contract that both holds and accounts the raw balances and  vault reserves that each pool has deposited. It inherits `ReentrancyGuard` which implements the `nonReentrant` modifier:

```solidity
modifier nonReentrant() {
    _nonReentrantBefore();
    _;
    _nonReentrantAfter();
}

function _nonReentrantBefore() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 status;
    /// @solidity memory-safe-assembly
    assembly {
        status := tload(statusSlot)
    }
    if (status == ENTERED) revert ReentrancyGuard__ReentrantCall();

    uint256 entered = ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, entered)
    }
}

function _nonReentrantAfter() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 notEntered = NOT_ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, notEntered)
    }
}
```

During the rebalance of a Bunni pool, it is intended to separate this re-entrancy logic into before/after hooks. These functions re-use the same global re-entrancy guard transient storage slot to lock the `BunniHub` against any potential re-entrant execution by a malic

#### Proof of Concept / Vulnerable Pattern
```solidity
** To run the following PoCs:
* Add the following malicious vault implementation inside `test/mocks/ERC4626Mock.sol`:

```solidity
interface MaliciousHook {
    function continueAttackFromMaliciousVault() external;
}

contract MaliciousERC4626 is ERC4626 {
    address internal immutable _asset;
    MaliciousHook internal immutable maliciousHook;
    bool internal attackStarted;

    constructor(IERC20 asset_, address _maliciousHook) {
        _asset = address(asset_);
        maliciousHook = MaliciousHook(_maliciousHook);
    }

    function asset() public view override returns (address) {
        return _asset;
    }

    function name() public pure override returns (string memory) {
        return "MockERC4626";
    }

    function symbol() public pure override returns (string memory) {
        return "MOCK-ERC4626";
    }

    function setupAttack() external {
        attackStarted = true;
    }

    function previewRedeem(uint256 shares) public view override returns (uint256 assets) {
        return type(uint128).max;
    }

    function withdraw(uint256 assets, address to, address owner) public override returns(uint256 shares){
        if(attackStarted) {
            malicious
```

- **Remediation Recommendation**:
** The root cause of the attack was the ability to disable the intended global re-entrancy protection by invoking `unlockForRebalance()` which was subsequently disabled. One solution would be to implement the rebalance re-entrancy protection on a per-pool basis such that one pool cannot manipulate the re-entrancy guard transient storage state of another. Additionally, allowing Bunni pools to be deployed with any arbitrary hook implementation greatly increases the attack surface. It is instead recommended to minimise this by constraining the hook to be the canonical `BunniHook` implementation such that it is not possible to call such `BunniHub` functions directly.

---
### Case Study: First depositor can break minting of shares
- **Severity**: High (Dataset Weight: 1.00, ID: #7382)
- **Vulnerability Mechanism**:
The attack vector and impact is the same as [TOB-YEARN-003](https://github.com/yearn/yearn-security/blob/master/audits/20210719_ToB_yearn_vaultsv2/ToB_-_Yearn_Vault_v_2_Smart_Contracts_Audit_Report.pdf), where users may not receive shares in exchange for their deposits if the total asset amount has been manipulated through a large “donation”.

#### Proof of Concept / Vulnerable Pattern
```solidity
In `BathToken.sol:569-571`, the allocation of shares is calculated as follows:

```solidity
(totalSupply == 0) ? shares = assets : shares = (
    assets.mul(totalSupply)
).div(_pool);
```

An early attacker can exploit this by:

* Attacker calls `openBathTokenSpawnAndSignal()` with `initialLiquidityNew = 1`, creating a new bath token with `totalSupply = 1`
* Attacker transfers a large amount of underlying tokens to the bath token contract, such as `1000000`
* Using `deposit()`, a victim deposits an amount less than `1000000`, such as `1000`:

  * `assets = 1000`
  * `(assets * totalSupply) / _pool = (1000 * 1) / 1000000 = 0.001`, which would round down to `0`
  * Thus, the victim receives no shares in return for his deposit

To avoid minting 0 shares, subsequent depositors have to deposit equal to or more than the amount transferred by the attacker. Otherwise, their deposits accrue to the attacker who holds the only share.

```solidity
it("Victim receives 0 shares", async () => {
    // 1. Attacker deposits 1 testCoin first when creating the liquidity pool
    const initialLiquidityNew = 1;
    const initialLiquidityExistingBathToken = ethers.utils.parseUnits("100", decimals);
    
```

- **Remediation Recommendation**:
* [Uniswap V2 solved this problem by sending the first 1000 LP tokens to the zero address](https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol#L119-L124). The same can be done in this case i.e. when `totalSupply() == 0`, send the first min liquidity LP tokens to the zero address to enable share dilution.
  * In `_deposit()`, ensure the number of shares to be minted is non-zero:

```solidity
require(shares != 0, "No shares minted");
```

Great issue, what do y’all think of this code snippet as a solution:

```solidity
/// @notice Deposit assets for the user and mint Bath Token shares to receiver
function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) {
    uint256 _pool = underlyingBalance();
    uint256 _before = underlyingToken.balanceO

---
### Case Study: Incorrect calculation in CreditDelegationBranch::withdrawUsdTokenFromMarket allows attacker mint any amount of usdz
- **Severity**: High (Dataset Weight: 1.00, ID: #11383)
- **Vulnerability Mechanism**:
Market::getCreditCapacityUsd function located in CreditDelegationBranch::withdrawUsdTokenFromMarket, which incorrectly calculates the credit capacity of a market by adding the total debt to the delegated credit instead of subtracting it. This flaw leads to an overestimation of the market's credit capacity, potentially allowing the system to operate in an unsafe state where it cannot cover its liabilities. This issue is particularly severe because it affects the core logic of credit delegation and debt management in the protocol and allows an attacker to mint an amount of usdz that the protocol cannot back 1:1 with usdc which breaks a key protocol invariant.

CreditDelegationBranch::withdrawUsdTokenFromMarket contains the following code:
```solidity
/// @notice Mints the requested amount of USD Token to the caller and updates the market's
    /// debt state.
    /// @dev Called by a registered engine to mint USD Token to profitable traders.
    /// @dev USD Token association with an engine's user happens at the engine contract level.
    /// @dev We assume amount is part of the market's reported unrealized debt.
    /// @dev Invariants:
    /// The Market of marketId MUST exist.
   

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
function test_creditcapacitywrongcalculation( 
        uint256 marketId,
        uint256 amount,
        uint256 amounttodepositcredit,
        uint128 vaultId,
        uint64 anyamount
    )
        external
    {
         amounttodepositcredit = bound({ x: amount, min: 1, max: type(uint32).max });

        PerpMarketCreditConfig memory fuzzMarketConfig = getFuzzPerpMarketCreditConfig(marketId);
       vaultId = uint128(bound(vaultId, INITIALVAULTID, FINALVAULTID));
        VaultConfig memory fuzzVaultConfig = getFuzzVaultConfig(vaultId);

        vm.assume(fuzzVaultConfig.asset != address(usdc));

        deal({ token: address(fuzzVaultConfig.asset), to: address(fuzzMarketConfig.engine), give: amounttodepositcredit*10 });
        changePrank({ msgSender: address(fuzzMarketConfig.engine) });

        // first user deposits credit into the market
        marketMakingEngine.depositCreditForMarket(fuzzMarketConfig.marketId, fuzzVaultConfig.asset, amounttodepositcredit);

        uint256 mmBalance = IERC20(fuzzVaultConfig.asset).balanceOf(address(marketMakingEngine));

        uint128 depositFee = uint128(vaultsConfig[fuzzVaultConfig.vaultId].depositFee);

        _setDepo
```

- **Remediation Recommendation**:
```solidity
/// @notice Returns a market's credit capacity in USD based on its delegated credit and total debt.
    /// @param delegatedCreditUsdX18 The market's credit delegated by vaults in USD.
    /// @param totalDebtUsdX18 The market's unrealized + realized debt in USD.
    /// @return creditCapacityUsdX18 The market's credit capacity in USD.
    function getCreditCapacityUsd(
        UD60x18 delegatedCreditUsdX18,
        SD59x18 totalDebtUsdX18
    )
        internal
        pure
        returns (SD59x18 creditCapacityUsdX18)
    {
        creditCapacityUsdX18 = delegatedCreditUsdX18.intoSD59x18().sub(totalDebtUsdX18);
    }
```

---
### Case Study: Inflation of ggAVAX share price by first depositor
- **Severity**: High (Dataset Weight: 0.97, ID: #17256)
- **Vulnerability Mechanism**:
Inflation of `ggAVAX` share price can be done by depositing as soon as the vault is created.

Impact:

1. Early depositor will be able steal other depositors funds
2. Exchange rate is inflated. As a result depositors are not able to deposit small funds.

```solidity
function convertToShares(uint256 assets) public view virtual returns (uint256) {
    uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero.

    return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets());
}
```

Its important to note that while it is true that cycle length is 14 days, in practice time between cycles can very between 0-14 days. This is because syncRewards validates that the next reward cycle is evenly divided by the length (14 days).

```solidity
function syncRewards() public {
----------
    // Ensure nextRewardsCycleEnd will be evenly divisible by `rewardsCycleLength`.
    uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength;
---------
}
```

Therefore:

* The closer the call to `syncRewards` is to the next evenly divisible value of `rewardsCycleLength`, the closer the next `rewardsCycleEnd` will be.
* The close

#### Proof of Concept / Vulnerable Pattern
```solidity
If `ggAVAX` is not seeded as soon as it is created, a malicious depositor can deposit 1 WEI of AVAX to receive 1 share.  
The depositor can donate WAVAX to the vault and call `syncRewards`. This will start inflating the price.

When the attacker front-runs the creation of the vault, the attacker:

1. Calls `depositAVAX` to receive 1 share
2. Transfers `WAVAX` to `ggAVAX`
3. Calls `syncRewards` to inflate exchange rate

The issue exists because the exchange rate is calculated as the ratio between the `totalSupply` of shares and the `totalAssets()`.  
When the attacker transfers `WAVAX` and calls `syncRewards()`, the `totalAssets()` increases gradually and therefore the exchange rate also increases.

Add the following test to `TokenggAVAX.t.sol`: <https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/test/unit/TokenggAVAX.t.sol#L108>
    
```solidity
function testShareInflation() public {
    uint256 depositAmount = 1;
    uint256 aliceDeposit = 2000 ether;
    uint256 donationAmount = 1000 ether;
    vm.deal(bob, donationAmount  + depositAmount);
    vm.deal(alice, aliceDeposit);
    vm.warp(1672876799);

    // create new ggAVAX
    ggAVAXImpl
```

- **Remediation Recommendation**:
When creating the vault add initial funds in order to make it harder to inflate the price. Best practice would add initial funds as part of the initialization of the contract (to prevent front-running).

The Warden has shown how, by performing a small deposit, followed by a transfer, shares can be rebased, causing a grief in the best case, and complete fund loss in the worst case for every subsequent depositor.

While the finding is fairly known, it’s impact should not be understated, and because of this I agree with High Severity.

I recommend watching this presentation by Riley Holterhus which shows possible mitigations for the attack: <https://youtu.be/_pO2jDgL0XE?t=601>

Initialize ggAVAX with a deposit: [multisig-labs/gogopool#49](https://github.com/multisig-labs/gogopool/pull/49)

---
### Case Study: Incorrect accounting bug of the `yDUSD` vault leads to total loss of depositors’ `DUSD` assets
- **Severity**: High (Dataset Weight: 0.97, ID: #21683)
- **Vulnerability Mechanism**:
The current implementation of the `yDUSD` vault does not properly support the auto-compounding token rewards mechanism by directly minting the `DUSD` assets to the vault.

Due to an incorrect accounting bug of the vault’s total supply (shares), depositors can lose some deposited `DUSD` assets (principal) or even all the assets when the `Ditto` protocol mints `DUSD` debts to the vault to account for any discounts.

When the match price is below the oracle price, the `OrdersFacet::_matchIsDiscounted()` will be invoked to account for a discount [by minting the discount (`newDebt`)](https://github.com/code-423n4/2024-07-dittoeth/blob/ca3c5bf8e13d0df6a2c1f8a9c66ad95bbad35bce/contracts/facets/OrdersFacet.sol#L178) (`@1` in the snippet below) to the `yDUSD` vault.

Assuming the `yDUSD` vault is empty (i.e., both `totalAssets` and `totalSupply` are 0), for simplicity’s sake. This step will increase the vault’s total `DUSD` assets (`totalAssets` — spot balance) without updating the vault’s total supply (`totalSupply` — tracked shares).

```solidity
function _matchIsDiscounted(MTypes.HandleDiscount memory h) external onlyDiamond {
    ...

    if (pctOfDiscountedDebt > C.DISCOUNT_THRESHOLD &

#### Proof of Concept / Vulnerable Pattern
```solidity
This section provides a coded PoC.

Place the `test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting()` in the `.test/YDUSD.t.sol` file and run the test using the command: `forge test -vv --mt test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting`.

The PoC shows that a user loses all `DUSD` assets (both principal and yield) when he deposits the assets into the `yDUSD` vault right after the protocol has minted debts to the vault.

```solidity
function test_PoC_yDUSD_Yault_totalSupply_IncorrectInternalAccounting() public {
    // Create discounts to generate newDebt
    uint88 newDebt = uint88(discountSetUp());
    assertEq(rebasingToken.totalAssets(), newDebt);
    assertEq(rebasingToken.totalSupply(), 0);
    assertEq(rebasingToken.balanceOf(receiver), 0);
    assertEq(rebasingToken.balanceOf(_diamond), 0);
    assertEq(rebasingToken.balanceOf(_yDUSD), 0);
    assertEq(token.balanceOf(_yDUSD), newDebt);

    // Receiver deposits DUSD into the vault to get yDUSD
    vm.prank(receiver);
    rebasingToken.deposit(DEFAULT_AMOUNT, receiver);

    assertEq(rebasingToken.totalAssets(), DEFAULT_AMOUNT + newDebt);
    assertEq(rebasingToken.totalSupply(), 0);       // 0 amoun
```

- **Remediation Recommendation**:
Rework the `yDUSD` vault by applying the concept of a single-sided auto-compounding token rewards mechanism of the [xERC4626](https://github.com/ERC4626-Alliance/ERC4626-Contracts/blob/main/src/xERC4626.sol), which is fully compatible with the `ERC4626` and ultimately maintains balances using internal accounting to prevent instantaneous changes in the exchange rate.

_Note: see[original submission](https://github.com/code-423n4/2024-07-dittoeth-findings/issues/7) for full discussion._

---
### Case Study: Market-vault disconnection will bring permanent inconsistent state
- **Severity**: High (Dataset Weight: 0.97, ID: #11374)
- **Vulnerability Mechanism**:
In the protocol, for the most of the time, market and vault's state vars are updated by deltas, not by direct updation. This delta-change operation does not work well when market and vault connection is changed. It doesn't recognize delta by broken connection. As a result, when a vault is unlinked from a market, market's delegated credit usd will remain the same. Vault's state vars (marketRealizedDebtUsd etc) won't reflect broken connection, either.

Root Cause  
Market and vault's connection is updated by MarketMakingEngineConfigurationBranch.connectVaultAndMarkets.

The owner can add or remove vaults and markets connection by calling this external function.

After connections are updated, vaults and markets state vars will be updated by Vault.recalculateVaultsCreditCapacity. This function logic is very complicated but can be summarized as the following:  
Update weights for all credit delegations for connected markets  
Calculate deltas of realizedDebtChange, unrealizedDebtChange, usdcCreditChange, wethRewardChange between vault and connected markets  
Update vault's states by deltas calculated above  
For all connected markets, do the following:  
Calculate creditDelegation delt

#### Proof of Concept / Vulnerable Pattern
```solidity
The following POC demonstrates the following scenario:  
Market 1 was connected to Vaults 1, 2  
Market's totalDelegatedCreditUsd is 1600 and creditCapacityUsd is 1680  
Market 1 is connected to Vault 1. Vault 2 has been disconnected from the market  
Recalculate market's credit deposits  
Market's updated totalDelegatedCreditUsd is still 1600 and creditCapacityUsd still remains 1680

```solidity
pragma solidity 0.8.25;

import { CreditDelegationBranch } from "@zaros/market-making/branches/CreditDelegationBranch.sol";
import { VaultRouterBranch } from "@zaros/market-making/branches/VaultRouterBranch.sol";
import { MarketMakingEngineConfigurationBranch } from
    "@zaros/market-making/branches/MarketMakingEngineConfigurationBranch.sol";
import { Vault } from "@zaros/market-making/leaves/Vault.sol";
import { Market } from "@zaros/market-making/leaves/Market.sol";
import { CreditDelegation } from "@zaros/market-making/leaves/CreditDelegation.sol";
import { MarketMakingEngineConfiguration } from "@zaros/market-making/leaves/MarketMakingEngineConfiguration.sol";
import { LiveMarkets } from "@zaros/market-making/leaves/LiveMarkets.sol";
import { Collateral } from "@zaros/market-making/le
```

- **Remediation Recommendation**:
Connection updating logic and Vault.recalculateVaultsCreditCapacity should be changed to handle disconnection scenario.


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

