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

Accounting mismatch between reported and actual balances affects all implementations across Optimism, Ethereum, and Arbitrum networks.

Summary

Accounting mismatch in the strategy contracts.

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// @check Accounting mismatch: Function retrieves claimable balance but doesn't include it in _totalAssets,
// while claiming to report total assets. This creates an inconsistency with balanceDeployed()
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;
}
  1. The _harvestAndReport() function claims to report total assets but has inconsistent accounting logic

  2. It retrieves claimable balance from the transmuter but excludes it from the final _totalAssets calculation

  3. This creates a state where assets that should be counted are ignored, breaking the core invariant that harvestAndReport() must match balanceDeployed()

Because of incorrect assumptions about which balances constitute total assets. The code retrieves claimable balances suggesting they should be counted, but then excludes them from final calculations, creating an accounting gap.

This is particularly problematic because:

  1. The strategy's accounting system relies on accurate reporting

  2. Share prices and user withdrawals/deposits depend on correct total asset calculations

  3. The inconsistency between harvestAndReport and balanceDeployed breaks a fundamental invariant

Vulnerability Details

The issue exists in all three strategy implementations (StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol) in their handling of claimable balances during harvesting _harvestAndReport, _harvestAndReport, _harvestAndReport

// In all strategy contracts:
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// @check accounting error: claimable WETH from transmuter is retrieved but not included
// in _totalAssets calculation, leading to underreporting of strategy's total value
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;
}

The strategies interact with Alchemix's Transmuter contract which converts alETH to WETH. The transmuter maintains two distinct balances:

  1. Unexchanged balance (alETH deposited but not yet converted)

  2. Claimable balance (WETH available to claim from converted alETH)

The bug occurs because the strategies' _harvestAndReport() function retrieves but ignores the claimable WETH balance in its total assets calculation, while these assets are actually owned by the strategy.

Impact across chains:

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

  • Ethereum (StrategyMainnet.sol): Affects alETH/WETH via Curve

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

This leads to:

  1. Systematic undervaluation of strategy assets

  2. Incorrect share price calculations

  3. Potential exploitation during deposit/withdraw operations

The issue is particularly severe because #ITransmuter

interface ITransmuter {
// @check These two balances should both be counted for accurate asset reporting
function getClaimableBalance(address _owner) external view returns (uint256);
function getUnexchangedBalance(address _owner) external view returns (uint256);
}

The strategies are designed to profit from alETH/WETH price differences, but the accounting bug undermines this by not properly tracking all available assets, affecting operations across all supported chains and tokens.

Impact

Looking at the core interaction pattern.

// In ITransmuter.sol
interface ITransmuter {
function getClaimableBalance(address _owner) external view returns (uint256);
function getUnexchangedBalance(address _owner) external view returns (uint256);
}
// In Strategy implementations
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
// @check Design flaw: Retrieves claimable balance but excludes it from total assets
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;
}

The root cause stems from

  1. The strategies interact with Alchemix's Transmuter which maintains two distinct balances:

    • Unexchanged alETH

    • Claimable WETH (from converted alETH)

  2. The accounting system retrieves the claimable WETH balance but fails to include it in the total assets calculation, despite these being real, claimable assets owned by the strategy

  3. This creates a persistent underreporting of total assets since claimable WETH is never counted in the strategy's value

Recommendations

// In StrategyOp.sol, StrategyMainnet.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));
- _totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
+ // @Recommendation - Include claimable balance in total assets calculation for accurate value reporting
+ _totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance + claimable;
}
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
+ // @Recommendation - Match _harvestAndReport accounting by including claimable balance
+ return transmuter.getUnexchangedBalance(address(this)) +
+ underlying.balanceOf(address(this)) +
+ asset.balanceOf(address(this)) +
+ transmuter.getClaimableBalance(address(this));
}
Updates

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Incorrect accounting in `_harvestAndReport` claimable should be included

balanceDeployed should include claimable

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Incorrect accounting in `_harvestAndReport` claimable should be included

Support

FAQs

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