Stratax Contracts

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

Oracle Price Staleness Not Validated

Author Revealed upon completion

Root + Impact

Description

  • The StrataxOracle contract serves as the price oracle for the Stratax protocol, providing token prices used in all leverage calculations. The getPrice function retrieves the latest price from Chainlink's latestRoundData() call. A properly implemented oracle consumer should validate multiple fields from the response: updatedAt for staleness, roundId for round completeness, and answeredInRound to ensure the answer was finalized.

  • The getPrice function only checks that answer > 0, discarding roundId, startedAt, updatedAt, and answeredInRound entirely. This means the oracle will return prices that are hours or days old during periods of Chainlink downtime, network congestion, or feed deprecation. Stale prices directly feed into calculateOpenParams and calculateUnwindParams, causing incorrect leverage and collateral calculations.

function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token");
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
@> (, int256 answer,,,) = priceFeed.latestRoundData();
@> require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}

Risk

Likelihood:

  • Chainlink price feeds have historically experienced downtime and delayed updates during periods of extreme market volatility (e.g., LUNA crash, FTX collapse). During these events, latestRoundData() continues to return the last successfully reported price while updatedAt lags far behind block.timestamp.

  • The oracle also accepts data from incomplete rounds where answeredInRound < roundId, which occurs when Chainlink nodes have not yet reached consensus on a new price.

Impact:

  • Leveraged positions created during oracle staleness use an incorrect price, resulting in positions that are immediately under-collateralized or over-collateralized relative to the actual market price.

  • During a rapid price drop, a user can create a position using a stale (higher) price, effectively borrowing more than the collateral supports, leaving bad debt in the Aave pool.

Proof of Concept

This Foundry test deploys a mock Chainlink price feed that returns a valid price (2500e8) but with an updatedAt timestamp set 24 hours in the past. Despite the data being a full day stale, getPrice returns it without revert because the only check performed is answer > 0.

function test_BRIDGE002_OracleAcceptsStalePrice() public {
vm.warp(1708300000); // Set realistic timestamp
uint256 staleTime = block.timestamp - 24 hours;
// Deploy a price feed that returns valid price but stale timestamp
StalePriceFeed staleFeed = new StalePriceFeed(2500e8, staleTime);
strataxOracle.setPriceFeed(WETH, address(staleFeed));
// getPrice succeeds despite 24-hour-old data
uint256 price = strataxOracle.getPrice(WETH);
assertEq(price, 2500e8); // Stale price accepted without any staleness check
}

Recommended Mitigation

Add three additional validation checks after retrieving latestRoundData: ensure the round is complete (updatedAt > 0), the data is fresh (block.timestamp - updatedAt <= MAX_STALENESS), and the answer was finalized in the current round (answeredInRound >= roundId). Define MAX_STALENESS as a configurable constant (e.g., 3600 seconds for a 1-hour heartbeat).

- function getPrice(address _token) public view returns (uint256 price) {
+ function getPrice(address _token) public view returns (uint256 price) {
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token");
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
- (, int256 answer,,,) = priceFeed.latestRoundData();
+ (uint80 roundId, int256 answer,, uint256 updatedAt, uint80 answeredInRound) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
+ require(updatedAt > 0, "Round not complete");
+ require(block.timestamp - updatedAt <= MAX_STALENESS, "Stale price");
+ require(answeredInRound >= roundId, "Stale round");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!