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