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");
@>
@> 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));
}
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());
@>
@> 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:
Call calculateUnwindParams to get collateralToWithdraw
Use that value to build the 1inch swap data (specifying it as the swap input amount)
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
A user opens a leveraged position with USDC collateral
The user calls calculateUnwindParams which returns collateralToWithdraw at ~1.05× the raw price conversion
The user builds 1inch swap data for this collateralToWithdraw amount
The user calls unwindPosition
Inside _executeUnwindOperation, the local collateralToWithdraw is calculated at ~1.163× the raw price conversion (using liqThreshold = 8600 for USDC)
Aave withdraws the larger amount (~1.163×)
The 1inch swap only consumes the smaller amount (~1.05×)
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);
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");
(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();
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));
}