Aere Network public source. Everything here can be checked against the live chain (chain id 2800, https://rpc.aere.network). Scope note, stated up front rather than buried: consensus on chain 2800 is classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at the signature, precompile, account and transport layers. Nothing here makes the consensus post-quantum, and no document in it should be read as claiming so.
246 lines
12 KiB
Solidity
246 lines
12 KiB
Solidity
// 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);
|
|
}
|