Stratax Contracts

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

M03. `_collateralToWithdraw` Parameter Discarded

Author Revealed upon completion

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.

// src/Stratax.sol:236-256 — unwindPosition: parameter accepted and stored
function unwindPosition(
address _collateralToken,
uint256 _collateralToWithdraw, // @> accepted by the interface
address _debtToken,
uint256 _debtAmount,
bytes calldata _oneInchSwapData,
uint256 _minReturnAmount
) external onlyOwner {
UnwindParams memory params = UnwindParams({
collateralToken: _collateralToken,
collateralToWithdraw: _collateralToWithdraw, // @> stored in the struct
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);
}
// src/Stratax.sol:552-579 — _executeUnwindOperation: parameter decoded but discarded
function _executeUnwindOperation(..., bytes calldata _params) internal returns (bool) {
(, address user, UnwindParams memory unwindParams) =
abi.decode(_params, (OperationType, address, UnwindParams));
// unwindParams.collateralToWithdraw is available here but never referenced below
// @> Contract ignores the caller's value and re-derives its own using a different formula
(,, 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:

// src/Stratax.sol:464-468 — calculateUnwindParams (off-chain view)
collateralToWithdraw = (debtTokenPrice * debtAmount * 10 ** IERC20(_collateralToken).decimals())
/ (collateralTokenPrice * 10 ** IERC20(_borrowToken).decimals());
// @> flat 5% slippage buffer
collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
// src/Stratax.sol:575-577 — _executeUnwindOperation (on-chain)
// @> LTV_PRECISION / liqThreshold = 10000 / 8250 ≈ 1.2121 for WETH — not ×1.05
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:

// SPDX-License-Identifier: UNLICENSED
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 {
// Trace the value through the call path:
//
// 1. unwindPosition(_collateralToWithdraw = X)
// → UnwindParams.collateralToWithdraw = X [stored]
// → abi.encode(..., UnwindParams{collateralToWithdraw: X}) [encoded]
// → aavePool.flashLoanSimple(...) [sent to Aave]
//
// 2. executeOperation → _executeUnwindOperation(_params)
// → abi.decode(...) → unwindParams.collateralToWithdraw = X [decoded]
//
// 3. Inside _executeUnwindOperation:
// uint256 collateralToWithdraw = (... oracle derivation ...)
// // @> X is never read. The local variable shadows/replaces it.
// aavePool.withdraw(..., collateralToWithdraw, ...)
//
// Verification: grep "unwindParams.collateralToWithdraw" src/Stratax.sol
// Returns exactly ONE match: the struct literal assignment in unwindPosition.
// No match exists inside _executeUnwindOperation.
//
// Consequence: calling unwindPosition with _collateralToWithdraw = 1
// produces the identical Aave withdrawal as _collateralToWithdraw = 1e30.
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%)
// SPDX-License-Identifier: UNLICENSED
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; // WETH on Aave V3 Ethereum
// Shared inputs: 1000 USDC debt, WETH = $3000
uint256 debtAmount = 1000e6; // 1000 USDC (6 dec)
uint256 debtPrice = 1e8; // $1 (8 dec)
uint256 collPrice = 3000e8; // $3000 (8 dec)
uint256 collDecimals = 1e18; // WETH (18 dec)
uint256 debtDecimals = 1e6; // USDC (6 dec)
// --- calculateUnwindParams formula (what the caller passes) ---
uint256 rawRatio = (debtPrice * debtAmount * collDecimals)
/ (collPrice * debtDecimals);
uint256 offChain = (rawRatio * 1050) / 1000; // ×1.05
// --- _executeUnwindOperation formula (what on-chain actually uses) ---
uint256 onChain = (debtAmount * debtPrice * collDecimals * LTV_PRECISION)
/ (collPrice * debtDecimals * liqThreshold); // ×10000/8250
console.log("Off-chain (x1.05): ", offChain); // 0.350 WETH
console.log("On-chain (x1.2121, liqThr): ", onChain); // 0.404 WETH
uint256 excessWei = onChain - offChain;
uint256 bpsOver = (excessWei * 10000) / offChain;
console.log("Excess withdrawn (wei): ", excessWei);
console.log("Excess withdrawn (bps): ", bpsOver); // ~1540 bps = 15.4%
// @> The 1inch swap calldata is built for offChain (0.350 WETH);
// on-chain, 0.404 WETH is withdrawn. The 0.054 WETH excess is not
// included in the swap → reduces returnAmount → may revert flash loan repayment.
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:

// src/Stratax.sol — _executeUnwindOperation, Step 2
- // Re-derive collateral amount on-chain (ignores caller-supplied value)
- (,, 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));
+ // Honour the caller-supplied value (pre-computed by calculateUnwindParams off-chain)
+ // This makes the withdrawal amount predictable and consistent with the 1inch swap input
+ require(unwindParams.collateralToWithdraw > 0, "Collateral to withdraw must be non-zero");
+ withdrawnAmount = aavePool.withdraw(
+ unwindParams.collateralToken,
+ unwindParams.collateralToWithdraw,
+ address(this)
+ );
+
+ // Validate remaining position health if any debt is still outstanding
+ (,,,,, 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.

Support

FAQs

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

Give us feedback!