Stratax Contracts

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

Missing Oracle Freshness Check leads to Stale Price Usage and Potential Inaccurate Valuations

Author Revealed upon completion

Root + Impact

Root Cause: Missing staleness check in StrataxOracle.getPrice().
Impact: Critical. The protocol can use outdated prices, leading to incorrect collateral calculations, unfair liquidations, or favorable bad debt creation.

Description

The StrataxOracle.getPrice() function retrieves price data from Chainlink's latestRoundData() but fails to validate the updatedAt timestamp. It only checks if answer > 0.

Normal behavior: Oracles should reject prices that haven't been updated within a specific heartbeat threshold (e.g., 1 hour for ETH/USD).

Specific Issue: If Chainlink stops updating (due to congestion or maintenance) or returns stale data, Stratax will continue to operate with old prices. This can allow the owner to open positions at favorable (but incorrect) rates or unwind positions when they should be insolvent.

// Root cause in StrataxOracle.sol
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);
// @> vulnerability: No check for updatedAt or round completeness
(, int256 answer,,,) = priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}

Risk

Likelihood:
High. Stale prices occur during periods of high volatility or network congestion, which are exactly when accurate pricing is most critical.

Impact:
Critical.

  • Over-borrowing: If collateral price is stale-high, user can borrow more than allowed, risking bad debt.

  • Under-collateralized Unwinds: If debt token price is stale-low, user can unwind with less collateral than required.

Proof of Concept

The following PoC demonstrates StrataxOracle returning a price that is 10 days old without reverting.

// test/PoC_OracleFreshness.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {StrataxOracle} from "../src/StrataxOracle.sol";
import {AggregatorV3Interface} from "../src/interfaces/external/AggregatorV3Interface.sol";
contract MockAggregator is AggregatorV3Interface {
int256 public price;
uint8 public decimalsVal;
uint256 public updatedAtVal;
constructor(int256 _price, uint8 _decimals) {
price = _price;
decimalsVal = _decimals;
updatedAtVal = block.timestamp;
}
function setUpdatedAt(uint256 _updatedAt) external {
updatedAtVal = _updatedAt;
}
function decimals() external view override returns (uint8) {
return decimalsVal;
}
function description() external pure override returns (string memory) {
return "Mock Aggregator";
}
function version() external pure override returns (uint256) {
return 1;
}
function getRoundData(uint80 _roundId)
external
view
override
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
return (_roundId, price, updatedAtVal, updatedAtVal, _roundId);
}
function latestRoundData()
external
view
override
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
return (1, price, updatedAtVal, updatedAtVal, 1);
}
}
contract PoC_OracleFreshness is Test {
StrataxOracle public oracle;
MockAggregator public mockFeed;
address public constant ETH = address(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE);
function setUp() public {
vm.warp(100 days); // Move forward in time to avoid underflow
oracle = new StrataxOracle();
// Price: 2000 USD, 8 decimals
mockFeed = new MockAggregator(2000 * 1e8, 8);
oracle.setPriceFeed(ETH, address(mockFeed));
}
function test_OracleReturnsStalePrice() public {
// Set price to be very old (e.g., 10 days ago)
uint256 staleTime = block.timestamp - 10 days;
mockFeed.setUpdatedAt(staleTime);
// Verify the aggregator returns old timestamp
(,,, uint256 updatedAt,) = mockFeed.latestRoundData();
assertEq(updatedAt, staleTime);
// Call StrataxOracle.getPrice
// It SHOULD revert if freshness check was implemented, but it WON'T here
uint256 price = oracle.getPrice(ETH);
console.log("Stale price returned:", price);
console.log("Current time:", block.timestamp);
console.log("Price updated at:", updatedAt);
// Assert that we got a price despite it being stale
assertEq(price, 2000 * 1e8);
}
}

Test Result

forge test --match-path test/PoC_OracleFreshness.t.sol -vv
Ran 1 test for test/PoC_OracleFreshness.t.sol:PoC_OracleFreshness
[PASS] test_OracleReturnsStalePrice() (gas: 35239)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.01ms

Recommended Mitigation

Add a validation check for the updatedAt timestamp.

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();
+ (, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
+ require(block.timestamp - updatedAt < 3600, "Price stale"); // Adjust heartbeat as needed
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}

Support

FAQs

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

Give us feedback!