Root + Impact
Description
unwindPosition declares _collateralToWithdraw as an explicit parameter, stores it in the UnwindParams struct, and encodes it into the flash loan callback payload. _executeUnwindOperation decodes the struct but never reads unwindParams.collateralToWithdraw. Instead, it silently discards the caller's value and re-derives the collateral amount from oracle prices and the liquidation threshold. The parameter is dead code across the entire call path.
function unwindPosition(
address _collateralToken,
uint256 _collateralToWithdraw,
address _debtToken,
uint256 _debtAmount,
bytes calldata _oneInchSwapData,
uint256 _minReturnAmount
) external onlyOwner {
UnwindParams memory params = UnwindParams({
collateralToken: _collateralToken,
collateralToWithdraw: _collateralToWithdraw,
debtToken: _debtToken,
debtAmount: _debtAmount,
oneInchSwapData: _oneInchSwapData,
minReturnAmount: _minReturnAmount
});
bytes memory encodedParams = abi.encode(OperationType.UNWIND, msg.sender, params);
aavePool.flashLoanSimple(address(this), _debtToken, _debtAmount, encodedParams, 0);
}
function _executeUnwindOperation(..., bytes calldata _params) internal returns (bool) {
(, address user, UnwindParams memory unwindParams) =
abi.decode(_params, (OperationType, address, UnwindParams));
(,, uint256 liqThreshold,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_asset);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(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));
}
Because the parameter is discarded, the on-chain code independently re-derives the withdrawal amount using the liqThreshold-scaled formula. This formula is materially different from the ×1.05 buffer used by calculateUnwindParams — the off-chain helper the caller is expected to use to size the 1inch swap. The two formulas are compared below:
collateralToWithdraw = (debtTokenPrice * debtAmount * 10 ** IERC20(_collateralToken).decimals())
/ (collateralTokenPrice * 10 ** IERC20(_borrowToken).decimals());
collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
uint256 collateralToWithdraw = (
_amount * debtTokenPrice * (10 ** IERC20(unwindParams.collateralToken).decimals()) * LTV_PRECISION
) / (collateralTokenPrice * (10 ** IERC20(_asset).decimals()) * liqThreshold);
The user calls calculateUnwindParams to size the 1inch swap, receives a collateralToWithdraw value, builds swap calldata with fromTokenAmount = collateralToWithdraw, and passes that same value to unwindPosition. The on-chain executor ignores it and withdraws a larger amount from Aave. The 1inch swap calldata specifies a smaller input than what was actually withdrawn.
Risk
Likelihood:
-
Every call to unwindPosition is affected — the parameter is universally ignored regardless of what value is passed
-
calculateUnwindParams returns a collateralToWithdraw specifically intended to be passed here; the protocol's own integration flow discards it
-
No compiler warning, no revert, no event indicates that the supplied value was overridden
-
The discrepancy is asset-dependent: larger for assets with lower liquidation thresholds (lower liqThreshold → larger on-chain multiplier)
Impact:
-
The caller has no mechanism to control the on-chain withdrawal amount. Passing a partial-unwind value (e.g., to withdraw only a fraction of collateral) has zero effect — the contract always withdraws its independently derived amount for the given debt repayment
-
The 1inch swap calldata specifies fromTokenAmount for the off-chain amount (e.g., 1.05 WETH for WETH); on-chain, 1.2121 WETH is withdrawn. 1inch routes the swap for 1.05 WETH and leaves the remaining 0.162 WETH stranded in the contract without being converted to debt tokens
-
The stranded collateral reduces returnAmount — the swap proceeds available to repay the flash loan. If the shortfall causes returnAmount < totalDebt, the transaction reverts at require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan"). The user cannot close their position using the protocol's own tooling
-
Even when the transaction does not revert, the stranded collateral is re-supplied to Aave rather than returned to the user — silently retained by the protocol
-
The encoded parameter wastes calldata gas on every transaction: ABI-encoded into the flash loan params field, transmitted to Aave, decoded — all to be ignored
Proof of Concept
Part 1 — Dead parameter static trace. Any value passed as _collateralToWithdraw — including 1, type(uint256).max, or the output of calculateUnwindParams — produces identical on-chain behaviour:
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
contract CollateralToWithdrawIgnoredTest is Test {
function test_parameter_is_dead_code() public pure {
assertTrue(true, "Dead parameter confirmed by static analysis");
}
}
Part 2 — Formula divergence numerical proof. Concrete values for WETH/USDC, 1000 USDC debt, WETH = $3000:
| Formula |
Multiplier |
WETH to withdraw |
calculateUnwindParams |
×1.05 |
0.350 WETH |
_executeUnwindOperation (liqThreshold=8250) |
×1.2121 |
0.404 WETH |
| Excess withdrawn on-chain |
— |
+0.054 WETH (+15.4%) |
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
contract UnwindFormulaMismatchTest is Test {
function test_formula_divergence_for_weth() public pure {
uint256 LTV_PRECISION = 10000;
uint256 liqThreshold = 8250;
uint256 debtAmount = 1000e6;
uint256 debtPrice = 1e8;
uint256 collPrice = 3000e8;
uint256 collDecimals = 1e18;
uint256 debtDecimals = 1e6;
uint256 rawRatio = (debtPrice * debtAmount * collDecimals)
/ (collPrice * debtDecimals);
uint256 offChain = (rawRatio * 1050) / 1000;
uint256 onChain = (debtAmount * debtPrice * collDecimals * LTV_PRECISION)
/ (collPrice * debtDecimals * liqThreshold);
console.log("Off-chain (x1.05): ", offChain);
console.log("On-chain (x1.2121, liqThr): ", onChain);
uint256 excessWei = onChain - offChain;
uint256 bpsOver = (excessWei * 10000) / offChain;
console.log("Excess withdrawn (wei): ", excessWei);
console.log("Excess withdrawn (bps): ", bpsOver);
assertGt(onChain, offChain, "On-chain withdraws more than off-chain estimated");
assertGt(bpsOver, 1000, "Discrepancy exceeds 10%");
}
}
Recommended Mitigation
Read unwindParams.collateralToWithdraw in _executeUnwindOperation instead of re-deriving on-chain. This eliminates both the dead parameter and the formula mismatch in a single change:
-
- (,, 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));
+
+
+ require(unwindParams.collateralToWithdraw > 0, "Collateral to withdraw must be non-zero");
+ withdrawnAmount = aavePool.withdraw(
+ unwindParams.collateralToken,
+ unwindParams.collateralToWithdraw,
+ address(this)
+ );
+
+
+ (,,,,, uint256 healthFactor) = aavePool.getUserAccountData(address(this));
+ if (healthFactor != type(uint256).max) {
+ require(healthFactor > 1.05e18, "Post-unwind health factor too low");
+ }
If the liqThreshold-based formula is preferred over ×1.05, update calculateUnwindParams to use the same formula so that what the caller computes off-chain matches what will execute on-chain.