Description
-
When unwinding, the contract should withdraw just enough collateral to cover the repaid debt (plus fees/slippage), keeping the position’s risk consistent. The correct proportionality uses LTV (loan‑to‑value), because LTV defines the borrowable value against collateral during regular operation.
-
_executeUnwindOperation uses liquidationThreshold (LT) instead of ltv in the collateral‑to‑withdraw calculation. Since liquidationThreshold is typically greater than ltv on Aave, dividing by a larger denominator yields a smaller withdrawal than required. That can under‑withdraw collateral, starving the subsequent swap and causing returnAmount < totalDebt → unwind reverts; or it leaves unnecessary collateral locked, deviating from the caller’s expectation.
(,, uint256 liqThreshold,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
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));
Risk
Likelihood: High
-
Aave markets frequently set liquidationThreshold > ltv. Under those common configurations, the current formula will compute a smaller withdraw amount than the LTV‑based proportion.
-
Any unwind that relies on this amount to fully repay the flash loan after the swap will hit the shortfall under realistic prices/slippage and may revert with Insufficient funds to repay flash loan.
Impact: High
-
Operational failure: Unwind transactions can revert, trapping positions and increasing operational risk during volatile markets.
-
Capital inefficiency: Even when the swap still covers the flash loan, the contract withdraws less collateral than intended, leaving excess collateral locked in Aave and breaking user expectations for “proportional” unwind.
Proof of Concept
function test_UnwindMathUsesLiquidationThresholdInsteadOfLTV() public view {
address dataProvider = address(stratax.aaveDataProvider());
bytes memory payload = abi.encodeWithSignature(
"getReserveConfigurationData(address)",
USDC
);
(bool ok, bytes memory out) = dataProvider.staticcall(payload);
require(ok, "aave data provider call failed");
( ,
uint256 ltv,
uint256 liqThreshold,
,
,
,
,
,
,
) = abi.decode(out, (uint256, uint256, uint256, uint256, uint256, bool, bool, bool, bool, bool));
assertTrue(liqThreshold >= ltv, "Unexpected config: LT should be >= LTV");
uint256 debtPrice = strataxOracle.getPrice(WETH);
uint256 collPrice = strataxOracle.getPrice(USDC);
uint8 collDec = IERC20(USDC).decimals();
uint8 debtDec = IERC20(WETH).decimals();
uint256 amount = 10 ** uint256(debtDec);
uint256 withLT = (amount * debtPrice * (10 ** uint256(collDec)) * stratax.LTV_PRECISION())
/ (collPrice * (10 ** uint256(debtDec)) * liqThreshold);
uint256 withLTV = (amount * debtPrice * (10 ** uint256(collDec)) * stratax.LTV_PRECISION())
/ (collPrice * (10 ** uint256(debtDec)) * ltv);
assertLt(withLT, withLTV, "Using LT under-withdraws collateral vs correct LTV math");
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/Stratax.t.sol:StrataxForkTest
[PASS] test_UnwindMathUsesLiquidationThresholdInsteadOfLTV() (gas: 117070)
Logs:
Available swap data files: 3
Randomly selected block: 24329390
Current fork block number is: 24329390
Traces:
[117070] StrataxForkTest::test_UnwindMathUsesLiquidationThresholdInsteadOfLTV()
├─ [11067] BeaconProxy::fallback() [staticcall]
│ ├─ [2515] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← [Return] Stratax: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ ├─ [2727] Stratax::aaveDataProvider() [delegatecall]
│ │ └─ ← [Return] 0x0a16f2FCC0D44FaE41cc54e079281D84A363bECD
│ └─ ← [Return] 0x0a16f2FCC0D44FaE41cc54e079281D84A363bECD
├─ [11703] 0x0a16f2FCC0D44FaE41cc54e079281D84A363bECD::getReserveConfigurationData(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ ├─ [7732] 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ ├─ [2680] 0x8147b99DF7672A21809c9093E6F6CE1a60F119Bd::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [delegatecall]
│ │ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ └─ ← [Return] 6, 7500, 7800, 10450 [1.045e4], 1000, true, true, false, true, false
├─ [0] VM::assertTrue(true, "Unexpected config: LT should be >= LTV") [staticcall]
│ └─ ← [Return]
├─ [22293] StrataxOracle::getPrice(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2) [staticcall]
│ ├─ [15642] 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419::latestRoundData() [staticcall]
│ │ ├─ [7409] 0x7d4E742018fb52E48b08BE73d041C18B21de6Fb5::latestRoundData() [staticcall]
│ │ │ └─ ← [Return] 23929 [2.392e4], 300469062333 [3.004e11], 1769552573 [1.769e9], 1769552591 [1.769e9], 23929 [2.392e4]
│ │ └─ ← [Return] 129127208515966885241 [1.291e20], 300469062333 [3.004e11], 1769552573 [1.769e9], 1769552591 [1.769e9], 129127208515966885241 [1.291e20]
│ └─ ← [Return] 300469062333 [3.004e11]
├─ [22293] StrataxOracle::getPrice(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ ├─ [15642] 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6::latestRoundData() [staticcall]
│ │ ├─ [7409] 0xc9E1a09622afdB659913fefE800fEaE5DBbFe9d7::latestRoundData() [staticcall]
│ │ │ └─ ← [Return] 732, 99968715 [9.996e7], 1769500821 [1.769e9], 1769500835 [1.769e9], 732
│ │ └─ ← [Return] 55340232221128655580 [5.534e19], 99968715 [9.996e7], 1769500821 [1.769e9], 1769500835 [1.769e9], 55340232221128655580 [5.534e19]
│ └─ ← [Return] 99968715 [9.996e7]
├─ [9664] 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48::decimals() [staticcall]
│ ├─ [2381] 0x43506849D7C04F9138D1A2050bbF3A0c054402dd::decimals() [delegatecall]
│ │ └─ ← [Return] 6
│ └─ ← [Return] 6
├─ [2444] 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2::decimals() [staticcall]
│ └─ ← [Return] 18
├─ [1754] BeaconProxy::fallback() [staticcall]
│ ├─ [515] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← [Return] Stratax: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ ├─ [414] Stratax::LTV_PRECISION() [delegatecall]
│ │ └─ ← [Return] 10000 [1e4]
│ └─ ← [Return] 10000 [1e4]
├─ [1754] BeaconProxy::fallback() [staticcall]
│ ├─ [515] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← [Return] Stratax: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ ├─ [414] Stratax::LTV_PRECISION() [delegatecall]
│ │ └─ ← [Return] 10000 [1e4]
│ └─ ← [Return] 10000 [1e4]
├─ [0] VM::assertLt(3853372993 [3.853e9], 4007507913 [4.007e9], "Using LT under-withdraws collateral vs correct LTV math") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 749.54ms (4.92ms CPU time)
Ran 1 test suite in 757.40ms (749.54ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
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));
// Repay Aave variable debt with flash-loaned tokens
IERC20(_asset).approve(address(aavePool), _amount);
aavePool.repay(_asset, _amount, 2, address(this));
uint256 withdrawnAmount;
{
- // Get LTV config (but mistakenly used liquidationThreshold before)
- (,, uint256 liqThreshold,,,,,,,) =
- aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
+ // Get LTV for the collateral asset (use borrow-time constraint, not liquidation)
+ (, uint256 ltv,,,,,,,,) =
+ aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
// Prices and decimals
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(unwindParams.collateralToken);
require(debtTokenPrice > 0 && collateralTokenPrice > 0, "Invalid prices");
- // OLD COMMENT: "Calculate collateral to withdraw ... / ... * liqThreshold"
- // FIX: Use LTV so we withdraw the collateral proportion that backed the repaid debt.
- uint256 collateralToWithdraw = (
- _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
- ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
+ // Calculate collateral to withdraw proportional to repaid debt at LTV (4-decimals precision)
+ uint256 collateralToWithdraw = (
+ _amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
+ ) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * ltv);
withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));
}
// ... rest of function unchanged ...
}