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.
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:
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.
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);
oracle = new StrataxOracle();
mockFeed = new MockAggregator(2000 * 1e8, 8);
oracle.setPriceFeed(ETH, address(mockFeed));
}
function test_OracleReturnsStalePrice() public {
uint256 staleTime = block.timestamp - 10 days;
mockFeed.setUpdatedAt(staleTime);
(,,, uint256 updatedAt,) = mockFeed.latestRoundData();
assertEq(updatedAt, staleTime);
uint256 price = oracle.getPrice(ETH);
console.log("Stale price returned:", price);
console.log("Current time:", block.timestamp);
console.log("Price updated at:", updatedAt);
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);
}