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

incorrect assumption that claimable balances should not be counted until they are actually claimed, despite representing real, claimable value

Summary

The _harvestAndReport() function incorrectly calculates total assets by not including the claimable balance in the final sum. While it queries the claimable balance from the transmuter, it never adds this value to _totalAssets. This leads to an underreporting of the strategy's true value.

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 Balance reporting mismatch between harvestAndReport and balanceDeployed
// harvestAndReport omits claimable balance while balanceDeployed includes it
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}

From an inconsistency between two key balance reporting functions:

  1. _harvestAndReport() - Used for share price calculations and profit reporting

  2. balanceDeployed() - Used for total balance checks

This occurs because:

  • balanceDeployed() correctly includes all balance types

  • _harvestAndReport() omits the claimable balance from its calculation

This inconsistency creates a systematic accounting error that affects the strategy's share price calculations and profit reporting mechanisms. The bug exists due to an incorrect assumption that claimable balances should not be counted until they are actually claimed, despite representing real, claimable value.

Vulnerability Details

The strategy's reporting mechanism has to accurately reflects all assets. "Reported balance must match actual balance"

But in the implementation across all three strategies shows inconsistency.

// 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));
// @check Incorrect balance accounting in harvest reporting
// The function omits claimable balance from total assets while these represent
// real value that can be claimed from the transmuter
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}
function balanceDeployed() public view returns (uint256) {
// @check Inconsistent balance calculation
// This includes all balances while _harvestAndReport omits claimable amounts
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The bug exists because of an architectural assumption in the balance reporting system. The strategies implement two different methods of calculating total assets that fundamentally disagree on whether claimable assets should be included in the total balance.

Impact Across Chains:

  1. On Optimism (StrategyOp.sol):

    • Affects Velodrome swaps when claiming and redeploying assets

    • Impacts alETH/WETH price calculations during swaps

  2. On Mainnet (StrategyMainnet.sol):

    • Affects Curve Router calculations and swap parameters

    • Impacts multi-hop trades through Curve pools

  3. On Arbitrum (StrategyArb.sol):

    • Affects Ramses Router swaps and pricing

    • Impacts arbitrage opportunities between alETH and WETH

The bug affects all implementations equally as they share the same core accounting logic, but manifests differently based on the DEX integration on each chain.

This discrepancy creates a systematic error in the YearnV3 tokenized strategy's accounting system, affecting:

  • Share price calculations

  • Profit reporting

  • TVL calculations

  • Performance fee calculations

The impact is amplified because these strategies handle both native tokens (WETH) and synthetic tokens (alETH), making accurate accounting crucial for maintaining proper peg relationships and swap ratios.

Impact

In the transmuter interface

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

The strategies implement two different balance calculation methods that disagree on handling claimable assets: _harvestAndReport, balanceDeployed

function _harvestAndReport() internal override returns (uint256 _totalAssets) {
uint256 claimable = transmuter.getClaimableBalance(address(this));
// @check: claimable balance is queried but never included in _totalAssets
_totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance;
}
function balanceDeployed() public view returns (uint256) {
// @check This includes all balance types, creating inconsistency with _harvestAndReport
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The root cause is that _harvestAndReport() queries the claimable balance but excludes it from total assets, while balanceDeployed() includes all balance types. This architectural decision creates a systematic accounting error that affects share price calculations, profit reporting, and overall strategy accounting across Optimism, Ethereum, and Arbitrum deployments.

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
+ // This ensures consistency with balanceDeployed() and accurate share price calculations
+ _totalAssets = unexchanged + asset.balanceOf(address(this)) + underlyingBalance + claimable;
}
// Add new helper function for consistent balance calculation
+ function _calculateTotalBalance() internal view returns (uint256) {
+ // @Recommendation - Centralize balance calculation logic
+ return transmuter.getUnexchangedBalance(address(this)) +
+ underlying.balanceOf(address(this)) +
+ asset.balanceOf(address(this)) +
+ transmuter.getClaimableBalance(address(this));
+ }
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
+ // @Recommendation - Use centralized balance calculation
+ return _calculateTotalBalance();
}
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.