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

Donate

Section 13 of 18

Build
+20 Lynx

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:

  1. A profitable strategy is about to call vault.report(gain=$1M, ...). An MEV searcher watches the mempool.
  2. Searcher front-runs with a giant deposit (say $50M).
  3. 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.
  4. 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.
  5. 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:

  1. Compute what's STILL LOCKED right now from the prior report (_calculateLockedProfit()). The decay clock is about to reset (because report() updates lastReport = block.timestamp after this call), so we capture the not-yet-unlocked remainder.
  2. 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.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

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