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

Inability for Users to Withdraw at Will Due to Keeper-Dependent claimAndSwap Execution

01. Relevant GitHub Links

02. Summary

The strategy design relies on an off-chain actor (a keeper) to call transmuterBuffer.exchange, which converts some portion of aLETH into WETH. Until the keeper subsequently calls strategy.claimAndSwap, these newly converted assets remain locked and cannot be withdrawn by users. As a result, users are not guaranteed the ability to withdraw their funds on demand unless the keeper performs timely claimAndSwap operations.

03. Vulnerability Details

In the given strategy implementation, user deposits are converted into aLETH and managed via the transmuter. Normally, users should be able to withdraw their deposited assets at any time. However, if a keeper has called transmuterBuffer.exchange but not subsequently performed a claimAndSwap, a portion of these assets remain in a state where they cannot be withdrawn as WETH. This creates a scenario where the user’s withdrawal is partially dependent on the keeper’s follow-up actions.

The availableWithdrawLimit function correctly returns the total amount that can be withdrawn under normal circumstances, but the actual liquidation of the position into a freely withdrawable form (e.g., WETH or the base asset) is held up until a keeper chooses to run claimAndSwap. This can cause delays or temporary inability for users to access their funds.

04. Impact

The impact is a partial denial of service for user withdrawals. While user funds are not permanently lost, they cannot be retrieved at will, introducing both uncertainty and inconvenience. This situation may erode user trust and reliability in the system. If the keeper fails or refuses to call claimAndSwap, users might have to wait indefinitely to access their own funds.

05. Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.18;
import "forge-std/console.sol";
import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol";
import {IStrategyInterfaceVelo} from "../interfaces/IStrategyInterface.sol";
import {IStrategyInterfaceRamses} from "../interfaces/IStrategyInterface.sol";
import {IVeloRouter} from "../interfaces/IVelo.sol";
import {IRamsesRouter} from "../interfaces/IRamses.sol";
interface Strategy is IStrategyInterface {
function balanceDeployed() external returns (uint256);
}
contract PocTest is Setup {
function setUp() public virtual override {
super.setUp();
}
function test_poc_user_cant_withdraw_anytime() public {
// deposit into strategy
uint256 test_amount = 1e18;
mintAndDepositIntoStrategy(strategy, user, test_amount);
// withdraw before exchange
vm.prank(user);
strategy.withdraw(test_amount, user, user);
// deposit into strategy
mintAndDepositIntoStrategy(strategy, user, test_amount);
// exchange
keeperExchange(test_amount);
// withdraw after exchange
vm.prank(user);
vm.expectRevert();
strategy.withdraw(test_amount, user, user);
assertLe(strategy.maxWithdraw(user), test_amount);
// claimAndSwap
uint256 claimable = strategy.claimableBalance();
vm.prank(keeper);
strategy.claimAndSwap(claimable, claimable * 101 / 100, 0);
// withdraw after claimAndSwap
vm.prank(user);
strategy.withdraw(test_amount, user, user);
}
function keeperExchange(uint256 _amount) public {
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);
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying)); // @note : Keeper가 직접 exchange 하네
//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));
}
}
$ forge test --mt test_poc_user_cant_withdraw_anytime --fork-url https://rpc.ankr.com/eth -vvv
Ran 1 test for src/test/testpoc.t.sol:PocTest
[PASS] test_poc_user_cant_withdraw_anytime() (gas: 3802121)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 22.21s (18.77s CPU time)

06. Tools Used

Manual Code Review and Foundry

07. Recommended Mitigation

A direct mitigation strategy is to consolidate the keeper’s responsibilities into the Strategy contract itself. Instead of relying on an external keeper, the Strategy can implement an internal function that both calls exchange and claimAndSwap atomically within a single transaction. By doing this, whenever it becomes profitable or necessary to perform these operations, the Strategy contract—acting as its own keeper—can ensure that the assets are immediately converted and made available for users to withdraw. This approach removes the dependency on an external actor’s timing and guarantees continuous access to user funds.

Alternatively, another approach is to modify the withdraw function. In cases where there are insufficient funds available for withdrawal, the contract can directly call the claim function on the transmuter to extract any claimable WETH and provide it to the user. This ensures that withdrawals are not blocked even when certain conditions (such as delayed claimAndSwap operations) temporarily limit available liquidity. By dynamically adjusting to the state of the transmuter during withdrawals, user experience can be further enhanced.

Updates

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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