Section 5 of 18
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.
- Vault is freshly deployed.
totalSupply == 0,totalIdle == 0,totalDebt == 0. - 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. - Attacker transfers a large amount of underlying, say
10**18, directly to the vault address. NOT adepositcall, justIERC20.transfer. This is where the donation vector matters: if_totalAssets()readstoken.balanceOf(this), the share price just inflated to10**18 + 1per share. - Victim deposits
10**17(a smaller amount than the donation, so the math rounds against them). The naive formula returns10**17 * 1 / (10**18 + 1), which rounds to 0. Victim's deposit succeeds, but they receive 0 shares. The vault'stotalIdleis now1 + 10**17and 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:
deposit(uint256 amount, address recipient)with thenonReentrantmodifier, 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._issueSharesForAmount(uint256 amount)overriding section 4's. Use the virtual-offset formula._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).