aere-contracts/contracts/staking/AereStakingV2.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

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 {}
}