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));
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 {
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:
The actual market value relationship between WETH and alETH
The opportunity cost of the claimed WETH
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:
-
Strategy has 10 WETH worth of claims
-
Market rate: 1 WETH = 0.95 alETH
-
Keeper executes with:
_amountClaim = 10 WETH
_minOut = 10.1 alETH
-
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 {
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 {
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 {
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));
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) {
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:
Affected Functions
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IVeloRouter.route[] calldata _path)
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 _routeNumber)
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
function claimAndSwap(
uint256 _amountClaim,
uint256 _minOut,
uint256 _routeNumber
) external onlyKeepers {
+
+ uint256 totalValueBefore = balanceDeployed();
+
+
+ uint256 underlyingBefore = underlying.balanceOf(address(this));
transmuter.claim(_amountClaim, address(this));
- uint256 balBefore = asset.balanceOf(address(this));
- require(_minOut > _amountClaim, "minOut too low");
+
+ 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");
+
+ uint256 totalValueAfter = balanceDeployed();
+ require(totalValueAfter > totalValueBefore, "Must increase total value");
+
+
+ uint256 profitAmount = totalValueAfter - totalValueBefore;
+ require(profitAmount >= MINIMUM_PROFIT_AMOUNT, "Insufficient profit");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+
+ uint256 public constant MINIMUM_PROFIT_BPS = 10100;
+ uint256 public constant MINIMUM_PROFIT_AMOUNT = 0.001 ether;
+ interface IPriceOracle {
+ function getPrice(address token0, address token1) external view returns (uint256);
+ }
contract Strategy... {
+
+ IPriceOracle public oracle;
+
+
+ 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");
+ _;
+ }
}