DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: high
Invalid

Idle funds not deployed during Yearn Strategy harvests

Summary

The _harvestAndReport function in StrategyMainnet, StrategyOp, and StrategyArb do not implement Yearn's V3 tokenized strategy requirements for harvesting and deploying idle funds, potentially impacting capital efficiency and yield generation.

Vulnerability Details

According to Yearn's documentation for _harvestAndReport:

https://docs.yearn.fi/developers/v3/strategy_writing_guide#_harvestandreport

https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyMainnet.sol#L151-L156

@> @dev Internal function to harvest all rewards, redeploy any idle
@> funds and return an accurate accounting of all funds currently
@> held by the Strategy.
@> This should do any needed harvesting, rewards selling, accrual,
@> redepositing etc. to get the most accurate view of current assets.

Current implementation in all three strategies:

https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyMainnet.sol#L172-L192

https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyOp.sol#L161-L174

https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyArb.sol#L148-L171

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
uint256 claimable = transmuter.getClaimableBalance(address(this));
// @audit - no claim, no redeposit of funds.
if (claimable > 0) {
// transmuter.claim(claimable, address(this));
}
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

This implementation:

  1. Fails to deploy idle funds as required by Yearn's specification.

  2. Fails to claim WETH.

  3. Doesn't match test expectations in Operation.t.sol

vm.prank(keeper);
(uint256 profit, uint256 loss) = strategy.report();
assertEq(strategy.claimableBalance(), 0, "!claimableBalance");

Notice that the report function is the one responsible for triggering harvestAndReport. Now let's look at this reportfunction:

https://github.com/yearn/tokenized-strategy/blob/9ef68041bd034353d39941e487499d111c3d3901/src/TokenizedStrategy.sol#L1090-L1095

function report()
external
nonReentrant
onlyKeepers
returns (uint256 profit, uint256 loss)
{
// Cache storage pointer since its used repeatedly.
StrategyData storage S = _strategyStorage();
@> // Tell the strategy to report the real total assets it has.
@> // It should do all reward selling and redepositing now and
@> // account for deployed and loose `asset` so we can accurately
@> // account for all funds including those potentially airdropped
@> // and then have any profits immediately locked.
uint256 newTotalAssets = IBaseStrategy(address(this))
.harvestAndReport();

Impact

  • Funds remain idle when they should be deployed and WETH claims are not processed during harvests, directly reducing yields for all depositors.

  • Every harvest cycle misses yield generation on 100% of idle balances and fails to make underlying WETH available for profitable depeg opportunities.

Likelihood: High(100% of the time) and Impact High(loss of funds/yield generation), break the protocol invariant of providing yield on users deposits.

Tools Used

Manual Review

Recommendations

Function has to deploy idle assets and claim available WETH. Implement the following across all three strategies:

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// Only harvest and redeploy if the strategy is not shutdown.
if (!TokenizedStrategy.isShutdown()) {
// @audit-info - 1. Deploy any idle alETH
uint256 idleBalance = asset.balanceOf(address(this));
if (idleBalance > 0) {
transmuter.deposit(idleBalance, address(this));
}
// @audit-info - 2. Claim any available WETH
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
transmuter.claim(claimable, address(this));
}
}
// @audit-info - 3. Account for all assets
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
// @audit-critical: this WETH balance should be converted to alETH price
// reported here:
//https://codehawks.cyfrin.io/c/2024-12-alchemix/s/cm4rru4tg0005y8j24ewwjgoj
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged +
asset.balanceOf(address(this)) +
underlyingBalance;
}
Updates

Appeal created

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
holydevoti0n Submitter
5 months ago
inallhonesty Lead Judge
5 months ago
inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.