Summary
In the claimAndSwap function across all strategy implementations
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, uint256 routeNumber) external onlyKeepers {
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 vulnerability stems from inconsistent accounting between the balanceDeployed() function and the slippage checks in claimAndSwap. While balanceDeployed() includes both underlying tokens (WETH) and asset tokens (alETH) in its total:
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) + underlying.balanceOf(address(this)) + asset.balanceOf(address(this));
}
The slippage protection in claimAndSwap only checks the asset balance difference
require((balAfter - balBefore) >= _minOut, "Slippage too high");
This shows that while the asset balance check passes, the total strategy value (balanceDeployed) can decrease, violating the intended that each swap should increase the total value by at least minOut.
Vulnerability Details
In StrategyMainnet.sol's claimAndSwap
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 root cause is in the profitability check logic
The strategy tracks total value through balanceDeployed():
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
The bug occurs because
When claiming WETH from transmuter, it's counted in balanceDeployed()
When swapping to alETH, the WETH is removed but counted again as alETH
The minOut check require(_minOut > _amountClaim) doesn't account for this double-counting
Impact
A keeper could execute a swap that appears profitable (passes minOut check) but actually reduces the strategy's total value. For example:
Claim 100 WETH (_amountClaim = 100)
Set minOut to 101 alETH (passes require(_minOut > _amountClaim))
Swap executes, but total strategy value decreases because the 100 WETH was already counted in balanceDeployed()
Recommendations
// StrategyMainnet.sol, StrategyOp.sol, StrategyArb.sol
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");
+ // @audit-fix Track total value before claim to ensure true profitability
+ uint256 totalBefore = balanceDeployed();
+
+ transmuter.claim(_amountClaim, address(this));
+
+ // @audit-fix Require minimum profit threshold over total value
+ require(_minOut > _amountClaim + PROFIT_THRESHOLD, "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");
+ // @audit-fix Verify total strategy value increased by required amount
+ require(balanceDeployed() >= totalBefore + _minOut, "Trade not profitable");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+ // @audit-fix Add configurable profit threshold
+ uint256 public constant PROFIT_THRESHOLD = 1e16; // 1% minimum profit