// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; /** * @title AereOracleAdapter — multi-source price oracle for AereLendingMarket * @notice Per-asset price feeds. Each asset has a primary feed (Chainlink- * style aggregator OR a Foundation-attested NAV pulled from the * AereNavOracle for RWAs that don't have a live market price). A * staleness window protects against frozen feeds. A deviation cap * vs the last good price protects against single-tick manipulation. * * FEEDS: * type 0 = ChainlinkLike (latestRoundData() returning (uint80, int256, uint256, uint256, uint80)) * type 1 = AereNavOracle (queryable rate stamped on a per-asset basis) * type 2 = ConstantOne (for assets pegged 1:1 to USD by mandate, e.g. BUIDL pre-launch) * * All prices normalised to 18 decimals. * * GOVERNANCE: * - Foundation (Ownable) can add/update feeds, set staleness + * deviation guards. NO admin price overrides — only the upstream * feed source can move the price. This guarantees the * Foundation cannot front-run liquidations. * - Foundation CAN circuit-break (set asset to paused), which * causes the lending market to halt new borrows but NOT * liquidations (so bad debt can still be cleared). */ contract AereLendingOracle is Ownable { enum FeedType { ChainlinkLike, NavOracle, ConstantOne } struct Feed { bool configured; bool paused; FeedType feedType; address source; // for ChainlinkLike: aggregator addr; for NavOracle: AereNavOracle addr bytes32 navAssetKey; // for NavOracle feeds only uint32 stalenessSeconds; // max permitted age of feed reading uint16 maxDeviationBps; // max bps move vs lastGoodPrice; 0 = no check uint8 sourceDecimals; // feed-native decimals (typically 8 for Chainlink) uint256 lastGoodPrice; // 18-decimals normalised uint64 lastGoodTimestamp; } mapping(address => Feed) public feeds; /* --------------------------------- events ------------------------------- */ event FeedConfigured(address indexed asset, FeedType feedType, address source, uint32 stalenessSeconds, uint16 maxDeviationBps); event FeedPaused(address indexed asset, bool paused); event PriceUpdated(address indexed asset, uint256 price, uint64 timestamp); /* --------------------------------- errors ------------------------------- */ error FeedNotConfigured(); error FeedPausedErr(); error FeedStale(); error FeedDeviationExceeded(); error InvalidFeedResponse(); error ZeroAddress(); error ZeroPrice(); /* ---------------------------------- views ------------------------------- */ /// @notice Returns the latest acceptable price for `asset` normalised to 18 decimals. /// @dev Reverts if the feed is paused / stale / deviation-violated. function getPrice(address asset) external view returns (uint256 price) { Feed memory f = feeds[asset]; if (!f.configured) revert FeedNotConfigured(); if (f.paused) revert FeedPausedErr(); price = _readNormalised(f); if (price == 0) revert ZeroPrice(); // Deviation check (only if we have a lastGoodPrice and a cap). if (f.lastGoodPrice != 0 && f.maxDeviationBps != 0) { uint256 ref = f.lastGoodPrice; uint256 diff = price > ref ? price - ref : ref - price; uint256 maxDiff = (ref * f.maxDeviationBps) / 10_000; if (diff > maxDiff) revert FeedDeviationExceeded(); } } /// @notice Price for the LIQUIDATION path ONLY. Honors the lending market's /// "LIQUIDATIONS NEVER PAUSE" invariant: it returns the best available /// price WITHOUT the pause, deviation-cap, or staleness reverts that /// gate new borrows, so a paused feed, a crash larger than the /// deviation cap, a stale feed, or even a reverting upstream aggregator /// can NEVER freeze the liquidations that clear bad debt. It prefers a /// real fresh reading (you want the true crash price), falls back to /// lastGoodPrice, and reverts only when no usable price exists at all. /// SECURITY-FIX (adversarial-review HIGH 2026-07-12): getPrice()'s /// pause/deviation reverts previously froze liquidate(), contradicting /// the documented invariant and concentrating bad debt on suppliers. function getPriceForLiquidation(address asset) external view returns (uint256 price) { Feed memory f = feeds[asset]; if (!f.configured) revert FeedNotConfigured(); // Staleness-tolerant fresh read via an external self-call, so a reverting // upstream aggregator degrades to the lastGoodPrice fallback rather than // freezing the liquidation. try this.readLiquidationUpstream(asset) returns (uint256 p) { price = p; } catch { price = 0; } if (price == 0) price = f.lastGoodPrice; // fall back to the last known reference if (price == 0) revert ZeroPrice(); } /// @dev Upstream read for the liquidation path: staleness-TOLERANT (a stale /// price beats a frozen liquidation) and returns 0 instead of reverting on /// a malformed/zero/future-dated response so the caller can fall back to /// lastGoodPrice. External so getPriceForLiquidation can try/catch it /// against a reverting aggregator. Applies NO pause and NO deviation cap. function readLiquidationUpstream(address asset) external view returns (uint256) { Feed memory f = feeds[asset]; if (f.feedType == FeedType.ConstantOne) return 1e18; if (f.feedType == FeedType.ChainlinkLike) { (, int256 answer,, uint256 updatedAt,) = IChainlinkLike(f.source).latestRoundData(); if (answer <= 0 || updatedAt == 0 || updatedAt > block.timestamp) return 0; return _normalize(uint256(answer), f.sourceDecimals); // staleness tolerated } (uint256 nav, uint64 ts) = IAereNavOracleAdapter(f.source).latestForAsset(f.navAssetKey); if (nav == 0 || ts == 0 || uint256(ts) > block.timestamp) return 0; return _normalize(nav, f.sourceDecimals); } /// @notice Push the current upstream reading into `lastGoodPrice` state. /// @dev OWNER-ONLY to prevent attackers from locking in a manipulated /// reference price during a one-tick exploit window. The owner /// is expected to poke after planned legitimate moves (e.g. /// monthly RWA NAV updates) so deviation caps stay tight. /// A permissionless `pokeBounded` lets anyone advance the /// reference IFF the new price is within `maxDeviationBps` of /// the existing `lastGoodPrice` — this lets the reference /// track real markets without giving anyone a one-tick override. function poke(address asset) external onlyOwner { Feed storage f = feeds[asset]; if (!f.configured) revert FeedNotConfigured(); uint256 price = _readNormalised(f); if (price == 0) revert ZeroPrice(); f.lastGoodPrice = price; f.lastGoodTimestamp = uint64(block.timestamp); emit PriceUpdated(asset, price, uint64(block.timestamp)); } /// @notice Permissionless poke restricted to within-band moves. /// If lastGoodPrice is zero (never poked), reverts — caller /// must wait for the owner's first poke. function pokeBounded(address asset) external { Feed storage f = feeds[asset]; if (!f.configured) revert FeedNotConfigured(); if (f.lastGoodPrice == 0) revert FeedNotConfigured(); uint256 price = _readNormalised(f); if (price == 0) revert ZeroPrice(); if (f.maxDeviationBps != 0) { uint256 ref = f.lastGoodPrice; uint256 diff = price > ref ? price - ref : ref - price; uint256 maxDiff = (ref * f.maxDeviationBps) / 10_000; if (diff > maxDiff) revert FeedDeviationExceeded(); } f.lastGoodPrice = price; f.lastGoodTimestamp = uint64(block.timestamp); emit PriceUpdated(asset, price, uint64(block.timestamp)); } /* -------------------------------- admin --------------------------------- */ function configureFeed( address asset, FeedType feedType, address source, bytes32 navAssetKey, uint32 stalenessSeconds, uint16 maxDeviationBps, uint8 sourceDecimals ) external onlyOwner { if (asset == address(0)) revert ZeroAddress(); if (feedType != FeedType.ConstantOne && source == address(0)) revert ZeroAddress(); Feed storage f = feeds[asset]; f.configured = true; f.feedType = feedType; f.source = source; f.navAssetKey = navAssetKey; f.stalenessSeconds = stalenessSeconds; f.maxDeviationBps = maxDeviationBps; f.sourceDecimals = sourceDecimals; // Auto-poke: lock in the current upstream reading as the deviation // reference. Prevents the documented "never-poked feed skips // deviation silently" exploit on freshly configured feeds. uint256 initial = _readNormalised(f); if (initial == 0) revert ZeroPrice(); f.lastGoodPrice = initial; f.lastGoodTimestamp = uint64(block.timestamp); emit FeedConfigured(asset, feedType, source, stalenessSeconds, maxDeviationBps); emit PriceUpdated(asset, initial, uint64(block.timestamp)); } function setFeedPaused(address asset, bool paused) external onlyOwner { Feed storage f = feeds[asset]; if (!f.configured) revert FeedNotConfigured(); f.paused = paused; emit FeedPaused(asset, paused); } /* ----------------------------- internal --------------------------------- */ function _readNormalised(Feed memory f) internal view returns (uint256) { if (f.feedType == FeedType.ConstantOne) { return 1e18; } if (f.feedType == FeedType.ChainlinkLike) { (, int256 answer,, uint256 updatedAt,) = IChainlinkLike(f.source).latestRoundData(); if (answer <= 0 || updatedAt == 0) revert InvalidFeedResponse(); // ROUND-3 FIX: future-dated timestamp would underflow the // subtraction and panic-revert (DoS the lending market). Treat // future-dated feeds as invalid responses instead. if (updatedAt > block.timestamp) revert InvalidFeedResponse(); if (block.timestamp - updatedAt > f.stalenessSeconds) revert FeedStale(); return _normalize(uint256(answer), f.sourceDecimals); } // NavOracle (uint256 nav, uint64 ts) = IAereNavOracleAdapter(f.source).latestForAsset(f.navAssetKey); if (ts == 0 || nav == 0) revert InvalidFeedResponse(); if (uint256(ts) > block.timestamp) revert InvalidFeedResponse(); if (block.timestamp - ts > f.stalenessSeconds) revert FeedStale(); return _normalize(nav, f.sourceDecimals); } function _normalize(uint256 raw, uint8 dec) internal pure returns (uint256) { if (dec == 18) return raw; if (dec < 18) return raw * (10 ** (18 - dec)); return raw / (10 ** (dec - 18)); } } interface IChainlinkLike { function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80); } interface IAereNavOracleAdapter { function latestForAsset(bytes32 assetKey) external view returns (uint256 nav, uint64 timestamp); }