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

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