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.
367 lines
19 KiB
Solidity
367 lines
19 KiB
Solidity
// 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 {}
|
|
}
|