Summary
In all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol), the issue lies in
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
When tokens are claimed from the transmuter but not yet swapped, they exist as underlying tokens in the strategy's balance. However, these same tokens are still being counted in the transmuter's claimable balance, leading to an artificial inflation of the total assets.
Because:
When tokens are claimed via transmuter.claim()
They appear in underlying.balanceOf()
But are still counted in transmuter.getClaimableBalance()
Leading to total > actual assets
The double counting can lead to incorrect share price calculations and potential economic exploits.
Vulnerability Details
In all strategy variants (StrategyOp.sol, StrategyMainnet.sol, StrategyArb.sol)
function balanceDeployed() public view returns (uint256) {
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}
From a fundamental misunderstanding in how the Transmuter's balances work
From ITransmuter.sol
interface ITransmuter {
function getClaimableBalance(address _owner) external view returns (uint256);
function getUnexchangedBalance(address _owner) external view returns (uint256);
}
Impact
-
The strategy reports inflated total assets because it counts the same tokens twice
-
This affects share price calculations since TokenizedStrategy uses totalAssets for share pricing
-
Users depositing or withdrawing will receive incorrect share amounts
-
The strategy could become undercollateralized without detection
The issue manifests in the claim and swap flow
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
}
Recommendations
// In StrategyOp.sol, StrategyMainnet.sol, and StrategyArb.sol
function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
+ // @Mitigation - Only count each token position once
+ // unexchanged: tokens deposited in transmuter
+ // underlying: claimed tokens pending swap
+ // asset: loose tokens not yet deposited
+ uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
+ uint256 underlying = underlying.balanceOf(address(this));
+ uint256 loose = asset.balanceOf(address(this));
+
+ // Claimable tokens are part of unexchanged balance, not additional
+ return unexchanged + underlying + loose;
}
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
+ // @Mitigation - Verify claim amount is available before claiming
+ require(_amountClaim <= transmuter.getClaimableBalance(address(this)), "Exceeds claimable");
+
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));
}