DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: medium
Valid

The calculation for the total amount of the asset in `_harvestAndReport` function is incorrect, which will lead to profit loss

Summary

The code contains a logical bug in the _harvestAndReport() function, specifically related to how claimable balances are handled. The claimable variable, which represents the potential WETH that can be retrieved from the transmuter, is currently not included in the cumulative total of assets calculated as _totalAssets. This will lead to potential profit loss.

Vulnerability Details

As per the doc of [alchemix](https://docs.alchemix.fi/alchemix-ecosystem/transmuter), the Transmuter is the primary mechanism for restoring alAsset prices and reducing supply by converting alAssets into their underlying assets. Users that deposit alAssets to the Transmuter will gradually be credited with the corresponding assets proportional to the amount of alAssets that they have deposited in the Transmuter. The alAssets are burned when a user claims the transmuted token. That means the returned value of transmuter.getClaimableBalance will gradually increase and the returned value of transmuter.getUnexchangedBalance will gradually decrease. Here is a POC to show this action:

Consider adding an test function in Operation.t.sol

function test_getUnexchangedBalance(uint256 _amount) public{
vm.assume(_amount > minFuzzAmount && _amount < maxFuzzAmount);
// Deposit into strategy
mintAndDepositIntoStrategy(strategy, user, _amount);
assertApproxEq(strategy.totalAssets(), _amount, _amount / 500);
vm.roll(1);
deployMockYieldToken();
addMockYieldToken();
depositToAlchemist(_amount);
airdropToMockYield(_amount / 2);
vm.prank(whale);
asset.transfer(user2, _amount);
vm.prank(user2);
asset.approve(address(transmuter), _amount);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
console.log("Unexchanged Balance 1:", transmuter.getUnexchangedBalance(address(strategy)));
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
console.log("Unexchanged Balance 2:", transmuter.getUnexchangedBalance(address(strategy)));
assertEq(strategy.totalAssets(), strategy.claimableBalance(), "Force Failure to show log");
}

The logs show as below:

Unexchanged Balance 1: 748717056508893793
Unexchanged Balance 2: 748176616591844954

We can see that the unexchanged balance will gradually decrease.

Let's back to the _harvestAndReport() function:

/**
* @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.
*
* NOTE: All applicable assets including loose assets should be
* accounted for in this function.
*
* Care should be taken when relying on oracles or swap values rather
* than actual amounts as all Strategy profit/loss accounting will
* be done based on this returned value.
*
* This can still be called post a shutdown, a strategist can check
* `TokenizedStrategy.isShutdown()` to decide if funds should be
* redeployed or simply realize any profits/losses.
*
* @return _totalAssets A trusted and accurate account for the total
* amount of 'asset' the strategy currently holds including idle funds.
*/
function _harvestAndReport()
internal
override
returns (uint256 _totalAssets)
{
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
// transmuter.claim(claimable, address(this));
}
// NOTE : we can do this in harvest or can do seperately in tend
// if (underlying.balanceOf(address(this)) > 0) {
// _swapUnderlyingToAsset(underlying.balanceOf(address(this)));
// }
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
// NOTE : possible some dormant WETH that isn't swapped yet (although we can restrict to only claim & swap in one tx)
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

We can see that the claimable variable, which represents the potential WETH that can be retrieved from the transmuter, is currently not included in the cumulative total of assets calculated as _totalAssets.

As per the comment, the _harvestAndReport() function should harvest all rewards, redeploy any idle funds and return an accurate accounting of all funds currently held by the Strategy. However, the current implementation of the _harvestAndReport() function ignores the claimable variable. This will result in potential profit loss when user withdraw their asset after calling report() . Here is the Poc:

function test_claim_and_swap(uint256 _amount) public {
vm.assume(_amount > minFuzzAmount && _amount < maxFuzzAmount);
// Deposit into strategy
mintAndDepositIntoStrategy(strategy, user, _amount);
console.log("user deposit amount:", _amount);
// console.log("Total Assets:", strategy.totalAssets());
// console.log("Claimable:", strategy.claimableBalance());
// console.log("Unexchanged Balance:", strategy.unexchangedBalance());
// console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
// console.log("Total Unexchanged:", transmuter.totalUnexchanged());
// console.log("Total Buffered:", transmuter.totalBuffered());
assertApproxEq(strategy.totalAssets(), _amount, _amount / 500);
vm.roll(1);
deployMockYieldToken();
//console.log("Deployed Mock Yield Token");
addMockYieldToken();
//console.log("Added Mock Yield Token");
depositToAlchemist(_amount);
//console.log("Deposited to Alchemist");
airdropToMockYield(_amount / 2);
//console.log("Airdropped to Mock Yield");
/*
airdrop(underlying, address(transmuterKeeper), _amount);
vm.prank(transmuterKeeper);
underlying.approve(address(transmuterBuffer), _amount);
*/
vm.prank(whale);
asset.transfer(user2, _amount);
vm.prank(user2);
asset.approve(address(transmuter), _amount);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
//Note : link to Transmuter tests https://github.com/alchemix-finance/v2-foundry/blob/reward-collector-fix/test/TransmuterV2.spec.ts
skip(7 days);
vm.roll(5);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
// console.log("Skip 7 days");
// console.log("Claimable:", strategy.claimableBalance());
// console.log("Unexchanged Balance:", strategy.unexchangedBalance());
// console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
// console.log("Total Unexchanged:", transmuter.totalUnexchanged());
// console.log("Total Buffered:", transmuter.totalBuffered());
assertGt(strategy.claimableBalance(), 0, "!claimableBalance");
assertEq(strategy.totalAssets(), _amount);
uint256 claimable = strategy.claimableBalance();
// we do this as oracle needs swap to be done recently
//smallCurveSwap();
skip(1 seconds);
vm.roll(1);
vm.prank(keeper);
(uint256 profit, uint256 loss) = strategy.report();
console.log("report Profit: ", profit);
console.log("report loss: ", loss);
vm.prank(keeper);
if (block.chainid == 1) {
// Mainnet
console.log("Mainnet");
IStrategyInterface(address(strategy)).claimAndSwap(
claimable,
claimable * 101 / 100,
0
);
} else if (block.chainid == 10) {
// NOTE on OP we swap directly to WETH
console.log("op");
IVeloRouter.route[] memory veloRoute = new IVeloRouter.route[]();
veloRoute[0] = IVeloRouter.route(address(underlying), address(asset), true, 0xF1046053aa5682b4F9a81b5481394DA16BE5FF5a);
// Velo Iterface
IStrategyInterfaceVelo(address(strategy)).claimAndSwap(claimable, claimable * 101 / 100, veloRoute);
} else if (block.chainid == 42161) {
// ARB
// NOTE we swap first to eFrax and then to WETH
console.log("arb");
IRamsesRouter.route[] memory ramsesRoute = new IRamsesRouter.route[]();
address eFrax = 0x178412e79c25968a32e89b11f63B33F733770c2A;
ramsesRoute[0] = IRamsesRouter.route(address(underlying), eFrax, true);
ramsesRoute[1] = IRamsesRouter.route(eFrax, address(asset), true);
IStrategyInterfaceRamses(address(strategy)).claimAndSwap(claimable, claimable * 103 / 100, ramsesRoute);
} else {
revert("Chain ID not supported");
}
uint256 shares = strategy.balanceOf(user);
console.log("user withdraw shares:",shares);
vm.prank(user);
strategy.approve(address(strategy),shares);
vm.prank(user);
uint256 assetReturned = strategy.redeem(shares,user,user);
console.log("user asset get back:",assetReturned);
console.log("user asset loss:",_amount - assetReturned);
vm.prank(keeper);
(profit, loss) = strategy.report();
console.log("report Profit: ", profit);
console.log("report loss: ", loss);
// console.log("Claimable:", strategy.claimableBalance());
// console.log("Unexchanged Balance:", strategy.unexchangedBalance());
// console.log("Exchangable Balance:", transmuter.getExchangedBalance(address(strategy)));
// console.log("Total Unexchanged:", transmuter.totalUnexchanged());
// console.log("Total Assets in Strategy:", strategy.totalAssets());
// console.log("Free Assets in Strategy:", asset.balanceOf(address(strategy)));
// console.log("Underlying in Strategy:", underlying.balanceOf(address(strategy)));
vm.prank(keeper);
//(uint256 profit, uint256 loss) = strategy.report();
assertEq(strategy.claimableBalance(), 0, "!claimableBalance");
assertGt(strategy.totalAssets(), _amount, "!totalAssets");
assertEq(strategy.totalAssets(), strategy.claimableBalance(), "Force Failure");
}

The test_claim_and_swap will first call report() , and then the user calls redeem to withdraw all funds, the result is:

Logs:
user deposit amount: 748717056508893793
report Profit: 0
report loss: 540439917048839
Mainnet
user withdraw shares: 748717056508893793
user asset get back: 748176616591844954
user asset loss: 540439917048839
report Profit: 547705996131862
report loss: 0

The first report() will result in some loss because at this time, the unexchanged balance has decreased but the claimable is not added to the _totalAssets. If the user withdraw all funds after the first report, the user will bear this loss.

Let's add the claimable to the _totalAssets (The code here is only for test, the more reasonable way is to harvest all rewards, redeploy any idle funds and return an accurate accounting of all funds currently held by the Strategy)

_totalAssets = claimable + unexchanged + asset.balanceOf(address(this)) + underlyingBalance;

and run the test again, the result is:

Logs:
user deposit amount: 748717056508893793
report Profit: 0
report loss: 0
Mainnet
user withdraw shares: 748717056508893793
user asset get back: 748717056508893793
user asset loss: 0
report Profit: 7266079083023
report loss: 0

We can see that there is no loss and the user can withdraw all his funds.

Impact

The user may suffer a loss in some case, the impact is High and the likelihood is Low, so the severity should be medium

Tools Used

Manual Review

Recommendations

Consider harvesting all rewards in _harvestAndReport() function and returning an accurate accounting of all funds currently held by the Strategy.

Updates

Appeal created

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Incorrect accounting in `_harvestAndReport` claimable should be included

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Incorrect accounting in `_harvestAndReport` claimable should be included

Support

FAQs

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