// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereStakingV2 * @notice Bug-fix redeploy of AereStaking. Delegated stake pool for AERE Network * validators: token holders delegate native AERE to validators and earn a * configurable APY (default 8%, matching the whitepaper / genesis rewardRate). * Validators post a self-stake bond and take a commission on delegator rewards. * * ─────────────────────────── WHY V2 ─────────────────────────── * V1 (0xAbDb01d9A4f41792129b2654Fb6DDB9689360DEc, chain 2800) shipped with * five confirmed bugs. V1 currently has 0 validators / 0 staked / 0 balance, * so a clean V2 with corrected logic is safe (no state to migrate). * * F2 (HIGH — reward drain on top-up). * V1.delegate() only set lastClaimBlock on the FIRST delegation. Topping up * an existing delegation left lastClaimBlock untouched, so the freshly-added * stake retroactively earned a full period of rewards on stake that did not * exist yet (~10x inflation for a 9x top-up). * FIX: every amount-changing path SETTLES pending rewards into an accrued * bucket and resets the accrual anchor (lastClaimTime) BEFORE changing the * balance, so a top-up earns 0 retroactive reward on the new stake. * * F3 (HIGH — locked self-stake). * V1 had NO path returning a validator's registerValidator() self-stake. * The only withdraw primitives (unbond/claimUnbonded) acted on DELEGATIONS, * and a validator never self-delegated, so the bond was locked forever. * FIX: deregisterValidator() queues the remaining self-stake into the SAME * unbonding queue used by delegators; claimUnbonded() releases it after * UNBONDING_PERIOD. Works on an already-deactivated (slashed-out) validator * too, so residual post-slash self-stake is always recoverable. totalStaked * is decremented by exactly the live (post-slash) self-stake, so slashed * value is accounted (it stays in the pool) and never double-withdrawn. * * F4 (MED — unbond brick on re-registration). * V1.registerValidator() overwrote the whole Validator struct, zeroing * totalDelegated while delegations still existed. A slashed-then-deactivated * validator that re-registered bricked every delegator's unbond (totalDelegated * underflow, panic 0x11), and pushed a DUPLICATE entry into validatorList. * FIX: re-registration PRESERVES totalDelegated, commissionAccrued and all * delegations; self-stake is ADDED (never reset); the validator is pushed to * validatorList exactly once (exists flag). * * F5 (MED — APY ~5x underpay). * V1.pendingRewards() divided by a hardcoded 315,360,000 blocks/year, which * assumes 0.1s blocks. The chain runs ~0.5s blocks (measured 0.5055 s/block), * so realized APY was ~1.6% instead of the intended 8% (exactly 5x low), and * any future block-time change would shift it again. * FIX: accrual is TIMESTAMP-based (reward = amount * rewardRateBps * * secondsElapsed / (10000 * 365 days)), immune to block time. The rate is an * owner-settable parameter, rewardRateBps, default 800 (8% APY) so the * Foundation can tune tokenomics without a redeploy. * * LOW (commission silently kept). * V1 deducted validator commission from each delegator's reward but never * credited it anywhere — it was silently dropped in the pool. * FIX: commission is credited to the validator's commissionAccrued balance on * every settle and withdrawn via claimCommission(). * * Everything else is intentionally identical to V1: delegate, unbond, * claimUnbonded, owner-only slash, MIN_VALIDATOR_SELF_STAKE, MIN_DELEGATION, * UNBONDING_PERIOD, SLASH_RATE_BPS. * * SCOPE / NON-GOALS (unchanged from V1, documented honestly): * - A validator's OWN self-stake earns no staking reward; only delegations * accrue (self-stake is a slashable bond). Same as V1. * - Rewards + commission are paid from this contract's native AERE balance, * which the Foundation must seed until protocol-level minting is wired to * consensus. Same as V1 (see claimRewards). * - slash() leaves the slashed AERE in the pool (totalStaked reduced, balance * unchanged) as surplus backing rewards. There is deliberately NO admin * withdrawal of principal. Same as V1. */ contract AereStakingV2 is Ownable, ReentrancyGuard { uint256 public constant MIN_VALIDATOR_SELF_STAKE = 500 ether; // 500 AERE uint256 public constant MIN_DELEGATION = 1 ether; // 1 AERE uint256 public constant UNBONDING_PERIOD = 7 days; uint256 public constant SLASH_RATE_BPS = 500; // 5% uint256 public constant MAX_COMMISSION_BPS = 5000; // 50% cap on commission uint256 public constant BPS_DENOM = 10000; uint256 public constant YEAR = 365 days; // 31,536,000 s (timestamp accrual base) uint256 public constant MAX_REWARD_RATE_BPS = 10000; // 100% APY guard on the settable rate /// @notice Owner-settable annual reward rate in basis points. Default 800 = 8% APY /// (matches whitepaper / genesis). Foundation can tune via setRewardRateBps. uint256 public rewardRateBps = 800; struct Validator { bool active; bool exists; // ever registered (validatorList dedup + re-register detection) uint256 selfStake; uint256 totalDelegated; uint256 commissionBps; // out of 10000 uint256 commissionAccrued; // validator's claimable commission (LOW fix) } struct Delegation { uint256 amount; uint256 stakedAt; uint256 lastClaimTime; // timestamp accrual anchor (F5: was lastClaimBlock) uint256 rewardAccrued; // settled NET rewards owed to the delegator (F2 fix) } struct UnbondRequest { uint256 amount; uint256 readyAt; } mapping(address => Validator) public validators; mapping(address => mapping(address => Delegation)) public delegations; // validator => delegator => stake mapping(address => UnbondRequest[]) public unbondQueue; address[] public validatorList; uint256 public totalStaked; event ValidatorRegistered(address indexed validator, uint256 selfStake, uint256 commissionBps); event ValidatorDeactivated(address indexed validator); event ValidatorDeregistered(address indexed validator, uint256 selfStakeUnbonded, uint256 readyAt); event Delegated(address indexed validator, address indexed delegator, uint256 amount); event UnbondInitiated(address indexed validator, address indexed delegator, uint256 amount, uint256 readyAt); event UnbondClaimed(address indexed delegator, uint256 amount); event RewardsClaimed(address indexed delegator, uint256 amount); event CommissionClaimed(address indexed validator, uint256 amount); event ValidatorSlashed(address indexed validator, uint256 amount, string reason); event RewardRateUpdated(uint256 oldRateBps, uint256 newRateBps); constructor(address initialOwner) { require(initialOwner != address(0), "AereStakingV2: zero owner"); _transferOwnership(initialOwner); } /* ----------------------------- reward math ------------------------------ */ /// @notice Gross reward for `amount` staked over `secondsElapsed`, at the current /// rewardRateBps. Timestamp-based → immune to block-time changes (F5). function _grossReward(uint256 amount, uint256 secondsElapsed) internal view returns (uint256) { if (amount == 0 || secondsElapsed == 0) return 0; return (amount * rewardRateBps * secondsElapsed) / (BPS_DENOM * YEAR); } /// @notice Settle a delegation's accrued rewards up to now, splitting off validator /// commission, and advance the accrual anchor. MUST be called before any /// change to d.amount so freshly-added/removed stake cannot earn rewards for /// a window in which it was not staked (F2). Credits commission to the /// validator (LOW fix). function _settle(address validator, address delegator) internal { Delegation storage d = delegations[validator][delegator]; if (d.amount > 0) { uint256 secs = block.timestamp - d.lastClaimTime; uint256 gross = _grossReward(d.amount, secs); if (gross > 0) { uint256 commission = (gross * validators[validator].commissionBps) / BPS_DENOM; d.rewardAccrued += (gross - commission); validators[validator].commissionAccrued += commission; } } d.lastClaimTime = block.timestamp; } /* ---------------------------- validator ops ----------------------------- */ /** * @notice Register as a validator, or re-activate a previously-deactivated one. * Sender deposits self-stake (msg.value, >= MIN_VALIDATOR_SELF_STAKE). * @param commissionBps Validator's cut of delegator rewards (e.g. 1000 = 10%). * * F4 FIX: on re-registration the existing totalDelegated, commissionAccrued and all * delegations are PRESERVED (never zeroed); self-stake is ADDED; the address is * appended to validatorList only once. */ function registerValidator(uint256 commissionBps) external payable nonReentrant { require(msg.value >= MIN_VALIDATOR_SELF_STAKE, "AereStakingV2: insufficient self-stake"); require(commissionBps <= MAX_COMMISSION_BPS, "AereStakingV2: max commission 50%"); Validator storage v = validators[msg.sender]; require(!v.active, "AereStakingV2: already active"); if (!v.exists) { v.exists = true; validatorList.push(msg.sender); // F4: exactly once, no duplicates } // F4: do NOT touch totalDelegated / commissionAccrued / delegations. v.active = true; v.selfStake += msg.value; // add to any residual (e.g. post-slash) self-stake v.commissionBps = commissionBps; totalStaked += msg.value; emit ValidatorRegistered(msg.sender, v.selfStake, commissionBps); } /** * @notice Exit as a validator: queue the entire remaining self-stake into the same * unbonding path used by delegators, deactivate, and stop earning. Recover * the funds with claimUnbonded() after UNBONDING_PERIOD (F3). * * Callable even if already deactivated (slashed below the minimum), so a * slashed-out validator can still recover its residual self-stake. Existing * delegations are untouched; delegators unbond independently. */ function deregisterValidator() external nonReentrant { Validator storage v = validators[msg.sender]; require(v.exists, "AereStakingV2: not a validator"); uint256 stake = v.selfStake; require(stake > 0, "AereStakingV2: no self-stake"); // effects: remove the live (post-slash) self-stake from the pool accounting. v.selfStake = 0; if (v.active) { v.active = false; emit ValidatorDeactivated(msg.sender); } totalStaked -= stake; uint256 readyAt = block.timestamp + UNBONDING_PERIOD; unbondQueue[msg.sender].push(UnbondRequest({ amount: stake, readyAt: readyAt })); emit ValidatorDeregistered(msg.sender, stake, readyAt); } /* ---------------------------- delegation ops ---------------------------- */ /// @notice Delegate AERE to a validator. msg.value is the delegation amount. /// F2 FIX: settles pending rewards and advances the accrual anchor BEFORE /// increasing the balance, so a top-up earns no retroactive reward. function delegate(address validator) external payable nonReentrant { require(msg.value >= MIN_DELEGATION, "AereStakingV2: below minimum delegation"); require(validators[validator].active, "AereStakingV2: validator not active"); Delegation storage d = delegations[validator][msg.sender]; if (d.amount == 0) { d.stakedAt = block.timestamp; d.lastClaimTime = block.timestamp; } else { _settle(validator, msg.sender); // F2: settle before top-up } d.amount += msg.value; validators[validator].totalDelegated += msg.value; totalStaked += msg.value; emit Delegated(validator, msg.sender, msg.value); } /// @notice Initiate unbonding of a delegation. Funds release after UNBONDING_PERIOD. /// Settles first so the delegator keeps rewards earned on the unbonded /// portion up to now (and cannot lose them). function unbond(address validator, uint256 amount) external nonReentrant { Delegation storage d = delegations[validator][msg.sender]; require(d.amount >= amount, "AereStakingV2: insufficient delegation"); _settle(validator, msg.sender); d.amount -= amount; validators[validator].totalDelegated -= amount; // F4: totalDelegated preserved on re-register, so no underflow totalStaked -= amount; uint256 readyAt = block.timestamp + UNBONDING_PERIOD; unbondQueue[msg.sender].push(UnbondRequest({ amount: amount, readyAt: readyAt })); emit UnbondInitiated(validator, msg.sender, amount, readyAt); } /// @notice Claim all matured unbonding requests (delegation exits and validator /// self-stake exits share this queue). function claimUnbonded() external nonReentrant { uint256 total = 0; UnbondRequest[] storage queue = unbondQueue[msg.sender]; uint256 i = 0; while (i < queue.length) { if (queue[i].readyAt <= block.timestamp) { total += queue[i].amount; queue[i] = queue[queue.length - 1]; queue.pop(); } else { i++; } } require(total > 0, "AereStakingV2: nothing to claim"); (bool ok, ) = msg.sender.call{value: total}(""); require(ok, "AereStakingV2: transfer failed"); emit UnbondClaimed(msg.sender, total); } /* -------------------------------- rewards ------------------------------- */ /// @notice Pending NET rewards for a delegator (after validator commission), /// timestamp-based at the current rewardRateBps (F5). Drop-in signature. function pendingRewards(address validator, address delegator) public view returns (uint256) { Delegation memory d = delegations[validator][delegator]; uint256 pending = d.rewardAccrued; if (d.amount > 0) { uint256 secs = block.timestamp - d.lastClaimTime; uint256 gross = _grossReward(d.amount, secs); uint256 commission = (gross * validators[validator].commissionBps) / BPS_DENOM; pending += (gross - commission); } return pending; } /// @notice Claim accrued delegator rewards. Paid from the pool's native balance. function claimRewards(address validator) external nonReentrant { _settle(validator, msg.sender); Delegation storage d = delegations[validator][msg.sender]; uint256 reward = d.rewardAccrued; require(reward > 0, "AereStakingV2: nothing to claim"); d.rewardAccrued = 0; // Rewards come from the protocol-level mining supply once wired to consensus; // until then the pool must be funded by the Foundation. require(address(this).balance >= reward, "AereStakingV2: pool insufficient (needs foundation top-up)"); (bool ok, ) = msg.sender.call{value: reward}(""); require(ok, "AereStakingV2: transfer failed"); emit RewardsClaimed(msg.sender, reward); } /// @notice Validator claims commission credited from delegator rewards (LOW fix). /// Commission materializes when a delegation is settled (on the delegator's /// delegate top-up / unbond / claimRewards). function claimCommission() external nonReentrant { Validator storage v = validators[msg.sender]; uint256 amount = v.commissionAccrued; require(amount > 0, "AereStakingV2: nothing to claim"); v.commissionAccrued = 0; require(address(this).balance >= amount, "AereStakingV2: pool insufficient (needs foundation top-up)"); (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "AereStakingV2: transfer failed"); emit CommissionClaimed(msg.sender, amount); } /* -------------------------------- admin --------------------------------- */ /// @notice Owner tunes the annual reward rate (F5). Default 800 = 8% APY. /// Applies from the next settle onward for every delegation. function setRewardRateBps(uint256 newRateBps) external onlyOwner { require(newRateBps > 0 && newRateBps <= MAX_REWARD_RATE_BPS, "AereStakingV2: rate out of range"); uint256 old = rewardRateBps; rewardRateBps = newRateBps; emit RewardRateUpdated(old, newRateBps); } /// @notice Slash a validator's self-stake (owner-only). Identical to V1: removes /// SLASH_RATE_BPS of the live self-stake from the pool accounting and /// deactivates the validator if it falls below the minimum. Slashed AERE /// stays in the pool as surplus (no admin withdrawal of principal). function slash(address validator, string calldata reason) external onlyOwner { Validator storage v = validators[validator]; require(v.active, "AereStakingV2: validator not active"); uint256 slashAmount = (v.selfStake * SLASH_RATE_BPS) / BPS_DENOM; v.selfStake -= slashAmount; totalStaked -= slashAmount; if (v.selfStake < MIN_VALIDATOR_SELF_STAKE) { v.active = false; emit ValidatorDeactivated(validator); } emit ValidatorSlashed(validator, slashAmount, reason); } /* --------------------------------- views -------------------------------- */ function validatorCount() external view returns (uint256) { return validatorList.length; } function unbondQueueLength(address account) external view returns (uint256) { return unbondQueue[account].length; } /// @notice Allow the Foundation to seed the reward pool. receive() external payable {} }