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

In the claimAndSwap() function's execution path, there's a temporary state where claimed tokens exist as underlying before being swapped to the asset

Summary

In all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol), the issue lies in

function balanceDeployed() public view returns (uint256) {
// @FOUND Double counting vulnerability in balance calculation
// The underlying.balanceOf() represents claimed but not yet swapped tokens
// These tokens are also counted in transmuter.getClaimableBalance()
// Leading to inflated total balance reporting
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

When tokens are claimed from the transmuter but not yet swapped, they exist as underlying tokens in the strategy's balance. However, these same tokens are still being counted in the transmuter's claimable balance, leading to an artificial inflation of the total assets.

Because:

  1. When tokens are claimed via transmuter.claim()

  2. They appear in underlying.balanceOf()

  3. But are still counted in transmuter.getClaimableBalance()

  4. Leading to total > actual assets

The double counting can lead to incorrect share price calculations and potential economic exploits.

Vulnerability Details

In all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol)

function balanceDeployed() public view returns (uint256) {
// @FOUND Incorrect balance accounting in total assets calculation
// The unexchanged balance from transmuter represents tokens that can be withdrawn
// While claimable balance represents tokens that can be claimed
// These are treated as independent when they actually represent the same underlying value
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

From a fundamental misunderstanding in how the Transmuter's balances work

From ITransmuter.sol

interface ITransmuter {
// @FOUND These functions operate on the same underlying token amount
// getUnexchangedBalance() and getClaimableBalance() represent different states
// of the same tokens, not separate token amounts
function getClaimableBalance(address _owner) external view returns (uint256);
function getUnexchangedBalance(address _owner) external view returns (uint256);
}

Impact

  1. The strategy reports inflated total assets because it counts the same tokens twice

    • Once as unexchanged balance

    • Again as claimable balance

  2. This affects share price calculations since TokenizedStrategy uses totalAssets for share pricing

  3. Users depositing or withdrawing will receive incorrect share amounts

  4. The strategy could become undercollateralized without detection

The issue manifests in the claim and swap flow

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
// @FOUND When claiming, the same tokens are counted both in unexchanged and underlying balances
transmuter.claim(_amountClaim, address(this));
// ...
}

Recommendations

// In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
+ // @Mitigation - Only count each token position once
+ // unexchanged: tokens deposited in transmuter
+ // underlying: claimed tokens pending swap
+ // asset: loose tokens not yet deposited
+ uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
+ uint256 underlying = underlying.balanceOf(address(this));
+ uint256 loose = asset.balanceOf(address(this));
+
+ // Claimable tokens are part of unexchanged balance, not additional
+ return unexchanged + underlying + loose;
}
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
+ // @Mitigation - Verify claim amount is available before claiming
+ require(_amountClaim <= transmuter.getClaimableBalance(address(this)), "Exceeds claimable");
+
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
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.