Stratax Contracts

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

calculateUnwindParams and _executeUnwindOperation use different formulas, causing swap calldata mismatch that permanently locks user positions

Author Revealed upon completion

Description

  • Stratax::calculateUnwindParams is the public view function users and frontends call to determine how much collateral will be withdrawn during an unwind, so they can prepare the corresponding 1inch swap calldata. Stratax::_executeUnwindOperation is the internal function that performs the actual withdrawal.

  • The two functions compute collateralToWithdraw using entirely different formulas. calculateUnwindParams applies a flat 5% slippage buffer on top of the price-ratio amount. _executeUnwindOperation applies an LTV-precision scaling using liqThreshold. For typical Aave parameters (ltv=7500, liqThreshold=8000), the execution function withdraws ~19% more collateral than the view function predicted. The _collateralToWithdraw parameter accepted by unwindPosition is stored in UnwindParams but is never read by _executeUnwindOperation — it is silently dead code.

// calculateUnwindParams (lines 464-468) — what the user sees:
collateralToWithdraw = (debtTokenPrice * debtAmount * 10 ** collateralDec)
/ (collateralTokenPrice * 10 ** debtDec);
// @> collateralToWithdraw = (collateralToWithdraw * 1050) / 1000; // +5% slippage
// _executeUnwindOperation (lines 575-577) — what actually executes:
// @> uint256 collateralToWithdraw = (
// @> _amount * debtTokenPrice * (10 ** collateralDec) * LTV_PRECISION
// @> ) / (collateralTokenPrice * (10 ** debtDec) * liqThreshold);

Risk

Likelihood:

  • Every user who calls calculateUnwindParams to prepare 1inch swap calldata and then calls unwindPosition with that calldata triggers the mismatch — this is the documented and intended usage pattern.

  • The discrepancy is always present regardless of market conditions, position size, or token pair, because it is structural: the two formulas differ by a constant factor of (LTV_PRECISION / liqThreshold) / 1.05.

Impact:

  • The contract withdraws ~19% more collateral from Aave than the user's 1inch calldata was built for. If 1inch interprets the calldata as an exact-input swap, the extra collateral cannot be swapped, the flash loan repayment falls short, and unwindPosition reverts — the user's position is permanently locked until they construct calldata for the correct amount, which calculateUnwindParams cannot provide.

  • Even if the swap does not revert, the extra collateral withdrawn but not swapped remains stranded in the contract and is not returned to the user, causing a direct financial loss of ~$190 per $1000 USDC of debt at typical parameters.

  • The _collateralToWithdraw parameter in unwindPosition is dead code: it is accepted, validated, and stored, but _executeUnwindOperation ignores it entirely and recalculates its own value. Any value the user passes — including the output of calculateUnwindParams — has no effect on execution.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
contract UnwindMismatchPoCTest is Test {
uint256 constant DEBT_AMOUNT = 1000e6;
uint256 constant USDC_PRICE = 1e8;
uint256 constant WETH_PRICE = 3000e8;
uint256 constant AAVE_LIQ_THRESHOLD = 8000;
uint256 constant LTV_PRECISION = 1e4;
/// @notice Proves the two formulas yield different amounts (~19% gap).
function test_formulaMismatch() public pure {
// calculateUnwindParams formula
uint256 base = (USDC_PRICE * DEBT_AMOUNT * 1e18) / (WETH_PRICE * 1e6);
uint256 viewEstimate = (base * 1050) / 1000; // +5% slippage
// _executeUnwindOperation formula
uint256 internalActual = (DEBT_AMOUNT * USDC_PRICE * 1e18 * LTV_PRECISION)
/ (WETH_PRICE * 1e6 * AAVE_LIQ_THRESHOLD);
uint256 discrepancyBps = ((internalActual - viewEstimate) * 10000) / viewEstimate;
assertGt(internalActual, viewEstimate, "execution withdraws more than view predicts");
assertGt(discrepancyBps, 1800, "discrepancy is at least 18%");
// User's calldata covers viewEstimate WETH; contract withdraws internalActual WETH.
// Extra WETH that cannot be swapped by the prepared calldata:
uint256 unswappedWeth = internalActual - viewEstimate;
uint256 stuckUsd = (unswappedWeth * 3000) / 1e18;
assertGt(stuckUsd, 190, "at least $190 stranded per $1000 USDC unwind");
}
}

Recommended Mitigation

- // Account for 5% slippage in swap
- collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
+ // Match the formula used in _executeUnwindOperation
+ (,, uint256 liqThreshold,,,,,,,) =
+ aaveDataProvider.getReserveConfigurationData(_collateralToken);
+ collateralToWithdraw = (debtAmount * debtTokenPrice
+ * 10 ** IERC20(_collateralToken).decimals() * LTV_PRECISION)
+ / (collateralTokenPrice * 10 ** IERC20(_borrowToken).decimals() * liqThreshold);

Both functions must use the same formula so that the calldata prepared from the view function matches what the execution function will actually withdraw. Alternatively, make _executeUnwindOperation read unwindParams.collateralToWithdraw directly instead of recalculating.

Support

FAQs

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

Give us feedback!