DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: medium
Invalid

non-atomic nature of claim/swap operations creates a temporary state where actual available liquidity is less than reported

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:

  1. When transmuter.claim() is executed:

    • Funds are removed from unexchangedBalance

    • Added to underlying token balance

    • Not yet converted to asset token

  2. 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 {
// @FOUND Liquidity gap vulnerability: Non-atomic operation creates a state where claimed funds
// are neither in unexchanged balance nor in asset balance
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:

  • ITransmuter.sol interface shows the state changes

interface ITransmuter {
// @FOUND State transition vulnerability: claim() removes funds from unexchanged balance
// before they're converted to asset token
function claim(uint256 _amount, address _owner) external;
function getUnexchangedBalance(address _owner) external view returns (uint256);
}
  • The balanceDeployed() function incorrectly assumes liquidity availability

function balanceDeployed() public view returns (uint256) {
// @FOUND Incorrect liquidity accounting: Assumes continuous availability of funds
// during multi-step claim/swap process
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

// In all strategy contracts
- function balanceDeployed() public view returns (uint256) {
- return transmuter.getUnexchangedBalance(address(this)) +
- underlying.balanceOf(address(this)) +
- asset.balanceOf(address(this));
- }
+ // @Mitigation - Implement accurate balance tracking that accounts for in-flight transactions
+ 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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
11 months ago

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.