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.
278 lines
13 KiB
Solidity
278 lines
13 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 sAEREv2 Liquid staking receipt for AERE (drip double-count fix)
|
|
* @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 Corrected, INERT redeploy of sAERE. The live vault
|
|
* (0xA2125bE9C6fd4196D9F94757Df18B3a2A5e650b0) is NOT migrated by this
|
|
* file; migration is a founder-supervised step. This is the fresh,
|
|
* audited-fix bytecode a future migration would point at.
|
|
*
|
|
* AUDIT FIX R7 (HIGH, drip double-count / un-synced arrival):
|
|
* seeded fuzzing found that OZ 4.9.6 computes previewRedeem / maxWithdraw
|
|
* / share-price math from totalAssets() BEFORE _withdraw runs sync(). A
|
|
* raw WAERE.transfer directly into the vault (an "un-synced arrival";
|
|
* anyone can do it, only AereSink.flush syncs atomically) was
|
|
* (1) instantly counted in totalAssets() so immediately redeemable,
|
|
* defeating the 7-day drip, AND (2) simultaneously re-booked into the
|
|
* drip reserve by the sync() inside that same redeem's _withdraw, so the
|
|
* SAME WAERE was double-counted. A large-enough redeem then left
|
|
* undistributed > balance, totalAssets() clamped to 0 with live shares
|
|
* outstanding, and re-opened the ERC-4626 inflation attack that R5
|
|
* HIGH-4 was deployed to stop.
|
|
*
|
|
* The fix has two independent, mutually reinforcing parts:
|
|
*
|
|
* a) totalAssets() now measures assets against the SYNCED balance
|
|
* (min(balance, lastObservedBalance)) rather than the raw balance.
|
|
* An un-synced arrival is therefore NOT counted as immediately
|
|
* redeemable no matter what call path reads it. Once sync() folds
|
|
* the arrival into the reward reserve, the observed balance and the
|
|
* raw balance reconverge and the arrival vests over 7 days exactly
|
|
* as intended. This holds even for pure off-chain view reads that
|
|
* cannot mutate state.
|
|
*
|
|
* b) The public deposit / mint / withdraw / redeem entrypoints call
|
|
* sync() at the START, so the reward reserve is refreshed before
|
|
* OZ's share-price math reads totalAssets(). sync() is idempotent
|
|
* (a second call in the same tx observes no new arrival and is a
|
|
* no-op), so the existing sync() inside _deposit / _withdraw stays.
|
|
*
|
|
* Consequence proven by the invariant battery: undistributed <= balance
|
|
* and totalAssets() > 0 hold at all times while shares are outstanding,
|
|
* so the reserve can never be double-counted into both the payout and
|
|
* the drip, and totalAssets() can never be driven to 0. The 7-day
|
|
* anti-sandwich drip (R5 HIGH-1) and the dead-seed + _decimalsOffset=6
|
|
* inflation guard (R5 HIGH-4) are both preserved unchanged.
|
|
*
|
|
* SEC Aug 5 2025 receipt-token safe-harbor still applies. Drip is not a
|
|
* fee, it is 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 sAEREv2 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 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 AND anchors totalAssets() to the
|
|
/// synced balance so un-synced arrivals are never counted early.
|
|
uint256 public lastObservedBalance;
|
|
|
|
event RewardArrived(uint256 amount, uint256 newRewardRate, uint256 newPeriodFinish);
|
|
|
|
constructor(IERC20 waereToken)
|
|
ERC20("Staked AERE", "sAERE")
|
|
ERC4626(waereToken)
|
|
{
|
|
SafeERC20LikeV2.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 to 6. Combined with
|
|
/// the dead-seed this widens the inflation moat by ~1e15x.
|
|
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;
|
|
}
|
|
|
|
/// @dev The balance the vault has actually observed and accounted for.
|
|
/// Any raw WAERE sitting above this is an un-synced arrival that has
|
|
/// not yet been folded into the reward reserve, so it must not be
|
|
/// counted as immediately-available assets. Because lastObservedBalance
|
|
/// is refreshed to the real balance on every deposit / withdraw / sync,
|
|
/// this equals the raw balance in every synced state.
|
|
function _syncedBalance() internal view returns (uint256) {
|
|
uint256 bal = IERC20(asset()).balanceOf(address(this));
|
|
return bal < lastObservedBalance ? bal : lastObservedBalance;
|
|
}
|
|
|
|
/// @notice ERC-4626 totalAssets excluding both the unvested reward reserve
|
|
/// AND any un-synced arrival. Share price grows linearly with time
|
|
/// (the drip), never as a single-block step from a raw transfer.
|
|
///
|
|
/// AUDIT FIX R7: the subtrahend is measured against the SYNCED
|
|
/// balance, not the raw balance. This makes an un-synced arrival
|
|
/// invisible to share-price math on every read path, so it can never
|
|
/// be paid out early and double-counted into the reserve. It also
|
|
/// guarantees totalAssets() >= (synced balance - reserve) > 0 while
|
|
/// the dead-seed principal is present, closing the inflation window.
|
|
function totalAssets() public view override returns (uint256) {
|
|
uint256 synced = _syncedBalance();
|
|
uint256 pending = _undistributedNow();
|
|
return synced > pending ? synced - 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 / mint /
|
|
/// redeem entrypoint; exposed 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, defeating 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;
|
|
}
|
|
|
|
/* --------------- sync at the START of every entrypoint --------------- */
|
|
// AUDIT FIX R7 part (b): refresh the reward reserve BEFORE OZ's
|
|
// deposit/mint/withdraw/redeem read totalAssets() via preview*. sync() is
|
|
// idempotent, so the sync() inside _deposit / _withdraw remains a no-op
|
|
// second call and the accounting is unchanged in already-synced states.
|
|
|
|
function deposit(uint256 assets, address receiver) public override returns (uint256) {
|
|
sync();
|
|
return super.deposit(assets, receiver);
|
|
}
|
|
|
|
function mint(uint256 shares, address receiver) public override returns (uint256) {
|
|
sync();
|
|
return super.mint(shares, receiver);
|
|
}
|
|
|
|
function withdraw(uint256 assets, address receiver, address owner) public override returns (uint256) {
|
|
sync();
|
|
return super.withdraw(assets, receiver, owner);
|
|
}
|
|
|
|
function redeem(uint256 shares, address receiver, address owner) public override returns (uint256) {
|
|
sync();
|
|
return super.redeem(shares, receiver, owner);
|
|
}
|
|
|
|
/* --------------- hook sync into the internal workflows ---------------- */
|
|
|
|
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. totalAssets_ is measured against the synced balance (R7).
|
|
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();
|
|
uint256 synced = balance < lastObservedBalance ? balance : lastObservedBalance;
|
|
totalAssets_ = synced > undistributed ? synced - undistributed : 0;
|
|
totalSupply_ = totalSupply();
|
|
rewardRate_ = rewardRate;
|
|
periodFinish_ = periodFinish;
|
|
lastObservedBalance_ = lastObservedBalance;
|
|
}
|
|
}
|
|
|
|
library SafeERC20LikeV2 {
|
|
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();
|
|
}
|
|
}
|