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.
279 lines
12 KiB
Solidity
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));
|
|
}
|
|
}
|