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

244 lines
12 KiB
Solidity

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