Found Academy useful? A $5 donation by May 14 helps us ship more, faster. Every donor counts (QF matching).

Donate

Section 5 of 18

Build
+15 Lynx

Vault: Deposit and First-Depositor Defense

What You Are Building

Two things in one section. The public deposit() flow that lets users put underlying into the vault. And the override of _issueSharesForAmount and _shareValue from section 4 that closes the first-depositor inflation attack at the math layer.

The deposit flow is mostly bookkeeping: validate inputs, compute shares from the math, mint, update totalIdle, pull tokens. The interesting part is the order. Pull tokens last, after every state change. The interesting fix is the override: virtual-offset shares, the modern best practice that Yearn V3 inherited from OpenZeppelin's ERC-4626.

The First-Depositor Attack

Section 4 told you the math has a hole at totalSupply == 0. Here is the actual attack.

  1. Vault is freshly deployed. totalSupply == 0, totalIdle == 0, totalDebt == 0.
  2. Attacker deposits 1 wei of underlying. The naive math returns _issueSharesForAmount(1) = 1 (the first-deposit branch). Attacker gets 1 share. State: totalSupply = 1, totalIdle = 1.
  3. Attacker transfers a large amount of underlying, say 10**18, directly to the vault address. NOT a deposit call, just IERC20.transfer. This is where the donation vector matters: if _totalAssets() reads token.balanceOf(this), the share price just inflated to 10**18 + 1 per share.
  4. Victim deposits 10**17 (a smaller amount than the donation, so the math rounds against them). The naive formula returns 10**17 * 1 / (10**18 + 1), which rounds to 0. Victim's deposit succeeds, but they receive 0 shares. The vault's totalIdle is now 1 + 10**17 and the attacker still owns the only share, claiming all of it.

Real exploits. Beefy Wrapper (Zellic 2022 report) lost user funds via this exact vector. Multiple Yearn forks (Pickle variants, Reaper Farm) have suffered related share-inflation incidents. The pattern is consistent enough that any new vault audit checklist starts with "first-depositor defense?" as the first question.

Section 6 closes the donation vector by using totalIdle + totalDebt (the explicit accumulator) instead of token.balanceOf(this). This section closes the first-depositor vector by overriding the share math to add a virtual offset.

The Virtual-Offset Defense

OpenZeppelin's ERC-4626 implementation introduced this approach and Yearn V3 adopted it. The idea: pretend the vault always has 10**OFFSET extra "phantom" shares and 1 extra "phantom" underlying that nobody owns. Bake those phantoms into both share-math formulas:

shares    = amount * (totalSupply + 10**OFFSET) / (_totalAssets() + 1)
assetValue = shares * (_totalAssets() + 1) / (totalSupply + 10**OFFSET)

The phantoms always exist on paper. Even when totalSupply == 0 and _totalAssets() == 0, the formula reads as amount * 10**OFFSET / 1, which is well-defined and well-behaved.

What this buys: the inflation attack becomes uneconomic. To make a victim's victim underlying round to 0 shares, the attacker needs to inflate _totalAssets() until victim * (totalSupply + 10**OFFSET) / (_totalAssets() + 1) < 1. With totalSupply = 1 and OFFSET = 8, the attacker needs to donate enough that victim * 10**8 / (donation + 2) < 1, i.e. donate at least victim * 10**8 - 2. For a 1 USDC victim deposit, that is 100M USDC of donation. No rational attacker burns 100M USDC to brick a 1 USDC deposit.

We pick OFFSET = 8. OpenZeppelin recommends 0 to 3 for typical tokens; we go higher because we want the attack cost to be visibly absurd. The trade-off: the first depositor pays for the virtual phantoms in the form of slightly diluted shares (their share count is amount * 10**8 instead of amount, but that just shifts decimals; their fractional ownership of the vault is the same).

The Override

function _issueSharesForAmount(uint256 amount) internal view virtual override returns (uint256) {
    uint256 virtualSupply = totalSupply + 10 ** DECIMALS_OFFSET;
    uint256 virtualAssets = _totalAssets() + 1;
    return amount * virtualSupply / virtualAssets;
}

function _shareValue(uint256 shares) internal view virtual override returns (uint256) {
    uint256 virtualSupply = totalSupply + 10 ** DECIMALS_OFFSET;
    uint256 virtualAssets = _totalAssets() + 1;
    return shares * virtualAssets / virtualSupply;
}

Both functions stay virtual because section 13 will override _shareValue again (to use _freeFunds() for the locked-profit defense). The DECIMALS_OFFSET constant (8) is declared in this contract; it lives near the override that uses it.

Section 4's "first-deposit returns amount 1:1" branch is gone. The virtual-offset version handles the totalSupply == 0 case naturally.

The deposit() Flow

function deposit(uint256 amount, address recipient) external returns (uint256 shares) {
    require(!emergencyShutdown, "Vault/emergency-shutdown");
    require(recipient != address(0) && recipient != address(this), "Vault/bad-recipient");

    if (amount == type(uint256).max) {
        amount = token.balanceOf(msg.sender);
    }
    require(amount > 0, "Vault/zero-deposit");

    if (depositLimit > 0) {
        require(_totalAssets() + amount <= depositLimit, "Vault/over-deposit-limit");
    }

    shares = _issueSharesForAmount(amount);
    require(shares > 0, "Vault/zero-shares-issued");

    _mint(recipient, shares);
    totalIdle += amount;

    require(
        token.transferFrom(msg.sender, address(this), amount),
        "Vault/transfer-failed"
    );

    return shares;
}

Step by step. Emergency shutdown blocks deposits (section 17 sets the flag). Zero recipient and self-recipient are both refused (sending to the vault itself locks shares). The type(uint256).max sentinel resolves to the caller's full balance, a Yearn V2 convention that matches the deposit-everything UX. We require amount > 0 after that resolution. If a depositLimit is set, we check it. We compute shares using the virtual-offset math. We require shares > 0 so that a deposit too small to round up to a share fails loudly instead of silently giving the vault free money.

Then in order: mint shares, update totalIdle, transfer tokens last.

Why transferFrom Goes Last

Two reasons. The first is reentrancy. If the underlying token has a transfer hook (ERC-777, fee-on-transfer wrappers, custom callback patterns), transferFrom can call back into our contract. By the time the callback fires, every state change is already complete: totalSupply reflects the new shares, balanceOf[recipient] reflects the new balance, totalIdle reflects the new accounting. A reentrant call sees consistent state. There is nothing for an attacker to exploit because the math has already settled.

The second is fee-on-transfer drift. If the token charges a transfer fee, transferFrom of amount actually moves amount - fee. The vault's totalIdle will diverge from token.balanceOf(this). Yearn V2 does not support fee-on-transfer tokens. Neither do we. The vault accounting assumes transferFrom of amount adds exactly amount to the vault's balance. If you ever deploy this vault with a fee-on-transfer token, the donation defense in section 6 also breaks (the invariant token.balanceOf(this) >= totalIdle becomes false after the first deposit). Don't.

Yearn V2 Original vs This Module

Yearn V2 originally had no built-in first-depositor defense. The team relied on operational seeding: when launching a new vault, deposit a meaningful amount yourself before opening to the public. This works if you trust the operator to follow the playbook. It fails for forks that copy the V2 contracts without copying the operational discipline.

Yearn V3 inherits OpenZeppelin's ERC-4626 implementation, which uses virtual-offset shares. We are teaching the V3 best practice on top of the V2 core, which is the modern reconstruction approach.

The Reentrancy Guard

This section also defines the nonReentrant modifier the rest of the module uses. The slot itself (_reentrancyStatus) lives in section 2's storage. The modifier:

modifier nonReentrant() {
    require(_reentrancyStatus == 0, "Vault/reentrant");
    _reentrancyStatus = 1;
    _;
    _reentrancyStatus = 0;
}

We define it here because deposit is the first vault function that takes a value transfer from an external caller. We apply the same modifier to report (section 11) and withdraw (section 14). The withdraw side is where the guard actually matters: the vault calls into untrusted strategy code in the middle of the queue iteration. Without the guard, a malicious strategy could re-enter the vault from inside its own withdraw callback and observe state mid-update. We apply it here for symmetry, even though deposit is reachable only via the trusted underlying token's transferFrom.

Your Task

The starter has the signatures, the constant, the modifier, and the NatSpec. Implement:

  1. deposit(uint256 amount, address recipient) with the nonReentrant modifier, following the 11-step flow above. Order matters: emergency check, recipient check, sentinel resolve, amount validation, depositLimit check, shares math, shares > 0 check, mint, totalIdle update, transferFrom LAST.
  2. _issueSharesForAmount(uint256 amount) overriding section 4's. Use the virtual-offset formula.
  3. _shareValue(uint256 shares) overriding section 4's. Use the same virtual-offset formula in the inverse direction.

Both overrides must stay marked virtual so section 13 can override _shareValue once more.

The next section adds the public totalAssets() view, convertToAssets, convertToShares, and the documentation rationale for using totalIdle instead of token.balanceOf(this) (the donation defense).

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Write your implementation, then click Run Tests. Tests execute on the server.