Summary
In claimAndSwap() function of StrategyOp.sol, the minOut enforcement mechanism across all strategy implementations.
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
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));
}
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, IVeloRouter.route[] calldata _path) internal {
require(minOut > _amount, "minOut too low");
uint256 underlyingBalance = underlying.balanceOf(address(this));
require(underlyingBalance >= _amount, "not enough underlying balance");
IVeloRouter(router).swapExactTokensForTokens(_amount, minOut, _path, address(this), block.timestamp);
}
-
The strategy implements two separate minOut checks that don't properly coordinate:
-
Neither check accounts for the total state change in the strategy's position
-
The balance checks only look at asset.balanceOf() instead of the full balanceDeployed() calculation
This creates a scenario where:
A keeper could provide a minOut value that passes the first check (minOut > _amount)
Execute a swap that results in fewer tokens than promised
The second check using balanceOf() would pass due to not accounting for the full position
The final deposit to transmuter could fail or revert, leaving the strategy with less value than the minOut guarantee
Vulnerability Details
StrategyMainnet.claimAndSwap()
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
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));
}
The minOut enforcement fails because:
The balance checks only consider asset.balanceOf() instead of the complete strategy position
The transmuter deposit happens after the slippage check
The strategy assumes the balance difference directly correlates to swap output
Impact across different implementations:
StrategyArb.claimAndSwap()
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IRamsesRouter.route[] calldata _path) external onlyKeepers {
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));
}
This affects all supported blockchains (Optimism, Ethereum, Arbitrum), the:
Token flows between WETH and alETH
Keepers who can execute these swaps
Depositors whose share value could be impacted by manipulated swaps
Bcause:
It exists in the core swap logic used across all implementations
It affects the primary value flow of the strategy (WETH ↔ alETH)
It could be exploited by keepers to extract value through manipulated swaps
Impact
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
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));
}
The balance checks ignore the WETH position
Slippage protection occurs before final position settlement
The transmuter deposit happens after all safety checks
Value Extraction:
Asset Loss Scenarios:
balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
Cross-Chain Impact:
Affects all implementations (Optimism, Ethereum, Arbitrum)
Each DEX integration (Velodrome, Curve, Ramses) is vulnerable
Protocol Integration Risk:
interface ITransmuter {
function deposit(uint256 _amount, address _owner) external;
function claim(uint256 _amount, address _owner) external;
}
Recommendations
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
- 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));
+
+ uint256 positionBefore = balanceDeployed();
+
+
+ transmuter.claim(_amountClaim, address(this));
+ _swapUnderlyingToAsset(_amountClaim, _minOut, _path);
+
+
+ uint256 newAssets = asset.balanceOf(address(this));
+ require(newAssets >= _minOut, "Insufficient swap output");
+
+
+ transmuter.deposit(newAssets, address(this));
+
+
+ uint256 positionAfter = balanceDeployed();
+ require(positionAfter >= positionBefore + _minOut, "Position change below minimum");
}
+ function validatePositionChange(uint256 before, uint256 after, uint256 minChange) internal pure {
+ require(after >= before, "Position decreased");
+ require(after - before >= minChange, "Insufficient position increase");
+ }