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

371 lines
19 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 AereRewardDistributorV2 - 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.
*
* AUDIT FIX (FINDING 2, medium) - part 1: the V1 setExcluded had no guard, so
* the owner could exclude an account that had ALREADY partially claimed. Its
* claim() then reverted IsExcluded and clawbackUnclaimed swept the rightful
* account's vested-but-unclaimed AERE to owner()/SINK after expiry,
* contradicting the advertised invariant. V2 blocks excluding any account that
* has already claimed (claimed[account] > 0), mirroring the RootFrozen freeze
* pattern. A claimant who has begun claiming can therefore always finish
* claiming their vested entitlement, and exclusion can never
* freeze-then-confiscate it.
*
* AUDIT FIX (FINDING 2, medium) - part 2 (RESIDUAL): part 1 still left a second
* road to the same confiscation. claim() is whenNotPaused but clawbackUnclaimed
* was NOT, and clawback swept the entire address(this).balance with no
* accounting of what claimants were still owed. So the owner could pause(),
* wait past claimExpiry, and sweep an in-progress claimant's vested-but-
* unclaimed AERE anyway. V2 closes this on two fronts:
* (a) clawbackUnclaimed is now whenNotPaused, so the owner can never clawback
* while the campaign is paused; and
* (b) the campaign's totalAllocated (the owner-DECLARED sum of every published
* leaf entitlement) is declared at publishRoot time and frozen with the
* root, and clawbackUnclaimed can recover ONLY the balance in excess of
* the reserve, i.e. balance - (totalAllocated - totalClaimed). AS LONG AS
* totalAllocated is declared honestly (see the RESERVE HONESTY DEPENDENCY
* in SAFETY POSTURE below), every leaf's full entitlement stays reserved
* and claimable forever, so clawback touches only overfunded dust that no
* leaf was ever entitled to, never a claimant's still-owed AERE. This
* guard is an accounting bound against overfunding, NOT an on-chain check
* on the owner: the contract cannot verify totalAllocated against the root
* (the root commits the leaves but hides their sum).
*
* ------------------------------ 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 that have NOT
* yet claimed, and claw back only balance beyond the declared reserve ONLY
* AFTER a long expiry and ONLY while not paused. The owner CANNOT exclude an
* account that has already claimed, CANNOT redirect or freeze a claim that is
* already in progress, cannot move a delivered claim, and cannot shorten the
* claim window. The clawback bound on a claimant's reserved entitlement holds
* subject to the RESERVE HONESTY DEPENDENCY below.
* - RESERVE HONESTY DEPENDENCY (read before trusting the clawback bound). The
* reserve clawbackUnclaimed must leave behind is totalAllocated - totalClaimed,
* and totalAllocated is an owner-supplied publishRoot argument. The contract
* CANNOT verify it against the Merkle root, because the root commits to the
* individual leaves but HIDES their sum. So "the owner cannot clawback a
* claimant's reserved entitlement" holds ONLY IF the owner publishes an honest
* totalAllocated equal to the true sum of all leaf entitlements. An owner who
* UNDER-declares totalAllocated could still sweep genuinely-entitled AERE after
* expiry. This does NOT widen the trust boundary: the same owner already
* defines every entitlement by choosing the root, so a dishonest owner could
* just as well have published a root that omitted or shrank those leaves. The
* reserve is an accounting guard against overfunding, not a constraint on an
* already-fully-trusted owner. OPERATIONAL MITIGATION: fund only against a
* clean post-filter root and set totalAllocated to the exact sum of the
* surviving (real) claimant leaves, so the reserve equals real outstanding
* entitlements and no entitled AERE is ever clawbackable.
* - EXCLUDING A NOT-YET-CLAIMED ACCOUNT LOCKS ITS ALLOCATION. setExcluded only
* blocks accounts that have not claimed, and it does NOT reduce totalAllocated,
* so clawbackUnclaimed keeps reserving that leaf's full entitlement. An
* excluded, never-claimed allocation therefore becomes permanently locked in
* the contract: neither claimable (the account is excluded) nor clawbackable
* (it stays inside the reserve). This is conservative-safe. It strands that
* balance in the contract rather than confiscating any claimant, and it is the
* intended, no-loss outcome for a revoked sybil.
* -------------------------------------------------------------------------
*
* 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 AereRewardDistributorV2 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
/// @notice Owner-DECLARED sum of every published leaf entitlement, supplied at
/// publishRoot time and frozen with the root. clawbackUnclaimed reserves
/// (totalAllocated - totalClaimed) so that, WHEN this value is declared
/// honestly, no claimant's owed AERE can be swept and only balance beyond
/// the reserve (overfunded dust) is recoverable. It is NOT verifiable
/// on-chain against the root (the root commits the leaves but hides their
/// sum); see the RESERVE HONESTY DEPENDENCY in the contract NatSpec.
uint256 public totalAllocated;
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,
uint256 totalAllocated
);
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 BadAllocation();
error NothingToClawback();
error IsExcluded();
error BadProof();
error NothingToClaim();
error ClawbackTooEarly();
error TransferFailed();
error ApproveFailed();
error AlreadyClaimed();
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.
/// @param _totalAllocated the sum of every leaf entitlement under `root`.
/// It is frozen with the root and pins the reserve that
/// clawbackUnclaimed must always leave behind, so overfunded dust can
/// be recovered while no claimant's owed AERE ever can. It cannot be
/// zero (an all-zero campaign has nothing to distribute).
function publishRoot(
bytes32 root,
uint64 _cliffDuration,
uint64 _vestingDuration,
uint64 _claimExpiry,
bool _deliverAsSAERE,
uint256 _totalAllocated
) external onlyOwner {
if (claimsStarted) revert RootFrozen();
if (root == bytes32(0)) revert ZeroRoot();
if (_totalAllocated == 0) revert BadAllocation();
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;
totalAllocated = _totalAllocated;
emit RootPublished(root, rootPublishedAt, _cliffDuration, _vestingDuration, _claimExpiry, _deliverAsSAERE, _totalAllocated);
}
/// @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 before an entry has drawn anything.
///
/// FINDING 2 FIX: an account that has already claimed
/// (claimed[account] > 0) can NOT be excluded. This removes the
/// freeze-then-clawback confiscation vector, so a claimant who has
/// begun claiming can always finish claiming their vested amount.
/// Re-inclusion (value == false) is always permitted.
function setExcluded(address account, bool value) external onlyOwner {
if (value && claimed[account] > 0) revert AlreadyClaimed();
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 ONLY the native AERE balance
/// in excess of the reserve, reserve = totalAllocated - totalClaimed.
/// Destination is the Foundation owner, or AereSink if `toSink`.
///
/// PROVIDED totalAllocated was declared honestly at publishRoot (a value
/// this contract cannot verify against the root; see the RESERVE HONESTY
/// DEPENDENCY in the contract NatSpec), this cannot touch a claimant's
/// vested-but-unclaimed AERE: every published leaf's full entitlement
/// stays reserved and claimable forever, even past expiry, and clawback
/// recovers only overfunded dust beyond that reserve. It is also
/// whenNotPaused, so the owner cannot pause the campaign and clawback
/// around a claim that is in progress. Reverts NothingToClawback when
/// the balance is fully reserved.
function clawbackUnclaimed(bool toSink) external onlyOwner nonReentrant whenNotPaused {
if (claimExpiry == 0) revert NotPublished();
if (block.timestamp <= claimExpiry) revert ClawbackTooEarly();
uint256 reserved = totalAllocated > totalClaimed ? totalAllocated - totalClaimed : 0;
uint256 bal = address(this).balance;
if (bal <= reserved) revert NothingToClawback();
uint256 amount = bal - reserved;
address payable dest = toSink ? SINK : payable(owner());
(bool ok, ) = dest.call{value: amount}("");
if (!ok) revert TransferFailed();
emit ClawedBack(dest, amount);
}
/// @notice The amount clawbackUnclaimed could recover right now (0 before
/// expiry, while paused, or when the balance is fully reserved).
function clawableNow() external view returns (uint256) {
if (claimExpiry == 0 || block.timestamp <= claimExpiry || paused()) return 0;
uint256 reserved = totalAllocated > totalClaimed ? totalAllocated - totalClaimed : 0;
uint256 bal = address(this).balance;
return bal > reserved ? bal - reserved : 0;
}
/* --------------------------------- 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);
}
}