Stratax Contracts

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

`Stratax::_executeUnwindOperation` ignores `unwindParams.collateralToWithdraw`, and its local calculation differs from `calculateUnwindParams`, causing excess collateral to sit idle in the contract

Author Revealed upon completion

Stratax::_executeUnwindOperation ignores unwindParams.collateralToWithdraw, and its local calculation differs from calculateUnwindParams, causing excess collateral to sit idle in the contract

Description

Two related issues in the unwind flow cause more collateral than necessary to be withdrawn from Aave, with the excess sitting idle in the contract after every unwind.

Bug 1: _executeUnwindOperation ignores the unwindParams.collateralToWithdraw value that was passed in by the caller and instead recalculates its own collateralToWithdraw locally using liqThreshold instead of ltv as is clearly started in the function comments:

function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
// ...
uint256 withdrawnAmount;
{
@> (,, uint256 liqThreshold,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(unwindParams.collateralToken);
require(debtTokenPrice > 0 && collateralTokenPrice > 0, "Invalid prices");
@> // Locally recalculated — ignores unwindParams.collateralToWithdraw entirely
@> uint256 collateralToWithdraw = (
@> _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
@> ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
@> withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
}
// Step 3: Swap collateral to debt token to repay flash loan
IERC20(unwindParams.collateralToken).approve(address(oneInchRouter), withdrawnAmount);
uint256 returnAmount = _call1InchSwap(unwindParams.oneInchSwapData, _asset, unwindParams.minReturnAmount);
// ...
}

This local calculation produces a withdrawal amount of LTV_PRECISION / liqThreshold × raw price conversion. For example, for USDC with a liqThreshold of 8600, this is 10000 / 8600 ≈ 1.163× the raw price conversion.

Bug 2: calculateUnwindParams — the function intended to be called prior to unwindPosition to derive the 1inch swap parameters — uses a completely different formula. It performs a straight price conversion with a flat 5% slippage buffer:

function calculateUnwindParams(address _collateralToken, address _borrowToken)
public
view
returns (uint256 collateralToWithdraw, uint256 debtAmount)
{
// ...
collateralToWithdraw = (debtTokenPrice * debtAmount * 10 ** IERC20(_collateralToken).decimals())
/ (collateralTokenPrice * 10 ** IERC20(_borrowToken).decimals());
@> // Account for 5% slippage in swap
@> collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
return (collateralToWithdraw, debtAmount);
}

This produces ~1.05× the raw price conversion — significantly less than the ~1.163× computed by _executeUnwindOperation.

The intended user flow is:

  1. Call calculateUnwindParams to get collateralToWithdraw

  2. Use that value to build the 1inch swap data (specifying it as the swap input amount)

  3. Call unwindPosition with the swap data and parameters

Inside _executeUnwindOperation, the Aave withdrawal uses the locally computed value (~1.163×), but the 1inch swap data only expects to consume the calculateUnwindParams value (~1.05×). The second bug effectively cancels out the first in terms of execution — the swap still executes successfully since the withdrawn amount exceeds what the swap needs. But the net effect is that more collateral than necessary is withdrawn from Aave and the surplus (~11.3% of the raw price conversion for USDC) sits idle in the contract after every unwind.

Risk

Likelihood:

  • Every unwind operation will leave excess collateral tokens in the contract.

  • The magnitude of the excess depends on the collateral token's liqThreshold — higher thresholds (closer to 10000) result in smaller differences, while lower thresholds amplify the mismatch.

Impact:

  • More collateral is withdrawn from Aave than necessary on every unwind. The excess tokens sit in the contract and must be manually recovered via recoverTokens — not the intended flow.

  • This is compounded by the fact that _executeUnwindOperation also supplies excess debt tokens back into Aave (lines 609-612), further complicating the position state.

Proof of Concept

  1. A user opens a leveraged position with USDC collateral

  2. The user calls calculateUnwindParams which returns collateralToWithdraw at ~1.05× the raw price conversion

  3. The user builds 1inch swap data for this collateralToWithdraw amount

  4. The user calls unwindPosition

  5. Inside _executeUnwindOperation, the local collateralToWithdraw is calculated at ~1.163× the raw price conversion (using liqThreshold = 8600 for USDC)

  6. Aave withdraws the larger amount (~1.163×)

  7. The 1inch swap only consumes the smaller amount (~1.05×)

  8. The difference remains as idle USDC in the contract

Add the following test to test/fork/Stratax.t.sol

function test_UnwindLeavesExcessCollateralInContract() public {
if (!hasApiKey && !usesSavedData) {
vm.skip(true);
}
console.log("");
console.log("============================================================");
console.log(" PoC: Stratax::_executeUnwindOperation Ignores collateralToWithdraw");
console.log("============================================================");
uint256 initialUSDC = IERC20(USDC).balanceOf(address(stratax));
console.log("");
console.log(" [INITIAL] Stratax USDC Balance: $", initialUSDC / 1e6);
// -------------------------------------------------------
// OPEN: USDC collateral, WETH debt (3x leverage)
// -------------------------------------------------------
uint256 collateralAmount = 1000 * 1e6;
(uint256 flashLoanAmount, uint256 borrowAmount) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: address(USDC),
borrowToken: address(WETH),
desiredLeverage: 30_000,
collateralAmount: collateralAmount,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
(bytes memory openSwapData,) = get1inchSwapData(WETH, USDC, borrowAmount, address(stratax));
deal(USDC, ownerTrader, collateralAmount);
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(stratax), collateralAmount);
stratax.createLeveragedPosition(
USDC, flashLoanAmount, collateralAmount, WETH, borrowAmount, openSwapData, (flashLoanAmount * 950) / 1000
);
(uint256 collAfterOpen, uint256 debtAfterOpen,,,, uint256 hfAfterOpen) =
IPool(AAVE_POOL).getUserAccountData(address(stratax));
console.log("");
console.log(" [OPEN] Aave Position:");
console.log(" Total Collateral (USD): $", collAfterOpen / 1e8);
console.log(" Total Debt (USD): $", debtAfterOpen / 1e8);
console.log(" Health Factor: ", hfAfterOpen / 1e14);
assertTrue(collAfterOpen > 0, "Should have collateral");
assertTrue(hfAfterOpen > 1e18, "HF should be > 1");
// -------------------------------------------------------
// UNWIND
// -------------------------------------------------------
(uint256 collToWithdraw, uint256 debtToRepay) = stratax.calculateUnwindParams(USDC, WETH);
console.log("");
console.log(" [UNWIND] calculateUnwindParams collateralToWithdraw: $", collToWithdraw / 1e6);
console.log(" [UNWIND] _executeUnwindOperation will IGNORE this");
console.log(" and use its own liqThreshold-based calculation instead.");
(bytes memory unwindSwapData,) = get1inchSwapData(USDC, WETH, collToWithdraw, address(stratax));
stratax.unwindPosition(USDC, collToWithdraw, WETH, debtToRepay, unwindSwapData, (debtToRepay * 950) / 1000);
vm.stopPrank();
// -------------------------------------------------------
// RESULT
// -------------------------------------------------------
uint256 finalUSDC = IERC20(USDC).balanceOf(address(stratax));
uint256 accruedUSDC = finalUSDC - initialUSDC;
console.log("");
console.log("============================================================");
console.log(" RESULT");
console.log("============================================================");
console.log(" Stratax USDC balance before: $", initialUSDC / 1e6);
console.log(" Stratax USDC balance after: $", finalUSDC / 1e6);
console.log(" Excess USDC left in contract: $", accruedUSDC / 1e6);
console.log("============================================================");
console.log("");
assertTrue(accruedUSDC > 0, "Contract should have accumulated excess USDC from the unwind");
}
Expected Result:
============================================================
RESULT
============================================================
Stratax USDC balance before: $ 0
Stratax USDC balance after: $ 496
Excess USDC left in contract: $ 496
============================================================

Recommended Mitigation

Have _executeUnwindOperation use unwindParams.collateralToWithdraw directly instead of recomputing a different value locally. The protocol can then decide whether the formula in calculateUnwindParams or the one currently in _executeUnwindOperation is more appropriate, and apply the chosen formula consistently in calculateUnwindParams:

{
- (,, uint256 liqThreshold,,,,,,,) =
- aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
-
- uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset);
- uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(unwindParams.collateralToken);
- require(debtTokenPrice > 0 && collateralTokenPrice > 0, "Invalid prices");
-
- uint256 collateralToWithdraw = (
- _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
- ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
-
- withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
+ withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, unwindParams.collateralToWithdraw, address(this));
}

Support

FAQs

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

Give us feedback!