aere-contracts/contracts/governance/AereStakedGovVotes.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

162 lines
7.2 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
/**
* @dev Minimal view interfaces into the CANONICAL live staking contracts on
* chain 2800. No change to either staking contract is required; both
* expose the state read here as public getters.
*
* AereStakingV2 0x1D95eF6D17aeAB732dF914Ba2d018c270BC155FC (delegated stake)
* AereLockedStaking 0x21108c28A849b05aE6b7a3a5bc435C9Bc897E7Ad (fixed-term locks)
*/
interface IAereStakingV2Votes {
function validatorCount() external view returns (uint256);
function validatorList(uint256 index) external view returns (address);
function delegations(address validator, address delegator)
external
view
returns (uint256 amount, uint256 stakedAt, uint256 lastClaimTime, uint256 rewardAccrued);
function validators(address who)
external
view
returns (
bool active,
bool exists,
uint256 selfStake,
uint256 totalDelegated,
uint256 commissionBps,
uint256 commissionAccrued
);
}
interface IAereLockedStakingVotes {
function lockCount(address user) external view returns (uint256);
function getLock(address user, uint256 lockId)
external
view
returns (uint256 amount, uint64 startedAt, uint64 unlockAt, uint8 tier, bool withdrawn);
}
/**
* @title AereStakedGovVotes (gAERE)
* @notice A THIN, NON-CUSTODIAL, CHECKPOINTED voting-power adapter over AERE's
* real staking. It is the ERC20Votes source the AereGovernor reads.
*
* WHY THIS EXISTS. The live staking contracts (AereStakingV2,
* AereLockedStaking) hold real delegated / locked AERE but do NOT expose
* ERC20Votes and are NOT checkpointed, so a raw current-balance read is
* flash-stake vote-borrowable within a single block. OpenZeppelin's
* Governor requires an IVotes source with historical snapshots
* (getPastVotes / getPastTotalSupply) so that a proposal's weight is
* fixed at a past block that an attacker cannot retroactively enter.
* This adapter provides exactly that: a checkpointed mirror of the
* correct staking source.
*
* HOW IT WORKS.
* - It CUSTODIES NOTHING. It never receives, holds, or moves AERE. The
* staked AERE stays in the staking contracts at all times.
* - gAERE balance is a pure MIRROR of an account's live staked power:
* sum of its AereStakingV2 delegations across validators, plus its
* own validator self-stake if it is a validator, plus its
* non-withdrawn AereLockedStaking locks.
* - Anyone may call sync(account) to reconcile that account's gAERE
* balance up or down to its current staked power. Each sync writes an
* ERC20Votes checkpoint, which is what makes governance weight
* snapshot-safe.
* - gAERE is NON-TRANSFERABLE (balances are derived, not spendable).
* Vote DELEGATION still works via the standard ERC20Votes delegate().
*
* HONEST SCOPE (chain 2800, today).
* AereStakingV2 currently reports validatorCount = 0 and totalStaked = 0
* and AereLockedStaking holds no live locks, so stakePowerOf() returns 0
* for every account and gAERE total supply is 0. Network-wide voting
* power is therefore 0 until real validators / delegations / locks
* exist and holders call sync() + delegate(). This adapter does not
* manufacture power; it faithfully reflects that none is staked yet.
*
* Clock: block number (OpenZeppelin default). getPastVotes at a proposal
* snapshot block is what defeats flash-stake vote borrowing.
*/
contract AereStakedGovVotes is ERC20Votes {
IAereStakingV2Votes public immutable staking; // AereStakingV2
IAereLockedStakingVotes public immutable locked; // AereLockedStaking
/// @notice Bounded scans so sync() can never be gas-bricked by an unbounded
/// validator / lock set. If a real account ever exceeds these, its
/// mirrored power is capped at the scanned window (documented, safe).
uint256 public constant MAX_VALIDATOR_SCAN = 512;
uint256 public constant MAX_LOCK_SCAN = 512;
event Synced(address indexed account, uint256 oldPower, uint256 newPower);
constructor(address stakingV2, address lockedStaking)
ERC20("AERE Staked Governance Votes", "gAERE")
ERC20Permit("AERE Staked Governance Votes")
{
require(stakingV2 != address(0) && lockedStaking != address(0), "gAERE: zero staking");
staking = IAereStakingV2Votes(stakingV2);
locked = IAereLockedStakingVotes(lockedStaking);
}
/**
* @notice Live staked voting power of `account`, read from the canonical
* staking contracts. Delegations + validator self-stake + live locks.
*/
function stakePowerOf(address account) public view returns (uint256 power) {
uint256 vc = staking.validatorCount();
if (vc > MAX_VALIDATOR_SCAN) vc = MAX_VALIDATOR_SCAN;
for (uint256 i = 0; i < vc; i++) {
address v = staking.validatorList(i);
(uint256 amt, , , ) = staking.delegations(v, account);
power += amt;
}
// If `account` is itself a validator, its live (post-slash) self-stake
// is a slashable bond and counts toward its governance weight.
(, bool exists, uint256 selfStake, , , ) = staking.validators(account);
if (exists) power += selfStake;
uint256 lc = locked.lockCount(account);
if (lc > MAX_LOCK_SCAN) lc = MAX_LOCK_SCAN;
for (uint256 j = 0; j < lc; j++) {
(uint256 lamt, , , , bool withdrawn) = locked.getLock(account, j);
if (!withdrawn) power += lamt;
}
}
/**
* @notice Permissionlessly reconcile `account`'s gAERE balance to its live
* staked power, writing a fresh checkpoint. Callable by anyone (so
* the mirror can be kept honest, e.g. burning power after an unbond).
* The account must still self-delegate (or delegate) for the mirrored
* balance to become countable voting power.
*/
function sync(address account) public {
uint256 target = stakePowerOf(account);
uint256 cur = balanceOf(account);
if (target > cur) {
_mint(account, target - cur);
} else if (cur > target) {
_burn(account, cur - target);
}
emit Synced(account, cur, target);
}
/// @notice Convenience: sync the caller.
function syncSelf() external {
sync(msg.sender);
}
/**
* @dev Non-transferable mirror: allow only mint (from == 0) and burn
* (to == 0). Regular transfers revert. Vote delegation is unaffected
* (it moves voting units, not token balances).
*/
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
require(from == address(0) || to == address(0), "gAERE: non-transferable mirror");
super._beforeTokenTransfer(from, to, amount);
}
}