Summary
The _harvestAndReport() includes underlying assets (which can be directly transfer into the strategy (WETH)) in total assets calculation, leading to inflated profit reporting and share dilution during report(). These directly transferred underlying assets (WETH) become irredeemable since they are not recorded in the transmuter and the strategy can only claim _amountClaim deposited amount on the transmutter which can then be swapped.
While the known issue reported cases that are associated with asset balance on all the strategies, this report deals with underlying assets on the strategy and how the accounting of underlying assets directly affects the shares of the Strategies
Vulnerability Details
The _harvestAndReport() function incorrectly includes the strategy's underlying token balance:
function _harvestAndReport()
internal
override
returns (uint256 _totalAssets)
{
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}
The issue arises because:
-
Only WETH claimed from the transmuter can be swapped to alETH via claimAndSwap:
function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
-
When WETH is directly transferred to the strategy:
The directly transferred underlying assets becomes irredeemable because:
It's not recorded in the transmuter
It can't be swapped through claimAndSwapas shown above
The strategy has no mechanism to handle these "orphaned" funds
Impact
-
Share Dilution:
-
Irredeemable Assets:
-
Incorrect Fee Distribution:
POC
function test_gainFees_When_underlying_asset_directky_transfer_affecting_others(
address _address,
uint16 _profitFactor
) public {
uint128 _amount = 1e18;
_profitFactor = uint16(bound(uint256(_profitFactor), 10, MAX_BPS));
vm.assume(
_address != address(0) &&
_address != address(strategy) &&
_address != protocolFeeRecipient &&
_address != performanceFeeRecipient &&
_address != address(yieldSource)
);
strategy = IMockStrategy(address(strategyMainnet));
setUpMainnetStrategy(strategy);
uint16 protocolFee = 1_000;
uint16 performanceFee = 1_000;
setFees(protocolFee, performanceFee);
vm.prank(management);
strategy.setPerformanceFee(performanceFee);
vm.prank(management);
strategy.setProfitMaxUnlockTime(0);
assertEq(strategy.profitMaxUnlockTime(), 0);
mintAndDepositIntoStrategy(strategy, _address, _amount);
increaseTimeAndCheckBuffer(strategy, profitMaxUnlockTime, 0);
uint256 profit = (_amount * _profitFactor) / MAX_BPS;
uint256 expectedPerformanceFee = (profit * performanceFee) / MAX_BPS;
uint256 expectedProtocolFee = (expectedPerformanceFee * protocolFee) /
MAX_BPS;
expectedPerformanceFee -= expectedProtocolFee;
uint256 totalExpectedFees = expectedPerformanceFee +
expectedProtocolFee;
asset.mint(address(strategy), profit);
undelyingAsset.mint(address(strategy), profit);
uint256 strategyBalanceOfAddressBefore = strategy.maxRedeem(_address);
vm.prank(keeper);
(uint256 _profit, ) = strategy.report();
uint256 strategyBalanceOfAddress = strategy.maxRedeem(_address);
(
,
address protocolFeesRecipient
) = mockFactory.protocol_fee_config();
vm.prank(protocolFeesRecipient);
strategy.redeem(strategy.maxRedeem(protocolFeesRecipient), protocolFeesRecipient, protocolFeesRecipient);
vm.prank(performanceFeeRecipient);
strategy.redeem(strategy.maxRedeem(performanceFeeRecipient), performanceFeeRecipient, performanceFeeRecipient);
vm.prank(_address);
strategy.redeem(strategyBalanceOfAddressBefore, _address, _address);
}
Tools Used
Manual, Foundry
Recommendations
Either Remove underlying balance from total assets calculation:
function _harvestAndReport()
internal
override
returns (uint256 _totalAssets)
{
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this));
}
Or during claimAndSwap, account for underlying asset balance of strategy:
function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
uint256 totalSwapAmount = underlying.balanceOf(address(this)) + _amountClaim;
require(_minOut > totalSwapAmount, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
totalSwapAmount,
_minOut,
pools[_routeNumber],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
tokenizedStrategy contract: https://github.com/yearn/tokenized-strategy/blob/9ef68041bd034353d39941e487499d111c3d3901/src/TokenizedStrategy.sol#L1095
Transmutter: https://github.com/alchemix-finance/v2-foundry/blob/master/src/TransmuterV2.sol