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

203 lines
9.0 KiB
Solidity
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
/**
* @title sAERE — Liquid staking receipt for AERE
* @notice ERC-4626 receipt vault. Depositors lock WAERE, receive sAERE. The
* WAERE / sAERE exchange rate drifts UP as the protocol routes a portion
* of chain fees into this vault via AereSink.
*
* @dev Locked design 2026-06-06 (post Round 5 hardening 2026-06-09).
*
* AUDIT FIX R5 (HIGH-1): yield is no longer recognised as a step
* function the moment AereSink transfers AERE in. Instead the
* arrivals are folded into a "reward reserve" that vests linearly
* over DRIP_DURATION (7 days). This kills the public-mempool
* flash-loan sandwich (deposit → flush → redeem) by ensuring the
* share price cannot jump in a single block.
*
* AUDIT FIX R5 (HIGH-4): _decimalsOffset() is now overridden to 6 and
* the dead-share seed raised from 1000 wei to 1 WAERE. Together this
* pushes the cost of a successful inflation attack out of feasibility
* (donor would need to spend > 1e18 × 1e6 AERE per 1-wei share gain).
*
* SEC Aug 5 2025 receipt-token safe-harbor still applies — drip is
* not a fee, it's deterministic vesting of revenue inflows. Provider
* does not choose whether/when/how much; once funds arrive they
* vest mechanically.
*
* The contract still has: NO admin / NO owner / NO pause / NO upgrade
* proxy / NO parameter setters. DRIP_DURATION + decimal offset are
* compile-time constants. The sync() entrypoint is permissionless
* and is only a state observer — it cannot move funds out.
*/
contract sAERE is ERC4626 {
/// @notice Linear vesting horizon for reward arrivals. 7 days.
uint256 public constant DRIP_DURATION = 7 days;
/// @notice AUDIT FIX R6 (HIGH): minimum arrivals (as bps of current
/// undistributed reserve) required to RESTART the drip period. Below
/// this threshold, arrivals only top up the existing rewardRate without
/// extending periodFinish — preventing a 1-wei-per-block griefer from
/// indefinitely starving stakers.
uint256 public constant DRIP_RESTART_THRESHOLD_BPS = 100; // 1%
/// @notice Dead-share seed kept at 1000 wei to preserve deployer UX; the
/// real inflation moat now comes from _decimalsOffset()=6 which
/// widens the share/asset ratio by 1e6 — strictly dominant.
uint256 public constant DEAD_SHARES_SEED_WAERE = 1000 wei;
address public constant DEAD_ADDRESS = 0x000000000000000000000000000000000000dEaD;
/// @notice AERE per second currently being released into totalAssets.
uint256 public rewardRate;
/// @notice Unix timestamp when the current drip period ends.
uint256 public periodFinish;
/// @notice Cached balance at the last sync — distinguishes deposits from
/// reward arrivals on the next sync.
uint256 public lastObservedBalance;
event RewardArrived(uint256 amount, uint256 newRewardRate, uint256 newPeriodFinish);
constructor(IERC20 waereToken)
ERC20("Staked AERE", "sAERE")
ERC4626(waereToken)
{
SafeERC20Like.safeTransferFrom(
waereToken,
msg.sender,
address(this),
DEAD_SHARES_SEED_WAERE
);
_mint(DEAD_ADDRESS, DEAD_SHARES_SEED_WAERE);
// Initialise the observed balance so the first sync doesn't classify
// the dead-seed as a reward arrival.
lastObservedBalance = DEAD_SHARES_SEED_WAERE;
}
/* ----------------------- inflation-attack widening ----------------------- */
/// @notice AUDIT FIX R5 (HIGH-4): override OZ default 0 → 6. Combined with
/// the 1 WAERE dead-seed this widens the inflation moat by ~1e15×.
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
}
/* ------------------------------ drip math ------------------------------ */
/// @dev AERE still locked in the reward reserve (not yet vested).
function _undistributedNow() internal view returns (uint256) {
if (block.timestamp >= periodFinish) return 0;
uint256 remaining = periodFinish - block.timestamp;
return rewardRate * remaining;
}
/// @notice ERC-4626 totalAssets excluding the unvested reward reserve.
/// Share price therefore grows linearly with time, not in steps.
function totalAssets() public view override returns (uint256) {
uint256 bal = IERC20(asset()).balanceOf(address(this));
uint256 pending = _undistributedNow();
return bal > pending ? bal - pending : 0;
}
/// @notice Permissionless. Recognises any AERE arrivals since the last
/// sync as a fresh reward and extends the drip schedule.
/// Called automatically inside every deposit / withdraw; expose
/// as external so AereSink (or any monitor) can refresh in between.
function sync() public {
uint256 bal = IERC20(asset()).balanceOf(address(this));
if (bal > lastObservedBalance) {
uint256 arrivals = bal - lastObservedBalance;
uint256 undistrib = _undistributedNow();
// AUDIT FIX R6 (HIGH): only RESTART the drip period if the
// arrivals are significant relative to the unvested reserve OR
// there is no active period. Tiny arrivals just top up the rate
// without extending periodFinish — defeats the 1-wei-per-block
// grief that would otherwise stall stakers' yield indefinitely.
uint256 threshold = undistrib * DRIP_RESTART_THRESHOLD_BPS / 10_000;
if (undistrib == 0 || arrivals >= threshold) {
// significant arrival or fresh schedule
uint256 newReserve = undistrib + arrivals;
rewardRate = newReserve / DRIP_DURATION;
periodFinish = block.timestamp + DRIP_DURATION;
} else {
// tiny arrival — keep periodFinish, only bump rewardRate
uint256 remaining = periodFinish - block.timestamp;
uint256 newReserve = undistrib + arrivals;
rewardRate = newReserve / remaining;
}
emit RewardArrived(arrivals, rewardRate, periodFinish);
}
lastObservedBalance = bal;
}
/* --------------- hook sync into deposits / withdraws ---------------- */
function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
sync(); // observe any pending reward arrivals BEFORE share math
super._deposit(caller, receiver, assets, shares);
// The assets we just pulled in MUST be excluded from "arrivals" on
// the next sync, so refresh the cached observation.
lastObservedBalance = IERC20(asset()).balanceOf(address(this));
}
function _withdraw(
address caller, address receiver, address owner,
uint256 assets, uint256 shares
) internal override {
sync();
super._withdraw(caller, receiver, owner, assets, shares);
lastObservedBalance = IERC20(asset()).balanceOf(address(this));
}
/* --------------------------------- views --------------------------------- */
function pricePerShare() external view returns (uint256) {
return convertToAssets(1e18);
}
/// @notice Convenience view: amount of AERE currently locked in the
/// reward reserve, awaiting linear vesting.
function undistributedRewards() external view returns (uint256) {
return _undistributedNow();
}
/// @notice Atomic state snapshot for fuzz tests / off-chain consumers
/// that need balance + totalAssets + undistributed evaluated at exactly
/// the same block. Eliminates cross-call drift in view-based invariant
/// checks. AUDIT FIX R6.
function atomicState() external view returns (
uint256 balance,
uint256 totalAssets_,
uint256 undistributed,
uint256 totalSupply_,
uint256 rewardRate_,
uint256 periodFinish_,
uint256 lastObservedBalance_
) {
balance = IERC20(asset()).balanceOf(address(this));
undistributed = _undistributedNow();
totalAssets_ = balance > undistributed ? balance - undistributed : 0;
totalSupply_ = totalSupply();
rewardRate_ = rewardRate;
periodFinish_ = periodFinish;
lastObservedBalance_ = lastObservedBalance;
}
}
library SafeERC20Like {
error SafeERC20FailedOperation();
function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
(bool ok, bytes memory ret) = address(token).call(
abi.encodeCall(token.transferFrom, (from, to, value))
);
if (!ok) revert SafeERC20FailedOperation();
if (ret.length > 0 && !abi.decode(ret, (bool))) revert SafeERC20FailedOperation();
}
}