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.
203 lines
9.0 KiB
Solidity
203 lines
9.0 KiB
Solidity
// 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();
|
||
}
|
||
}
|