Stratax Contracts

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

Oracle Revert Blocks Position Unwinding Via `unwindPosition()`

Author Revealed upon completion

The Stratax contract manages leveraged positions on Aave. Users open positions via createLeveragedPosition() and close them via unwindPosition(). Both the open and unwind paths rely on the StrataxOracle to fetch real-time prices from Chainlink feeds. The oracle calls within the unwind path are made without try/catch or fallback mechanisms.

During the unwind flash loan callback in _executeUnwindOperation(), the contract calls IStrataxOracle(strataxOracle).getPrice() twice to determine how much collateral to withdraw:

// src/Stratax.sol - _executeUnwindOperation()
function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
(, address user, UnwindParams memory unwindParams) = abi.decode(_params, (OperationType, address, UnwindParams));
// Step 1: Repay the Aave debt using flash loaned tokens
IERC20(_asset).approve(address(aavePool), _amount);
aavePool.repay(_asset, _amount, 2, address(this));
// Step 2: Calculate and withdraw only the collateral that backed the repaid debt
uint256 withdrawnAmount;
{
// ... snip ...
// @audit These oracle calls can revert if Chainlink feed is unavailable,
// @audit blocking the entire unwind operation with no fallback path
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));
}
// ... snip ...
}

These calls propagate to StrataxOracle.getPrice(), which invokes priceFeed.latestRoundData() and includes a require(answer > 0) check:

// src/StrataxOracle.sol - getPrice()
function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token"); // @audit reverts if no feed
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
(, int256 answer,,,) = priceFeed.latestRoundData(); // @audit reverts if Chainlink feed is down
require(answer > 0, "Invalid price from oracle"); // @audit reverts on zero/negative price
price = uint256(answer);
}

If the Chainlink feed reverts (due to deprecation, sequencer downtime on L2s, or access control changes) or returns zero/negative, the entire unwindPosition() transaction reverts. The data flow is as follows:

  1. The user calls unwindPosition() to close their leveraged position.

  2. unwindPosition() initiates a flash loan via aavePool.flashLoanSimple().

  3. Aave calls back executeOperation(), which routes to _executeUnwindOperation().

  4. _executeUnwindOperation() calls IStrataxOracle(strataxOracle).getPrice() twice for the debt and collateral tokens.

  5. StrataxOracle.getPrice() calls priceFeed.latestRoundData().

  6. If latestRoundData() reverts or returns answer <= 0, the entire transaction reverts.

  7. The flash loan callback fails, the flash loan is rolled back, and the position remains open.

Since there is no alternative unwind mechanism, the position is stuck until the oracle recovers. The recoverTokens() function does not mitigate this, as collateral is held in Aave (as aTokens on behalf of the Stratax contract) and the debt remains. Only aavePool.withdraw() and aavePool.repay() can close the position, and both require the oracle to compute collateral withdrawal amounts within _executeUnwindOperation().

Notably, calculateOpenParams() has a conditional pattern that allows the caller to pre-supply prices to skip the oracle call (e.g., if (details.collateralTokenPrice == 0)), but _executeUnwindOperation() has no such bypass -- it always fetches prices from the oracle. The _collateralToWithdraw parameter supplied by the caller via unwindPosition() is completely ignored in _executeUnwindOperation(), which recalculates the value from oracle prices. The local variable collateralToWithdraw shadows the struct field.

The owner can set a new oracle address via setStrataxOracle(), which provides a partial mitigation path. However, the new oracle must also have valid price feeds configured for the relevant tokens, requiring admin availability and a new oracle deployment, which may not be immediately available during an oracle outage.

This issue has a medium impact as users cannot exit positions during oracle downtime, and positions continue accruing variable-rate debt. If the oracle downtime coincides with adverse price movement, the position's health factor drops and Aave liquidation (which uses its own oracle, not the Stratax oracle) may trigger, imposing a liquidation penalty (typically 5-10%) on the user. However, funds are not permanently lost; they remain in Aave and would eventually be recoverable once the oracle is restored or replaced.

This issue has a low likelihood as complete Chainlink feed unavailability (as opposed to staleness) is rare for major assets. Chainlink has deprecated feeds historically (e.g., LUNA/USD feed removal after the Terra collapse) and L2 sequencer downtime has caused Chainlink feeds to become stale (e.g., Arbitrum sequencer outage in June 2023), but these are infrequent events. The setStrataxOracle() admin function provides an escape hatch that reduces the effective duration of any outage.

recommendation

Wrap oracle calls in _executeUnwindOperation() with try/catch and implement a fallback mechanism. Allow the unwindPosition() function to accept pre-computed price parameters (similar to how calculateOpenParams() accepts price inputs) as a bypass when the oracle is unavailable. Alternatively, cache the last known valid oracle prices and use them as a fallback with a staleness check. Add an emergency unwind function that accepts owner-provided price parameters, allowing the owner to manually specify prices to compute collateral withdrawal amounts when the oracle is unavailable.

Support

FAQs

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

Give us feedback!