Stratax Contracts

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

Missing Chainlink staleness and round-completeness checks in getPrice allow stale prices to reach critical financial calculations

Author Revealed upon completion

Description

  • StrataxOracle::getPrice fetches the latest price from a Chainlink feed and returns it to Stratax.sol, where it is used to calculate flash loan amounts, collateral withdrawal sizes, and liquidation thresholds. The only validation performed is that the returned answer is positive.

  • latestRoundData() returns five values: roundId, answer, startedAt, updatedAt, and answeredInRound. The implementation destructures only answer and silently ignores the remaining four. This means a frozen feed (no updates due to node outage or circuit breaker) or an unfinished round will return an accepted price with no on-chain signal.

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:

  • A Chainlink node infrastructure outage or deliberate circuit breaker activation freezes updatedAt while market prices continue to move — a scenario with historical precedent (LUNA crash, May 2022).

  • A Chainlink round transition leaves answeredInRound < roundId, meaning the on-chain answer was computed in a previous round and the current round is unresolved.

Impact:

  • A frozen high collateral price causes _executeUnwindOperation to calculate too little collateral to withdraw. The 1inch swap returns fewer funds than needed to repay the flash loan, reverting with "Insufficient funds to repay flash loan" and locking the user's position while Aave can liquidate it using its own live oracle.

  • A stale price when opening a position allows creating undercollateralized leverage or incorrectly blocks a healthy position from being opened.

  • A stale price during a forced unwind (Stratax.sol:570–571) leads to extracting the wrong collateral amount, potentially leaving residual debt or over-extracting collateral from the user.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
contract MockStaleFeed {
int256 public price;
uint256 public updatedAt;
uint80 public roundId;
uint80 public answeredInRound;
constructor(int256 _price, uint256 _staleDuration) {
price = _price;
updatedAt = block.timestamp - _staleDuration;
roundId = 1;
answeredInRound = 1;
}
function decimals() external pure returns (uint8) { return 8; }
function latestRoundData()
external
view
returns (uint80, int256, uint256, uint256, uint80)
{
return (roundId, price, 0, updatedAt, answeredInRound);
}
function setRoundData(uint80 _roundId, uint80 _answeredInRound) external {
roundId = _roundId;
answeredInRound = _answeredInRound;
}
}
contract StaleOraclePoCTest is Test {
StrataxOracle oracle;
MockStaleFeed feed;
address constant TOKEN = address(0xBEEF);
function setUp() public {
vm.warp(1 days);
oracle = new StrataxOracle();
feed = new MockStaleFeed(3000e8, 45 minutes);
oracle.setPriceFeed(TOKEN, address(feed));
}
/// @notice getPrice() accepts 45-minute-old data without reverting.
function test_stalePriceAccepted() public view {
uint256 price = oracle.getPrice(TOKEN);
assertEq(price, 3000e8, "Stale price returned with no revert");
}
/// @notice getPrice() accepts data from an incomplete round (answeredInRound < roundId).
function test_incompleteRoundAccepted() public {
feed.setRoundData(5, 3);
uint256 price = oracle.getPrice(TOKEN);
assertEq(price, 3000e8, "Incomplete round accepted as valid");
}
}

Recommended Mitigation

+ uint256 public constant MAX_PRICE_AGE = 3600; // adjust per feed heartbeat
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(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Stale price");
+ require(answeredInRound >= roundId, "Incomplete round");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!