aere-contracts/contracts/retropgf/AereRetroPGFRound1.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

279 lines
12 KiB
Solidity

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