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.
371 lines
19 KiB
Solidity
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 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);
|
|
}
|
|
}
|