Description
-
On L2 networks such as Arbitrum or Optimism, Chainlink recommends checking the Sequencer Uptime Feed before trusting price feeds. If the sequencer is down, price updates may be paused or stale; even after the sequencer comes back up, consumers should enforce a grace period before using prices to avoid inconsistent states.
-
StrataxOracle.getPrice does not perform a sequencer‑uptime check (and has no field to configure a sequencer feed). When deployed on L2, callers can proceed with operations while the sequencer is down or just recovered, situations where prices can be effectively stale even if updatedAt appears recent relative to L2 block time. This enables users (or automation) to act on stale prices during sequencer downtime, opening unhealthy positions or unwinding at mispriced rates.
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: Medium
-
The README claims compatibility with “All EVM‑compatible chains,” which includes L2s using sequencers. Operationally, sequencer incidents do occur; during those periods the price feed may be stale even if updatedAt looks fresh in L2 time.
-
Teams often deploy the same contracts across L1/L2 without adding the necessary L2‑specific oracle guards, so this mismatch will surface in practice.
Impact: Medium
-
Stale‑price exploitation: Attackers can open or unwind positions at stale oracle prices, capturing unfair PnL or pushing positions into under‑collateralization once the feed catches up.
-
Cascading failures: Bots/UIs may rely on getPrice and proceed during downtime, leading to reverts later, unhealthy HFs, or unexpected liquidations after recovery.
Proof of Concept
Copy test test_SequencerDownPricesStillAcceptedL2Risk() to test/fork/Stratax.t.sol: inside the StrataxForkTest contract.
Copy mock contract MockSequencerAwareAggregator to test/fork/Stratax.t.sol: after the StrataxForkTest contract.
Run command forge test --mt test_SequencerDownPricesStillAcceptedL2Risk --via-ir -vv.
contract MockSequencerAwareAggregator {
uint8 private constant DEC = 8;
int256 public answer;
uint80 public roundId;
uint80 public answeredInRound;
uint256 public startedAt;
uint256 public updatedAt;
bool public sequencerDown;
constructor(
int256 _answer,
uint80 _roundId,
uint80 _answeredInRound,
uint256 _startedAt,
uint256 _updatedAt,
bool _sequencerDown
) {
answer = _answer;
roundId = _roundId;
answeredInRound = _answeredInRound;
startedAt = _startedAt;
updatedAt = _updatedAt;
sequencerDown = _sequencerDown;
}
function setSequencerDown(bool v) external { sequencerDown = v; }
function decimals() external pure returns (uint8) { return DEC; }
function latestRoundData()
external
view
returns (uint80, int256, uint256, uint256, uint80)
{
return (roundId, answer, startedAt, updatedAt, answeredInRound);
}
}
function test_SequencerDownPricesStillAcceptedL2Risk() public {
uint256 nowTs = block.timestamp;
MockSequencerAwareAggregator usdcAgg = new MockSequencerAwareAggregator(
int256(1e8),
100, 100,
nowTs - 10, nowTs - 10,
true
);
MockSequencerAwareAggregator wethAgg = new MockSequencerAwareAggregator(
int256(2000e8),
200, 200,
nowTs - 10, nowTs - 10,
true
);
strataxOracle.setPriceFeed(USDC, address(usdcAgg));
strataxOracle.setPriceFeed(WETH, address(wethAgg));
uint256 collateralAmount = 1_000 * 1e6;
(uint256 fl, uint256 borrow) = Stratax(address(stratax)).calculateOpenParams(
Stratax.TradeDetails({
collateralToken: USDC,
borrowToken: WETH,
desiredLeverage: 30_000,
collateralAmount: collateralAmount,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
assertTrue(fl > 0, "flash loan should be computed even while sequencer is DOWN");
assertTrue(borrow > 0, "borrow should be computed even while sequencer is DOWN");
assertTrue(usdcAgg.sequencerDown() && wethAgg.sequencerDown(), "sequencer is DOWN in the PoC");
}
Recommended Mitigation
Add optional Sequencer Uptime Feed support to StrataxOracle and enforce a post‑recovery grace period. Keep it disabled on L1 (feed unset) and enabled on L2 (feed set via owner).
Introduce storage for the sequencer feed and a configurable gracePeriod (e.g., 1 hour).
In getPrice, if the sequencer feed is set:
Fetch uptime status: answer == 0 → sequencer up; 1 → down.
If down → revert.
If up, ensure block.timestamp - sequencerUpdatedAt >= gracePeriod before trusting asset prices.
contract StrataxOracle {
address public owner;
mapping(address => address) public priceFeeds;
+ // Optional L2 sequencer feed (set only on L2)
+ address public sequencerUptimeFeed;
+ // Post-recovery grace period to avoid using prices immediately after sequencer resumes
+ uint256 public sequencerGracePeriod = 1 hours;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
+ event SequencerUptimeFeedUpdated(address indexed feed);
+ event SequencerGracePeriodUpdated(uint256 oldValue, uint256 newValue);
+ function setSequencerUptimeFeed(address _feed) external onlyOwner {
+ sequencerUptimeFeed = _feed; // zero address disables the check (e.g., on L1)
+ emit SequencerUptimeFeedUpdated(_feed);
+ }
+
+ function setSequencerGracePeriod(uint256 _grace) external onlyOwner {
+ uint256 old = sequencerGracePeriod;
+ sequencerGracePeriod = _grace;
+ emit SequencerGracePeriodUpdated(old, _grace);
+ }
function getPrice(address _token) public view returns (uint256 price) {
+ // L2-only: enforce sequencer uptime & grace period if configured
+ if (sequencerUptimeFeed != address(0)) {
+ (, int256 status,, uint256 sequencerUpdatedAt,) =
+ AggregatorV3Interface(sequencerUptimeFeed).latestRoundData();
+ require(status == 0, "Sequencer down");
+ require(block.timestamp >= sequencerUpdatedAt + sequencerGracePeriod, "Grace period not over");
+ }
address priceFeedAddress = priceFeeds[_token];
require(priceFeedAddress != address(0), "Price feed not set for token");
AggregatorV3Interface priceFeed = AggregatorV3Interface(priceFeedAddress);
(uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) =
priceFeed.latestRoundData();
require(answer > 0, "Invalid price from oracle");
+ // (Optional) retain the existing freshness/round checks if you adopted them earlier:
+ // require(updatedAt != 0 && answeredInRound >= roundId, "Oracle: invalid round");
+ // require(updatedAt + maxStaleTime >= block.timestamp, "Oracle: price is stale");
price = uint256(answer);
}
}