// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; /** * @title AereOracleV2 * @notice Bug-fix redeploy of the legacy AereOracle (V1 at * 0xf0A13823A4bFa86358Fe30aaf1f44A36AcbCf399). * * Multi-feed price oracle with a reporter quorum. Reporters submit price * updates per symbol (e.g. "AERE/USD", "BTC/USD"). The on-chain price is the * median of the latest FRESH submission from each authorized reporter. * * ──────────────────────── WHY V2 (finding #11) ──────────────────────── * V1.getPrice returned the median even when only ONE reporter was fresh * (contributors == 1). With the others stale, a single fresh reporter fully * controlled the feed — and the live V1 ran at reporterCount == 1, so the * median was always a single key's number. There was no on-chain minimum * quorum and no setter to raise one. * * V2 fix — a QUORUM-STALENESS guard: * getPrice reverts InsufficientQuorum unless at least `minContributors` * reporters are BOTH authorized AND fresh (within maxStaleness). Stale * submissions are still discarded exactly as in V1, so the quorum can only * be met by that many independently-fresh reporters — a single fresh * reporter (or any number below the quorum) can no longer set the price. * `minContributors` is configurable by the owner (Foundation), floored at 1, * and defaults to 3 for a real median. * * The reporter model, submit(), the getPrice return signature * (uint128 price, uint64 timestamp, uint256 contributors), DECIMALS, * maxStaleness and the median math are otherwise identical to V1, so V2 is a * drop-in replacement for any consumer once the quorum is operationally met. */ contract AereOracleV2 is Ownable { uint8 public constant DECIMALS = 8; uint64 public maxStaleness = 5 minutes; /// Minimum number of FRESH, authorized reporters required for getPrice to /// return a price. Invariant: minContributors >= 1. Default 3. uint256 public minContributors; address[] public reporters; mapping(address => bool) public isReporter; struct Submission { uint128 price; // scaled to 8 decimals uint64 timestamp; } // symbol => reporter => submission mapping(bytes32 => mapping(address => Submission)) public submissions; bytes32[] public symbols; mapping(bytes32 => bool) private _knownSymbol; event ReporterAdded(address indexed reporter); event ReporterRemoved(address indexed reporter); event PriceSubmitted(bytes32 indexed symbol, address indexed reporter, uint128 price, uint64 timestamp); event SymbolAdded(bytes32 indexed symbol); event MaxStalenessUpdated(uint64 newValue); event MinContributorsUpdated(uint256 newValue); error InsufficientQuorum(uint256 fresh, uint256 required); constructor(uint256 _minContributors) { require(_minContributors >= 1, "minContributors"); minContributors = _minContributors; emit MinContributorsUpdated(_minContributors); } function addReporter(address r) external onlyOwner { require(r != address(0) && !isReporter[r], "bad"); isReporter[r] = true; reporters.push(r); emit ReporterAdded(r); } function removeReporter(address r) external onlyOwner { require(isReporter[r], "not reporter"); isReporter[r] = false; for (uint256 i = 0; i < reporters.length; i++) { if (reporters[i] == r) { reporters[i] = reporters[reporters.length - 1]; reporters.pop(); break; } } emit ReporterRemoved(r); } function setMaxStaleness(uint64 v) external onlyOwner { require(v >= 30 seconds && v <= 1 hours, "range"); maxStaleness = v; emit MaxStalenessUpdated(v); } /// @notice Raise/lower the required fresh-reporter quorum. Floored at 1. /// Set this to <= the number of independently-operated reporters the /// Foundation actually runs before repointing consumers here. function setMinContributors(uint256 v) external onlyOwner { require(v >= 1, "minContributors"); minContributors = v; emit MinContributorsUpdated(v); } function submit(bytes32 symbol, uint128 price) external { require(isReporter[msg.sender], "not reporter"); require(price > 0, "price"); if (!_knownSymbol[symbol]) { _knownSymbol[symbol] = true; symbols.push(symbol); emit SymbolAdded(symbol); } submissions[symbol][msg.sender] = Submission(price, uint64(block.timestamp)); emit PriceSubmitted(symbol, msg.sender, price, uint64(block.timestamp)); } /// @notice Returns the median fresh price for a symbol and the count of /// contributors. Reverts InsufficientQuorum unless at least /// `minContributors` authorized reporters are fresh. function getPrice(bytes32 symbol) external view returns (uint128 price, uint64 timestamp, uint256 contributors) { uint256 n = reporters.length; uint128[] memory fresh = new uint128[](n); uint64 newest = 0; uint256 count = 0; for (uint256 i = 0; i < n; i++) { Submission memory s = submissions[symbol][reporters[i]]; if (s.timestamp == 0) continue; if (block.timestamp - s.timestamp > maxStaleness) continue; fresh[count++] = s.price; if (s.timestamp > newest) newest = s.timestamp; } // QUORUM-STALENESS GUARD: require a real quorum of FRESH reporters. This // is the fix over V1, which required only count > 0. if (count < minContributors) revert InsufficientQuorum(count, minContributors); // insertion sort (small n in practice) for (uint256 i = 1; i < count; i++) { uint128 key = fresh[i]; uint256 j = i; while (j > 0 && fresh[j - 1] > key) { fresh[j] = fresh[j - 1]; j--; } fresh[j] = key; } price = (count % 2 == 1) ? fresh[count / 2] : uint128((uint256(fresh[count / 2 - 1]) + uint256(fresh[count / 2])) / 2); return (price, newest, count); } function symbolCount() external view returns (uint256) { return symbols.length; } function reporterCount() external view returns (uint256) { return reporters.length; } }