// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereLockedStaking * @notice Liquidity-lock staking with fixed terms (whitepaper §3.3). * * Users lock native AERE for a fixed term and earn fixed APY paid in AERE * on maturity. Early exit forfeits all reward and returns principal only. * * Tiers (matched to stake-v2.html): * 0 — 30 days · 10% APY * 1 — 90 days · 15% APY * 2 — 180 days · 22% APY * 3 — 365 days · 30% APY * * Reward pool is seeded by the Foundation; the contract refuses to pay * a maturity if its balance cannot cover principal + reward (so other * stakers' principal can never be drained to pay rewards). */ contract AereLockedStaking is Ownable, ReentrancyGuard { // tier => APY in basis points (10000 = 100%) uint256[4] public APY_BPS = [1000, 1500, 2200, 3000]; // tier => lock duration in seconds uint64[4] private DURATIONS = [30 days, 90 days, 180 days, 365 days]; uint256 public constant MIN_STAKE = 1 ether; // 1 AERE uint256 public constant MAX_STAKE = 100_000 ether; // sanity cap per lock struct Lock { uint256 amount; uint64 startedAt; uint64 unlockAt; uint8 tier; bool withdrawn; } mapping(address => Lock[]) private _locks; // Total principal currently locked (excludes rewards owed). Used to // determine how much of `address(this).balance` is the reward reserve. uint256 public totalPrincipal; event Staked(address indexed user, uint256 indexed lockId, uint256 amount, uint8 tier, uint64 unlockAt); event Withdrawn(address indexed user, uint256 indexed lockId, uint256 principal, uint256 reward); event EarlyExited(address indexed user, uint256 indexed lockId, uint256 principal); event RewardReserveDeposited(address indexed from, uint256 amount); /** * @notice Lock `msg.value` for the tier's term and earn its APY on maturity. */ function stake(uint8 tier) external payable nonReentrant returns (uint256 lockId) { require(tier < 4, "AereLockedStaking: bad tier"); require(msg.value >= MIN_STAKE, "AereLockedStaking: below min"); require(msg.value <= MAX_STAKE, "AereLockedStaking: above max"); uint64 nowTs = uint64(block.timestamp); uint64 unlockAt = nowTs + DURATIONS[tier]; lockId = _locks[msg.sender].length; _locks[msg.sender].push(Lock({ amount: msg.value, startedAt: nowTs, unlockAt: unlockAt, tier: tier, withdrawn: false })); totalPrincipal += msg.value; emit Staked(msg.sender, lockId, msg.value, tier, unlockAt); } /** * @notice Reward owed if this lock matures untouched. Returns 0 if the * lock has already been withdrawn or early-exited. * reward = principal * APY_BPS * duration / 365 days / 10000 */ function rewardOf(address user, uint256 lockId) public view returns (uint256) { if (lockId >= _locks[user].length) return 0; Lock memory l = _locks[user][lockId]; if (l.withdrawn) return 0; return (l.amount * APY_BPS[l.tier] * DURATIONS[l.tier]) / (365 days * 10000); } /** * @notice Claim a matured lock. Pays principal + full reward. */ function withdraw(uint256 lockId) external nonReentrant { Lock storage l = _locks[msg.sender][lockId]; require(!l.withdrawn, "AereLockedStaking: already withdrawn"); require(block.timestamp >= l.unlockAt, "AereLockedStaking: still locked"); l.withdrawn = true; totalPrincipal -= l.amount; uint256 reward = (l.amount * APY_BPS[l.tier] * DURATIONS[l.tier]) / (365 days * 10000); uint256 total = l.amount + reward; // Pay only if the reward reserve covers it (the principal we hold // for other locks must never be touched). require(address(this).balance >= total + totalPrincipal, "AereLockedStaking: reward reserve insufficient"); (bool ok, ) = msg.sender.call{value: total}(""); require(ok, "AereLockedStaking: transfer failed"); emit Withdrawn(msg.sender, lockId, l.amount, reward); } /** * @notice Forfeit the reward and recover principal before maturity. */ function earlyExit(uint256 lockId) external nonReentrant { Lock storage l = _locks[msg.sender][lockId]; require(!l.withdrawn, "AereLockedStaking: already withdrawn"); l.withdrawn = true; totalPrincipal -= l.amount; (bool ok, ) = msg.sender.call{value: l.amount}(""); require(ok, "AereLockedStaking: transfer failed"); emit EarlyExited(msg.sender, lockId, l.amount); } // ── Views ──────────────────────────────────────────────────────────── function lockCount(address user) external view returns (uint256) { return _locks[user].length; } function getLock(address user, uint256 lockId) external view returns (Lock memory) { return _locks[user][lockId]; } function tierDuration(uint8 tier) external view returns (uint64) { require(tier < 4, "AereLockedStaking: bad tier"); return DURATIONS[tier]; } /// @notice AERE held above outstanding principal — the actual reward reserve. function rewardReserve() external view returns (uint256) { uint256 bal = address(this).balance; return bal > totalPrincipal ? bal - totalPrincipal : 0; } // ── Reward reserve seeding ─────────────────────────────────────────── /// @notice Foundation tops up the reward reserve. Anyone can donate. receive() external payable { emit RewardReserveDeposited(msg.sender, msg.value); } }