// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; /** * @title AereFoundationAggregator — Chainlink-compatible single-asset feed * @notice One deployment per (asset, denomination) pair. The Foundation * pushes price updates (in 8-decimal Chainlink convention) backed * by an off-chain attestation pipeline (e.g. fund-administrator * signed NAV reports for BUIDL/USDY/OUSG, exchange median for * BTC LST de-peg detection, etc.). * * Exposes the EXACT Chainlink AggregatorV3 surface that * AereLendingOracle expects: * function latestRoundData() external view returns * (uint80 roundId, int256 answer, uint256 startedAt, * uint256 updatedAt, uint80 answeredInRound); * * SAFETY: * - `description()` is set at deploy and immutable (e.g. "BUIDL.e/USD"). * - `decimals()` is fixed at 8 to match the Chainlink ecosystem. * - `maxJumpBps`: cap on per-update price move. Set at deploy. * Foundation cannot push a single update that exceeds this cap. * Forces multi-update walks for any large legitimate move * (gives external observers a chance to challenge / freeze). * - `minHeartbeat`: minimum seconds between updates. Prevents * Foundation from spamming small updates to walk the price * rapidly within a single block. * - PAUSE: Foundation can pause; while paused, `latestRoundData` * returns the LAST GOOD price + stale timestamp so the * AereLendingOracle staleness check trips and lending falls * back to "no fresh price" → safest behaviour (blocks new * borrows, allows liquidations on the pre-pause price). * * WORKFLOW: * 1. Foundation gets off-chain NAV (BUIDL daily, USDY hourly, etc.). * 2. Multisig signs `updateAnswer(price)` via Ledger. * 3. Contract validates: not paused, ≥ minHeartbeat since last, * within maxJumpBps of current price. * 4. Round is incremented, answer + updatedAt stored. * 5. AereLendingOracle reads via configured feed. */ contract AereFoundationAggregator is Ownable { /* ------------------------------- immutable ------------------------------- */ string public description; uint8 public constant decimals = 8; uint16 public immutable maxJumpBps; // e.g. 500 = 5% uint32 public immutable minHeartbeat; // e.g. 300 = 5 min /* --------------------------------- state -------------------------------- */ uint80 public latestRoundId; int256 public latestAnswer; uint256 public latestUpdatedAt; bool public paused; /// @notice roundId → {answer, updatedAt} mapping(uint80 => RoundData) internal _rounds; struct RoundData { int256 answer; uint256 updatedAt; } /* --------------------------------- events ------------------------------- */ event AnswerUpdated(int256 indexed answer, uint256 indexed roundId, uint256 updatedAt); event PausedSet(bool paused); /* --------------------------------- errors ------------------------------- */ error InvalidPrice(); error HeartbeatTooSoon(uint256 next); error JumpExceeded(uint256 deviationBps, uint256 cap); error AggregatorPaused(); error UnknownRound(); /* ----------------------------- constructor ------------------------------ */ constructor( string memory description_, uint16 maxJumpBps_, uint32 minHeartbeat_, int256 initialAnswer ) { require(initialAnswer > 0, "initial price zero"); require(maxJumpBps_ > 0 && maxJumpBps_ <= 5_000, "jump cap"); description = description_; maxJumpBps = maxJumpBps_; minHeartbeat = minHeartbeat_; latestRoundId = 1; latestAnswer = initialAnswer; latestUpdatedAt = block.timestamp; _rounds[1] = RoundData(initialAnswer, block.timestamp); emit AnswerUpdated(initialAnswer, 1, block.timestamp); } /* ----------------------------- price update ----------------------------- */ function updateAnswer(int256 newAnswer) external onlyOwner { if (paused) revert AggregatorPaused(); if (newAnswer <= 0) revert InvalidPrice(); if (block.timestamp < latestUpdatedAt + minHeartbeat) { revert HeartbeatTooSoon(latestUpdatedAt + minHeartbeat); } // Jump check. int256 prev = latestAnswer; uint256 absDiff; if (newAnswer > prev) absDiff = uint256(newAnswer - prev); else absDiff = uint256(prev - newAnswer); uint256 cap = (uint256(prev) * maxJumpBps) / 10_000; if (absDiff > cap) revert JumpExceeded((absDiff * 10_000) / uint256(prev), maxJumpBps); unchecked { latestRoundId += 1; } latestAnswer = newAnswer; latestUpdatedAt = block.timestamp; _rounds[latestRoundId] = RoundData(newAnswer, block.timestamp); emit AnswerUpdated(newAnswer, latestRoundId, block.timestamp); } function setPaused(bool _paused) external onlyOwner { paused = _paused; emit PausedSet(_paused); } /* ------------------------------ Chainlink view -------------------------- */ function latestRoundData() external view returns ( uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound ) { // When paused: still return last good values; the AereLendingOracle's // staleness window will eventually expire and tip downstream into // safe-mode automatically. We do NOT lie about updatedAt — paused // means the upstream attestor stopped vouching, so the on-chain // timestamp legitimately freezes. return (latestRoundId, latestAnswer, latestUpdatedAt, latestUpdatedAt, latestRoundId); } function getRoundData(uint80 roundId) external view returns ( uint80 _roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound ) { RoundData memory r = _rounds[roundId]; if (r.updatedAt == 0) revert UnknownRound(); return (roundId, r.answer, r.updatedAt, r.updatedAt, roundId); } }