aere-contracts/contracts/season1/AereRewardDistributor.sol
Aere Network acac2f00a6
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 unpublished line of work joins the sanitized public line
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.
2026-08-15 13:59:30 +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 account, single-key EOA today, not a 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);
}
}