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