aere-contracts/contracts/season1/AereRewardDistributor.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

264 lines
12 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "./interfaces/ISeason1.sol";
/**
* @title AereRewardDistributor — AERE Season 1 "Altitude" reward claim (SECURITY-CRITICAL)
* @notice Merkle-rooted claim of NATIVE AERE against a Foundation-published
* root, with cliff + linear vesting, pre-claim sybil EXCLUSION,
* post-expiry CLAWBACK, an optional locked-sAERE delivery path, and an
* emergency PAUSE.
*
* ┌─────────────────────────── SAFETY POSTURE ───────────────────────────┐
* │ - This contract DEPLOYS UNFUNDED and holds 0 AERE. No Airdrop Reserve │
* │ funds move during the build. Only the founder signs the later │
* │ funding transaction, and ONLY AFTER an external audit. │
* │ - MUST BE EXTERNALLY AUDITED before it is funded. It will custody real│
* │ AERE once funded; treat every line as adversarial surface. │
* │ - The owner (Foundation multisig) can pause, exclude sybils before │
* │ claims begin, and claw back ONLY the UNCLAIMED remainder AFTER a │
* │ long expiry. The owner CANNOT redirect a valid, vested claim away │
* │ from its rightful account, and cannot shorten the claim window. │
* └───────────────────────────────────────────────────────────────────────┘
*
* Vesting: for an entitlement `total`, with start = rootPublishedAt,
* - t < start + cliff -> 0
* - t >= start + duration -> total
* - otherwise -> total * (t - start) / duration
* (cliff delays first unlock; at cliff a lump of total*cliff/duration is
* available, then it accrues linearly to full at start + duration.)
*
* Delivery: liquid native AERE by default, OR (if deliverAsSAERE) the vested
* amount is wrapped to WAERE and deposited into the sAERE ERC-4626 vault with
* the shares minted straight to the claimant (a staked, drip-redeemable
* position instead of liquid AERE).
*
* Leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount)))),
* the OpenZeppelin StandardMerkleTree leaf encoding (second-preimage safe).
*
* DISCLAIMER: Season 1 rewards are discretionary and depend on the network
* growing. Altitude Points are not a token and confer no right to value or
* returns. There is no listing. Nothing here is a promise of value.
*/
contract AereRewardDistributor is Ownable, Pausable, ReentrancyGuard {
/* ------------------------------ immutable refs -------------------------- */
/// @notice ERC-20 AERE (WAERE) used to feed the sAERE vault on the sAERE path.
IWaereWrap public immutable WAERE;
/// @notice sAERE ERC-4626 vault (asset() must be WAERE).
ISAEREVault public immutable SAERE;
/// @notice Optional clawback destination (AereSink).
address payable public immutable SINK;
/* --------------------------------- campaign ----------------------------- */
bytes32 public merkleRoot;
uint64 public rootPublishedAt; // vesting start; 0 == not published
uint64 public cliffDuration; // seconds after start before any unlock
uint64 public vestingDuration; // seconds after start to full unlock (>= cliff)
uint64 public claimExpiry; // absolute unix; after this, owner may clawback
bool public deliverAsSAERE; // deliver locked sAERE instead of liquid AERE
bool public claimsStarted; // once true, the root/params are frozen
uint256 public totalClaimed; // cumulative AERE (or AERE-equivalent) delivered
mapping(address => uint256) public claimed; // per-account cumulative delivered
mapping(address => bool) public excluded; // sybil revocation
/* --------------------------------- events ------------------------------- */
event Funded(address indexed from, uint256 amount);
event RootPublished(
bytes32 indexed merkleRoot,
uint64 rootPublishedAt,
uint64 cliffDuration,
uint64 vestingDuration,
uint64 claimExpiry,
bool deliverAsSAERE
);
event ExclusionSet(address indexed account, bool excluded);
event Claimed(address indexed account, uint256 amount, uint256 cumulative, uint256 total, bool asSAERE);
event ClaimExpiryExtended(uint64 newExpiry);
event ClawedBack(address indexed to, uint256 amount);
/* --------------------------------- errors ------------------------------- */
error NotPublished();
error RootFrozen();
error ZeroRoot();
error BadVesting();
error BadExpiry();
error IsExcluded();
error BadProof();
error NothingToClaim();
error ClawbackTooEarly();
error TransferFailed();
error ApproveFailed();
constructor(address waere, address sAERE, address payable sink) {
require(waere != address(0) && sAERE != address(0) && sink != address(0), "zero-ref");
// Sanity: sAERE must vault WAERE, else the sAERE path cannot work.
require(ISAEREVault(sAERE).asset() == waere, "sAERE-asset-mismatch");
WAERE = IWaereWrap(waere);
SAERE = ISAEREVault(sAERE);
SINK = sink;
// Deploys UNFUNDED: no value accepted in the constructor; balance == 0.
}
/// @notice Accept native AERE funding. The founder sends the funding tx
/// LATER, after an external audit. Nothing here pulls reserve funds.
receive() external payable {
emit Funded(msg.sender, msg.value);
}
/* --------------------------------- admin -------------------------------- */
/// @notice Publish (or, before the first claim, re-publish to correct) the
/// Merkle root and vesting/expiry parameters. Frozen once the first
/// claim lands.
function publishRoot(
bytes32 root,
uint64 _cliffDuration,
uint64 _vestingDuration,
uint64 _claimExpiry,
bool _deliverAsSAERE
) external onlyOwner {
if (claimsStarted) revert RootFrozen();
if (root == bytes32(0)) revert ZeroRoot();
if (_vestingDuration == 0 || _vestingDuration < _cliffDuration) revert BadVesting();
// Expiry must outlast the vesting schedule so every claimant can claim.
if (_claimExpiry <= block.timestamp + _vestingDuration) revert BadExpiry();
merkleRoot = root;
rootPublishedAt = uint64(block.timestamp);
cliffDuration = _cliffDuration;
vestingDuration = _vestingDuration;
claimExpiry = _claimExpiry;
deliverAsSAERE = _deliverAsSAERE;
emit RootPublished(root, rootPublishedAt, _cliffDuration, _vestingDuration, _claimExpiry, _deliverAsSAERE);
}
/// @notice Exclude (or re-include) an address. Excluding blocks that
/// address from claiming; it can never move a claim to a third
/// party. Intended for sybil revocation, ideally before claims
/// begin, but permitted anytime as a safety valve.
function setExcluded(address account, bool value) external onlyOwner {
excluded[account] = value;
emit ExclusionSet(account, value);
}
/// @notice Push the claim expiry LATER (never earlier). Lets the Foundation
/// extend the window; it can never shorten it to strand claimants.
function extendClaimExpiry(uint64 newExpiry) external onlyOwner {
if (newExpiry <= claimExpiry) revert BadExpiry();
claimExpiry = newExpiry;
emit ClaimExpiryExtended(newExpiry);
}
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
/* --------------------------------- claim -------------------------------- */
/// @notice Claim the currently-vested, not-yet-claimed portion of `account`'s
/// entitlement. Anyone may submit (relayer/paymaster friendly); the
/// reward always goes to `account`, which is bound in the leaf.
function claim(
address account,
uint256 totalAmount,
bytes32[] calldata proof
) external nonReentrant whenNotPaused {
if (merkleRoot == bytes32(0)) revert NotPublished();
if (excluded[account]) revert IsExcluded();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount))));
if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert BadProof();
uint256 vested = _vested(totalAmount, block.timestamp);
uint256 already = claimed[account];
if (vested <= already) revert NothingToClaim();
uint256 amount = vested - already;
// Effects.
claimed[account] = vested;
totalClaimed += amount;
if (!claimsStarted) claimsStarted = true;
// Interaction / delivery.
if (deliverAsSAERE) {
_deliverSAERE(account, amount);
} else {
(bool ok, ) = payable(account).call{value: amount}("");
if (!ok) revert TransferFailed();
}
emit Claimed(account, amount, vested, totalAmount, deliverAsSAERE);
}
/// @dev Wrap native AERE to WAERE and deposit into sAERE, minting shares
/// directly to the claimant.
function _deliverSAERE(address account, uint256 amount) internal {
WAERE.deposit{value: amount}();
if (!WAERE.approve(address(SAERE), amount)) revert ApproveFailed();
SAERE.deposit(amount, account);
}
/* ------------------------------- clawback ------------------------------- */
/// @notice After the (long) claim expiry, recover the UNCLAIMED remainder.
/// Destination is the Foundation owner, or AereSink if `toSink`.
/// Recovers only whatever native AERE is left; it cannot touch a
/// claim that has already been delivered.
function clawbackUnclaimed(bool toSink) external onlyOwner nonReentrant {
if (claimExpiry == 0) revert NotPublished();
if (block.timestamp <= claimExpiry) revert ClawbackTooEarly();
uint256 bal = address(this).balance;
address payable dest = toSink ? SINK : payable(owner());
(bool ok, ) = dest.call{value: bal}("");
if (!ok) revert TransferFailed();
emit ClawedBack(dest, bal);
}
/* --------------------------------- views -------------------------------- */
function _vested(uint256 total, uint256 atTime) internal view returns (uint256) {
if (rootPublishedAt == 0) return 0;
uint256 start = rootPublishedAt;
if (atTime < start + cliffDuration) return 0;
uint256 end = start + vestingDuration;
if (atTime >= end) return total;
return (total * (atTime - start)) / vestingDuration;
}
/// @notice Vested amount for a given entitlement at a given time.
function vestedAmount(uint256 total, uint256 atTime) external view returns (uint256) {
return _vested(total, atTime);
}
/// @notice Currently-claimable amount for `account` given their entitlement
/// `totalAmount` (caller supplies the total; not proof-checked here).
function claimableNow(address account, uint256 totalAmount) external view returns (uint256) {
if (excluded[account]) return 0;
uint256 vested = _vested(totalAmount, block.timestamp);
uint256 already = claimed[account];
return vested > already ? vested - already : 0;
}
/// @notice Verify a leaf against the current root without state changes.
function verifyEntry(address account, uint256 totalAmount, bytes32[] calldata proof)
external
view
returns (bool)
{
if (merkleRoot == bytes32(0)) return false;
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount))));
return MerkleProof.verify(proof, merkleRoot, leaf);
}
}