aere-contracts/contracts/sink/AereSink.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
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.
2026-07-20 01:02:37 +03:00

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