Section 13 of 18
Vault: Locked Profit Degradation (just-in-time defense)
What You Are Building
Yearn V2's signature security mechanism. After a strategy reports gain, the gain doesn't immediately become withdrawable, it linearly unlocks over a configurable period (default ~6 hours). New deposits during the window pay the gross share price (so they don't profit from the locked portion); withdrawals see the net price (locked portion excluded). The asymmetry is the defense.
The Attack This Defeats
Without locked profit, here is what happens:
- A profitable strategy is about to call
vault.report(gain=$1M, ...). An MEV searcher watches the mempool. - Searcher front-runs with a giant deposit (say $50M).
- The harvest transaction executes.
vault.totalAssets()jumps to include the $1M gain. Searcher's shares now claim a fraction of that gain proportional to their pre-existing share of total supply. - On the next block, searcher calls
withdraw(maxShares). They receive their deposit back PLUS their pro-rata cut of the $1M gain. They didn't earn it. They MEV'd it. - Existing depositors who were "supposed" to receive that $1M now share it with the searcher.
Yearn V2's defense: lock the freshly-reported gain so it's not in the share value at withdraw time, but IS in the share value at deposit time. New depositors pay full price; new withdrawers can't extract. The asymmetry traps the searcher.
The Mechanism
function _calculateLockedProfit() internal view returns (uint256) {
uint256 lockedFundsRatio = (block.timestamp - lastReport) * lockedProfitDegradation;
if (lockedFundsRatio < DEGRADATION_COEFFICIENT) {
uint256 locked = lockedProfit;
return locked - (lockedFundsRatio * locked / DEGRADATION_COEFFICIENT);
}
return 0;
}
lockedProfit (storage) holds the underlying-denominated profit being locked. lockedProfitDegradation is the per-second decay rate, scaled by DEGRADATION_COEFFICIENT = 1e18. With the default lockedProfitDegradation = 46_296_296_296_296, the unlock period is 1e18 / 46_296_296_296_296 ≈ 21_600 seconds = 6 hours.
At t = lastReport, lockedFundsRatio = 0, the function returns the full lockedProfit (everything is locked). At t = lastReport + 6h, lockedFundsRatio = 1e18 = DEGRADATION_COEFFICIENT, the function falls through to return 0 (everything is unlocked).
_freeFunds: The Asymmetry
function _freeFunds() internal view returns (uint256) {
return _totalAssets() - _calculateLockedProfit();
}
_freeFunds() is _totalAssets() minus the still-locked profit. It is what _shareValue() uses (override below), which is what withdraw uses to compute payout. Result: a withdrawer right after a $1M harvest report sees a vault that's worth $1M LESS than it really is, so they can't claim the locked part.
Note: _issueSharesForAmount in section 5 still uses _totalAssets() (the gross). A new depositor pays the full price including the locked profit. They will only see that locked portion as it unlocks over the next 6 hours, by which time they've held positions through the lock window.
The Override
function _shareValue(uint256 shares) internal view override returns (uint256) {
uint256 virtualSupply = totalSupply + 10 ** DECIMALS_OFFSET;
uint256 virtualAssets = _freeFunds() + 1;
return shares * virtualAssets / virtualSupply;
}
This overrides section 5's first-depositor-defense version. The virtual-offset math (+10**OFFSET, +1) is preserved, that's the inflation defense. The change is the substitution of _freeFunds() for _totalAssets() in the numerator.
The two defenses compose: virtual offset defends against first-depositor inflation; locked-profit-via-_freeFunds defends against JIT extraction. Together they cover the two most-cited vault attacks.
Updating LockedProfit on Report
function _updateLockedProfit(uint256 gain, uint256 totalFees) internal override {
uint256 alreadyLocked = _calculateLockedProfit();
uint256 newLocked = gain > totalFees ? gain - totalFees : 0;
lockedProfit = alreadyLocked + newLocked;
}
This overrides section 11's naive stub. Two-step:
- Compute what's STILL LOCKED right now from the prior report (
_calculateLockedProfit()). The decay clock is about to reset (becausereport()updateslastReport = block.timestampafter this call), so we capture the not-yet-unlocked remainder. - Add the new gain (net of fees) on top.
The gain > totalFees ? gain - totalFees : 0 guards against fee math returning a number bigger than gain (it shouldn't, per section 16's cap, but defense in depth).
Configuring the Unlock Period
function setLockedProfitDegradation(uint256 _degradation) external onlyGovernance {
require(_degradation <= DEGRADATION_COEFFICIENT, "Vault/degradation-too-fast");
lockedProfitDegradation = _degradation;
}
Setting too high (fast unlock, e.g. degradation == DEGRADATION_COEFFICIENT means everything unlocks in 1 second) defeats the defense. Setting too low (slow unlock, e.g. 1e6 means days) makes the vault feel unresponsive.
Yearn's default is ~6 hours. Long enough to defeat single-block MEV extraction, short enough that a depositor isn't stuck out of their gain for days.
What's Next
Section 14 builds the withdraw() flow that actually uses _shareValue() (with its now-_freeFunds-aware math) when computing payouts. Section 15 builds the strategy harvest() that triggers report() and feeds the locked-profit cycle.