Summary
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 profitability check only looks at the difference in asset token balance (alETH), it ignores the value of the underlying tokens (WETH) being swapped away.
The balanceDeployed() function counts both tokens
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
This creates a discrepancy between
A keeper could execute a swap that
Claims 100 WETH
Swaps for 101 alETH
Appears profitable (+1 token)
Actually loses value when both tokens should be counted equally
Vulnerability Details
https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyArb.sol#L71-L78
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, ...) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, ...);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
}
The issue affects all actors
Keepers can execute technically profitable but economically losing swaps
Depositors lose value through unfavorable swaps
Strategy performance is compromised
For example
Initial State:
- Strategy has 100 WETH claimed from transmuter
- balanceDeployed() = 100 (counting WETH)
Keeper executes swap:
- Swaps 100 WETH for 101 alETH
- balBefore = 0 alETH
- balAfter = 101 alETH
- minOut check passes (101 > 100)
- But actual value could be less if WETH:alETH rate isn't 1:1
Final State:
- Strategy now has 101 alETH worth less than original 100 WETH
- balanceDeployed() = 101 but true value decreased
The profit check in claimAndSwap only looks at asset balance increase
balanceDeployed() counts both tokens at face value
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
This affects all supported tokens (WETH/alETH) across all implementations, potentially leading to value loss through seemingly profitable but actually disadvantageous swaps.
Impact
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, ...) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, ...);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
}
The bug manifests because the strategy claims WETH from the transmuter:
Swaps WETH for alETH
Only verifies that received alETH > spent WETH in nominal terms
Ignores actual value relationship between tokens
Value Extraction
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
Affected Functions
function _swapUnderlyingToAsset(uint256 _amount, uint256 minOut, ...) internal {
require(minOut > _amount, "minOut too low");
}
Recommendations
+
+ interface IPriceOracle {
+ function getPrice(address token) external view returns (uint256);
+ }
+
+ contract Strategy... {
+ IPriceOracle public oracle;
+ uint256 public constant MIN_PROFIT_BPS = 50;
+
+ constructor(...) {
+
+ oracle = IPriceOracle(CHAIN_SPECIFIC_ORACLE);
}
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, ...) external onlyKeepers {
+
+ uint256 totalValueBefore = _calculateTotalValue();
transmuter.claim(_amountClaim, address(this));
- uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, ...);
- uint256 balAfter = asset.balanceOf(address(this));
- require((balAfter - balBefore) >= _minOut, "Slippage too high");
+
+ uint256 totalValueAfter = _calculateTotalValue();
+ uint256 profitBps = ((totalValueAfter - totalValueBefore) * 10000) / totalValueBefore;
+ require(profitBps >= MIN_PROFIT_BPS, "Insufficient profit");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+
+ function _calculateTotalValue() internal view returns (uint256) {
+ uint256 underlyingPrice = oracle.getPrice(address(underlying));
+ uint256 assetPrice = oracle.getPrice(address(asset));
+
+ return (underlying.balanceOf(address(this)) * underlyingPrice) +
+ (asset.balanceOf(address(this)) * assetPrice) +
+ (transmuter.getUnexchangedBalance(address(this)) * assetPrice);
}
- function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
+ function balanceDeployed() public view returns (uint256) {
+ return _calculateTotalValue();
}
}