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

Donate

Section 6 of 18

Build
+15 Lynx

Vault: totalIdle, totalDebt, and Donation Defense

What You Are Building

Section 4 introduced _totalAssets() as totalIdle + totalDebt without much explanation. This section adds the public totalAssets(), the ERC-4626-compatible aliases (convertToAssets, convertToShares), and maxAvailableShares. The actual code is short. The reason this is a dedicated section is the donation attack: why the vault must NOT trust token.balanceOf(this) and how the explicit totalIdle accumulator is the structural defense.

If you only remember one thing from this section, remember the invariant: token.balanceOf(this) >= totalIdle. Anything in excess of that inequality is donated underlying that no depositor has a claim on.

The Donation Attack

Anyone can transfer the underlying token to the vault address. ERC-20 has no concept of "consent to receive"; you call IERC20.transfer(vault, amount) and the vault's balance increases. There is no callback the vault can hook to refuse.

If the vault's internal _totalAssets() reads token.balanceOf(this) + totalDebt, the attacker has just inflated the vault's reported assets for free. With inflated assets, pricePerShare rises (more underlying behind each share). Whoever holds shares benefits from the donation; whoever deposits afterward pays the inflated price (gets fewer shares per underlying).

That is the simple version. Combined with the first-depositor attack from section 5, the donation becomes a weapon: attacker deposits 1 wei, donates a large amount, victim's deposit rounds to 0 shares. We closed the first-depositor part with virtual-offset math. This section closes the donation part with explicit accounting.

The real-world version of this happened to multiple Yearn V1 forks in 2021 and 2022 before the explicit-accumulator pattern became standard. It still happens to badly-written ERC-4626 implementations that read IERC20(asset).balanceOf(address(this)) directly in totalAssets(). The defense is structural, not accidental.

The Defense

Maintain totalIdle as an explicit accumulator that increments on deposit() and decrements on withdraw() and strategy credit. Direct ERC-20 transfers to the vault address do NOT increment it.

function _totalAssets() internal view virtual returns (uint256) {
    return totalIdle + totalDebt;
}

A donation increases token.balanceOf(this) but not totalIdle. The share-math reads _totalAssets(), which reads totalIdle, which is unchanged. The attacker's donation is stuck in the contract: the vault's balance went up, but no shares have a claim on the excess.

The invariant: token.balanceOf(this) >= totalIdle. Equality holds if no donations have happened. Strict inequality holds otherwise; the difference is dead weight.

Section 17 adds sweep() so governance can move stuck non-vault tokens out (and arguably could move stuck token out too, but that is a contested design choice; we follow Yearn V2 in NOT allowing sweep of the vault's own underlying).

The Public Surface

Four short functions live in this section:

function totalAssets() external view returns (uint256) {
    return _totalAssets();
}

function maxAvailableShares() external view returns (uint256) {
    return totalSupply;
}

function convertToAssets(uint256 shares) external view returns (uint256) {
    return _shareValue(shares);
}

function convertToShares(uint256 amount) external view returns (uint256) {
    return _issueSharesForAmount(amount);
}

totalAssets() is the ERC-4626 standard view of vault assets. We expose the internal accumulator.

maxAvailableShares() is the ERC-4626 view for "how many shares are outstanding, total." It is totalSupply because every share is, in principle, redeemable for underlying (subject to liquidity constraints when the vault has more totalDebt than totalIdle, section 14 handles that).

convertToAssets and convertToShares are ERC-4626's preview-style functions, exposed as wrappers around the internal share-math. They give integrators a way to ask "if I had N shares, what would I get?" or "if I deposited X underlying, how many shares would I get?" without simulating a deposit. Note that ERC-4626 also defines previewDeposit, previewMint, previewWithdraw, previewRedeem which account for fees and rounding direction; we expose only the simple convertX/Y for clarity.

Why This Defense is Free

Maintaining totalIdle is not extra work. We need it anyway: section 5's deposit() increments it (totalIdle += amount), section 14's withdraw() decrements it, section 11's report() swaps underlying between totalIdle and strategy.totalDebt. The accumulator exists because the vault's accounting needs it.

The donation defense is just choosing to read FROM the accumulator instead of from token.balanceOf(this). Same data either way; one path is donation-resistant and the other is not. Pick the resistant one.

What Donations Are Useful For (Briefly)

A donation is unrecoverable from the depositor's perspective (no shares were minted), but it is not unrecoverable from the protocol's perspective. Yearn V2's sweep() can extract donations made in non-token ERC-20s; sweep is restricted to non-token to prevent governance from stealing depositor funds.

Some protocols use donations as a deliberate channel: a partner protocol might donate yield rebates directly to a vault to boost its pricePerShare. The donation defense does NOT prevent this, because the share math will eventually catch up. For donations to flow to depositors, the donor must call a function that the vault has explicitly opted into (e.g. a donate() function that adds the amount to totalIdle). We do not implement such a function in this module; the surface stays minimal.

ERC-4626 Forward Compatibility

The names convertToAssets, convertToShares, totalAssets, maxAvailableShares (and asset() returning the underlying token) are ERC-4626 standard. By exposing them with the standard signatures, this vault is partially ERC-4626 compatible. The full ERC-4626 surface (previewDeposit, previewMint, previewWithdraw, previewRedeem, maxDeposit, maxMint, maxWithdraw, maxRedeem) is out of scope for this module; the module ships in V2 spirit, not V3 / 4626 conformance. A bonus exercise: add the missing ERC-4626 functions yourself.

Your Task

Short section. Implement:

  1. totalAssets() external view, return _totalAssets().
  2. maxAvailableShares() external view, return totalSupply.
  3. convertToAssets(uint256 shares) external view, return _shareValue(shares).
  4. convertToShares(uint256 amount) external view, return _issueSharesForAmount(amount).

The mechanics are minimal. The point of the section is the documentation: write the NatSpec naming the donation attack, naming the invariant token.balanceOf(this) >= totalIdle, and noting that this defense pairs with section 5's first-depositor defense to close both halves of the share-inflation attack class.

Part 2 begins next section. The strategy system is what makes Yearn V2 interesting: the multi-strategy capital allocation, the harvest cycle, the dynamic debt rebalancing. Sections 7 through 12 build it.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

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