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.
329 lines
14 KiB
Solidity
329 lines
14 KiB
Solidity
// 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);
|
|
}
|
|
}
|
|
}
|