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