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

Incorrect balance reporting and potential fund loss

Summary

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
// @check Incorrect withdrawal logic - if amount > totalAvailable, it withdraws totalAvailable
// but doesn't handle the remaining amount, violating the invariant that balance reduction
// must not exceed requested amount
if (_amount > totalAvailabe) {
transmuter.withdraw(totalAvailabe, address(this)); // Only withdraws partial amount
} else {
transmuter.withdraw(_amount, address(this));
}
}

The function withdraws only totalAvailable but doesn't handle the remaining amount (_amount - totalAvailable).

When this happens:

  1. User requests withdrawal of amount X

  2. Available balance is Y where Y < X

  3. Function withdraws Y

  4. Balance reduces by Y

  5. But contract promised to withdraw X

  6. Safety property violation: balance reduced by Y when promised X

This is fundamental logic error in handling partial withdrawals. The function should either:

  • Revert if full amount cannot be withdrawn

  • Or implement proper partial withdrawal logic with clear user communication

The vulnerability directly impacts the core withdrawal functionality and could lead to users receiving less funds than requested without proper indication.

Vulnerability Details

The _freeFunds function

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
// @check withdrawal vulnerability: When _amount > totalAvailabe,
// the function silently withdraws less than requested without handling
// the remaining amount or reverting. This breaks the core withdrawal
// safety invariant and can lead to stuck funds.
if (_amount > totalAvailabe) {
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}

The function:

  1. Checks if requested amount exceeds available balance

  2. If true, withdraws only available balance

  3. Does not handle the remaining amount

  4. Does not revert or signal partial withdrawal

its impact across Implementations:

  1. Arbitrum (StrategyArb.sol):

  • Affects WETH/alETH pairs on Ramses

  • Partial withdrawals through transmuter

  1. Optimism (StrategyOp.sol):

  • Impacts Velodrome-based trading

  • Incomplete withdrawals possible

  1. Mainnet (StrategyMainnet.sol):

  • Affects Curve-based operations

  • Same withdrawal vulnerability

The bug affects all three blockchain implementations and their respective DEX integrations (Ramses, Velodrome, Curve), potentially leading to:

  • Stuck funds

  • Silent partial withdrawals

  • Broken accounting

  • Compromised withdrawal guarantees

This is safety violation in the core withdrawal logic across all implementations of the Alchemix strategy system.

Impact

In the Transmuter interface

interface ITransmuter {
function withdraw(uint256 _amount, address _owner) external;
function getUnexchangedBalance(address _owner) external view returns (uint256);
}

And in the strategy implementations

function _freeFunds(uint256 _amount) internal override {
uint256 totalAvailabe = transmuter.getUnexchangedBalance(address(this));
// @check The core issue: Function attempts partial withdrawal without
// proper handling of the full requested amount, violating the
// fundamental withdrawal safety property
if (_amount > totalAvailabe) {
transmuter.withdraw(totalAvailabe, address(this));
} else {
transmuter.withdraw(_amount, address(this));
}
}

The root cause from:

  1. The function promises to free a specific amount of funds (_amount)

  2. When insufficient funds are available, it:

    • Withdraws only what's available (totalAvailabe)

    • Makes no attempt to fulfill the remaining amount

    • Doesn't revert or signal partial fulfillment

  3. This directly violates the safety property that states balance reduction must not exceed requested amount

This design creates a mismatch between:

  • What the function promises (withdraw _amount)

  • What it actually does (withdraws min (totalAvailabe, _amount))

  • How it handles the shortfall (it doesn't)

The bug affects all three implementations (Arbitrum, Optimism, Mainnet) because they share this core withdrawal logic, despite using different DEXes (Ramses, Velodrome, Curve) for their swap operations.

Recommendations

// In StrategyArb.sol, StrategyMainnet.sol, and StrategyOp.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 - Enforce strict withdrawal requirements
+ uint256 totalAvailable = transmuter.getUnexchangedBalance(address(this));
+ require(_amount <= totalAvailable, "Insufficient available balance");
+ transmuter.withdraw(_amount, address(this));
}
+ // @Recommendation - Add explicit partial withdrawal function
+ function _partialFreeFunds(uint256 _amount) internal returns (uint256 withdrawn) {
+ uint256 totalAvailable = transmuter.getUnexchangedBalance(address(this));
+ withdrawn = _amount > totalAvailable ? totalAvailable : _amount;
+ transmuter.withdraw(withdrawn, address(this));
+ emit PartialWithdrawal(_amount, withdrawn);
+ }
Updates

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.