Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: high

User Equity Locked in Aave Due to Incorrect Collateral Withdrawal Calculation

Author Revealed upon completion

Root + Impact

Description

When unwinding a leveraged position, the protocol should withdraw enough collateral to repay the debt plus the user's remaining equity (margin/profit). The unwindPosition function accepts a collateralToWithdraw parameter that allows users to specify how much collateral they want to withdraw. However, the _executeUnwindOperation function completely ignores this user-provided parameter and instead calculates the withdrawal amount based on the Aave liquidation threshold. This formula calculates the minimum collateral required to back the debt (Debt / LiquidationThreshold), not the total collateral associated with the position. Any collateral exceeding this threshold (the user's equity/profit) is left inside the Aave protocol. Since there are no other functions to withdraw collateral, these funds are permanently locked or only accessible by the owner via recoverTokens.

// src/Stratax.sol:236-256
function unwindPosition(
address _collateralToken,
uint256 _collateralToWithdraw, // @> User-provided parameter
address _debtToken,
uint256 _debtAmount,
bytes calldata _oneInchSwapData,
uint256 _minReturnAmount
) external onlyOwner {
UnwindParams memory params = UnwindParams({
collateralToWithdraw: _collateralToWithdraw, // @> Stored but never used
// ...
});
// ...
}
// src/Stratax.sol:574-579
// @> Logic calculates collateral based on Liquidation Threshold (LT), not the user's total balance.
uint256 collateralToWithdraw = (
_amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
// @> Only the calculated amount is withdrawn. The rest (User's Equity) stays in Aave.
withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));

Risk

Likelihood:

  • This calculation runs on every unwindPosition call. Unless a user's position is exactly at the liquidation threshold (about to be liquidated), they will always have excess collateral (equity).

Impact:

  • Loss of Funds: Users lose 100% of their equity (margin) when closing a position. For example, if a user deposits $2000 to borrow $1000, and later unwinds, the contract might only withdraw $1250 (to cover the $1000 debt + buffer), leaving $750 of the user's money locked in Aave.

  • Permanent Lock: Since all positions are stored under the contract address (address(this)) and there is no user-level position tracking, the locked collateral cannot be attributed to specific users or withdrawn individually.

  • No Recovery Mechanism: The only way to recover these funds is through the recoverTokens function, which requires owner privileges and can only recover tokens in the contract balance, not from Aave positions.

Proof of Concept

// Assumptions:
// Collateral (WETH) Price = $2000. Debt (USDC) = $1000.
// Liquidation Threshold (LT) = 80%.
// User supplied $2000 WETH (1 WETH), Borrowed $1000 USDC. (Health Factor is high).
// 1. User calls unwindPosition with collateralToWithdraw = 1 WETH (full position)
// 2. Code ignores user parameter and calculates collateralToWithdraw:
// Value needed = Debt / LT = $1000 / 0.80 = $1250.
// WETH amount = $1250 / $2000 = 0.625 WETH.
// 3. Contract withdraws only 0.625 WETH.
// 4. Contract swaps 0.625 WETH -> ~$1250 USDC.
// 5. Repays $1000 USDC Flashloan + $9 fee = $1009 USDC. Leaves ~$241 USDC in contract.
// 6. The remaining user collateral (1 WETH - 0.625 WETH = 0.375 WETH = $750) is LEFT in Aave.
// 7. The user has lost access to $750 worth of ETH.
// 8. Even though user specified collateralToWithdraw = 1 WETH, only 0.625 WETH was withdrawn.

Recommended Mitigation

Use the user-provided collateralToWithdraw parameter and add validation to ensure it covers the debt repayment:

function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
(, address user, UnwindParams memory unwindParams) = abi.decode(_params, (OperationType, address, UnwindParams));
// Step 1: Repay the Aave debt using flash loaned tokens
IERC20(_asset).approve(address(aavePool), _amount);
aavePool.repay(_asset, _amount, 2, address(this));
// Step 2: Calculate and withdraw only the collateral that backed the repaid debt
uint256 withdrawnAmount;
{
// Get LTV from Aave for the collateral token
- (,, uint256 liqThreshold,,,,,,,) =
+ (, uint256 ltv,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
// Get prices and decimals
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(unwindParams.collateralToken);
require(debtTokenPrice > 0 && collateralTokenPrice > 0, "Invalid prices");
- // Calculate collateral to withdraw: (debtAmount * debtPrice * collateralDec * LTV_PRECISION) / (collateralPrice * debtDec * ltv)
- uint256 collateralToWithdraw = (
- _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
- ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
+ // Use user-provided amount, but validate it covers the debt
+ uint256 collateralToWithdraw = unwindParams.collateralToWithdraw;
+
+ // Calculate minimum required collateral to cover debt
+ uint256 minRequiredCollateral = (
+ _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
+ ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * ltv);
+
+ require(collateralToWithdraw >= minRequiredCollateral, "Insufficient collateral to cover debt");
withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
}
// ...
}

Support

FAQs

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

Give us feedback!