// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereMiningDistributor * @notice Merkle-claim payout distributor for AireFlow / mining-subscription * epoch rewards. * * 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; } }