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