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

unexchanged balance from the transmuter already includes the claimable balance, but the function also adds underlyingBalance separately

Summary

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
uint256 claimable = transmuter.getClaimableBalance(address(this));
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
// @check Double counting vulnerability: unexchanged balance from transmuter already includes
// claimable funds, but underlyingBalance is added separately leading to inflated total assets
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

from a fundamental misunderstanding of the Transmuter's balance accounting system. The getUnexchangedBalance() function from ITransmuter.sol already includes the claimable balance in its calculation. However, the strategy's _harvestAndReport() function adds the underlyingBalance separately, creating a double-counting scenario.

This leads to:

  1. Inflated total asset reporting

  2. Incorrect share price calculations

  3. Potential exploitation through strategic deposits before harvests

Vulnerability Details

The core vulnerability lies in the balance reporting mechanism across all three strategy implementations.

// In ITransmuter.sol
interface ITransmuter {
// @check getUnexchangedBalance returns both deposited and claimable balances
function getUnexchangedBalance(address _owner) external view returns (uint256);
function getClaimableBalance(address _owner) external view returns (uint256);
}
// In StrategyMainnet.sol, StrategyOp.sol, and StrategyArb.sol
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// @check accounting error: underlyingBalance is added separately
// despite being already included in unexchanged balance from transmuter
uint256 claimable = transmuter.getClaimableBalance(address(this));
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}
function balanceDeployed() public view returns (uint256) {
// @check Same accounting error propagates to balanceDeployed calculation
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

because of incorrect assumptions about the Transmuter's balance accounting system. The getUnexchangedBalance() function already includes both deposited and claimable balances, but the strategy implementations incorrectly add underlyingBalance separately.

this leads to:

  1. Inflated total asset reporting

  2. Incorrect share price calculations

  3. Potential manipulation through strategic deposits/withdrawals

  4. Inaccurate yield calculations

and affects all token interactions (WETH, alETH) across all three chains, making it a systemic issue in the protocol's core accounting logic.

Impact

In the Transmuter contract interface

interface ITransmuter {
function getUnexchangedBalance(address _owner) external view returns (uint256);
function getClaimableBalance(address _owner) external view returns (uint256);
}

The getUnexchangedBalance() function already includes both deposited and claimable balances. However, in all three strategy implementations (Mainnet, Optimism, Arbitrum), the balance calculation double counts these values

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// @check Double counting: unexchanged balance already includes claimable amounts
uint256 claimable = transmuter.getClaimableBalance(address(this));
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
// @check Incorrect addition: underlyingBalance is already part of unexchanged
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

This creates an accounting error where:

  1. unexchanged includes all deposited and claimable balances

  2. underlyingBalance is added again, despite being part of the unexchanged balance

  3. This double counting inflates the reported total assets

The bug through all balance-dependent functions and affects the core accounting across all supported chains (Ethereum, Optimism, Arbitrum) and tokens (WETH, alETH).

Recommendations

// In StrategyMainnet.sol, StrategyOp.sol, and StrategyArb.sol
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
- uint256 claimable = transmuter.getClaimableBalance(address(this));
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
- uint256 underlyingBalance = underlying.balanceOf(address(this));
+ // @Recommendation - Only count unexchanged balance and loose assets
+ // since unexchanged already includes claimable amounts
_totalAssets = unexchanged + asset.balanceOf(address(this));
}
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
+ // @Recommendation - Maintain consistent accounting with _harvestAndReport
+ return transmuter.getUnexchangedBalance(address(this)) + asset.balanceOf(address(this));
}

These changes ensure:

  1. Accurate balance accounting by removing double counting

  2. Consistent balance reporting across all functions

  3. Better transparency through events

  4. Runtime balance validation capabilities

Updates

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.