// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; /** * @title AereRetroPGFRound1 — Retroactive Public Goods Funding, Round 1 * @notice 10M AERE pool distributed across 5 categories to projects that have * already shipped on AERE Network. Round 1 is the first of an annual * cadence; later rounds re-deploy this contract with different totals * and category caps. * * CATEGORIES (per immediate_build_plan_2026-06-06): * 1. Infrastructure (validator-host operators, indexers, RPC, faucet) * 2. Developer tooling (SDKs, CLIs, AI assistant, examples) * 3. Applications (dApps live on chain 2800) * 4. Education (courses, blog, technical writing) * 5. Security (audit contests, bug-bounty reporters, samczsun-tier review) * plus a 6th "Buffer" category for late-emerging proposals. * * @dev PAYOUT MECHANISM: optimistic Merkle-rooted payout list. * 1. Foundation Safe (multisig) publishes a Merkle root of * (recipient, category, amount) tuples in `proposePayoutRoot()`. * 2. A 14-day CHALLENGE PERIOD begins. Anyone can dispute by calling * `challenge(reason)` — this freezes the round indefinitely * until the multisig either fixes the root or, if the challenge * is malicious, calls `dismissChallenge(challengeId)` after a * public response. * 3. After the challenge window elapses without a challenge, * `finalize()` activates the payout list. * 4. Recipients call `claim(category, amount, proof)` to receive * their AERE. Claims can be batched off-chain by a relayer * and submitted by a third party via `claimFor`. * 5. After 365 days the unclaimed remainder rolls back to the * Ecosystem Reserve via `sweepUnclaimed()`. The reserve * receiver is set at deploy and is immutable. * * Per-category caps are immutable at deploy. Foundation cannot * publish a Merkle root whose category sums exceed the cap; on- * chain enforcement happens at claim time (a category counter is * tracked and overruns revert). * * IMMUTABILITY: * - totalPool, categoryCaps, fundingToken, ecosystemReserve all immutable * - challengeWindow, sweepWindow constants * - Foundation can only (a) propose / finalize / sweep / dismiss * — never withdraw funds outside the published list */ contract AereRetroPGFRound1 is Ownable, ReentrancyGuard { /* ---------------------------- immutable config ---------------------------- */ /// @notice Token paid out (WAERE). IERC20 public immutable FUNDING_TOKEN; /// @notice Receiver for unclaimed funds after sweepWindow elapses. address public immutable ECOSYSTEM_RESERVE; /// @notice Total AERE in the round (10M per spec). uint256 public immutable TOTAL_POOL; /// @notice 14-day challenge window before payouts go live. uint256 public constant CHALLENGE_WINDOW = 14 days; /// @notice 365-day claim window. Unclaimed AERE rolls back to Ecosystem Reserve. uint256 public constant SWEEP_WINDOW = 365 days; uint8 public constant CATEGORIES = 6; /// @notice Per-category caps. Length = CATEGORIES. Sum must equal TOTAL_POOL. uint256[6] public categoryCap; /* ---------------------------- mutable round state -------------------------- */ enum State { Funding, Proposed, Challenged, Finalized, Swept } State public state; /// @notice Merkle root of the proposed payout list. bytes32 public payoutRoot; /// @notice Timestamp at which the proposed root finalises automatically. uint256 public earliestFinalizeTs; /// @notice Timestamp at which unclaimed funds become sweep-eligible. uint256 public earliestSweepTs; /// @notice Total AERE claimed per category. Enforces category caps. uint256[6] public categoryClaimed; /// @notice claim-leaf consumed flag (keccak256 of leaf → claimed). mapping(bytes32 => bool) public leafClaimed; /// @notice Active challenges. Multiple challenges may exist concurrently; /// each must be dismissed individually OR a new root may be proposed /// after at least one challenge has been dismissed. mapping(uint256 => Challenge) public challenges; uint256 public nextChallengeId; uint256 public openChallengeCount; struct Challenge { address challenger; uint64 timestamp; string reason; bool dismissed; } /* ---------------------------------- events ---------------------------------- */ event Funded(address indexed funder, uint256 amount); event PayoutRootProposed(bytes32 indexed root, uint256 earliestFinalize); event Challenged(uint256 indexed id, address indexed challenger, string reason); event ChallengeDismissed(uint256 indexed id, string response); event Finalized(bytes32 indexed root, uint256 earliestSweep); event Claimed(address indexed recipient, uint8 indexed category, uint256 amount); event Swept(address indexed receiver, uint256 amount); /* ---------------------------------- errors ---------------------------------- */ error WrongState(State got, State want); error CapsMustSumToTotal(); error ZeroAddress(); error InsufficientFunding(); error TimelockNotElapsed(uint256 nowTs, uint256 earliest); error AlreadyClaimed(); error InvalidProof(); error CategoryOverflow(uint8 category, uint256 wouldBe, uint256 cap); error UnknownCategory(uint8 category); error OutstandingChallenges(uint256 count); error SweepWindowNotElapsed(); /* ---------------------------------- modifiers ------------------------------- */ modifier inState(State s) { if (state != s) revert WrongState(state, s); _; } /* ---------------------------- constructor ---------------------------- */ constructor( address fundingToken, address ecosystemReserve, uint256 totalPool, uint256[6] memory _categoryCaps ) { if (fundingToken == address(0) || ecosystemReserve == address(0)) revert ZeroAddress(); uint256 sum; for (uint8 i = 0; i < 6; i++) sum += _categoryCaps[i]; if (sum != totalPool) revert CapsMustSumToTotal(); FUNDING_TOKEN = IERC20(fundingToken); ECOSYSTEM_RESERVE = ecosystemReserve; TOTAL_POOL = totalPool; for (uint8 i = 0; i < 6; i++) categoryCap[i] = _categoryCaps[i]; state = State.Funding; } /* ---------------------------------- lifecycle ------------------------------- */ /// @notice Owner funds the round. Caller must approve TOTAL_POOL first. function fund() external onlyOwner inState(State.Funding) { bool ok = FUNDING_TOKEN.transferFrom(msg.sender, address(this), TOTAL_POOL); if (!ok) revert InsufficientFunding(); emit Funded(msg.sender, TOTAL_POOL); } /// @notice Owner publishes a Merkle root. Funds must already be in the /// contract. Starts a 14-day challenge window. function proposePayoutRoot(bytes32 root) external onlyOwner { if (state != State.Funding && state != State.Challenged) { revert WrongState(state, State.Funding); } if (FUNDING_TOKEN.balanceOf(address(this)) < TOTAL_POOL) revert InsufficientFunding(); payoutRoot = root; earliestFinalizeTs = block.timestamp + CHALLENGE_WINDOW; state = State.Proposed; // Reset claim flags? No — leafClaimed is keyed by leaf hash, so a // new root with different leaves naturally avoids replay. emit PayoutRootProposed(root, earliestFinalizeTs); } /// @notice Anyone can raise a challenge against the proposed root during /// the challenge window. State transitions to Challenged; the /// multisig must dismiss the challenge or propose a new root. function challenge(string calldata reason) external inState(State.Proposed) { uint256 id = nextChallengeId++; challenges[id] = Challenge({ challenger: msg.sender, timestamp: uint64(block.timestamp), reason: reason, dismissed: false }); openChallengeCount++; state = State.Challenged; emit Challenged(id, msg.sender, reason); } /// @notice Owner dismisses a challenge with a public response. function dismissChallenge(uint256 id, string calldata response) external onlyOwner { Challenge storage c = challenges[id]; require(c.timestamp != 0 && !c.dismissed, "no challenge"); c.dismissed = true; openChallengeCount--; if (openChallengeCount == 0) { // Restore to Proposed; the challenge window is NOT reset, // earliestFinalizeTs continues from the original timer so // dismissals can't be used to stall claimants forever. state = State.Proposed; } emit ChallengeDismissed(id, response); } /// @notice After challenge window with no open challenges, anyone can /// finalise so claims become possible. function finalize() external inState(State.Proposed) { if (block.timestamp < earliestFinalizeTs) { revert TimelockNotElapsed(block.timestamp, earliestFinalizeTs); } if (openChallengeCount > 0) revert OutstandingChallenges(openChallengeCount); state = State.Finalized; earliestSweepTs = block.timestamp + SWEEP_WINDOW; emit Finalized(payoutRoot, earliestSweepTs); } /* ---------------------------------- claims ---------------------------------- */ /// @notice Recipient (or a relayer) claims a payout by presenting the /// Merkle proof for their (recipient, category, amount) leaf. /// @dev Leaf encoding: keccak256(abi.encode(recipient, category, amount)). /// Category overflow is enforced at claim time. function claim( address recipient, uint8 category, uint256 amount, bytes32[] calldata proof ) external nonReentrant inState(State.Finalized) { if (category >= CATEGORIES) revert UnknownCategory(category); bytes32 leaf = keccak256(abi.encode(recipient, category, amount)); if (leafClaimed[leaf]) revert AlreadyClaimed(); if (!MerkleProof.verify(proof, payoutRoot, leaf)) revert InvalidProof(); uint256 wouldBe = categoryClaimed[category] + amount; if (wouldBe > categoryCap[category]) { revert CategoryOverflow(category, wouldBe, categoryCap[category]); } leafClaimed[leaf] = true; categoryClaimed[category] = wouldBe; bool ok = FUNDING_TOKEN.transfer(recipient, amount); if (!ok) revert InsufficientFunding(); emit Claimed(recipient, category, amount); } /* ----------------------------------- sweep ---------------------------------- */ /// @notice After 365 days post-finalize, anyone can roll unclaimed funds /// back to the Ecosystem Reserve. function sweepUnclaimed() external nonReentrant inState(State.Finalized) { if (block.timestamp < earliestSweepTs) revert SweepWindowNotElapsed(); uint256 bal = FUNDING_TOKEN.balanceOf(address(this)); state = State.Swept; if (bal > 0) { bool ok = FUNDING_TOKEN.transfer(ECOSYSTEM_RESERVE, bal); if (!ok) revert InsufficientFunding(); } emit Swept(ECOSYSTEM_RESERVE, bal); } /* ----------------------------------- views ---------------------------------- */ function categoryRemaining(uint8 cat) external view returns (uint256) { return categoryCap[cat] - categoryClaimed[cat]; } function totalRemaining() external view returns (uint256) { return FUNDING_TOKEN.balanceOf(address(this)); } }