Chainlink's latestRoundData() returns five values: roundId, answer, startedAt, updatedAt, and answeredInRound. These fields exist specifically to allow consumers to detect stale or incomplete data.
StrataxOracle.getPrice() discards all fields except answer, performing no staleness validation. The price could be hours or days old without the protocol knowing.
This stale price is consumed in two critical paths:
calculateOpenParams() (Stratax.sol:393-403) — determines flash loan and borrow amounts
_executeUnwindOperation() (Stratax.sol:570-571) — determines how much collateral to withdraw from Aave
Likelihood:
Chainlink feeds have heartbeat intervals (e.g., 1 hour for ETH/USD). Between heartbeats, the price is stale by definition
L2 sequencer downtime freezes all Chainlink feeds on that chain — the protocol has no sequencer uptime check
Oracle outages have occurred multiple times historically
Impact:
During createLeveragedPosition: Stale prices produce incorrect flashLoanAmount and borrowAmount, potentially creating overleveraged positions that are immediately liquidatable on Aave
During unwindPosition: _executeUnwindOperation calculates collateralToWithdraw using stale prices. If the real price is lower than the stale price, too little collateral is withdrawn, the swap produces fewer debt tokens, and the flash loan repayment fails — the position cannot be unwound
On L2 (Arbitrum, Optimism, Base): sequencer downtime means all prices are frozen. The protocol operates blindly on old data
Real-World Precedent:
Mango Markets (2022-10-11) — $114,000,000 lost: Oracle price manipulation enabled massive borrowing against inflated collateral
Euler Finance (2023-03-13) — $197,000,000 lost: Price manipulation and oracle issues contributed to the exploit
Multiple Chainlink heartbeat misses documented, where prices were stale for hours
How the issue manifests:
ETH/USD Chainlink feed has a 1-hour heartbeat. The last update was 55 minutes ago at $2000
In the last 55 minutes, ETH dropped 15% to $1700 on the market
Owner calls unwindPosition() to close a leveraged ETH position
_executeUnwindOperation fetches the stale $2000 price from the oracle
Collateral-to-withdraw is calculated based on $2000: (debtAmount * debtPrice * collateralDec * LTV_PRECISION) / ($2000 \* debtDec \* liqThreshold)\
At $2000, less collateral is withdrawn than needed (the real exchange rate is$1700)
The swap converts this collateral to debt tokens at the real $1700 rate — producing fewer tokens than expected
require(returnAmount >= totalDebt) fails because the swap output doesn't cover the flash loan + premium
The entire unwind reverts — the position cannot be closed while the oracle is stale
Meanwhile, Aave uses its own (fresh) oracle and may liquidate the position
Expected outcome: The position becomes stuck — unable to unwind via Stratax (stale oracle) while simultaneously at risk of liquidation on Aave (fresh oracle). The owner loses control of their position.
The root cause is that getPrice() uses the raw Chainlink answer without verifying it is fresh and from a completed round. The fix must validate all staleness fields before accepting the price.
Primary fix — Validate all Chainlink staleness fields:
Why this works:
updatedAt > 0 ensures the round has been completed (not pending)
answeredInRound >= roundId ensures the answer belongs to the current or a more recent round (guards against carried-over stale answers)
block.timestamp - updatedAt < MAX_STALENESS ensures the price is within an acceptable freshness window
If any check fails, the function reverts, preventing the protocol from operating on stale data
Additional hardening for L2 deployment: Add a Chainlink L2 Sequencer Uptime Feed check to detect sequencer downtime and implement a grace period after recovery. See Chainlink L2 Sequencer Feeds.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.