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

The strategy reports WETH balance as immediately available liquidity, when in reality it requires additional transactions and external DEX liquidity to be converted to usable assets.

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) {
// @FOUND Incorrect liquidity accounting by including underlying tokens in total balance
// This creates a mismatch between reported and available liquidity since underlying tokens
// require an additional claim+swap step before being usable
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:

  1. Unexpchanged balance (alETH in transmuter)

  2. Underlying tokens (WETH)

  3. Asset tokens (alETH)

However, the underlying tokens (WETH) require a two-step process to become usable:

  1. Must be claimed via transmuter.claim()

  2. 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 {
// @FOUND Critical mismatch between available and reported liquidity
// The function only checks unexpchangedBalance but balanceDeployed()
// includes underlying tokens, leading to potential withdrawal failures
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) {
// @FOUND Incorrect liquidity accounting includes underlying tokens
// that require claim + swap operations to be usable
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
function availableWithdrawLimit(address) public view override returns (uint256) {
// @FOUND Function relies on incorrect balance calculation
// Returns potentially unavailable funds as withdrawable
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

  1. balanceDeployed() includes three components:

    • Unexpchanged balance (alETH in transmuter)

    • Underlying tokens (WETH)

    • Direct asset balance (alETH)

  2. However, _freeFunds() can only access:

    • Unexpchanged balance from transmuter

    • Direct asset balance

The underlying tokens (WETH) require a specific sequence through the ITransmuter interface

interface ITransmuter {
function claim(uint256 _amount, address _owner) external;
// Must be called before underlying can be swapped
function withdraw(uint256 _amount, address _owner) external;
// Only works with unexpchanged balance
}

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 {
// This permissioned function is required to make underlying tokens usable
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));
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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