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

Dormant WETH in the contract will never be swapped back to alETH

Summary

The issue lies in the fact that the claimAndSwap function only processes the newly claimed WETH from the transmuter, while ignoring any dormant WETH already present in the contract.

Vulnerability Details

The claimAndSwap function will be called by keeper to claim WETH from transmuter and swap these WETH to alETH at premium. The vulnerability is that the function only processes the newly claimed WETH from the transmuter, while ignoring any dormant WETH already present in the contract. This can lead to scenarios where the dormant WETH remains idle and unutilized, resulting in a loss of potential value for the strategy.

As per the comment in _harvestAndReport() function, it is possible that some dormant WETH left in the contract.

// 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));

Since claimAndSwap function will not handle these dormant WETH and there is no function to withdraw these dormant WETH, as a result, these WETH will be locked in the contract.

POC

You can change the test_claim_and_swap function in Operation.t.sol :

airdrop(underlying, address(strategy), 100);
uint256 weth = underlying.balanceOf(address(strategy));
console.log("Dormant WETH balance ",weth);
skip(1 seconds);
vm.roll(1);
vm.prank(keeper);
if (block.chainid == 1) {
...
...
...
else {
revert("Chain ID not supported");
}
weth = underlying.balanceOf(address(strategy));
console.log("Dormant WETH balance after swap",weth);

This function will airdrop some WETH to the strategy and log the balance before and after the swap.

The full code is here :

function test_claim_and_swap(uint256 _amount) public {
vm.assume(_amount > minFuzzAmount && _amount < maxFuzzAmount);
// Deposit into strategy
mintAndDepositIntoStrategy(strategy, user, _amount);
console.log("Amount deposited:", _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();
airdrop(underlying, address(strategy), 100);
uint256 weth = underlying.balanceOf(address(strategy));
console.log("Dormant WETH balance ",weth);
skip(1 seconds);
vm.roll(1);
vm.prank(keeper);
if (block.chainid == 1) {
// 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");
}
weth = underlying.balanceOf(address(strategy));
console.log("Dormant WETH balance after swap",weth);
// check balances post swap
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");
}

run make test, the result will be

Dormant WETH balance 100
Dormant WETH balance after swap 100

We can see that the dormant WETH will not be swapped back to alETH.

Impact

Dormant WETH will be locked in the contract.

Tools Used

Manual Review

Recommendations

Consider swapping dormant WETH to alETH in claimAndSwap function.

Updates

Appeal created

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

Dormant WETH in the contract will never be swapped back to alETH

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

Dormant WETH is not properly treated

Support

FAQs

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