Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The published line and the local line had no common ancestor: the public one carried the redaction pass, the local one carried three weeks of corrections that never shipped. This commit ports the local work onto the public line, keeps every public redaction, and extends the same discretion to seven client mentions that were still named in published comments. Carried: LICENSE year and LICENSING.md; the measured burn figures replacing the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is a share of validator coinbase revenue, which is zero today); 'audited' removed from next to Bouncy Castle; citation paths rewritten to published form with CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and ownership comments brought down to what the code does; the AerePyth repair; the shutter test helper the tests cite; runnable package.json entries; the CI file split into a GitHub/Gitea twin pair with a real measured test-run status; and the .gitignore hardening written after a compiled artifact leaked a local path in a sibling repository. A false '2-of-3 multisig' description of the owner account is corrected to what the chain measures: an externally owned account. The self-audit findings catalog stays unpublished pending an explicit decision.
155 lines
7.0 KiB
Solidity
155 lines
7.0 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.19;
|
|
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title AereMiningDistributor
|
|
* @notice Generic Merkle-claim payout distributor, originally written for the
|
|
* AireFlow / mining-subscription epoch payouts.
|
|
*
|
|
* @dev Its intended counterparty, AereMiningSubscription, is NOT OFFERED and NOT
|
|
* DEPLOYED: Aere is a proof-of-authority chain with no mining, no hash rate
|
|
* and no mining rewards. See the note at the top of
|
|
* AereMiningSubscription.sol. The Merkle claim mechanism in this file is
|
|
* generic and makes no claim about the source, size or existence of any
|
|
* payout; amounts are whatever the Foundation posts in a root and funds.
|
|
* No return of any kind is guaranteed or implied.
|
|
*
|
|
* Workflow each epoch (default = 30 days, matches AereMiningSubscription):
|
|
*
|
|
* 1. Foundation runs an off-chain scheduler that walks all active
|
|
* subscriptions, computes each subscriber's reward share (a function
|
|
* of tier + days-active), produces a Merkle tree of
|
|
* (subscriber, amount) leaves and a root.
|
|
* 2. Foundation funds this contract with the epoch's total AERE payout
|
|
* by plain transfer or calling `fundEpoch{value:X}()`.
|
|
* 3. Foundation calls `postEpoch(root, totalAllocated, claimDeadline)`.
|
|
* The new epoch id is returned. The root is immutable for that epoch.
|
|
* 4. Each subscriber claims via `claim(epochId, index, amount, proof)`.
|
|
* The contract pays them in native AERE.
|
|
* 5. After `claimDeadline`, Foundation may `sweepUnclaimed(epochId)`
|
|
* to recover unclaimed funds (rolled into the next epoch's bucket).
|
|
*
|
|
* Funding source: AereMiningReserve wallet (whitepaper §3.2, 1.4B AERE
|
|
* earmarked over the network's life).
|
|
*
|
|
* This is not in custody of subscribers — they pull, contract never pushes.
|
|
* Single-claim guard: bitmap per epoch indexed by leaf index.
|
|
*/
|
|
contract AereMiningDistributor is Ownable, ReentrancyGuard {
|
|
struct Epoch {
|
|
bytes32 merkleRoot;
|
|
uint256 totalAllocated; // funds reserved for this epoch's leaves
|
|
uint256 totalClaimed;
|
|
uint64 claimDeadline;
|
|
bool swept;
|
|
}
|
|
|
|
Epoch[] public epochs;
|
|
/// epochId => packed bitmap (1 bit per leaf index claimed)
|
|
mapping(uint256 => mapping(uint256 => uint256)) private claimedBitmap;
|
|
|
|
event EpochPosted(uint256 indexed epochId, bytes32 merkleRoot, uint256 totalAllocated, uint64 claimDeadline);
|
|
event Funded(address indexed from, uint256 amount);
|
|
event Claimed(uint256 indexed epochId, uint256 indexed index, address indexed account, uint256 amount);
|
|
event Swept(uint256 indexed epochId, uint256 unclaimed);
|
|
|
|
receive() external payable { emit Funded(msg.sender, msg.value); }
|
|
function fundEpoch() external payable { emit Funded(msg.sender, msg.value); }
|
|
|
|
function epochCount() external view returns (uint256) { return epochs.length; }
|
|
|
|
function postEpoch(bytes32 merkleRoot, uint256 totalAllocated, uint64 claimDeadline)
|
|
external onlyOwner returns (uint256 epochId)
|
|
{
|
|
require(merkleRoot != bytes32(0), "MiningDist: zero root");
|
|
require(totalAllocated > 0, "MiningDist: zero total");
|
|
require(claimDeadline > block.timestamp, "MiningDist: deadline past");
|
|
require(address(this).balance >= _liveFunds() + totalAllocated, "MiningDist: underfunded");
|
|
epochs.push(Epoch({
|
|
merkleRoot: merkleRoot,
|
|
totalAllocated: totalAllocated,
|
|
totalClaimed: 0,
|
|
claimDeadline: claimDeadline,
|
|
swept: false
|
|
}));
|
|
epochId = epochs.length - 1;
|
|
emit EpochPosted(epochId, merkleRoot, totalAllocated, claimDeadline);
|
|
}
|
|
|
|
function isClaimed(uint256 epochId, uint256 index) public view returns (bool) {
|
|
uint256 wordIndex = index / 256;
|
|
uint256 bitIndex = index % 256;
|
|
uint256 word = claimedBitmap[epochId][wordIndex];
|
|
return (word & (1 << bitIndex)) != 0;
|
|
}
|
|
|
|
function _setClaimed(uint256 epochId, uint256 index) private {
|
|
uint256 wordIndex = index / 256;
|
|
uint256 bitIndex = index % 256;
|
|
claimedBitmap[epochId][wordIndex] |= (1 << bitIndex);
|
|
}
|
|
|
|
function claim(uint256 epochId, uint256 index, address account, uint256 amount, bytes32[] calldata proof)
|
|
external nonReentrant
|
|
{
|
|
require(epochId < epochs.length, "MiningDist: bad epoch");
|
|
Epoch storage e = epochs[epochId];
|
|
require(block.timestamp <= e.claimDeadline, "MiningDist: expired");
|
|
require(!isClaimed(epochId, index), "MiningDist: already claimed");
|
|
|
|
bytes32 leaf = keccak256(abi.encodePacked(index, account, amount));
|
|
require(_verify(proof, e.merkleRoot, leaf), "MiningDist: bad proof");
|
|
|
|
_setClaimed(epochId, index);
|
|
e.totalClaimed += amount;
|
|
require(e.totalClaimed <= e.totalAllocated, "MiningDist: over-allocated");
|
|
|
|
(bool ok, ) = account.call{ value: amount }("");
|
|
require(ok, "MiningDist: payout failed");
|
|
emit Claimed(epochId, index, account, amount);
|
|
}
|
|
|
|
function sweepUnclaimed(uint256 epochId) external onlyOwner nonReentrant {
|
|
require(epochId < epochs.length, "MiningDist: bad epoch");
|
|
Epoch storage e = epochs[epochId];
|
|
require(!e.swept, "MiningDist: already swept");
|
|
require(block.timestamp > e.claimDeadline, "MiningDist: too early");
|
|
uint256 unclaimed = e.totalAllocated - e.totalClaimed;
|
|
e.swept = true;
|
|
if (unclaimed > 0) {
|
|
(bool ok, ) = msg.sender.call{ value: unclaimed }("");
|
|
require(ok, "MiningDist: sweep failed");
|
|
}
|
|
emit Swept(epochId, unclaimed);
|
|
}
|
|
|
|
/// Funds still reserved by every un-swept epoch, INCLUDING epochs whose
|
|
/// claimDeadline has passed but that have not yet been swept. An expired
|
|
/// epoch still physically holds its unclaimed AERE (sweepUnclaimed sends
|
|
/// exactly that reserve to the owner), so it must keep reserving those
|
|
/// funds until it is actually swept — otherwise the same wei could both
|
|
/// back a freshly posted epoch AND be swept to the owner, double-counting
|
|
/// it and stranding the new epoch's honest claimants. Reservation leaves
|
|
/// _liveFunds exactly once: when sweepUnclaimed sets `swept = true`.
|
|
function _liveFunds() internal view returns (uint256 reserved) {
|
|
for (uint256 i = 0; i < epochs.length; i++) {
|
|
Epoch storage e = epochs[i];
|
|
if (!e.swept) {
|
|
reserved += e.totalAllocated - e.totalClaimed;
|
|
}
|
|
}
|
|
}
|
|
|
|
function _verify(bytes32[] calldata proof, bytes32 root, bytes32 leaf) internal pure returns (bool) {
|
|
bytes32 hash = leaf;
|
|
for (uint256 i = 0; i < proof.length; i++) {
|
|
bytes32 p = proof[i];
|
|
hash = hash < p ? keccak256(abi.encodePacked(hash, p)) : keccak256(abi.encodePacked(p, hash));
|
|
}
|
|
return hash == root;
|
|
}
|
|
}
|