Stratax Contracts

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

Unwind Uses Wrong Risk Parameter and Ignores User-Supplied Withdrawal Amount

Author Revealed upon completion

Summary

Stratax.unwindPosition() accepts _collateralToWithdraw, but the flash-loan callback ignores it and recomputes withdrawal amount with liquidationThreshold instead of LTV. This creates deterministic under-withdrawal during unwind and breaks caller intent.

Affected Components

  • src/Stratax.sol:236 (unwindPosition input includes _collateralToWithdraw)

  • src/Stratax.sol:244 (UnwindParams.collateralToWithdraw encoded)

  • src/Stratax.sol:552 (_executeUnwindOperation)

  • src/Stratax.sol:566 (reads liqThreshold)

  • src/Stratax.sol:575 (withdraw formula uses liqThreshold)

  • src/Stratax.sol:579 (withdraw executes with recomputed value, not user value)

Root Cause

Two logic issues combine:

  1. User input is ignored:

    • _collateralToWithdraw is provided by caller and encoded into UnwindParams, but never used in callback execution.

  2. Wrong risk variable used:

    • callback uses liquidationThreshold in denominator for unwind sizing, not LTV-based sizing/user-provided sizing.

As a result, unwind behavior diverges from caller intent and from expected debt-to-collateral conversion semantics.

Impact

  • Forced partial unwind behavior even when caller requests specific collateral withdrawal.

  • Deterministic withdrawal shortfall relative to LTV-based sizing.

  • Residual collateral remains in Aave position after debt is fully repaid, requiring extra non-obvious manual recovery flow.

This is a protocol logic failure in core position management and can cause significant user-facing loss of expected unwind proceeds.

End-to-End PoC (Real Build System)

Validated in real Codespace environment:

  • Codespace: strix-ssh-20260216-134645-5g67qw6p66v43r5j

  • Repo path: /workspaces/strix/2026-02-stratax-contracts

  • Branch/commit: main / f6525334ddeb7910733432a992daecb0a8041430

  • Forge: 1.5.1-stable

  • Fork block: 24329390

PoC Test File

  • test/PoC_UnwindWrongRisk_E2E.t.sol

Reproduction Command

cd /workspaces/strix/2026-02-stratax-contracts
ETH_RPC_URL="https://eth-mainnet.g.alchemy.com/v2/aqfRaSLIvV1wbEvT3QDXf" \
/home/codespace/.foundry/bin/forge test \
--match-contract PoC_UnwindWrongRisk_E2E \
--match-test test_E2E_Unwind_IgnoresUserAmount_UsesLiqThresholdMath -vv

Observed Output (Key Evidence)

  • LTV: 7500

  • Liq threshold: 7800

  • Debt before unwind: 711165158412156265

  • User supplied _collateralToWithdraw: 1

  • Actual USDC withdrawn from Aave: 2740384615

  • Expected (liqThreshold formula): 2740384615

  • Expected (LTV formula): 2849999999

  • Remaining aUSDC after unwind: 397537642

  • Remaining variable debt after unwind: 0

Why This Confirms the Bug

  1. Caller passed _collateralToWithdraw = 1, but actual withdrawal was 2,740,384,615.

    • Confirms caller-provided value is ignored.

  2. Actual withdrawal exactly matches liqThreshold formula and is lower than LTV-based sizing.

    • Confirms wrong risk parameter is used.

Proof-of-Concept Test Code

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
import {Stratax} from "../src/Stratax.sol";
import {StrataxOracle} from "../src/StrataxOracle.sol";
import {IProtocolDataProvider} from "../src/interfaces/external/IProtocolDataProvider.sol";
import {ConstantsEtMainnet} from "./Constants.t.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
contract PoC_UnwindWrongRisk_E2E is Test, ConstantsEtMainnet {
Stratax public stratax;
StrataxOracle public strataxOracle;
address public ownerTrader;
uint256 internal constant FIXED_BLOCK = 24329390;
uint256 internal constant FIXED_BORROW_AMOUNT = 711165158412156264;
uint256 internal constant FIXED_UNWIND_SWAP_AMOUNT = 2244374998;
function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"), FIXED_BLOCK);
ownerTrader = address(0x123);
strataxOracle = new StrataxOracle();
strataxOracle.setPriceFeed(USDC, USDC_PRICE_FEED);
strataxOracle.setPriceFeed(WETH, WETH_PRICE_FEED);
Stratax strataxImplementation = new Stratax();
UpgradeableBeacon beacon = new UpgradeableBeacon(address(strataxImplementation), address(this));
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
INCH_ROUTER,
USDC,
address(strataxOracle)
);
BeaconProxy proxy = new BeaconProxy(address(beacon), initData);
stratax = Stratax(address(proxy));
stratax.transferOwnership(ownerTrader);
}
function test_E2E_Unwind_IgnoresUserAmount_UsesLiqThresholdMath() public {
uint256 collateralAmount = 1000 * 10 ** 6;
(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
})
);
assertEq(borrowAmount, FIXED_BORROW_AMOUNT, "Fixture borrow amount mismatch");
bytes memory openSwapData = _getSavedSwapData(WETH, USDC, borrowAmount);
deal(USDC, ownerTrader, collateralAmount);
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(stratax), collateralAmount);
stratax.createLeveragedPosition(
USDC,
flashLoanAmount,
collateralAmount,
WETH,
borrowAmount,
openSwapData,
(flashLoanAmount * 950) / 1000
);
IProtocolDataProvider dataProvider = IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER);
(address aUsdc,,) = dataProvider.getReserveTokensAddresses(USDC);
(,, address variableDebtWeth) = dataProvider.getReserveTokensAddresses(WETH);
uint256 aUsdcBefore = IERC20(aUsdc).balanceOf(address(stratax));
uint256 debtBefore = IERC20(variableDebtWeth).balanceOf(address(stratax));
(uint256 unwindCollateralHint, uint256 unwindDebtAmount) = stratax.calculateUnwindParams(USDC, WETH);
assertEq(unwindCollateralHint, FIXED_UNWIND_SWAP_AMOUNT, "Fixture unwind amount mismatch");
assertEq(unwindDebtAmount, debtBefore, "Debt mismatch before unwind");
bytes memory unwindSwapData = _getSavedSwapData(USDC, WETH, unwindCollateralHint);
uint256 userSuppliedCollateralToWithdraw = 1;
stratax.unwindPosition(
USDC,
userSuppliedCollateralToWithdraw,
WETH,
debtBefore,
unwindSwapData,
(debtBefore * 950) / 1000
);
vm.stopPrank();
uint256 aUsdcAfter = IERC20(aUsdc).balanceOf(address(stratax));
uint256 debtAfter = IERC20(variableDebtWeth).balanceOf(address(stratax));
uint256 actualWithdrawn = aUsdcBefore - aUsdcAfter;
(, uint256 ltv, uint256 liqThreshold,,,,,,,) = dataProvider.getReserveConfigurationData(USDC);
uint256 debtTokenPrice = strataxOracle.getPrice(WETH);
uint256 collateralTokenPrice = strataxOracle.getPrice(USDC);
uint256 numerator = debtBefore * debtTokenPrice * (10 ** IERC20(USDC).decimals()) * stratax.LTV_PRECISION();
uint256 denominatorBase = collateralTokenPrice * (10 ** IERC20(WETH).decimals());
uint256 expectedWithdrawUsingLiqThreshold = numerator / (denominatorBase * liqThreshold);
uint256 expectedWithdrawUsingLTV = numerator / (denominatorBase * ltv);
assertGt(actualWithdrawn, userSuppliedCollateralToWithdraw, "Caller amount was unexpectedly respected");
assertApproxEqAbs(actualWithdrawn, expectedWithdrawUsingLiqThreshold, 2);
assertLt(actualWithdrawn, expectedWithdrawUsingLTV);
assertGt(aUsdcAfter, 0);
assertEq(debtAfter, 0);
}
function _getSavedSwapData(address fromToken, address toToken, uint256 amount) internal view returns (bytes memory) {
string memory root = vm.projectRoot();
string memory path = string.concat(root, "/test/fixtures/swap_data_block_", vm.toString(FIXED_BLOCK), ".json");
string memory json = vm.readFile(path);
string memory fromSymbol = fromToken == WETH ? "WETH" : "USDC";
string memory toSymbol = toToken == WETH ? "WETH" : "USDC";
string memory key = string.concat(".swaps.", fromSymbol, "_to_", toSymbol, "_", vm.toString(amount), ".swapData");
bytes memory swapDataBytes = vm.parseJson(json, key);
return abi.decode(swapDataBytes, (bytes));
}
}

Recommended Fix

Use caller-provided unwind amount (or a consistently validated computed amount) and remove liqThreshold-based recomputation in callback.

Minimal Patch Direction

  • In _executeUnwindOperation(...):

    • Replace recomputation block with usage of unwindParams.collateralToWithdraw.

    • Validate non-zero and bounded amount.

Example:

// before swap
uint256 collateralToWithdraw = unwindParams.collateralToWithdraw;
require(collateralToWithdraw > 0, "Invalid collateralToWithdraw");
uint256 withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));

Also add invariant tests:

  • callback withdrawal must equal user-provided amount (modulo pool rounding), not recomputed from unrelated risk parameters.

  • unwind math path must be internally consistent between calculateUnwindParams and execution.

Support

FAQs

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

Give us feedback!