Stratax Contracts

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

Oracle freshness not enforced (stale/invalid prices accepted)

Author Revealed upon completion

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
}
}

Support

FAQs

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

Give us feedback!