aere-contracts/contracts/lending/AereLendingOracle.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

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);
}