Section 15 of 18
BaseStrategy: harvest, tend, and the Realize-Report-Reinvest Cycle
What You Are Building
The strategy-side counterpart to the vault's report(). StrategyHarvest extends BaseStrategy (section 8) and adds the periodic cycle that closes the loop between strategy state and vault accounting:
- Read what the vault wants back.
- Realize the strategy's P&L.
- Call
vault.report()to settle. - Reinvest whatever underlying is left in the strategy.
The keeper bot calls harvest() on a cadence picked by the strategist via harvestTrigger(). Yearn keepers across mainnet harvest strategies many times per day.
Two Inheritance Chains, Continued
This is the second link in the Strategy chain. The first was BaseStrategy (section 8). Concrete strategy implementations (a Curve LP autocompounder, an Aave lender, a Convex booster) inherit from StrategyHarvest, not from BaseStrategy directly. They get the harvest cycle for free.
abstract contract StrategyHarvest is BaseStrategy {
event Harvested(uint256 profit, uint256 loss, uint256 debtPayment, uint256 debtOutstanding);
...
}
Still abstract, the abstract methods from section 8 are unimplemented here. Concrete subclasses fill them in.
The harvest() Cycle
function harvest() external onlyKeepers {
uint256 profit;
uint256 loss;
uint256 debtPayment;
uint256 debtOutstanding_ = vault.debtOutstanding();
if (emergencyExit) {
uint256 freed = _liquidateAllPositions();
if (freed < debtOutstanding_) {
loss = debtOutstanding_ - freed;
debtPayment = freed;
} else {
debtPayment = debtOutstanding_;
profit = freed - debtOutstanding_;
}
} else {
(profit, loss, debtPayment) = _prepareReturn(debtOutstanding_);
}
debtOutstanding_ = vault.report(profit, loss, debtPayment);
_adjustPosition(debtOutstanding_);
emit Harvested(profit, loss, debtPayment, debtOutstanding_);
}
Five steps:
-
Read vault.debtOutstanding(): this is what the vault wants back. May be zero (strategy is at target), positive (strategy is over-allocated, governance reduced its debtRatio, or vault total shrunk), or equal to
params.totalDebt(emergency shutdown). -
Realize P&L:
- Emergency exit branch: liquidate every position via
_liquidateAllPositions(). The freed amount goes back to the vault. If freed exceeds outstanding, the difference is profit; if it falls short, the difference is loss. Strategy will be revoked or migrated; this is the unwind. - Normal branch: subclass's
_prepareReturn(_debtOutstanding)realizes gain/loss from this period and frees enough underlying to send back at least_debtOutstanding. Returns the (profit, loss, debtPayment) tuple.
- Emergency exit branch: liquidate every position via
-
Call vault.report: section 11 settles the swap. Strategy now has its underlying balance net of (gain sent to vault + debtPayment sent back) and plus (any new credit received). The return value is the new debtOutstanding (some may have been settled by debtPayment in the same cycle).
-
Reinvest: subclass's
_adjustPosition(_debtOutstanding)deploys the strategy's now-net underlying balance back into the protocol it integrates with. If_debtOutstanding > 0the subclass should KEEP that much in idle (the next harvest will return it). -
Emit Harvested: event for off-chain monitoring (Yearn's keeper dashboards, alerting).
tend(), Light-Touch Adjustment
function tend() external onlyKeepers {
_adjustPosition(vault.debtOutstanding());
}
Cheaper than harvest() because it skips _prepareReturn and vault.report. Used when the strategy needs to rebalance positions without realizing P&L. Common case: a Curve LP needs to top up an Aave deposit but doesn't want to incur the gas cost of claiming and selling rewards.
The strategist's tendTrigger() decides when keepers should call this. Default: never.
The Trigger Functions
function harvestTrigger(uint256 callCostInWei) external view virtual returns (bool) {
return false;
}
function tendTrigger(uint256 callCostInWei) external view virtual returns (bool) {
return false;
}
Keepers POLL these views. When they return true, the keeper sends a harvest() (or tend()) transaction. The strategist's job is to override these with policy:
- "Harvest if accrued unclaimed yield exceeds gas cost × 2."
- "Tend if the underlying lending market's utilization changed by 10% since last tend."
- "Harvest at least once every 7 days regardless of yield."
A bad trigger (always true) wastes gas. A bad trigger (always false) means the strategy never compounds. This is where strategist craft lives.
What a Concrete Strategy Looks Like
A real Yearn V2 strategy might look like:
contract StrategyAaveLender is StrategyHarvest {
IAavePool public aave;
address public aToken;
constructor(address _vault, address _strategist, address _keeper, address _aave)
BaseStrategy(_vault, _strategist, _keeper)
{
aave = IAavePool(_aave);
aToken = aave.getAToken(want_);
}
function estimatedTotalAssets() public view override returns (uint256) {
return IERC20(want_).balanceOf(address(this)) + IERC20(aToken).balanceOf(address(this));
}
function _prepareReturn(uint256 _debtOutstanding) internal override
returns (uint256 _profit, uint256 _loss, uint256 _debtPayment)
{
uint256 totalAssets = estimatedTotalAssets();
uint256 totalDebt = vault.strategies(address(this)).totalDebt;
if (totalAssets > totalDebt) _profit = totalAssets - totalDebt;
else _loss = totalDebt - totalAssets;
uint256 wantToFree = _debtOutstanding + _profit;
if (wantToFree > 0) (_debtPayment, _loss) = _liquidatePosition(wantToFree);
}
function _adjustPosition(uint256 _debtOutstanding) internal override {
uint256 idle = IERC20(want_).balanceOf(address(this));
if (idle > _debtOutstanding) {
uint256 toDeposit = idle - _debtOutstanding;
IERC20(want_).approve(address(aave), toDeposit);
aave.supply(want_, toDeposit, address(this), 0);
}
}
function _liquidatePosition(uint256 _amountNeeded) internal override
returns (uint256 _liquidatedAmount, uint256 _loss)
{
uint256 idle = IERC20(want_).balanceOf(address(this));
if (idle >= _amountNeeded) return (_amountNeeded, 0);
uint256 needFromAave = _amountNeeded - idle;
uint256 withdrawn = aave.withdraw(want_, needFromAave, address(this));
_liquidatedAmount = idle + withdrawn;
if (_liquidatedAmount < _amountNeeded) _loss = _amountNeeded - _liquidatedAmount;
}
function _liquidateAllPositions() internal override returns (uint256) {
return aave.withdraw(want_, type(uint256).max, address(this));
}
function _prepareMigration(address _newStrategy) internal override {
// Aave aTokens are transferable, so we move them to the new strategy.
IERC20(aToken).transfer(_newStrategy, IERC20(aToken).balanceOf(address(this)));
}
}
Roughly 50 lines of strategist code. The harvest cycle (this section), the access control (section 8), and the vault interface (sections 7, 11) gave the rest for free.
What's Next
Section 16 closes the fee loop: management fee accrues continuously on totalDebt, performance fees apply to gain, all minted as new shares (dilution-not-drain). Section 17 ships the deployable Vault constructor and the emergency switch.