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

Swaps can appear profitable while actually reducing total strategy value

Summary

In the claimAndSwap function, the profitability check can be bypassed due to incorrect accounting of the total strategy value. In StrategyMainnet.claimAndSwap,

function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
// BUG: balanceDeployed() includes underlying tokens, but we only check asset balance
// This means a swap can appear profitable while actually reducing total strategy value
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));
}

The balanceDeployed() function includes both asset and underlying tokens in total value:

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

However, claimAndSwap only checks the change in asset balance (balAfter - balBefore), ignoring the underlying token reduction. This means a swap that increases asset balance by X but reduces underlying balance by >X would pass the checks but reduce total strategy value.

Vulnerability Details

In claimAndSwap() of StrategyMainnet

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
// BUG: The check minOut > _amountClaim is insufficient
// It only ensures the output amount is higher than input in nominal terms
// But doesn't account for potential value loss in the overall strategy
require(_minOut > _amountClaim, "minOut too low");
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
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));
}

The fundamental issue is in the profitability check logic. The strategy assumes that getting more alETH than the WETH input (_minOut > _amountClaim) guarantees a profitable trade. However, this ignores the fact that:

  1. The actual market value relationship between WETH and alETH

  2. The opportunity cost of the claimed WETH

  3. The total strategy value change

Impact: A keeper could execute trades that appear profitable by nominal token amounts but actually reduce the strategy's total value. For example:

  1. Strategy has 10 WETH worth of claims

  2. Market rate: 1 WETH = 0.95 alETH

  3. Keeper executes with:

    • _amountClaim = 10 WETH

    • _minOut = 10.1 alETH

  4. Trade passes checks but loses value (~0.4 ETH worth)

This affects all implementations:

StrategyOp._swapUnderlyingToAsset

function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IVeloRouter.route[] calldata _path) internal {
// BUG: Same issue with basic nominal check
require(minOut > _amount, "minOut too low");
IVeloRouter(router).swapExactTokensForTokens(_amount, minOut, _path, address(this), block.timestamp);
}

StrategyArb._swapUnderlyingToAsset

function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IRamsesRouter.route[] calldata _path) internal {
// BUG: Same issue with basic nominal check
require(minOut > _amount, "minOut too low");
IRamsesRouter(router).swapExactTokensForTokens(_amount, minOut, _path, address(this), block.timestamp);
}

This directly impacts depositors as their share value could decrease through technically valid but economically unfavorable swaps executed by keepers.

Impact

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber) external onlyKeepers {
// BUG: Strategy only validates nominal token amounts, not actual value
require(_minOut > _amountClaim, "minOut too low");
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
router.exchange(...);
uint256 balAfter = asset.balanceOf(address(this));
// BUG: This check doesn't guarantee value preservation
require((balAfter - balBefore) >= _minOut, "Slippage too high");
}

the strategy validates profitability by comparing nominal token amounts (alETH vs WETH) rather than actual value. This creates two specific problems:

Value Mismatch

function balanceDeployed() public view returns (uint256) {
// Strategy tracks total value including both asset and underlying
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

But claimAndSwap only checks

require(_minOut > _amountClaim, "minOut too low");

The strategy assumes 1:1 value between WETH and alETH, which isn't always true in market conditions.

Impact:

Value Extraction:

  • Keepers can execute trades that pass nominal checks but reduce total strategy value

  • Example: Trade 10 WETH for 10.1 alETH when alETH trades at 0.95 WETH

Affected Functions

// StrategyOp.sol
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IVeloRouter.route[] calldata _path)
// StrategyMainnet.sol
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber)
// StrategyArb.sol
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IRamsesRouter.route[] calldata _path)
/**
* ╔══════════════════════════════════════════════════════════════════╗
* ║ CROSS-CHAIN IMPACT ║
* ╠═══════════════════╦══════════════════╦═══════════════════════════╣
* ║ Chain ║ DEX Router ║ Impact Detail ║
* ╠═══════════════════╬══════════════════╬═══════════════════════════╣
* ║ Optimism ║ Velodrome ║ Shared validation flaw ║
* ║ Ethereum ║ Curve ║ Same flawed logic ║
* ║ Arbitrum ║ Ramses ║ Common vulnerability ║
* ╠═══════════════════╩══════════════════╩═══════════════════════════╣
* ║ USER IMPACT ║
* ╠═══════════════════╦══════════════════════════════════════════════╣
* ║ Actor Type ║ Impact Description ║
* ╠═══════════════════╬══════════════════════════════════════════════╣
* ║ Depositors ║ Share value decrease through unfavorable ║
* ║ ║ swaps that pass technical validation ║
* ║ Strategy ║ Total value diminishment while maintaining ║
* ║ ║ compliance with existing checks ║
* ╚═══════════════════╩══════════════════════════════════════════════╝
*
* @notice All implementations share core vulnerability despite
* using different DEX routers on each chain
* @dev security consideration for cross-chain deployments
*/

Recommendations

// For StrategyMainnet.sol
function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
+ // Track total strategy value before operations
+ uint256 totalValueBefore = balanceDeployed();
+
+ // Store initial underlying balance to track actual amount used
+ uint256 underlyingBefore = underlying.balanceOf(address(this));
transmuter.claim(_amountClaim, address(this));
- uint256 balBefore = asset.balanceOf(address(this));
- require(_minOut > _amountClaim, "minOut too low");
+ // Validate minimum profitable ratio considering market conditions
+ require(_minOut >= _amountClaim * MINIMUM_PROFIT_BPS / 10000, "Insufficient profit margin");
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");
+ // Ensure total strategy value increased
+ uint256 totalValueAfter = balanceDeployed();
+ require(totalValueAfter > totalValueBefore, "Must increase total value");
+
+ // Verify minimum profit threshold met
+ uint256 profitAmount = totalValueAfter - totalValueBefore;
+ require(profitAmount >= MINIMUM_PROFIT_AMOUNT, "Insufficient profit");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+ // Add configurable profit parameters
+ uint256 public constant MINIMUM_PROFIT_BPS = 10100; // 1% minimum profit
+ uint256 public constant MINIMUM_PROFIT_AMOUNT = 0.001 ether; // Minimum absolute profit
// For all strategy implementations
+ interface IPriceOracle {
+ function getPrice(address token0, address token1) external view returns (uint256);
+ }
contract Strategy... {
+ // Add oracle integration
+ IPriceOracle public oracle;
+
+ // Add safety checks
+ modifier ensureProfitable(uint256 amountIn, uint256 minOut) {
+ require(amountIn > 0, "Invalid input amount");
+ uint256 marketPrice = oracle.getPrice(address(underlying), address(asset));
+ uint256 minAcceptableOut = (amountIn * marketPrice * MINIMUM_PROFIT_BPS) / (10000 * 1e18);
+ require(minOut >= minAcceptableOut, "Insufficient output");
+ _;
+ }
}
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.