// 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); } }