Section 14 of 18
Vault: withdraw with Strategy Queue Iteration
What You Are Building
The user-facing withdraw() function. Mirror of deposit() from section 5, but considerably more complex: when the vault's totalIdle doesn't cover the requested withdrawal, the vault walks the withdrawalQueue array and pulls from each strategy in order. Losses incurred while liquidating are accounted to the withdrawer, capped at a maxLoss parameter.
This is the section where the most subtle vault bugs live. Multiple Yearn forks have had withdraw-flow exploits.
The Four Rules
Before reading the code, internalize these:
Rule 1: Strategy .withdraw() must NOT revert on partial fill. The vault treats a partial fill as success, with the missing amount counted as loss for that strategy.
Rule 2: The strategy's totalDebt must be reduced by the amount actually freed (plus loss reported), not by the amount requested. Cap freed at amountNeeded so a misbehaving strategy returning extra cannot cause an underflow on the totalDebt subtraction. Reducing by amountNeeded (instead of freed + loss) causes vault totalDebt to drift from reality; failing to cap freed causes the subtraction to underflow under Solidity 0.8 and bricks the withdraw.
Rule 3: The user's burn must use the requested maxShares, not a recomputed amount. A recompute opens a re-entrancy-style hole if any external call updates state midway.
Rule 4: The function must carry the nonReentrant modifier (defined in section 5). IStrategy(strategy).withdraw is an external call into untrusted code in the middle of the loop. Without the guard, a malicious strategy could re-enter the vault during its own callback, observe inconsistent state, and either drain funds or DoS the withdrawal.
The Reaper Farm exploit (August 2022, $1.7M) was a recipient-account-verification bug rather than a direct violation of these rules, but lived on the same withdraw-flow surface where these defenses operate.
The Function
function withdraw(uint256 maxShares, address recipient, uint256 maxLoss)
external nonReentrant returns (uint256 value)
{
require(maxLoss <= MAX_BPS, "Vault/max-loss-too-high");
require(recipient != address(0) && recipient != address(this), "Vault/bad-recipient");
if (maxShares == type(uint256).max) {
maxShares = balanceOf[msg.sender];
}
require(maxShares > 0, "Vault/zero-shares");
require(maxShares <= balanceOf[msg.sender], "Vault/insufficient-shares");
value = _shareValue(maxShares); // uses _freeFunds (section 13)
uint256 totalLoss;
if (value > totalIdle) {
uint256 needed = value - totalIdle;
for (uint256 i = 0; i < MAXIMUM_STRATEGIES; i++) {
address strategy = withdrawalQueue[i];
if (strategy == address(0)) break;
StrategyParams storage params = strategies[strategy];
uint256 amountNeeded = needed;
if (amountNeeded > params.totalDebt) amountNeeded = params.totalDebt;
if (amountNeeded == 0) continue;
uint256 balanceBefore = token.balanceOf(address(this));
uint256 loss = IStrategy(strategy).withdraw(amountNeeded);
uint256 freed = token.balanceOf(address(this)) - balanceBefore;
// Cap freed at amountNeeded so a misbehaving strategy returning extra
// cannot cause an underflow on params.totalDebt -= (freed + loss).
if (freed > amountNeeded) freed = amountNeeded;
totalLoss += loss;
params.totalDebt -= (freed + loss);
totalDebt -= (freed + loss);
if (loss > 0) {
params.totalLoss += loss;
}
totalIdle += freed;
needed = needed > freed ? needed - freed : 0;
if (needed == 0) break;
}
if (totalIdle < value) {
uint256 shortfall = value - totalIdle;
totalLoss += shortfall;
value = totalIdle;
}
}
require(
totalLoss <= (value + totalLoss) * maxLoss / MAX_BPS,
"Vault/loss-exceeds-max"
);
_burn(msg.sender, maxShares);
totalIdle -= value;
require(token.transfer(recipient, value), "Vault/transfer-failed");
}
The Idle Path (Easy Case)
If _shareValue(maxShares) <= totalIdle, the vault has enough cash on hand. Skip the queue entirely, burn the shares, transfer underlying out. Common case for small withdrawals.
The Queue Iteration Path
When idle is short, the vault walks the queue. For each strategy:
- Compute amountNeeded: how much we still need this strategy to free, capped at the strategy's
params.totalDebt(don't ask for more than they hold). - Snapshot balance:
token.balanceOf(this)BEFORE the strategy.withdraw call. The difference after the call is what they actually freed. This is how we measure partial fills. - Call strategy.withdraw: the strategy's
withdraw(from section 8's BaseStrategy) calls_liquidatePosition(amountNeeded)and transfers the freed amount back. Returnsloss, the gap between requested and delivered. - Update accounting: reduce
params.totalDebtand vaulttotalDebtby(freed + loss). The strategy's debt to the vault has decreased by what they returned PLUS what they wrote off as loss. - Track loss: accumulate
totalLossfor the final maxLoss check. - Increment totalIdle: by
freed. - Decrement needed: by
freed. Ifneeded == 0, exit the loop.
If the loop exits with totalIdle < value (queue exhausted, still short), the user accepts the shortfall as additional loss.
The MaxLoss Check
require(
totalLoss <= (value + totalLoss) * maxLoss / MAX_BPS,
"Vault/loss-exceeds-max"
);
maxLoss is in BPS (10_000 = 100%). If the user passed maxLoss = 100 (1%), the total loss must be ≤ 1% of (value + totalLoss). The denominator is the requested value INCLUDING the loss component, which is the right base for "1% of what I asked for is allowed to be lost."
If the loss exceeds the cap, the entire withdraw reverts. The user can resubmit with a higher maxLoss (accepting more loss) or wait for strategies to be in better shape.
Burning the Requested Amount
_burn(msg.sender, maxShares);
The student instinct is to recompute shares from value: "well I'm only giving them value underlying, so I should only burn value / pricePerShare shares." Don't. Burn the exact maxShares the user requested. The shares represent their CLAIM on the vault, and they used those shares to claim the value (which may have been reduced by loss). They paid the full maxShares price for whatever they received.
A recompute opens a class of bug where, between _shareValue and _burn, an external call re-enters and changes state. Yearn V2 doesn't have those external calls in this path, but the discipline matters.
What's Next
Section 15 builds the harvest() and tend() functions on BaseStrategy's subclass, the strategy-side counterpart to the vault's report(). Sections 16, 17 finish the policy layer (fees, emergency, governance).