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

The actual balance change doesn't correspond to the intended withdrawal amount

Summary

Incorrect balance accounting when withdrawing more than available. In all three strategy implementations StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol, the vulnerability is present within the _freeFunds function. This indicates that the issue is not isolated to a single variant but affects the core functionality across multiple strategies.

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
if (_amount > totalAvailabe) {
// @check Incorrect balance accounting when withdrawing more than available
// The function withdraws totalAvailable but doesn't account for the difference,
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}

The vulnerability irugunates from incorrect balance accounting when withdrawing funds. freeFundsSafety requires that balanceAfter >= balanceBefore - amount, but this is violated when:

  1. A withdrawal request exceeds the available unexchanged balance

  2. The function withdraws the total available amount without properly accounting for the difference between requested and available amounts

  3. This creates a discrepancy where the actual balance reduction can exceed the requested withdrawal amount

The issue is compounded by the fact that balanceDeployed() includes three components:

return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));

When _freeFunds withdraws the total available amount instead of the requested amount, it breaks the mathematical relationship that should exist between the withdrawal request and the resulting balance change.

Vulnerability Details

In StrategyOp.sol (and similarly in other implementations)

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
if (_amount > totalAvailabe) {
// @check accounting error: When withdrawing more than available balance,
// the function withdraws totalAvailable without properly tracking the difference
// between requested amount and actual withdrawn amount
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}

The root cause is a fundamental mismatch between:

function balanceDeployed() public view returns (uint256) {
// @check Balance calculation includes three components but withdrawal
// logic only considers unexchanged balance
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The withdrawal logic in _freeFunds

Impact across implementations:

  • Optimism (StrategyOp.sol): Affects WETH/alETH handling through Velodrome

  • Mainnet (StrategyMainnet.sol): Impacts WETH/alETH operations via Curve

  • Arbitrum (StrategyArb.sol): Affects WETH/alETH management through Ramses

The bug manifests when:

  1. A withdrawal request exceeds available unexchanged balance

  2. The function withdraws all available funds without proper accounting

  3. This breaks the mathematical relationship between requested withdrawal and actual balance change

This affects all supported blockchains (Optimism, Ethereum, Arbitrum) and tokens (WETH, alETH) in the system, potentially leading to incorrect balance reporting and withdrawal processing.

Impact

The bug is caused by a fundamental design flaw in the balance accounting system across all strategy implementations.

  • In the balance tracking system.

function balanceDeployed() public view returns (uint256) {
// @check The total balance includes three components:
// 1. Unexchanged balance from transmuter
// 2. Underlying token balance (WETH)
// 3. Asset balance (alETH)
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
  • However, in the withdrawal logic

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
if (_amount > totalAvailabe) {
// @check When withdrawal amount exceeds unexchanged balance:
// 1. Only considers unexchanged balance
// 2. Ignores underlying and asset balances
// 3. Withdraws different amount than requested
// This breaks the freeFundsSafety: balanceAfter >= balanceBefore - amount
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}

The root cause is the inconsistency between how balances are tracked versus how they are withdrawn:

  • Balance tracking considers all three components (unexchanged, underlying, asset)

  • Withdrawal logic only operates on unexchanged balance

  • When withdrawing more than available unexchanged balance, the function withdraws a different amount than requested without proper accounting

And this design flaw exists in all three implementations (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol) and affects operations across Optimism, Ethereum, and Arbitrum networks.

Recommendations

// In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
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));
- }
+ // @Recommendation - Implement proper balance accounting for all components
+ uint256 unexchangedBalance = transmuter.getUnexchangedBalance(address(this));
+ uint256 underlyingBalance = underlying.balanceOf(address(this));
+ uint256 assetBalance = asset.balanceOf(address(this));
+
+ // @Recommendation - Track exact withdrawal amounts
+ uint256 remainingToWithdraw = _amount;
+
+ // @Recommendation - First use available asset balance
+ if (assetBalance > 0) {
+ uint256 assetToUse = Math.min(assetBalance, remainingToWithdraw);
+ remainingToWithdraw -= assetToUse;
+ }
+
+ // @Recommendation - Then handle unexchanged balance if needed
+ if (remainingToWithdraw > 0 && unexchangedBalance > 0) {
+ uint256 toWithdraw = Math.min(unexchangedBalance, remainingToWithdraw);
+ transmuter.withdraw(toWithdraw, address(this));
+ remainingToWithdraw -= toWithdraw;
+ }
+
+ // @Recommendation - Revert if cannot fulfill complete withdrawal
+ require(remainingToWithdraw == 0, "Insufficient available balance");
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago

Appeal created

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.