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

The balanceDeployed() function double counts the underlying token balance

Summary

In all strategy variants (StrategyArb.sol, StrategyMainnet.sol, StrategyOp.sol), the balance accounting is incorrect due to double counting

function balanceDeployed() public view returns (uint256) {
// @check Double counting vulnerability: underlying.balanceOf() is counted twice
// 1. First time through transmuter.getUnexchangedBalance() which includes underlying balance
// 2. Second time directly through underlying.balanceOf()
return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
}

The cause happen from a fundamental misunderstanding of what getUnexchangedBalance() returns from the Transmuter contract. The getUnexchangedBalance() function already includes the underlying token balance in its calculation.

This leads to:

  1. First count: Through transmuter.getUnexchangedBalance()

  2. Second count: Direct underlying.balanceOf()

Vulnerability Details

In the balance accounting system across all three strategy implementations (Mainnet, Optimism, and Arbitrum) that interact with Alchemix's Transmuter contract.

Affected Components:

  1. StrategyMainnet.sol

  2. StrategyOp.sol

  3. StrategyArb.sol

Core Issue: balanceDeployed()

function balanceDeployed() public view returns (uint256) {
// @check Accounting error: Double counting of underlying assets
// transmuter.getUnexchangedBalance() already includes the underlying.balanceOf()
// This causes artificial inflation of total assets by counting WETH twice
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) + // <- Double counting here
asset.balanceOf(address(this));
}

from fundamental misunderstanding of the Transmuter's accounting system. The getUnexchangedBalance() function from ITransmuter.sol already includes the underlying token (WETH) balance in its calculation

interface ITransmuter {
// @check getUnexchangedBalance returns total unexchanged balance INCLUDING underlying tokens
function getUnexchangedBalance(address _owner) external view returns (uint256);
}

Impact Across Chains:

Ethereum Mainnet:

  • Affects WETH/alETH strategy using Curve for swaps

  • Double counts WETH positions

Optimism:

  • Affects WETH/alETH strategy using Velodrome for swaps

  • Same accounting error in balance reporting

Arbitrum:

  • Affects WETH/alETH strategy using Ramses for swaps

  • Identical balance inflation issue

Real-world Implications:

Share Price Calculation:

  • Inflated total assets lead to incorrect share price calculations

  • Users receive fewer shares than they should when depositing

Risk Management:

  • Strategy appears to have more assets than it actually does

  • Could lead to exceeding intended risk parameters

Withdrawal Mechanics

function availableWithdrawLimit(address) public view override returns (uint256) {
// @check Withdrawal limits based on inflated balance calculations
return asset.balanceOf(address(this)) + transmuter.getUnexchangedBalance(address(this));
}

Impact

In all three strategy variants (StrategyMainnet.sol, StrategyOp.sol, StrategyArb.sol), the balance calculation is implemented as

function balanceDeployed() public view returns (uint256) {
// @check The bug originates here:
// 1. transmuter.getUnexchangedBalance() already includes underlying.balanceOf()
// 2. underlying.balanceOf() is then added again, causing double counting
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The root cause is that transmuter.getUnexchangedBalance() from ITransmuter.sol already includes the underlying token (WETH) balance in its calculation

interface ITransmuter {
// Returns total unexchanged balance which includes underlying tokens
function getUnexchangedBalance(address _owner) external view returns (uint256);
function getClaimableBalance(address _owner) external view returns (uint256);
}

This what creates a double-counting scenario:

  1. First count: Through getUnexchangedBalance() which includes WETH

  2. Second count: Direct addition of underlying.balanceOf()

Recommendations

// StrategyMainnet.sol, StrategyOp.sol, StrategyArb.sol
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
+ // @Recommendation - Remove double counting by excluding direct underlying.balanceOf()
+ // since it's already included in getUnexchangedBalance()
+ return transmuter.getUnexchangedBalance(address(this)) + asset.balanceOf(address(this));
}
function availableWithdrawLimit(address) public view override returns (uint256) {
- return asset.balanceOf(address(this)) + transmuter.getUnexchangedBalance(address(this));
+ // @Recommendation - Keep consistent with balanceDeployed() calculation
+ return balanceDeployed();
}
+// @Recommendation - Add new view function for accurate balance breakdown
+function getDetailedBalances() public view returns (
+ uint256 unexchangedBalance,
+ uint256 claimableBalance,
+ uint256 looseAssetBalance
+) {
+ return (
+ transmuter.getUnexchangedBalance(address(this)),
+ transmuter.getClaimableBalance(address(this)),
+ asset.balanceOf(address(this))
+ );
+
// Consider Implementing Balance Checks in Critical Functions
-function claimAndSwap(...) external onlyKeepers {
+// @Recommendation - Add balance validation to critical functions
+function claimAndSwap(...) external onlyKeepers validateBalance {
Updates

Lead Judging Commences

inallhonesty Lead Judge
11 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.