Summary
Incorrect assumption about atomic state transitions during the claim and swap process. The balanceDeployed() function assumes continuous liquidity availability, but there's a critical gap:
-
When transmuter.claim() is executed:
Funds are removed from unexchangedBalance
Added to underlying token balance
Not yet converted to asset token
-
During this state transition:
getUnexchangedBalance() decreases
Before the swap completes, the asset.balanceOf() hasn't increased
Creates a temporary liquidity gap
Vulnerability Details
In StrategyArb.sol, StrategyMainnet.sol, and StrategyOp.sol, the vulnerability's in the interaction between claimAndSwap and balanceDeployed functions:
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 root cause lies in the state transition assumptions during the claim and swap process:
interface ITransmuter {
function claim(uint256 _amount, address _owner) external;
function getUnexchangedBalance(address _owner) external view returns (uint256);
}
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
Impact
Creating temporary liquidity gaps during claim/swap operations
Potentially allowing withdrawals based on incorrect liquidity assumptions
Breaking the core invariant that available liquidity must always meet reported amounts
Recommendations
Atomic Operation Pattern Implementation
// In StrategyArb.sol, StrategyMainnet.sol, and StrategyOp.sol
- function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
+ // @Mitigation - Ensure atomic claim and swap operation with proper balance checks
+ function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
+ uint256 preClaimBalance = balanceDeployed();
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));
+ require(balanceDeployed() >= preClaimBalance, "Balance consistency check failed");
}
Accurate Balance Reporting
- 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) {
+ uint256 unexchangedBalance = transmuter.getUnexchangedBalance(address(this));
+ uint256 assetBalance = asset.balanceOf(address(this));
+ return unexchangedBalance + assetBalance;
+ }
Enhanced Withdrawal Protection
// In all strategy contracts
function availableWithdrawLimit(address) public view override returns (uint256) {
- return asset.balanceOf(address(this)) + transmuter.getUnexchangedBalance(address(this));
+ // @Mitigation - Conservative withdrawal limit accounting
+ uint256 available = asset.balanceOf(address(this)) +
+ transmuter.getUnexchangedBalance(address(this));
+ if (claimInProgress) {
+ return available > SAFETY_MARGIN ? available - SAFETY_MARGIN : 0;
+ }
+ return available;
}