Stratax Contracts

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

Discrepancy between view and execution logic for unwinding

Author Revealed upon completion

Description

  • A public view helper should predict, as closely as possible, what the execution path will do. When a UI/operator calls calculateUnwindParams, the returned collateralToWithdraw should match the amount that will actually be withdrawn inside the flash‑loan callback. This keeps off‑chain swap calldata (amounts, slippage) and on‑chain execution aligned.

  • calculateUnwindParams(collateralToken, borrowToken) computes collateralToWithdraw by value equality using oracle prices and then adds a fixed +5% buffer for slippage. In contrast, _executeUnwindOperation ignores this helper result and recomputes the withdrawal using a different formula that divides by liquidationThreshold (LT) rather than using the LTV‑style value equivalence. As a result, the actual withdrawn collateral during unwind systematically differs (typically less) than what calculateUnwindParams returns. This misleads users and UIs that craft 1inch calldata based on the view function’s output, increasing the chance of swap mis‑sizing or unnecessary reverts.

/// view helper (value parity + 5%):
function calculateUnwindParams(address _collateralToken, address _borrowToken)
public view returns (uint256 collateralToWithdraw, uint256 debtAmount)
{
(,, address debtToken) = aaveDataProvider.getReserveTokensAddresses(_borrowToken);
debtAmount = IERC20(debtToken).balanceOf(address(this));
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_borrowToken);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(_collateralToken);
// value equality, scaled by decimals
collateralToWithdraw =
(debtTokenPrice * debtAmount * 10**IERC20(_collateralToken).decimals())
/ (collateralTokenPrice * 10**IERC20(_borrowToken).decimals());
// +5% buffer
collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
}
// execution (flash‑loan callback): recomputes using liquidationThreshold (LT)
(,, uint256 liqThreshold,,,,,,,) =
aaveDataProvider.getReserveConfigurationData(unwindParams.collateralToken);
// collateralToWithdraw = (_amount * debtPrice * 10^collDec * LTV_PRECISION)
// / (collateralPrice * 10^debtDec * liqThreshold);
withdrawnAmount = aavePool.withdraw(unwindParams.collateralToken, collateralToWithdraw, address(this));

Risk

Likelihood: Medium

  • Aave markets commonly have liquidationThreshold > LTV; combined with the helper’s +5% buffer, the view output is routinely greater than the execution withdrawal.

  • Integrators and UIs will naturally use calculateUnwindParams to size the 1inch swap and user expectations, so this drift will be encountered during normal unwinds.

Impact: Medium

  • Calldata mismatch / failed unwinds: The 1inch calldata built for the view amount does not match what the execution actually withdraws, leading to under‑sized or over‑sized swaps, reverts, or suboptimal outcomes.

  • Poor UX / trust erosion: Users are told (by the app) that “X collateral will be withdrawn,” but the chain actually withdraws a different amount, making outcomes hard to predict and audit.

Proof of Concept

/// @dev Mock 1inch-like router: pulls all approved `inputToken` and pays back `outputAmount` of `outputToken`.
/// Calldata ABI: abi.encode(inputToken, outputToken, outputAmount)
contract MockOneInchRouter_UnwindDrift {
function _pullAllApproved(address token, address from) internal returns (uint256 spent) {
uint256 allowance = IERC20(token).allowance(from, address(this));
uint256 bal = IERC20(token).balanceOf(from);
spent = allowance < bal ? allowance : bal;
if (spent > 0) require(IERC20(token).transferFrom(from, address(this), spent), "pull fail");
}
fallback() external payable {
(address inputToken, address outputToken, uint256 outputAmount) =
abi.decode(msg.data, (address, address, uint256));
uint256 spent = _pullAllApproved(inputToken, msg.sender);
require(IERC20(outputToken).transfer(msg.sender, outputAmount), "pay fail");
bytes memory out = abi.encode(outputAmount, spent);
assembly { return(add(out, 0x20), mload(out)) }
}
}
function test_Unwind_ViewVsExecution_Discrepancy() public {
// --- Deploy fresh Stratax wired to the mock router ---
MockOneInchRouter_UnwindDrift mock = new MockOneInchRouter_UnwindDrift();
Stratax impl = new Stratax();
UpgradeableBeacon b = new UpgradeableBeacon(address(impl), address(this));
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
address(mock), // use mock 1inch
USDC,
address(strataxOracle)
);
BeaconProxy p = new BeaconProxy(address(b), initData);
Stratax s = Stratax(address(p));
s.transferOwnership(ownerTrader);
// --- Open a position (USDC collateral, WETH debt) ---
uint256 collateralAmount = 1_000 * 1e6; // 1,000 USDC
(uint256 flAmount, uint256 borrowAmount) = s.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: USDC,
borrowToken: WETH,
desiredLeverage: 30_000, // 3x
collateralAmount: collateralAmount,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
// Fund owner and router for OPEN swap repayment
deal(USDC, ownerTrader, collateralAmount);
uint256 premiumOpen = (flAmount * s.flashLoanFeeBps()) / s.FLASHLOAN_FEE_PREC();
uint256 minReturnOpen = flAmount + premiumOpen;
deal(USDC, address(mock), minReturnOpen);
bytes memory openSwapData = abi.encode(WETH, USDC, minReturnOpen);
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(s), collateralAmount);
s.createLeveragedPosition(
USDC, flAmount, collateralAmount, WETH, borrowAmount, openSwapData, minReturnOpen
);
vm.stopPrank();
// --- Ask the VIEW helper for unwind params ---
(uint256 viewCollateralToWithdraw, uint256 debtAmount) = s.calculateUnwindParams(USDC, WETH);
// --- Compute what EXECUTION will use (liquidationThreshold-based formula) ---
// Read LT and prices/decimals to mirror _executeUnwindOperation
(,, uint256 liqThreshold,,,,,,,) =
IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER).getReserveConfigurationData(USDC);
uint256 debtPrice = strataxOracle.getPrice(WETH); // 8 decimals
uint256 collPrice = strataxOracle.getPrice(USDC); // 8 decimals
uint8 collDec = IERC20(USDC).decimals();
uint8 debtDec = IERC20(WETH).decimals();
uint256 execCollateralToWithdraw = (
debtAmount * debtPrice * (10 ** collDec) * s.LTV_PRECISION()
) / (collPrice * (10 ** debtDec) * liqThreshold);
// --- Prepare UNWIND (ensure swap repays flash-loan + premium) ---
uint256 premiumUnwind = (debtAmount * s.flashLoanFeeBps()) / s.FLASHLOAN_FEE_PREC();
uint256 payback = debtAmount + premiumUnwind + 1e15; // tiny surplus
deal(WETH, address(mock), payback);
bytes memory unwindSwapData = abi.encode(USDC, WETH, payback);
// Snapshot aToken(USDC) balance to measure actual withdrawn amount
(address aUSDC,,) = IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER).getReserveTokensAddresses(USDC);
uint256 aBalBefore = IERC20(aUSDC).balanceOf(address(s));
vm.startPrank(ownerTrader);
s.unwindPosition(USDC, viewCollateralToWithdraw, WETH, debtAmount, unwindSwapData, 0);
vm.stopPrank();
uint256 aBalAfter = IERC20(aUSDC).balanceOf(address(s));
uint256 withdrawnActual = aBalBefore - aBalAfter;
// --- Assertions ---
// 1) Execution withdraw is close to its own internal (LT-based) computation
uint256 tol = 10_000; // ~0.01 USDC tolerance for rounding
assertApproxEqAbs(withdrawnActual, execCollateralToWithdraw, tol, "execution matches LT-based math");
// 2) Execution withdraw is meaningfully smaller than VIEW helper (+5% buffer)
assertTrue(withdrawnActual + tol < viewCollateralToWithdraw, "view overestimates vs execution");
}

Recommended Mitigation

  • Compute collateralToWithdraw off‑chain or via calculateUnwindParams, pass it in UnwindParams, and use it verbatim in _executeUnwindOperation (subject to a reasonable bounds check and available balance). This keeps the swap calldata and on‑chain withdraw perfectly aligned.

function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal returns (bool)
{
(, address user, UnwindParams memory u) = abi.decode(_params, (OperationType, address, UnwindParams));
+ // Use the caller-provided amount (already sized by the view/helper/UI)
+ // Optional: cap by actual collateral to avoid over-withdraw attempts.
+ uint256 toWithdraw = u.collateralToWithdraw;
- // Old: recomputation using liquidationThreshold (drifts from view)
- (,, uint256 liqThreshold,,,,,,,) =
- aaveDataProvider.getReserveConfigurationData(u.collateralToken);
- uint256 collateralToWithdraw = (...) / (... * liqThreshold);
- uint256 withdrawnAmount = aavePool.withdraw(u.collateralToken, collateralToWithdraw, address(this));
+ uint256 withdrawnAmount = aavePool.withdraw(u.collateralToken, toWithdraw, address(this));
// proceed with swap & repayment...
}

Support

FAQs

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

Give us feedback!