Section 4 of 18
Vault: pricePerShare and Share Issuance
What You Are Building
Two questions every vault must answer. If a user holds N shares, how much underlying do they own? If a user deposits X underlying, how many shares should they receive? These are mirror images of each other and they both depend on totalAssets().
This section implements _shareValue, _issueSharesForAmount, _totalAssets, and the public pricePerShare. Get the rounding wrong on either of the first two and the vault either dilutes existing depositors or hands the new depositor more than they deserve. Both have happened in production; the historical incidents are the subject of section 5.
The Mirror-Image Formulas
Pure proportional accounting. If the vault holds totalAssets underlying and totalSupply shares are outstanding, then 1 share is worth totalAssets / totalSupply underlying. Multiply through:
_shareValue(N) = N * totalAssets / totalSupply
_issueSharesForAmount(X) = X * totalSupply / totalAssets
Solidity integer division rounds down. We let it. For _shareValue rounding down means the vault never owes more underlying than it actually holds: a withdrawal cannot extract more than totalAssets reports. For _issueSharesForAmount rounding down means the new depositor never receives more shares than the math justifies, so existing depositors are never diluted by rounding.
The asymmetry that prevents value leakage in either direction comes from this: both functions round the same way (down), so a deposit-then-withdraw round trip cannot extract more than was put in.
The First-Deposit Edge Case
When totalSupply == 0, the formula divides by zero. We have to handle the first depositor specially. The naive answer is 1:1, the first depositor of amount gets amount shares.
function _issueSharesForAmount(uint256 amount) internal view virtual returns (uint256) {
if (totalSupply == 0) {
return amount;
}
uint256 totalAssets_ = _totalAssets();
require(totalAssets_ > 0, "Vault/zero-assets-with-supply");
return amount * totalSupply / totalAssets_;
}
That naive 1:1 is the first-depositor attack surface. An attacker deposits 1 wei and gets 1 share. Then they transfer a large amount of underlying directly to the vault address. Now totalAssets reports 1 + large, so pricePerShare reads as (1 + large) / 1 = 1 + large. A subsequent depositor of victim underlying gets victim * 1 / (1 + large) shares, which rounds to 0 if victim < 1 + large. The attacker now owns 100% of the vault and the victim's deposit is stranded.
Section 5 fixes this at the deposit-flow layer using virtual-offset shares. For now we leave the naive math in place and mark these functions virtual so section 5 can override.
_totalAssets
_totalAssets() is the running view of how much underlying the vault owns on paper.
function _totalAssets() internal view virtual returns (uint256) {
return totalIdle + totalDebt;
}
Two terms. totalIdle is underlying sitting in the vault contract, not allocated to any strategy. totalDebt is underlying allocated to (and conceptually held by) strategies. Their sum is the vault's full pool.
What _totalAssets does NOT do is read token.balanceOf(this). That difference is the donation defense and the subject of section 6. Briefly: if _totalAssets read the contract balance, anyone could transfer underlying to the vault address and inflate the share price. Using the explicit totalIdle accumulator instead means donations sit in the contract but do not affect share math. Section 6 elaborates with the public view and the documentation rationale.
pricePerShare
The canonical Yearn API. One public view that returns the current price of one share in units of underlying.
function pricePerShare() external view returns (uint256) {
if (totalSupply == 0) return 10 ** decimals;
return _shareValue(10 ** decimals);
}
The trick is the 10 ** decimals argument. _shareValue(N) returns the underlying value of N shares. Calling it with 10 ** decimals (the unit-share quantity, e.g. 1e6 for a 6-decimal share token like a USDC vault) gives the value of one whole share, in the same decimal unit as the underlying. A USDC vault returning pricePerShare() = 1_050_000 means 1 share is worth 1.05 USDC.
When totalSupply == 0 we return 10 ** decimals by convention: starting price is one underlying per share, then the math takes over once there are deposits.
Why These Functions Are Virtual
Section 5 will override _issueSharesForAmount and _shareValue with virtual-offset versions to defend against first-depositor inflation. Section 13 will override _shareValue AGAIN to use _freeFunds() instead of _totalAssets() (the locked-profit defense for withdrawals).
For Solidity to allow these chains of overrides, every function in the chain must be marked virtual. The override in section 5 must also be marked virtual so section 13 can override it in turn. Forgetting virtual on an intermediate override is a common bug: the compiler errors only at the most-derived contract, far from the actual source of the problem.
Comparison to Compound's exchangeRate
Compound's exchangeRateStoredInternal() returns (totalCash + totalBorrows - totalReserves) / totalSupply. Yearn's _totalAssets() returns totalIdle + totalDebt. Different decomposition, same idea: sum the on-paper claims, divide by share supply.
Compound's exchange rate grows because totalBorrows grows (interest accrues). Yearn's pricePerShare grows because totalDebt is updated upward when strategies report gains. Same growth pattern, different yield source. Both are share-token systems where the balance is constant and the value per share appreciates.
Your Task
The starter has signatures, NatSpec, and the virtual modifiers in place. Implement:
_shareValue(uint256 shares), return 0 iftotalSupply == 0; otherwiseshares * _totalAssets() / totalSupply._issueSharesForAmount(uint256 amount), returnamountiftotalSupply == 0; require_totalAssets() > 0; returnamount * totalSupply / _totalAssets()._totalAssets(), returntotalIdle + totalDebt.pricePerShare(), return10 ** decimalsiftotalSupply == 0; otherwise_shareValue(10 ** decimals).
Keep all four functions marked virtual. Section 5 needs to override the first two; section 13 needs to override _shareValue again.
The next section adds the public deposit() flow on top of this math, plus the virtual-offset override that closes the first-depositor hole.