// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereGovLock * @notice A self-contained, non-transferable, CHECKPOINTED ERC20Votes governance * vote source built on a CUSTODY-LOCK (veToken-style) model. It is a * drop-in IVotes source for the existing AereGovernor * (0xd16C2551Bf4f3d0DDF961282078917b2e8679999) and its TimelockController * (0xf95738Cf366Dd2ea76fBa1b2736163BDc11D2e55). * * WHY THIS EXISTS. The previous vote source, AereStakedGovVotes (gAERE * 0xD7575795A055cb093957B86f0607871A2D37805C), was a LAZY PULL-BASED * MIRROR of external staking contracts. It had a fatal class of bug: * voting power could DETACH from the capital that was supposed to back * it. Concretely, AereLockedStaking.earlyExit() returns a user's * principal instantly WITHOUT burning any mirrored gAERE, and sync() is * opt-in, so a single unit of never-truly-locked AERE could be recycled * through many accounts (sybils) to manufacture N-fold voting power. * Worse, quorum was measured off an OPT-IN synced total supply, so the * denominator was meaningless. * * THE FIX (this contract). Power CANNOT detach from capital because the * capital lives HERE, in custody, for the whole life of the vote: * - lock() is the ONLY way votes are minted, and it requires native * AERE to be transferred INTO this contract in the same call * (payable). Votes minted == principal deposited, exactly 1:1. * - withdraw() is the ONLY way votes are burned, and it is the ONLY * way principal leaves. It is gated by unlockTime, burns the votes * FIRST, then returns the exact principal. There is NO earlyExit, * NO admin drain, NO detached-claim path. * - Therefore, at ALL times and at EVERY historical snapshot: * totalSupply() == sum of all live locks == this contract's * native balance credited through lock(). * One unit of capital yields AT MOST one unit of votes. Recycling is * impossible: to give capital to a sybil you must withdraw, which * burns the votes before the capital can move. * * NON-TRANSFERABLE, DELEGATABLE. Balances represent a custody claim, not * a spendable asset, so transfer / transferFrom / approve REVERT. Vote * DELEGATION works normally via ERC20Votes.delegate() (it moves voting * units, never the underlying custody claim), so holders can delegate to * a representative without ever detaching capital. * * CLOCK. Block-number clock (OpenZeppelin default), matching AereGovernor, * so getPastVotes / getPastTotalSupply are snapshot-fixed at a proposal's * past block and cannot be retroactively entered. Lock expiry uses wall * clock (block.timestamp); vote accounting uses block number. This split * is the standard veToken pattern. * * MEANINGFUL QUORUM. Because totalSupply() == total locked capital, * GovernorVotesQuorumFraction computes quorum as a real fraction of real * locked capital, not of an opt-in mirror. */ contract AereGovLock is ERC20Votes, ReentrancyGuard { /// @notice One custody lock per account. `amount` is the native AERE held in /// custody for this account; `unlockTime` is the earliest wall-clock /// time at which it (and the votes it backs) may be withdrawn. struct Lock { uint256 amount; uint256 unlockTime; } /// @notice account => its single custody lock position. mapping(address => Lock) private _locks; /// @notice Minimum lock duration enforced on every lock / extension, in /// seconds. Set once at deployment. May be 0 (no minimum). uint256 public immutable minLockDuration; /// @notice Sanity cap on how far in the future a lock may be set, to prevent /// fat-finger effectively-permanent locks and timestamp overflow. uint256 public constant MAX_LOCK_DURATION = 1460 days; // ~4 years event Locked(address indexed account, uint256 addedAmount, uint256 newAmount, uint256 unlockTime); event Withdrawn(address indexed account, uint256 amount); /** * @param _minLockDuration Minimum seconds a deposit must stay locked. Pass 0 * to disable the minimum. Must be <= MAX_LOCK_DURATION. */ constructor(uint256 _minLockDuration) ERC20("AERE Governance Lock", "govAERE") ERC20Permit("AERE Governance Lock") { require(_minLockDuration <= MAX_LOCK_DURATION, "govAERE: min > max"); minLockDuration = _minLockDuration; } // --------------------------------------------------------------------- // Custody lock: capital IN -> votes minted 1:1. // --------------------------------------------------------------------- /** * @notice Lock native AERE into custody and mint an equal amount of * non-transferable, checkpointed votes. This is the ONLY mint path. * * `amount` MUST equal msg.value; it is an explicit argument purely so * the deposited quantity is unambiguous in calldata and events. The * real transfer of value is msg.value (native AERE) into this * contract, so the votes are always fully backed by custody here. * * If the caller already has a lock, this ADDS to it and the position * adopts the new (later or equal) unlock time. The unlock time can * only move FORWARD, never be shortened by a subsequent lock, so * capital already committed cannot be released early by re-locking. * * @param amount Quantity of native AERE to lock. Must equal msg.value and be > 0. * @param unlockTime Wall-clock timestamp when withdrawal becomes allowed. */ function lock(uint256 amount, uint256 unlockTime) external payable nonReentrant { require(amount > 0, "govAERE: zero amount"); require(msg.value == amount, "govAERE: value != amount"); Lock storage position = _locks[msg.sender]; // Enforce the minimum duration from now, and never allow shortening an // existing commitment. uint256 minUnlock = block.timestamp + minLockDuration; require(unlockTime >= minUnlock, "govAERE: below min duration"); require(unlockTime <= block.timestamp + MAX_LOCK_DURATION, "govAERE: above max duration"); require(unlockTime >= position.unlockTime, "govAERE: cannot shorten lock"); position.amount += amount; position.unlockTime = unlockTime; // Mint votes 1:1 with the deposited capital. _mint writes an ERC20Votes // checkpoint, making the new weight snapshot-safe. _mint(msg.sender, amount); emit Locked(msg.sender, amount, position.amount, unlockTime); } /** * @notice Extend an existing lock's unlock time WITHOUT adding capital. Votes * are unchanged (they are already 1:1 with the locked capital). Only * allowed to move the unlock time forward. */ function extendLock(uint256 newUnlockTime) external { Lock storage position = _locks[msg.sender]; require(position.amount > 0, "govAERE: no lock"); require(newUnlockTime > position.unlockTime, "govAERE: not later"); require(newUnlockTime >= block.timestamp + minLockDuration, "govAERE: below min duration"); require(newUnlockTime <= block.timestamp + MAX_LOCK_DURATION, "govAERE: above max duration"); position.unlockTime = newUnlockTime; emit Locked(msg.sender, 0, position.amount, newUnlockTime); } // --------------------------------------------------------------------- // Custody release: votes burned FIRST -> capital OUT. The only exit. // --------------------------------------------------------------------- /** * @notice Withdraw the full locked principal AFTER unlockTime, burning the * backing votes in the same call. This is the ONLY burn path and the * ONLY way capital leaves the contract. Votes are burned BEFORE the * native transfer (checks-effects-interactions), so at no instant can * an account hold votes whose capital has already left. */ function withdraw() external nonReentrant { Lock storage position = _locks[msg.sender]; uint256 amount = position.amount; require(amount > 0, "govAERE: no lock"); require(block.timestamp >= position.unlockTime, "govAERE: still locked"); // EFFECTS: clear the position and burn the votes before moving value. position.amount = 0; position.unlockTime = 0; _burn(msg.sender, amount); // INTERACTION: return the exact principal. (bool ok, ) = payable(msg.sender).call{value: amount}(""); require(ok, "govAERE: native transfer failed"); emit Withdrawn(msg.sender, amount); } // --------------------------------------------------------------------- // Views. // --------------------------------------------------------------------- /// @notice The custody lock (amount, unlockTime) for `account`. function locks(address account) external view returns (uint256 amount, uint256 unlockTime) { Lock storage position = _locks[account]; return (position.amount, position.unlockTime); } /// @notice Principal currently held in custody for `account` (== its votes balance). function lockedBalanceOf(address account) external view returns (uint256) { return _locks[account].amount; } /// @notice Unlock timestamp for `account`'s lock (0 if none). function unlockTimeOf(address account) external view returns (uint256) { return _locks[account].unlockTime; } /** * @notice Total native AERE held in custody through lock(). Under normal use * this equals totalSupply(). It is exposed so anyone can verify the * backing invariant on-chain. (A forced native send via selfdestruct * could push the raw contract balance above this figure; such surplus * is never counted as votes and is never withdrawable, so it cannot * inflate voting power.) */ function totalLocked() external view returns (uint256) { return totalSupply(); } // --------------------------------------------------------------------- // Non-transferability. Balances are custody claims, not spendable tokens. // Mint (from == 0) and burn (to == 0) are allowed; everything else reverts. // Vote delegation is unaffected (it moves voting units, not balances). // --------------------------------------------------------------------- function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { require(from == address(0) || to == address(0), "govAERE: non-transferable"); super._beforeTokenTransfer(from, to, amount); } function transfer(address, uint256) public pure override returns (bool) { revert("govAERE: non-transferable"); } function transferFrom(address, address, uint256) public pure override returns (bool) { revert("govAERE: non-transferable"); } function approve(address, uint256) public pure override returns (bool) { revert("govAERE: non-transferable"); } function increaseAllowance(address, uint256) public pure override returns (bool) { revert("govAERE: non-transferable"); } function decreaseAllowance(address, uint256) public pure override returns (bool) { revert("govAERE: non-transferable"); } }