Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
221 lines
9.4 KiB
Solidity
221 lines
9.4 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)
|
|
* collects these reserve numbers, builds the Merkle tree, and posts
|
|
* the root via proposeSnapshot, signed by the Foundation owner
|
|
* account 0x0243A4f4 (measured on chain 2800: a single-key EOA
|
|
* today, not a multisig). Hardware-wallet storage of that key is
|
|
* NOT asserted here; the chain cannot attest to it.
|
|
*
|
|
* 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));
|
|
}
|
|
}
|