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

Transmuter claim reduces total balance before swap

Summary

In StrategyMainnet.sol#claimAndSwap

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
// @check Incorrect balance tracking - only checks asset balance instead of total deployed balance
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}

from incorrect balance tracking in the profitability check. The function tracks only the asset balance change (asset.balanceOf(address(this))) but fails to account for the total deployed balance which includes:

  • Unexchanged balance in transmuter

  • Underlying token balance

  • Asset balance

This creates a discrepancy between what verifies (balanceDeployed()) and what the contract actually checks, allowing trades that appear profitable in terms of asset balance but decrease the total strategy value.

Vulnerability Details

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
// @check Transmuter claim reduces total balance before swap
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
// @check Balance check doesn't account for transmuter balance reduction
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}

The bug exists because of incorrect accounting in the profitability check. When transmuter.claim() is called, it reduces the strategy's position in the transmuter (unexchangedBalance) and provides WETH (underlying). The subsequent swap profitability check only verifies:

  1. _minOut > _amountClaim

  2. (balAfter - balBefore) >= _minOut

However, this fails to account for the total value change including the transmuter position reduction.

Impact Across Chains:

  • Ethereum: Affects Curve swaps through CurveRouterNG

  • Optimism: Affects Velodrome swaps

  • Arbitrum: Affects Ramses swaps

The vulnerability allows unprofitable trades because:

  1. Initial state: Strategy has X alETH in transmuter

  2. Keeper calls claimAndSwap:

    • Claims Y WETH from transmuter (reduces transmuter position by Y alETH)

    • Swaps Y WETH for Z alETH where Z > Y but Z < Y + fees

    • Trade appears profitable (Z > Y) but total strategy value decreases

Impact

Looking at the balanceDeployed() function across all strategies

function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
}

The issue stems from the claimAndSwap() function only checking asset balance changes

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
// Step 1: Claims WETH, reducing transmuter balance
transmuter.claim(_amountClaim, address(this));
// Step 2: Only tracks asset (alETH) balance changes
uint256 balBefore = asset.balanceOf(address(this));
// Step 3: Swap occurs
router.exchange(...);
// Step 4: Validates only the asset delta, ignoring transmuter position loss
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
}

Claiming from the transmuter reduces the strategy's position value (unexchangedBalance), but the profitability check only validates the increase in asset balance from the swap. This creates a scenario where:

  1. Total Value Before = unexchangedBalance + 0 + 0

  2. After Claim = (unexchangedBalance - X) + X + 0

  3. After Swap = (unexchangedBalance - X) + 0 + Y

Even if Y > X (swap appears profitable), the total value can decrease due to fees and slippage, breaking the core invariant that all operations must increase total strategy value.

Recommendations

// StrategyMainnet.sol, StrategyOp.sol, StrategyArb.sol
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
+ // @Recommendation - Track total strategy value before operations
+ uint256 totalBefore = balanceDeployed();
transmuter.claim(_amountClaim, address(this));
- uint256 balBefore = asset.balanceOf(address(this));
require(_minOut > _amountClaim, "minOut too low");
router.exchange(
routes[_routeNumber],
swapParams[_routeNumber],
_amountClaim,
_minOut,
pools[_routeNumber],
address(this)
);
- uint256 balAfter = asset.balanceOf(address(this));
- require((balAfter - balBefore) >= _minOut, "Slippage too high");
+ // @Recommendation - Ensure total strategy value increases by minimum profit margin
+ uint256 totalAfter = balanceDeployed();
+ require(totalAfter > totalBefore, "Trade not profitable");
+ require(totalAfter >= totalBefore + minimumProfitMargin, "Insufficient profit");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+// @Recommendation - Add configurable profit margin
+uint256 public minimumProfitMargin;
+
+function setMinimumProfitMargin(uint256 _margin) external onlyManagement {
+ minimumProfitMargin = _margin;
+}
Updates

Lead Judging Commences

inallhonesty Lead Judge
11 months ago

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

[INVALID] Keepers can execute swaps that appear profitable in isolation but actually decrease total strategy value

Support

FAQs

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