Summary
In all three strategy implementations (StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol), the vulnerability exists in the balance calculation
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
The vulnerability arises from an architectural assumption within the balance accounting system. The balanceDeployed() function adds together three different token states:
Unexpchanged balance (alETH in transmuter)
Underlying tokens (WETH)
Asset tokens (alETH)
However, the underlying tokens (WETH) require a two-step process to become usable:
Must be claimed via transmuter.claim()
Must be swapped back to alETH via the respective DEX router
This creates a scenario where the reported liquidity includes tokens that aren't immediately available for withdrawals because the same balance calculation is used in availableWithdrawLimit(), which could lead to withdrawal promises that cannot be immediately fulfilled.
Vulnerability Details
In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol: function _freeFunds, function balanceDeployed, function availableWithdrawLimit
function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
if (_amount > totalAvailabe) {
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
function availableWithdrawLimit(address) public view override returns (uint256) {
return asset.balanceOf(address(this)) +
transmuter.getUnexchangedBalance(address(this));
}
The fundamental issue here lies in the inconsistency between how the strategy reports its total balance versus how it handles withdrawals
-
balanceDeployed() includes three components:
-
However, _freeFunds() can only access:
The underlying tokens (WETH) require a specific sequence through the ITransmuter interface
interface ITransmuter {
function claim(uint256 _amount, address _owner) external;
function withdraw(uint256 _amount, address _owner) external;
}
Impact
The strategy reports higher liquidity than actually available
Withdrawal attempts may fail when claimAndSwap
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
}
The keeper-dependent conversion process means underlying tokens cannot be considered immediately available liquidity.
This creates a systemic risk where the strategy could promise withdrawals it cannot immediately fulfill, potentially leading to temporary fund lockups or failed transactions.
Recommendations
Separate balance reporting functions to distinguish between immediately available and total assets in StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
// In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
+ // @Mitigation - Split balance reporting to prevent including non-liquid assets
+ function immediatelyAvailableBalance() public view returns (uint256) {
+ return transmuter.getUnexchangedBalance(address(this)) +
+ asset.balanceOf(address(this));
+ }
+
+ function totalManagedAssets() public view returns (uint256) {
+ return immediatelyAvailableBalance() +
+ underlying.balanceOf(address(this));
+ }
- function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
- }
// @Mitigation - Use immediately available balance for withdrawal limits
function availableWithdrawLimit(address) public view override returns (uint256) {
- return asset.balanceOf(address(this)) + transmuter.getUnexchangedBalance(address(this));
+ return immediatelyAvailableBalance();
}
Add safety checks for withdrawal operations
// @Mitigation - Add validation to prevent withdrawals exceeding available liquidity
function _freeFunds(uint256 _amount) internal override {
+ require(_amount <= immediatelyAvailableBalance(), "Insufficient available liquidity");
uint256 totalAvailable = transmuter.getUnexchangedBalance(address(this));
if (_amount > totalAvailable) {
transmuter.withdraw(totalAvailable, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}