Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
View results
Submission Details
Severity: medium
Valid

Oracle freshness not enforced (stale/invalid prices accepted)

Description

  • When consuming Chainlink feeds, clients should validate that the returned answer is recent and from a valid round. Typical checks include:

    • answer > 0

    • updatedAt is within an acceptable freshness window (e.g., not older than X seconds)

    • answeredInRound >= roundId (to avoid stale results carried over to a later round)

  • StrataxOracle.getPrice only checks that answer > 0 and does not validate updatedAt or the round linkage. Consequently, stale or frozen price data is accepted and used in leverage/open and unwind parameter calculations. This can lead to opening unhealthy positions or failing unwinds under real market conditions.

// 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);
(, int256 answer,,,) = priceFeed.latestRoundData();
// @> Only checks the answer value; ignores updatedAt and answeredInRound
require(answer > 0, "Invalid price from oracle");
price = uint256(answer);
}

Risk

Likelihood: High

  • Chainlink feeds can become temporarily stale during network issues or maintenance windows; when that happens, updatedAt will be old, yet the current code will still accept it.

  • Integration or deployment on alt‑L2s/sidechains with less reliable infrastructure increases the frequency of stale updates. In live operations, this will occur.

Impact: High

  • Bad positions / reverts later: Users can open with an outdated price, making calculated borrow amounts unrealistic; swaps may later fail to cover flash‑loan debts or bring the HF below 1, causing reverts or liquidation risk.

  • Inaccurate unwinds: Unwind math derived from stale data can under‑ or over‑withdraw collateral, again breaking the flash‑loan repayment or leaving capital stuck.

Proof of Concept

  • Copy tests test_OracleAllowsStalePricesInOpenParams() and test_OracleAllowsRoundMismatchAnsweredInRoundLTRoundId() to test/fork/Stratax.t.sol: inside the StrataxForkTest contract.

  • Copy mock contract MockStaleAggregator to test/fork/Stratax.t.sol: after the StrataxForkTest contract.

  • Run command forge test --mt test_OracleAllowsStalePricesInOpenParams -vvvv.

  • Run command forge test --mt test_OracleAllowsRoundMismatchAnsweredInRoundLTRoundId -vvvv.

function test_OracleAllowsStalePricesInOpenParams() public {
// Create stale USDC and WETH feeds: price > 0, but updatedAt way in the past
uint256 veryOld = 1; // practically "stale" timestamp
// USDC ~ 1.00 (8 decimals), WETH ~ 2000 (8 decimals)
MockStaleAggregator usdcAgg = new MockStaleAggregator(
int256(1e8), 10, 10, veryOld, veryOld
);
MockStaleAggregator wethAgg = new MockStaleAggregator(
int256(2000e8), 20, 20, veryOld, veryOld
);
// Replace feeds to point to stale aggregators (this test contract owns strataxOracle)
strataxOracle.setPriceFeed(USDC, address(usdcAgg));
strataxOracle.setPriceFeed(WETH, address(wethAgg));
// Compute open params with oracle lookup (prices set to 0 to force oracle usage)
(uint256 flashLoanAmount, uint256 borrowAmount) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: USDC,
borrowToken: WETH,
desiredLeverage: 30_000, // 3x
collateralAmount: 1_000 * 1e6, // 1,000 USDC
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
// The call succeeds and returns positive numbers despite stale updatedAt.
assertTrue(flashLoanAmount > 0, "Expected flashLoanAmount > 0 with stale price");
assertTrue(borrowAmount > 0, "Expected borrowAmount > 0 with stale price");
}
function test_OracleAllowsRoundMismatchAnsweredInRoundLTRoundId() public {
uint256 nowTs = block.timestamp;
// answeredInRound < roundId indicates an invalid carry-over; still accepted by current oracle.
MockStaleAggregator badRoundUSDC = new MockStaleAggregator(
int256(1e8), 100, 99, nowTs - 1 days, nowTs - 1 days
);
MockStaleAggregator okWETH = new MockStaleAggregator(
int256(2000e8), 200, 200, nowTs - 1 days, nowTs - 1 days
);
strataxOracle.setPriceFeed(USDC, address(badRoundUSDC));
strataxOracle.setPriceFeed(WETH, address(okWETH));
(uint256 flashLoanAmount, uint256 borrowAmount) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: USDC,
borrowToken: WETH,
desiredLeverage: 30_000,
collateralAmount: 500 * 1e6,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
// Still succeeds; demonstrates lack of answeredInRound/roundId validation.
assertTrue(flashLoanAmount > 0, "Expected positive result despite round mismatch");
assertTrue(borrowAmount > 0, "Expected positive result despite round mismatch");
}
/// @dev Mock that mimics a Chainlink Aggregator with 8 decimals
contract MockStaleAggregator {
uint8 private constant DEC = 8;
int256 public answer;
uint80 public roundId;
uint80 public answeredInRound;
uint256 public startedAt;
uint256 public updatedAt;
constructor(
int256 _answer,
uint80 _roundId,
uint80 _answeredInRound,
uint256 _startedAt,
uint256 _updatedAt
) {
answer = _answer;
roundId = _roundId;
answeredInRound = _answeredInRound;
startedAt = _startedAt;
updatedAt = _updatedAt;
}
function decimals() external pure returns (uint8) { return DEC; }
function latestRoundData()
external
view
returns (uint80, int256, uint256, uint256, uint80)
{
return (roundId, answer, startedAt, updatedAt, answeredInRound);
}

Output 1:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/Stratax.t.sol:StrataxForkTest
[PASS] test_OracleAllowsStalePricesInOpenParams() (gas: 671133)
Logs:
Available swap data files: 3
Randomly selected block: 24329390
Current fork block number is: 24329390
Traces:
[671133] StrataxForkTest::test_OracleAllowsStalePricesInOpenParams()
├─ [259243] → new MockStaleAggregator@0xc7183455a4C133Ae270771860664b6B7ec320bB1
│ └─ ← [Return] 846 bytes of code
├─ [259243] → new MockStaleAggregator@0xa0Cb889707d426A7A386870A03bc70d1b0697598
│ └─ ← [Return] 846 bytes of code
├─ [10538] StrataxOracle::setPriceFeed(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, MockStaleAggregator: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ ├─ [339] MockStaleAggregator::decimals() [staticcall]
│ │ └─ ← [Return] 8
│ ├─ emit PriceFeedUpdated(token: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, priceFeed: MockStaleAggregator: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Stop]
├─ [8538] StrataxOracle::setPriceFeed(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, MockStaleAggregator: [0xa0Cb889707d426A7A386870A03bc70d1b0697598])
│ ├─ [339] MockStaleAggregator::decimals() [staticcall]
│ │ └─ ← [Return] 8
│ ├─ emit PriceFeedUpdated(token: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, priceFeed: MockStaleAggregator: [0xa0Cb889707d426A7A386870A03bc70d1b0697598])
│ └─ ← [Stop]
├─ [49919] BeaconProxy::fallback(TradeDetails({ collateralToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, borrowToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, desiredLeverage: 30000 [3e4], collateralAmount: 1000000000 [1e9], collateralTokenPrice: 0, borrowTokenPrice: 0, collateralTokenDec: 6, borrowTokenDec: 18 })) [staticcall]
│ ├─ [2515] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← [Return] Stratax: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ ├─ [41540] Stratax::calculateOpenParams(TradeDetails({ collateralToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, borrowToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, desiredLeverage: 30000 [3e4], collateralAmount: 1000000000 [1e9], collateralTokenPrice: 0, borrowTokenPrice: 0, collateralTokenDec: 6, borrowTokenDec: 18 })) [delegatecall]
│ │ ├─ [11703] 0x0a16f2FCC0D44FaE41cc54e079281D84A363bECD::getReserveConfigurationData(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ ├─ [7732] 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ │ ├─ [2680] 0x8147b99DF7672A21809c9093E6F6CE1a60F119Bd::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [delegatecall]
│ │ │ │ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ │ │ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ │ │ └─ ← [Return] 6, 7500, 7800, 10450 [1.045e4], 1000, true, true, false, true, false
│ │ ├─ [3629] StrataxOracle::getPrice(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ ├─ [1478] MockStaleAggregator::latestRoundData() [staticcall]
│ │ │ │ └─ ← [Return] 10, 100000000 [1e8], 1, 1, 10
│ │ │ └─ ← [Return] 100000000 [1e8]
│ │ ├─ [3629] StrataxOracle::getPrice(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2) [staticcall]
│ │ │ ├─ [1478] MockStaleAggregator::latestRoundData() [staticcall]
│ │ │ │ └─ ← [Return] 20, 200000000000 [2e11], 1, 1, 20
│ │ │ └─ ← [Return] 200000000000 [2e11]
│ │ └─ ← [Return] 2000000000 [2e9], 1068750000000000000 [1.068e18]
│ └─ ← [Return] 2000000000 [2e9], 1068750000000000000 [1.068e18]
├─ [0] VM::assertTrue(true, "Expected flashLoanAmount > 0 with stale price") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertTrue(true, "Expected borrowAmount > 0 with stale price") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.46s (3.87ms CPU time)
Ran 1 test suite in 1.47s (1.46s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Output 2:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/Stratax.t.sol:StrataxForkTest
[PASS] test_OracleAllowsRoundMismatchAnsweredInRoundLTRoundId() (gas: 671874)
Logs:
Available swap data files: 3
Randomly selected block: 24329390
Current fork block number is: 24329390
Traces:
[671874] StrataxForkTest::test_OracleAllowsRoundMismatchAnsweredInRoundLTRoundId()
├─ [259243] → new MockStaleAggregator@0xc7183455a4C133Ae270771860664b6B7ec320bB1
│ └─ ← [Return] 846 bytes of code
├─ [259243] → new MockStaleAggregator@0xa0Cb889707d426A7A386870A03bc70d1b0697598
│ └─ ← [Return] 846 bytes of code
├─ [10538] StrataxOracle::setPriceFeed(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, MockStaleAggregator: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ ├─ [339] MockStaleAggregator::decimals() [staticcall]
│ │ └─ ← [Return] 8
│ ├─ emit PriceFeedUpdated(token: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, priceFeed: MockStaleAggregator: [0xc7183455a4C133Ae270771860664b6B7ec320bB1])
│ └─ ← [Stop]
├─ [8538] StrataxOracle::setPriceFeed(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, MockStaleAggregator: [0xa0Cb889707d426A7A386870A03bc70d1b0697598])
│ ├─ [339] MockStaleAggregator::decimals() [staticcall]
│ │ └─ ← [Return] 8
│ ├─ emit PriceFeedUpdated(token: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, priceFeed: MockStaleAggregator: [0xa0Cb889707d426A7A386870A03bc70d1b0697598])
│ └─ ← [Stop]
├─ [49919] BeaconProxy::fallback(TradeDetails({ collateralToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, borrowToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, desiredLeverage: 30000 [3e4], collateralAmount: 500000000 [5e8], collateralTokenPrice: 0, borrowTokenPrice: 0, collateralTokenDec: 6, borrowTokenDec: 18 })) [staticcall]
│ ├─ [2515] UpgradeableBeacon::implementation() [staticcall]
│ │ └─ ← [Return] Stratax: [0x2e234DAe75C793f67A35089C9d99245E1C58470b]
│ ├─ [41540] Stratax::calculateOpenParams(TradeDetails({ collateralToken: 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48, borrowToken: 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, desiredLeverage: 30000 [3e4], collateralAmount: 500000000 [5e8], collateralTokenPrice: 0, borrowTokenPrice: 0, collateralTokenDec: 6, borrowTokenDec: 18 })) [delegatecall]
│ │ ├─ [11703] 0x0a16f2FCC0D44FaE41cc54e079281D84A363bECD::getReserveConfigurationData(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ ├─ [7732] 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ │ ├─ [2680] 0x8147b99DF7672A21809c9093E6F6CE1a60F119Bd::getConfiguration(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [delegatecall]
│ │ │ │ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ │ │ │ └─ ← [Return] 0x100000000000000000000007d01bf08eb001a13b860003e8a50628d21e781d4c
│ │ │ └─ ← [Return] 6, 7500, 7800, 10450 [1.045e4], 1000, true, true, false, true, false
│ │ ├─ [3629] StrataxOracle::getPrice(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) [staticcall]
│ │ │ ├─ [1478] MockStaleAggregator::latestRoundData() [staticcall]
│ │ │ │ └─ ← [Return] 100, 100000000 [1e8], 1769469323 [1.769e9], 1769469323 [1.769e9], 99
│ │ │ └─ ← [Return] 100000000 [1e8]
│ │ ├─ [3629] StrataxOracle::getPrice(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2) [staticcall]
│ │ │ ├─ [1478] MockStaleAggregator::latestRoundData() [staticcall]
│ │ │ │ └─ ← [Return] 200, 200000000000 [2e11], 1769469323 [1.769e9], 1769469323 [1.769e9], 200
│ │ │ └─ ← [Return] 200000000000 [2e11]
│ │ └─ ← [Return] 1000000000 [1e9], 534375000000000000 [5.343e17]
│ └─ ← [Return] 1000000000 [1e9], 534375000000000000 [5.343e17]
├─ [0] VM::assertTrue(true, "Expected positive result despite round mismatch") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertTrue(true, "Expected positive result despite round mismatch") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.06s (3.11ms CPU time)
Ran 1 test suite in 1.06s (1.06s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Introduce freshness and round integrity checks in StrataxOracle, and consider a configurable staleness threshold:

  • Reject prices where updatedAt == 0 or updatedAt < block.timestamp - maxStaleTime.

  • Require answeredInRound >= roundId.

  • Keep the existing answer > 0 check.

contract StrataxOracle {
address public owner;
+ // Global max staleness in seconds (e.g., 1 hour). Make configurable.
+ uint256 public maxStaleTime = 1 hours;
mapping(address => address) public priceFeeds;
+ event MaxStaleTimeUpdated(uint256 oldValue, uint256 newValue);
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
+ function setMaxStaleTime(uint256 _seconds) external onlyOwner {
+ uint256 old = maxStaleTime;
+ maxStaleTime = _seconds;
+ emit MaxStaleTimeUpdated(old, _seconds);
+ }
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);
+ (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
+ = priceFeed.latestRoundData();
+ require(answer > 0, "Invalid price from oracle");
+ require(updatedAt != 0 && startedAt != 0, "Oracle: incomplete round");
+ require(answeredInRound >= roundId, "Oracle: stale round");
+ require(updatedAt + maxStaleTime >= block.timestamp, "Oracle: price is stale");
+ price = uint256(answer); // 8 decimals as enforced on setPriceFeed
}
}
Updates

Lead Judging Commences

izuman Lead Judge 16 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Price feed has no staleness check

StrataxOracle contract fails to check if the price is stale, which can mess up swap calculations.

Support

FAQs

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

Give us feedback!