// SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.24; /// @dev Minimal Chainlink aggregator interface. interface IAggregatorV3 { function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); function decimals() external view returns (uint8); } /// @dev TWAP source (Uniswap v3 reader wired at deployment for RAIN/ARB, WP §9). interface ITwapSource { /// @return WAD USD price over the configured window (30–60 min) function twapPrice() external view returns (uint256); } /// @title OracleHub — prices + circuit breaker (White Paper v3.2 §9) /// @notice ETH/wBTC/USDC via Chainlink; RAIN/ARB via long-window Uniswap v3 TWAP. /// A circuit breaker trips on implausibly fast moves; while tripped, /// liquidations become partial/rate-limited (never frozen) and new borrows /// against that asset pause. Stress prices apply the fixed WP §4.3 markdown. contract OracleHub { error NotTimelock(); error UnknownAsset(); error StalePrice(); error InvalidPrice(); error SequencerDown(); error SequencerGracePeriod(); enum SourceKind { None, Chainlink, Twap } struct AssetConfig { SourceKind kind; address source; // aggregator or twap reader uint256 stalenessBound; // max age for chainlink answers uint256 breakerThresholdBps; // max move within window before breaker trips uint256 breakerWindow; // seconds the breaker stays active once tripped uint256 stressMarkdownBps; // WP §4.3: volatile −50% => 5000 } struct BreakerState { uint256 refPrice; // reference price for move detection uint256 refTime; uint256 trippedUntil; } address public immutable timelock; mapping(address => AssetConfig) public configs; mapping(address => BreakerState) public breakers; /// @notice Chainlink L2 sequencer-uptime feed (Arbitrum One: /// 0xFdB631F5EE196F0ed6FAa767959853A9F217697D). answer == 0 -> up. /// After recovery, liquidation-relevant reads revert for a grace period /// so positions can be topped up before liquidations resume. IAggregatorV3 public sequencerFeed; uint256 public sequencerGracePeriod = 1 hours; event AssetConfigured(address indexed asset, SourceKind kind, address source); event BreakerTripped(address indexed asset, uint256 oldPrice, uint256 newPrice, uint256 until); uint256 private constant BPS = 10_000; constructor(address _timelock) { timelock = _timelock; } function configureAsset(address asset, AssetConfig calldata cfg) external { if (msg.sender != timelock) revert NotTimelock(); configs[asset] = cfg; emit AssetConfigured(asset, cfg.kind, cfg.source); } function setSequencerFeed(address feed, uint256 gracePeriod) external { if (msg.sender != timelock) revert NotTimelock(); sequencerFeed = IAggregatorV3(feed); sequencerGracePeriod = gracePeriod; } /// @notice Reverts if the L2 sequencer is down or within the recovery grace period. function checkSequencer() public view { if (address(sequencerFeed) == address(0)) return; // not configured (tests/L1) (, int256 answer, uint256 startedAt,,) = sequencerFeed.latestRoundData(); if (answer != 0) revert SequencerDown(); if (block.timestamp - startedAt < sequencerGracePeriod) revert SequencerGracePeriod(); } /// @notice Current WAD price + breaker status. Anyone may poke via getPriceAndUpdate. /// @dev Enforces the sequencer-uptime check: while the Arbitrum sequencer is down /// or freshly recovered, price-dependent actions revert, preventing /// liquidations against stale prices. function getPrice(address asset) public view returns (uint256 price, bool breakerActive) { checkSequencer(); AssetConfig memory cfg = configs[asset]; price = _rawPrice(asset, cfg); breakerActive = block.timestamp < breakers[asset].trippedUntil; } /// @notice State-updating read: detects implausibly fast moves and trips the breaker. function getPriceAndUpdate(address asset) external returns (uint256 price, bool breakerActive) { AssetConfig memory cfg = configs[asset]; price = _rawPrice(asset, cfg); BreakerState storage b = breakers[asset]; if (b.refPrice != 0 && cfg.breakerThresholdBps != 0) { uint256 diff = price > b.refPrice ? price - b.refPrice : b.refPrice - price; if (diff * BPS > b.refPrice * cfg.breakerThresholdBps) { b.trippedUntil = block.timestamp + cfg.breakerWindow; emit BreakerTripped(asset, b.refPrice, price, b.trippedUntil); } } // seed on first observation, then roll reference forward once per window if (b.refPrice == 0 || block.timestamp >= b.refTime + cfg.breakerWindow) { b.refPrice = price; b.refTime = block.timestamp; } breakerActive = block.timestamp < b.trippedUntil; } /// @notice Price under the fixed conservative stress assumption (WP §4.3). function stressPrice(address asset) external view returns (uint256) { AssetConfig memory cfg = configs[asset]; uint256 p = _rawPrice(asset, cfg); return p * (BPS - cfg.stressMarkdownBps) / BPS; } function _rawPrice(address asset, AssetConfig memory cfg) internal view returns (uint256) { if (cfg.kind == SourceKind.Chainlink) { (, int256 answer,, uint256 updatedAt,) = IAggregatorV3(cfg.source).latestRoundData(); if (answer <= 0) revert InvalidPrice(); if (block.timestamp - updatedAt > cfg.stalenessBound) revert StalePrice(); uint8 dec = IAggregatorV3(cfg.source).decimals(); return uint256(answer) * 1e18 / (10 ** dec); } else if (cfg.kind == SourceKind.Twap) { uint256 p = ITwapSource(cfg.source).twapPrice(); if (p == 0) revert InvalidPrice(); return p; } revert UnknownAsset(); } }