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.
154 lines
6.0 KiB
Solidity
154 lines
6.0 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.19;
|
|
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
|
|
/**
|
|
* @title AereLockedStaking
|
|
* @notice Liquidity-lock staking with fixed terms (whitepaper §3.3).
|
|
*
|
|
* Users lock native AERE for a fixed term and earn fixed APY paid in AERE
|
|
* on maturity. Early exit forfeits all reward and returns principal only.
|
|
*
|
|
* Tiers (matched to stake-v2.html):
|
|
* 0 — 30 days · 10% APY
|
|
* 1 — 90 days · 15% APY
|
|
* 2 — 180 days · 22% APY
|
|
* 3 — 365 days · 30% APY
|
|
*
|
|
* Reward pool is seeded by the Foundation; the contract refuses to pay
|
|
* a maturity if its balance cannot cover principal + reward (so other
|
|
* stakers' principal can never be drained to pay rewards).
|
|
*/
|
|
contract AereLockedStaking is Ownable, ReentrancyGuard {
|
|
// tier => APY in basis points (10000 = 100%)
|
|
uint256[4] public APY_BPS = [1000, 1500, 2200, 3000];
|
|
// tier => lock duration in seconds
|
|
uint64[4] private DURATIONS = [30 days, 90 days, 180 days, 365 days];
|
|
|
|
uint256 public constant MIN_STAKE = 1 ether; // 1 AERE
|
|
uint256 public constant MAX_STAKE = 100_000 ether; // sanity cap per lock
|
|
|
|
struct Lock {
|
|
uint256 amount;
|
|
uint64 startedAt;
|
|
uint64 unlockAt;
|
|
uint8 tier;
|
|
bool withdrawn;
|
|
}
|
|
|
|
mapping(address => Lock[]) private _locks;
|
|
|
|
// Total principal currently locked (excludes rewards owed). Used to
|
|
// determine how much of `address(this).balance` is the reward reserve.
|
|
uint256 public totalPrincipal;
|
|
|
|
event Staked(address indexed user, uint256 indexed lockId, uint256 amount, uint8 tier, uint64 unlockAt);
|
|
event Withdrawn(address indexed user, uint256 indexed lockId, uint256 principal, uint256 reward);
|
|
event EarlyExited(address indexed user, uint256 indexed lockId, uint256 principal);
|
|
event RewardReserveDeposited(address indexed from, uint256 amount);
|
|
|
|
/**
|
|
* @notice Lock `msg.value` for the tier's term and earn its APY on maturity.
|
|
*/
|
|
function stake(uint8 tier) external payable nonReentrant returns (uint256 lockId) {
|
|
require(tier < 4, "AereLockedStaking: bad tier");
|
|
require(msg.value >= MIN_STAKE, "AereLockedStaking: below min");
|
|
require(msg.value <= MAX_STAKE, "AereLockedStaking: above max");
|
|
|
|
uint64 nowTs = uint64(block.timestamp);
|
|
uint64 unlockAt = nowTs + DURATIONS[tier];
|
|
|
|
lockId = _locks[msg.sender].length;
|
|
_locks[msg.sender].push(Lock({
|
|
amount: msg.value,
|
|
startedAt: nowTs,
|
|
unlockAt: unlockAt,
|
|
tier: tier,
|
|
withdrawn: false
|
|
}));
|
|
|
|
totalPrincipal += msg.value;
|
|
emit Staked(msg.sender, lockId, msg.value, tier, unlockAt);
|
|
}
|
|
|
|
/**
|
|
* @notice Reward owed if this lock matures untouched. Returns 0 if the
|
|
* lock has already been withdrawn or early-exited.
|
|
* reward = principal * APY_BPS * duration / 365 days / 10000
|
|
*/
|
|
function rewardOf(address user, uint256 lockId) public view returns (uint256) {
|
|
if (lockId >= _locks[user].length) return 0;
|
|
Lock memory l = _locks[user][lockId];
|
|
if (l.withdrawn) return 0;
|
|
return (l.amount * APY_BPS[l.tier] * DURATIONS[l.tier]) / (365 days * 10000);
|
|
}
|
|
|
|
/**
|
|
* @notice Claim a matured lock. Pays principal + full reward.
|
|
*/
|
|
function withdraw(uint256 lockId) external nonReentrant {
|
|
Lock storage l = _locks[msg.sender][lockId];
|
|
require(!l.withdrawn, "AereLockedStaking: already withdrawn");
|
|
require(block.timestamp >= l.unlockAt, "AereLockedStaking: still locked");
|
|
|
|
l.withdrawn = true;
|
|
totalPrincipal -= l.amount;
|
|
|
|
uint256 reward = (l.amount * APY_BPS[l.tier] * DURATIONS[l.tier]) / (365 days * 10000);
|
|
uint256 total = l.amount + reward;
|
|
|
|
// Pay only if the reward reserve covers it (the principal we hold
|
|
// for other locks must never be touched).
|
|
require(address(this).balance >= total + totalPrincipal, "AereLockedStaking: reward reserve insufficient");
|
|
|
|
(bool ok, ) = msg.sender.call{value: total}("");
|
|
require(ok, "AereLockedStaking: transfer failed");
|
|
emit Withdrawn(msg.sender, lockId, l.amount, reward);
|
|
}
|
|
|
|
/**
|
|
* @notice Forfeit the reward and recover principal before maturity.
|
|
*/
|
|
function earlyExit(uint256 lockId) external nonReentrant {
|
|
Lock storage l = _locks[msg.sender][lockId];
|
|
require(!l.withdrawn, "AereLockedStaking: already withdrawn");
|
|
|
|
l.withdrawn = true;
|
|
totalPrincipal -= l.amount;
|
|
|
|
(bool ok, ) = msg.sender.call{value: l.amount}("");
|
|
require(ok, "AereLockedStaking: transfer failed");
|
|
emit EarlyExited(msg.sender, lockId, l.amount);
|
|
}
|
|
|
|
// ── Views ────────────────────────────────────────────────────────────
|
|
|
|
function lockCount(address user) external view returns (uint256) {
|
|
return _locks[user].length;
|
|
}
|
|
|
|
function getLock(address user, uint256 lockId) external view returns (Lock memory) {
|
|
return _locks[user][lockId];
|
|
}
|
|
|
|
function tierDuration(uint8 tier) external view returns (uint64) {
|
|
require(tier < 4, "AereLockedStaking: bad tier");
|
|
return DURATIONS[tier];
|
|
}
|
|
|
|
/// @notice AERE held above outstanding principal — the actual reward reserve.
|
|
function rewardReserve() external view returns (uint256) {
|
|
uint256 bal = address(this).balance;
|
|
return bal > totalPrincipal ? bal - totalPrincipal : 0;
|
|
}
|
|
|
|
// ── Reward reserve seeding ───────────────────────────────────────────
|
|
|
|
/// @notice Foundation tops up the reward reserve. Anyone can donate.
|
|
receive() external payable {
|
|
emit RewardReserveDeposited(msg.sender, msg.value);
|
|
}
|
|
}
|