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

When funds are claimed from the transmuter but not yet swapped, they exist simultaneously

Summary

In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol, balanceDeployed() function.

function balanceDeployed() public view returns (uint256) {
// @check due to unsafe addition of balances
// The function adds unexchangedBalance + underlyingBalance + assetBalance without checking
// that unexchangedBalance and underlyingBalance could represent the same funds
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The vulnerability is derived from incorrect accounting assumptions in the balance tracking logic. The balanceDeployed() function performs direct addition of three balances:

  1. Unexchanged balance from transmuter

  2. Underlying token balance

  3. Asset balance

However, when funds are claimed from the transmuter but not yet swapped, they exist simultaneously as:

  • Part of the unexchanged balance (not yet removed from transmuter accounting)

  • Actual underlying tokens in the contract

This creates a window where the same funds are counted twice, violating the invariant:

assert total >= unexchanged + claimable

The issue becomes evident specifically during the execution path:

  1. Initial state with funds in transmuter

  2. claimAndSwap() is called

  3. After transmuter.claim() executes but before swap completion

  4. Balance calculation double counts the claimed amount

This represents a fundamental accounting error in the protocol's balance tracking mechanism

Vulnerability Details

The primary issue resides within the balance accounting mechanism across all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol).

In the Transmuter interface: #L4-L11

interface ITransmuter {
// @check These functions modify balances that affect accounting
function deposit(uint256 _amount, address _owner) external;
function claim(uint256 _amount, address _owner) external;
function withdraw(uint256 _amount, address _owner) external;
function getClaimableBalance(address _owner) external view returns (uint256);
function getUnexchangedBalance(address _owner) external view returns (uint256);
}

The vulnerability is evident in the balance tracking logic: #function balanceDeployed()

function balanceDeployed() public view returns (uint256) {
// @check accounting error: Double counting during claim->swap transition
// When funds are claimed but not yet swapped:
// 1. They remain counted in unexchangedBalance
// 2. They also appear in underlying.balanceOf()
// This leads to inflated total balance reporting
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The issue arises during the claim and swap procedure: #function claimAndSwap

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
// @check Balance inconsistency window opens here
transmuter.claim(_amountClaim, address(this));
// Time window where balances are double counted
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
}

The vulnerability originates from a fundamental flaw in the accounting logic during the claim->swap transition period. The system inadequately tracks the state transition of funds as they shift from 'unexchanged' status in the transmuter to 'claimed' underlying tokens.

Impact across supported chains:

  • Optimism (StrategyOp.sol)

  • Ethereum (StrategyMainnet.sol)

  • Arbitrum (StrategyArb.sol)

This affects all token interactions:

  • WETH (underlying)

  • alETH (synthetic)

  • Related yTokens

The bug creates a systematic overstatement of total assets during claim->swap transitions, which could:

  1. Break share price calculations

  2. Lead to incorrect TVL reporting

  3. Create arbitrage opportunities

  4. Impact withdrawal calculations

It affects core accounting across all supported chains and token implementations in the Alchemix ecosystem.

Impact

In all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol), the accounting system fails to adequatly handle the intermediate state between claiming funds from the transmuter and completing the swap.

Code path of the issue claimAndSwap()

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
// @check State transition point 1: Funds are claimed but still counted in unexchanged balance
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
// @check State transition point 2: Same funds now exist as underlying tokens
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
}

The balance calculation compounds this issue

function balanceDeployed() public view returns (uint256) {
// @check Both balances are added, causing double counting
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

The root cause is that the system:

  1. Maintains unexchanged balance in transmuter even after claiming

  2. Adds claimed underlying tokens to the total balance

  3. Fails to subtract claimed amounts from unexchanged balance immediately

This creates a window where the same funds are counted twice in the total balance, breaking the core invariant.

Recommendations

// In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
+ // @Recommendation - Add state tracking for claimed amounts
+ uint256 private claimedNotSwapped;
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
+ // @Recommendation - Track claimed amount before state change
+ claimedNotSwapped = _amountClaim;
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");
+ // @Recommendation - Reset claimed tracking after swap
+ claimedNotSwapped = 0;
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
function balanceDeployed() public view returns (uint256) {
+ // @Recommendation - Accurate balance calculation accounting for claimed funds
+ uint256 unexchangedBalance = transmuter.getUnexchangedBalance(address(this));
+ uint256 underlyingBalance = underlying.balanceOf(address(this));
+
+ return (unexchangedBalance - claimedNotSwapped) +
+ underlyingBalance +
+ asset.balanceOf(address(this));
}
// Additional system-wide recommendations:
// 1. Add Balance Tracking Interface
+ interface IBalanceTracking {
+ event ClaimInitiated(uint256 amount, uint256 timestamp);
+ event SwapCompleted(uint256 amount, uint256 timestamp);
+
+ function getAdjustedBalance() external view returns (uint256);
+ }
// 2. Implement Atomic Operations
+ function atomicClaimAndSwap(
+ uint256 _amountClaim,
+ uint256 _minOut,
+ bytes calldata _swapData
+ ) external onlyKeepers {
+ // Execute claim and swap in single transaction
+ // Prevents intermediate state issues
+ }
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.