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

218 lines
9.2 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
/**
* @title AereNavOracle — Merkle-rooted asset reserve attestations
* @notice Publishes proof-of-backing for every bridged stablecoin and RWA
* settlement deposit held by AERE protocol contracts. The Foundation
* publishes a snapshot Merkle root once per epoch (24h by default);
* anyone can later verify a specific (asset, reserveAmount) pair
* against the published root via a Merkle proof.
*
* Used by:
* - aere.network/reserves — public dashboard
* - USDC.e/USDT.e/USDe.e/EURC.e display pages
* - AereSettlementHub for the collateral wave 1 assets
* - third-party integrators verifying reserve before custody
*
* @dev Optimistic challenge: every newly proposed root sits for
* CHALLENGE_WINDOW (24h default) before becoming "attested".
* During that window anyone can submit `challenge(epoch, reason)`
* which flags the epoch for Foundation review. Challenged epochs
* cannot be promoted to attested until the Foundation dismisses
* the challenge with a public response (similar to AereRetroPGF
* pattern).
*
* Leaf format (single-hash to match standard Merkle libs):
* keccak256(abi.encode(asset, chainId, reserveAmount, decimals))
*
* Asset reserve sources (off-chain):
* - bridged stablecoins: AereHypERC20Collateral.lockedSupply on
* Ethereum mainnet for the corresponding token.
* - RWA tokens: on-chain balanceOf on the contract that
* custodies them on AERE chain 2800 (set in the SettlementHub
* receiver allowlist).
* - native AERE: fixed 2.8B from genesis minus burned
* balance at 0x...dEaD.
*
* The Foundation off-chain ingester (Python cron on Hostinger VPS,
* Ledger-signed) collects these reserve numbers, builds the Merkle
* tree, and posts the root via proposeSnapshot.
*
* IMMUTABILITY:
* - CHALLENGE_WINDOW constant.
* - Foundation can only propose / dismiss / attest — never
* retroactively alter an attested snapshot.
* - History is append-only; older snapshots remain queryable.
*/
contract AereNavOracle is Ownable {
/* ------------------------------- immutable ------------------------------- */
uint256 public constant CHALLENGE_WINDOW = 24 hours;
/* --------------------------------- state -------------------------------- */
enum State { Proposed, Challenged, Attested }
struct Snapshot {
bytes32 root;
uint64 proposedAt;
uint64 attestedAt;
State state;
string ipfsCid; // off-chain JSON of the full reserve list, optional
}
/// @notice epoch number → snapshot
mapping(uint256 => Snapshot) public snapshots;
/// @notice monotonically-increasing epoch counter.
uint256 public latestEpoch;
/// @notice epoch → open-challenge counter (multiple may exist).
mapping(uint256 => uint256) public openChallenges;
mapping(uint256 => Challenge[]) public epochChallenges;
struct Challenge {
address challenger;
uint64 timestamp;
string reason;
bool dismissed;
}
/* --------------------------------- events ------------------------------- */
event SnapshotProposed(uint256 indexed epoch, bytes32 root, string ipfsCid);
event SnapshotChallenged(uint256 indexed epoch, uint256 indexed challengeId, address challenger, string reason);
event ChallengeDismissed(uint256 indexed epoch, uint256 indexed challengeId, string response);
event SnapshotAttested(uint256 indexed epoch, bytes32 root);
/* --------------------------------- errors ------------------------------- */
error WrongState(State got, State want);
error TimelockNotElapsed(uint256 nowTs, uint256 earliest);
error UnknownEpoch();
error UnknownChallenge();
error InvalidProof();
error EpochAlreadyExists();
/* -------------------------------- propose -------------------------------- */
/// @notice Foundation publishes a new snapshot root. Starts the 24h
/// challenge window. Epoch must be monotonically-greater than
/// the latest one.
function proposeSnapshot(uint256 epoch, bytes32 root, string calldata ipfsCid) external onlyOwner {
if (snapshots[epoch].proposedAt != 0) revert EpochAlreadyExists();
snapshots[epoch] = Snapshot({
root: root,
proposedAt: uint64(block.timestamp),
attestedAt: 0,
state: State.Proposed,
ipfsCid: ipfsCid
});
if (epoch > latestEpoch) latestEpoch = epoch;
emit SnapshotProposed(epoch, root, ipfsCid);
}
/* ------------------------------ challenge ------------------------------- */
/// @notice Anyone can challenge a proposed snapshot within the window.
/// Returns the challenge id.
function challenge(uint256 epoch, string calldata reason) external returns (uint256 challengeId) {
Snapshot storage s = snapshots[epoch];
if (s.proposedAt == 0) revert UnknownEpoch();
if (s.state != State.Proposed && s.state != State.Challenged) revert WrongState(s.state, State.Proposed);
if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) {
revert TimelockNotElapsed(block.timestamp, uint256(s.proposedAt) + CHALLENGE_WINDOW);
}
challengeId = epochChallenges[epoch].length;
epochChallenges[epoch].push(Challenge({
challenger: msg.sender,
timestamp: uint64(block.timestamp),
reason: reason,
dismissed: false
}));
openChallenges[epoch]++;
s.state = State.Challenged;
emit SnapshotChallenged(epoch, challengeId, msg.sender, reason);
}
function dismissChallenge(uint256 epoch, uint256 challengeId, string calldata response) external onlyOwner {
Challenge[] storage list = epochChallenges[epoch];
if (challengeId >= list.length) revert UnknownChallenge();
Challenge storage c = list[challengeId];
if (c.dismissed) revert UnknownChallenge();
c.dismissed = true;
openChallenges[epoch]--;
Snapshot storage s = snapshots[epoch];
if (openChallenges[epoch] == 0) s.state = State.Proposed;
emit ChallengeDismissed(epoch, challengeId, response);
}
/* ----------------------------- attestation ----------------------------- */
/// @notice Permissionless attestation after the 24h challenge window
/// with no open challenges. Locks the snapshot as attested.
function attest(uint256 epoch) external {
Snapshot storage s = snapshots[epoch];
if (s.proposedAt == 0) revert UnknownEpoch();
if (s.state != State.Proposed) revert WrongState(s.state, State.Proposed);
uint256 earliest = uint256(s.proposedAt) + CHALLENGE_WINDOW;
if (block.timestamp < earliest) revert TimelockNotElapsed(block.timestamp, earliest);
if (openChallenges[epoch] != 0) revert WrongState(State.Challenged, State.Proposed);
s.state = State.Attested;
s.attestedAt = uint64(block.timestamp);
emit SnapshotAttested(epoch, s.root);
}
/* -------------------------------- views -------------------------------- */
/// @notice Verify a (asset, chainId, reserveAmount, decimals) tuple
/// against the attested root for `epoch`.
/// @return ok True if the proof is valid AND the snapshot is attested.
function verifyReserve(
uint256 epoch,
address asset,
uint256 chainId,
uint256 reserveAmount,
uint8 decimals,
bytes32[] calldata proof
) external view returns (bool ok) {
Snapshot storage s = snapshots[epoch];
if (s.state != State.Attested) return false;
bytes32 leaf = keccak256(abi.encode(asset, chainId, reserveAmount, decimals));
return MerkleProof.verify(proof, s.root, leaf);
}
function snapshotStatus(uint256 epoch) external view returns (
bytes32 root,
uint64 proposedAt,
uint64 attestedAt,
State state,
uint256 openChallengeCount,
string memory ipfsCid
) {
Snapshot storage s = snapshots[epoch];
return (s.root, s.proposedAt, s.attestedAt, s.state, openChallenges[epoch], s.ipfsCid);
}
/// @notice ROUND-3 FIX: bounded loop. Attacker proposing many challenged-but-
/// never-attested snapshots could DoS consumer dApps reading this view.
/// Cap at 128 epochs (~128 days at daily NAV updates).
uint256 public constant MAX_EPOCH_SCAN = 128;
function latestAttestedEpoch() external view returns (uint256 epoch, bytes32 root) {
uint256 scanned = 0;
for (uint256 e = latestEpoch; e > 0 && scanned < MAX_EPOCH_SCAN; e--) {
if (snapshots[e].state == State.Attested) {
return (e, snapshots[e].root);
}
unchecked { scanned++; }
}
return (0, bytes32(0));
}
}