aere-contracts/contracts/oracles/AereFoundationAggregator.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

146 lines
6.2 KiB
Solidity

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