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

Donate

Section 16 of 18

Build
+15 Lynx

Vault: Performance Fee, Management Fee, Fee-as-Share-Mint

What You Are Building

The fee accounting layer. Three fees on every successful report():

  1. Vault management fee: BPS per year, accrued continuously on totalDebt (deployed capital only, idle isn't being managed). Paid to rewards.
  2. Vault performance fee: BPS of the report's gain. Paid to rewards.
  3. Per-strategy performance fee: BPS of gain, set per-strategy. Paid to the strategy address itself. (The strategist sweeps from there.)

All three are paid by minting new vault shares to the recipient. NOT by transferring underlying out of the vault. The cost is borne by existing depositors via dilution. No underlying ever leaves the vault on a fee.

This section overrides the _assessFees stub from section 11.

Why Fee-as-Share-Mint

The natural alternative is "deduct fees from gain in underlying terms, transfer to rewards." Two problems with that:

  1. The vault would need to maintain spendable balance to pay fees. With multi-strategy designs, the vault often has very little idle, most underlying is deployed.
  2. The strategist's fee would need to be paid in the underlying token, which means the strategist holds USDC instead of yvUSDC, breaking the alignment ("you eat what you cook").

Fee-as-share-mint solves both. The recipient gets shares in the vault. They participate in vault performance going forward. They sell shares (claim underlying) when they want to.

The COST is dilution: existing depositors' fractional ownership of the vault decreases by the proportion of new shares minted. They paid the fee via slightly fewer underlying per share when they next withdraw.

The Math

function _assessFees(address strategy, uint256 gain) internal override returns (uint256 totalFees) {
    StrategyParams storage params = strategies[strategy];

    uint256 duration = block.timestamp - params.lastReport;
    uint256 mgmtFee = (duration * totalDebt * managementFee) / SECS_PER_YEAR / MAX_BPS;

    uint256 strategyPerfFee;
    uint256 vaultPerfFee;
    if (gain > 0) {
        strategyPerfFee = (gain * params.performanceFee) / MAX_BPS;
        vaultPerfFee = (gain * performanceFee) / MAX_BPS;
    }

    totalFees = mgmtFee + strategyPerfFee + vaultPerfFee;

    if (totalFees > gain) {
        // Cap and rescale.
        ...
        totalFees = mgmtFee + strategyPerfFee + vaultPerfFee;
    }

    if (totalFees == 0) return 0;

    uint256 rewardsFee = mgmtFee + vaultPerfFee;
    if (rewardsFee > 0) {
        uint256 shares = _issueSharesForAmount(rewardsFee);
        if (shares > 0) _mint(rewards, shares);
    }
    if (strategyPerfFee > 0) {
        uint256 shares = _issueSharesForAmount(strategyPerfFee);
        if (shares > 0) _mint(strategy, shares);
    }
}

Three sub-numbers, one cap, two share-mints.

Management Fee Math

mgmtFee = duration * totalDebt * managementFee / SECS_PER_YEAR / MAX_BPS

Continuous accrual: per-second rate is totalDebt * (managementFee / MAX_BPS) / SECS_PER_YEAR. Multiply by duration (seconds since last report for THIS strategy) to get the underlying-equivalent fee.

Why on totalDebt and not _totalAssets()? Because management fee is a fee on management of capital. Idle underlying isn't being managed, no strategist work product justifies a fee on it. Yearn V2 made this choice deliberately. Some forks charge on totalAssets; that's a design choice that benefits the foundation at depositors' expense.

The 200 BPS (2%) cap on setManagementFee is the Yearn convention. Higher than that approaches "what is your strategy actually doing to deserve this."

Performance Fees

strategyPerfFee = (gain * params.performanceFee) / MAX_BPS;
vaultPerfFee = (gain * performanceFee) / MAX_BPS;

Two separate fees. The strategist gets a cut of the gain THIS strategy generated (the pay-for-performance contract). The vault foundation gets a cut of the same gain (the platform fee).

Yearn V2 caps performanceFee at MAX_BPS / 2 (50%). Anything higher is extractive. Common in production: 20% strategist + 10% foundation.

Both apply to GROSS gain, not gain-after-strategist-fee. So a $1M gain with 20% strategist + 10% foundation pays $200k strategist + $100k foundation = $300k total = 30% of gain.

The Cap

if (totalFees > gain) {
    uint256 scale = gain;
    mgmtFee = (mgmtFee * scale) / totalFees;
    strategyPerfFee = (strategyPerfFee * scale) / totalFees;
    vaultPerfFee = (vaultPerfFee * scale) / totalFees;
    totalFees = mgmtFee + strategyPerfFee + vaultPerfFee;
}

Total fees cannot exceed gain. Otherwise: imagine a 0-gain harvest with mgmtFee accrued for a long duration. Without the cap, the vault would mint fee shares for a "fee" larger than the realized profit, dilution would exceed the gain, depositors net-negative.

The cap rescales all three components proportionally (preserving their relative shares) and shrinks total to fit gain. The unpaid management fee is forfeited, the strategy didn't earn enough this period to justify management fees.

Why Mint With Gross Share Price (_totalAssets)

_issueSharesForAmount (after section 5's override) uses _totalAssets(), not _freeFunds(). Fee minting also uses this path. Why?

Because if fees minted at the locked-profit-aware price (_freeFunds()), the fee recipients would receive MORE shares for the same underlying (the price they pay would be lower, since _freeFunds() < _totalAssets() during a lock window). That's not what the policy intends, fee recipients should be valued at the gross share price.

In practice this means fee recipients are "early" depositors at the moment of harvest. Their share count is computed as if the gain was already in the open price. They benefit from the unlock as it happens, like any other holder.

Setters

function setPerformanceFee(uint256 fee) external onlyGovernance {
    require(fee <= MAX_BPS / 2, "Vault/perf-fee-too-high");
    performanceFee = fee;
}
function setManagementFee(uint256 fee) external onlyGovernance {
    require(fee <= 200, "Vault/mgmt-fee-too-high");
    managementFee = fee;
}
function setRewards(address newRewards) external onlyGovernance {
    require(newRewards != address(0), "Vault/zero-rewards");
    rewards = newRewards;
}

Three governance setters. Caps are enforced. The bug to avoid: an unbounded setter that lets governance set 100% performance fee or zero rewards address. Both have appeared in audited fork code.

What's Next

Section 17 ships the deployable Vault contract: a real constructor, the emergency shutdown switch, the 2-step governance transfer, and the sweep function for stuck non-token assets.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

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