// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AereSink — Immutable 3-bucket protocol revenue router * @notice Single entry point for all protocol fee streams that should accrue * to AERE holders. Anything sent in is routed to: * 1. BURN_VAULT — extra burn on top of AereFeeBurnVault's 37.5% * 2. BUYBACK-AND-BURN — non-AERE fees → swapped to AERE → burned * 3. STAKER-YIELD → swapped/transferred to sAERE vault, lifting * the WAERE/sAERE exchange rate * * @dev Locked design 2026-06-06. Per saere_architecture_2026-06-06.md. * * Immutability invariants (verified in tests): * - NO owner, NO admin, NO governance role * - NO setBucket, NO setRecipient, NO setRouter, NO pause * - Bucket recipients (BURN_VAULT, sAERE_VAULT) set at deploy, immutable * - Bucket basis-point splits set at deploy, immutable * - Bps must sum to exactly 10000 (constructor reverts if not) * - Router is immutable; if it ever needs replacement, redeploy and * rewire fee sources * * Fee-source contracts (AereCoinbaseSplitter, AereFeeMonetization, * AereSettlementHub, etc.) call `flush(token, amount)` to route their * accumulated balances through here. They must approve the sink for * the amount first. * * AereSink does NOT custody funds across calls. Every `flush()` fully * dispatches into the three buckets. Residual dust (≤2 wei per call * from integer rounding) accumulates in the contract; an external * `sweepDust()` is provided that re-flushes the dust through the same * buckets — anyone can call it, no permission. * * Reentrancy: BURN bucket is a simple transfer (no callback). STAKER * bucket is a simple transfer to the ERC-4626 vault — the vault does * NOT mint shares on raw transfer, so the rate just appreciates. * BUYBACK bucket calls into the router (Uniswap-V2-style) which is * the only external-callback surface; nonReentrant on flush() and * sweepDust() blocks reentry. */ interface IAereV2Router { /// @notice Uniswap V2-style swap. token-in approval must be set beforehand. /// @return amounts Amount of each token in the path swapped. function swapExactTokensForTokens( uint256 amountIn, uint256 amountOutMin, address[] calldata path, address to, uint256 deadline ) external returns (uint256[] memory amounts); } /// @notice Oracle interface for sandwich-resistant amountOutMin computation. /// Compatible with AereLendingOracle.getPrice() — returns 18-decimals /// normalised USD price. Used to compute the swap floor independent /// of the DEX pool state (which can be sandwich-manipulated). interface ISwapGuardOracle { function getPrice(address asset) external view returns (uint256); } /// @notice sAERE vault sync hook. AUDIT FIX R5 (HIGH-1): after we transfer /// AERE to the staker bucket, ping sync() so the receiving vault /// immediately recognises the arrival and starts dripping it linearly /// instead of letting a public-mempool sandwicher race the next tx /// to extract a step-function pricePerShare jump. interface ISaereSyncable { function sync() external; } contract AereSink is ReentrancyGuard { /* --------------------------- immutable state --------------------------- */ /// @notice Native protocol token (WAERE — ERC-20 wrapper around AERE). address public immutable AERE; /// @notice AereFeeBurnVault. Existing deployed, no admin, no withdraw. address public immutable BURN_VAULT; /// @notice sAERE ERC-4626 receipt vault. Raw transfer here lifts the /// exchange rate (totalAssets goes up, totalShares does not). address public immutable SAERE_VAULT; /// @notice AereSwap V2-compatible router used for non-AERE → AERE swaps /// (buyback-and-burn + staker-yield buckets when input is not AERE). address public immutable DEX_ROUTER; /// @notice Basis points (out of 10000) for each bucket. Sum = 10000. uint16 public immutable BURN_BPS; uint16 public immutable BUYBACK_BPS; uint16 public immutable STAKER_YIELD_BPS; /// @notice Max slippage tolerance for buyback swaps, in bps. e.g. 200 = 2%. /// Immutable: chosen at deploy. Applied against ORACLE price floor /// to compute amountOutMin, NOT against DEX pool snapshot (which /// is sandwich-able by the same actor reading + swapping in one tx). uint16 public immutable MAX_SLIPPAGE_BPS; /// @notice External oracle for sandwich-resistant amountOutMin. If set /// (non-zero), every non-AERE → AERE swap computes a floor from /// oracle prices and rejects swaps below that floor. If zero, /// swaps accept ANY non-zero output (test-only mode — production /// deploys MUST set this). address public immutable ORACLE; /// @notice 18-decimal normalisation factor for oracle prices. uint8 public constant ORACLE_DECIMALS = 18; /* ---------------------------------- events ---------------------------------- */ event Flushed( address indexed feeder, address indexed token, uint256 amount, uint256 toBurn, uint256 toBuyback, uint256 toStakers ); event DustSwept(address indexed token, uint256 amount); event SwapFailed(address indexed token, uint256 amountIn, string reason); /* ---------------------------------- errors ---------------------------------- */ error BpsMustSumTo10000(uint16 burn, uint16 buyback, uint16 staker); error ZeroAddress(); error ZeroAmount(); error UnreachablePath(); error TransferFailed(); /* -------------------------------- constructor ------------------------------- */ constructor( address _aere, address _burnVault, address _sAereVault, address _dexRouter, uint16 _burnBps, uint16 _buybackBps, uint16 _stakerYieldBps, uint16 _maxSlippageBps, address _oracle ) { if (_aere == address(0) || _burnVault == address(0) || _sAereVault == address(0) || _dexRouter == address(0)) { revert ZeroAddress(); } if (uint256(_burnBps) + uint256(_buybackBps) + uint256(_stakerYieldBps) != 10_000) { revert BpsMustSumTo10000(_burnBps, _buybackBps, _stakerYieldBps); } AERE = _aere; BURN_VAULT = _burnVault; SAERE_VAULT = _sAereVault; DEX_ROUTER = _dexRouter; BURN_BPS = _burnBps; BUYBACK_BPS = _buybackBps; STAKER_YIELD_BPS = _stakerYieldBps; MAX_SLIPPAGE_BPS = _maxSlippageBps; // _oracle == address(0) is test-mode (sandwich-able). Production // deploys MUST pass a real oracle (e.g. AereLendingOracle) so // _swapAndForward can compute a sandwich-resistant amountOutMin. ORACLE = _oracle; } /* ----------------------------------- core ---------------------------------- */ /** * @notice Route `amount` of `token` through the three buckets. * @dev Caller must `IERC20(token).approve(address(this), amount)` first. * If `token == AERE`, all three buckets settle in AERE directly * (no swap needed): BURN + BUYBACK both burn (semantic merge), * STAKER goes to sAERE. * * If `token != AERE`, each bucket's portion is swapped via the * DEX_ROUTER along path [token, AERE], then dispatched. */ function flush(address token, uint256 amount) external nonReentrant { if (amount == 0) revert ZeroAmount(); // Pull the tokens. bool ok = IERC20(token).transferFrom(msg.sender, address(this), amount); if (!ok) revert TransferFailed(); uint256 burnPart = amount * BURN_BPS / 10_000; uint256 buybackPart = amount * BUYBACK_BPS / 10_000; // Last bucket eats any rounding residue (typically ≤2 wei) — no dust // accumulation when input is already AERE. uint256 stakerPart = amount - burnPart - buybackPart; if (token == AERE) { // BURN + BUYBACK are both "destroy AERE" → one transfer. uint256 toBurnTotal = burnPart + buybackPart; if (toBurnTotal > 0) { _safeTransfer(token, BURN_VAULT, toBurnTotal); } if (stakerPart > 0) { _safeTransfer(token, SAERE_VAULT, stakerPart); _pingSaere(); // R5 HIGH-1: trigger drip } } else { uint256 swapForBurnAmt = burnPart + buybackPart; if (swapForBurnAmt > 0) { _swapAndForward(token, swapForBurnAmt, BURN_VAULT); } if (stakerPart > 0) { _swapAndForward(token, stakerPart, SAERE_VAULT); _pingSaere(); // R5 HIGH-1: trigger drip } } emit Flushed(msg.sender, token, amount, burnPart, buybackPart, stakerPart); } /// AUDIT FIX R5 (HIGH-1): best-effort ping; the sync() entrypoint is /// permissionless and idempotent so failure is safe to ignore (the next /// caller will re-sync). Wrapped in try-style call so non-syncable vaults /// (test mocks, older deployments) do not brick the flush. function _pingSaere() internal { (bool ok, ) = SAERE_VAULT.call(abi.encodeCall(ISaereSyncable.sync, ())); ok; // intentionally unused — best-effort } /** * @notice Re-flush any token dust held by the sink. Permissionless. * @dev Useful for residual amounts left from failed mid-flush swaps, * or anyone who sent tokens here directly. */ function sweepDust(address token) external nonReentrant { uint256 bal = IERC20(token).balanceOf(address(this)); if (bal == 0) revert ZeroAmount(); uint256 burnPart = bal * BURN_BPS / 10_000; uint256 buybackPart = bal * BUYBACK_BPS / 10_000; uint256 stakerPart = bal - burnPart - buybackPart; if (token == AERE) { uint256 toBurnTotal = burnPart + buybackPart; if (toBurnTotal > 0) _safeTransfer(token, BURN_VAULT, toBurnTotal); if (stakerPart > 0) { _safeTransfer(token, SAERE_VAULT, stakerPart); _pingSaere(); } } else { uint256 swapForBurnAmt = burnPart + buybackPart; if (swapForBurnAmt > 0) _swapAndForward(token, swapForBurnAmt, BURN_VAULT); if (stakerPart > 0) { _swapAndForward(token, stakerPart, SAERE_VAULT); _pingSaere(); } } emit DustSwept(token, bal); } /* -------------------------------- internals -------------------------------- */ function _safeTransfer(address token, address to, uint256 amount) internal { (bool ok, bytes memory ret) = token.call( abi.encodeCall(IERC20.transfer, (to, amount)) ); if (!ok || (ret.length > 0 && !abi.decode(ret, (bool)))) revert TransferFailed(); } /// @notice Compute the minimum acceptable AERE out for a swap of /// `amountIn` of `tokenIn`, using ORACLE prices. If ORACLE is /// zero (test-mode) returns 1 (legacy behavior). If oracle reverts /// or returns zero for either asset, returns 1 (swap will accept /// any non-zero — same fallback as the dust path). /// /// Math: expected_out_in_aere_units /// = amountIn * priceOf(tokenIn) / priceOf(AERE) /// Then apply (1 - MAX_SLIPPAGE_BPS / 10_000) tolerance. /// /// Both prices are oracle-normalised to 18 decimals. Assumes both /// tokens are 18-decimal. For non-18-decimal tokens this will /// systematically misprice; production deploys should add a /// decimals-normalising oracle wrapper. function _computeOracleFloor(address tokenIn, uint256 amountIn) internal view returns (uint256) { if (ORACLE == address(0)) return 1; try ISwapGuardOracle(ORACLE).getPrice(tokenIn) returns (uint256 priceIn) { if (priceIn == 0) return 1; try ISwapGuardOracle(ORACLE).getPrice(AERE) returns (uint256 priceAere) { if (priceAere == 0) return 1; // expected = amountIn * priceIn / priceAere (both in 1e18 USD) uint256 expected = (amountIn * priceIn) / priceAere; // Apply max-slippage tolerance. uint256 floor = expected * (10_000 - uint256(MAX_SLIPPAGE_BPS)) / 10_000; return floor == 0 ? 1 : floor; } catch { return 1; } } catch { return 1; } } function _swapAndForward(address tokenIn, uint256 amountIn, address recipient) internal { // Approve router for this exact amount. IERC20(tokenIn).approve(DEX_ROUTER, amountIn); address[] memory path = new address[](2); path[0] = tokenIn; path[1] = AERE; // ROUND-3 FIX (HIGH): compute amountOutMin from ORACLE prices, NOT // from DEX pool reserves. DEX pool reserves can be sandwich- // manipulated by the same actor reading + swapping in one tx. // Oracle prices are independent of pool state. uint256 amountOutMin = _computeOracleFloor(tokenIn, amountIn); // 5-minute deadline in case the tx is held in mempool. uint256 deadline = block.timestamp + 300; try IAereV2Router(DEX_ROUTER).swapExactTokensForTokens( amountIn, amountOutMin, path, recipient, deadline ) returns (uint256[] memory) { // Success — AERE has been forwarded to `recipient`. } catch (bytes memory err) { // Swap failed (no liquidity, slippage, etc.). The tokens stay in // this contract as dust; sweepDust() can be called later when // liquidity returns. We emit and continue rather than revert so // a single dead pair doesn't lock up the whole flush. string memory msg_ = err.length > 0 ? string(err) : "swap-failed"; emit SwapFailed(tokenIn, amountIn, msg_); // Clear the approval we just set, since the swap didn't consume it. IERC20(tokenIn).approve(DEX_ROUTER, 0); } } }