// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IBurnVaultV2 { function burn() external payable; } interface IAereSinkV2 { function flush(address token, uint256 amount) external; } interface IWAERE { function deposit() external payable; function approve(address guy, uint256 wad) external returns (bool); } /** * @title AereCoinbaseSplitterV2 * @notice V2 of the validator coinbase splitter. Replaces V1's 2-way split * (burn / validator-rebate) with a 3-way split that ADDS the AereSink * bucket so a portion of every validator reward feeds the sAERE * flywheel. * * Three buckets, all in basis points, owner-configurable within hard * caps: * burnBps — direct burn at AereFeeBurnVault (default 3750 = 37.5%) * sinkBps — routed through AereSink (default 1500 = 15.0%) * rebateBps — back to validator (default 4750 = 47.5%, derived) * * The 3-way default lifts the protocol-level deflation rate from 37.5% * to ~52.5% net (burn bucket plus most of the sink — see saere_architecture * for AereSink's internal 15/40/45 split which makes 55% of sink output * a burn). * * @dev Owner can adjust bps within these limits: * - burnBps ∈ [0, 5000] * - sinkBps ∈ [0, 3000] * - rebateBps = 10000 - burnBps - sinkBps (auto-derived; revert if < 0) * The sink address itself is owner-settable but rate-limited: a * setSink call has a 7-day timelock to give validators warning before * a sink swap. * * This is the "in-place upgrade" that Week-1 references. The V1 * contract stays deployed and any AERE still flowing through it * continues to honor the V1 burn behaviour. Validators migrate to * V2 by switching the forwarder daemon's target address (off-chain * config). Once migration is complete, V1 burnBps can be set to 0 * and the V1 contract retired. * * Funds-safety: V2 cannot custody AERE across calls; every * splitAndDistribute / splitToSelf fully dispatches msg.value. * The sink bucket is wrapped via WAERE → IAereSink.flush() in one * transaction; if the sink call reverts, the whole tx reverts and * the validator can retry. */ contract AereCoinbaseSplitterV2 is Ownable, ReentrancyGuard { /* --------------------------------- state --------------------------------- */ address payable public burnVault; address public sink; // AereSink address public immutable WAERE_ADDR; uint256 public burnBps = 3750; // 37.5% uint256 public sinkBps = 1500; // 15.0% // rebateBps = 10000 - burnBps - sinkBps (computed live) uint256 public constant BPS = 10_000; uint256 public constant SINK_CHANGE_TIMELOCK = 7 days; uint256 public constant MAX_BURN_BPS = 5000; uint256 public constant MAX_SINK_BPS = 3000; // Cumulative lifetime accounting. uint256 public totalBurned; uint256 public totalSentToSink; uint256 public totalRebated; mapping(address => uint256) public burnedBy; mapping(address => uint256) public sentToSinkBy; mapping(address => uint256) public rebatedTo; // Sink rotation timelock. address public pendingSink; uint256 public pendingSinkEarliestSetTs; /* --------------------------------- events --------------------------------- */ event Burned(address indexed contributor, uint256 amount, uint256 newTotalBurned); event SinkRouted(address indexed contributor, uint256 amount, uint256 newTotalSentToSink); event Rebated(address indexed validator, uint256 amount, uint256 newTotalRebated); event BpsChanged(uint256 oldBurnBps, uint256 oldSinkBps, uint256 newBurnBps, uint256 newSinkBps); event BurnVaultChanged(address oldVault, address newVault); event PendingSinkProposed(address indexed newSink, uint256 earliestSet); event SinkChanged(address oldSink, address newSink); event PendingSinkCancelled(); /* --------------------------------- errors --------------------------------- */ error ZeroAddress(); error BpsOutOfRange(); error TimelockNotElapsed(uint256 nowTs, uint256 earliest); error NoPendingProposal(); error DistributeFailed(); error ZeroAmount(); /* ------------------------------- constructor ------------------------------ */ constructor(address payable _burnVault, address _waere) { if (_burnVault == address(0) || _waere == address(0)) revert ZeroAddress(); burnVault = _burnVault; WAERE_ADDR = _waere; // sink starts unset — until owner calls proposeSink+acceptSink, V2 // behaves like V1 (no sink bucket; the 15% sinkBps default would // also be skipped, with that 15% rebated instead). } /* ------------------------------- admin (rate) ----------------------------- */ /// Owner can adjust burn/sink bps within caps. rebateBps is derived. function setBps(uint256 _burnBps, uint256 _sinkBps) external onlyOwner { if (_burnBps > MAX_BURN_BPS) revert BpsOutOfRange(); if (_sinkBps > MAX_SINK_BPS) revert BpsOutOfRange(); if (_burnBps + _sinkBps > BPS) revert BpsOutOfRange(); emit BpsChanged(burnBps, sinkBps, _burnBps, _sinkBps); burnBps = _burnBps; sinkBps = _sinkBps; } function setBurnVault(address payable _vault) external onlyOwner { if (_vault == address(0)) revert ZeroAddress(); emit BurnVaultChanged(burnVault, _vault); burnVault = _vault; } /* ------------------------------- admin (sink) ----------------------------- */ function proposeSink(address _sink) external onlyOwner { if (_sink == address(0)) revert ZeroAddress(); pendingSink = _sink; pendingSinkEarliestSetTs = block.timestamp + SINK_CHANGE_TIMELOCK; emit PendingSinkProposed(_sink, pendingSinkEarliestSetTs); } function acceptSink() external onlyOwner { if (pendingSink == address(0)) revert NoPendingProposal(); if (block.timestamp < pendingSinkEarliestSetTs) { revert TimelockNotElapsed(block.timestamp, pendingSinkEarliestSetTs); } address old = sink; sink = pendingSink; delete pendingSink; delete pendingSinkEarliestSetTs; emit SinkChanged(old, sink); } function cancelPendingSink() external onlyOwner { if (pendingSink == address(0)) revert NoPendingProposal(); delete pendingSink; delete pendingSinkEarliestSetTs; emit PendingSinkCancelled(); } /* ----------------------------------- core --------------------------------- */ /** * @notice Split msg.value 3-ways. Anyone can call. Sink bucket is skipped * (added to rebate) when sink is unset. */ function splitAndDistribute(address payable validator) external payable nonReentrant { if (msg.value == 0) revert ZeroAmount(); if (validator == address(0)) revert ZeroAddress(); uint256 burnAmt = (msg.value * burnBps) / BPS; uint256 sinkAmt = (sink == address(0)) ? 0 : (msg.value * sinkBps) / BPS; uint256 rebateAmt = msg.value - burnAmt - sinkAmt; if (burnAmt > 0) { IBurnVaultV2(burnVault).burn{value: burnAmt}(); totalBurned += burnAmt; burnedBy[msg.sender] += burnAmt; emit Burned(msg.sender, burnAmt, totalBurned); } if (sinkAmt > 0) { // Wrap msg.value's sink share into WAERE, then approve+flush. IWAERE(WAERE_ADDR).deposit{value: sinkAmt}(); IWAERE(WAERE_ADDR).approve(sink, sinkAmt); IAereSinkV2(sink).flush(WAERE_ADDR, sinkAmt); totalSentToSink += sinkAmt; sentToSinkBy[msg.sender] += sinkAmt; emit SinkRouted(msg.sender, sinkAmt, totalSentToSink); } if (rebateAmt > 0) { (bool ok, ) = validator.call{value: rebateAmt}(""); if (!ok) revert DistributeFailed(); totalRebated += rebateAmt; rebatedTo[validator] += rebateAmt; emit Rebated(validator, rebateAmt, totalRebated); } } function splitToSelf() external payable nonReentrant { _splitToValidator(payable(msg.sender)); } function _splitToValidator(address payable validator) internal { if (msg.value == 0) revert ZeroAmount(); uint256 burnAmt = (msg.value * burnBps) / BPS; uint256 sinkAmt = (sink == address(0)) ? 0 : (msg.value * sinkBps) / BPS; uint256 rebateAmt = msg.value - burnAmt - sinkAmt; if (burnAmt > 0) { IBurnVaultV2(burnVault).burn{value: burnAmt}(); totalBurned += burnAmt; burnedBy[msg.sender] += burnAmt; emit Burned(msg.sender, burnAmt, totalBurned); } if (sinkAmt > 0) { IWAERE(WAERE_ADDR).deposit{value: sinkAmt}(); IWAERE(WAERE_ADDR).approve(sink, sinkAmt); IAereSinkV2(sink).flush(WAERE_ADDR, sinkAmt); totalSentToSink += sinkAmt; sentToSinkBy[msg.sender] += sinkAmt; emit SinkRouted(msg.sender, sinkAmt, totalSentToSink); } if (rebateAmt > 0) { (bool ok, ) = validator.call{value: rebateAmt}(""); if (!ok) revert DistributeFailed(); totalRebated += rebateAmt; rebatedTo[validator] += rebateAmt; emit Rebated(validator, rebateAmt, totalRebated); } } /* ----------------------------------- views -------------------------------- */ function rebateBps() external view returns (uint256) { return BPS - burnBps - sinkBps; } function splitStats() external view returns ( uint256 lifetimeBurned, uint256 lifetimeSentToSink, uint256 lifetimeRebated, uint256 currentBurnBps, uint256 currentSinkBps, uint256 currentRebateBps, address currentSink ) { return ( totalBurned, totalSentToSink, totalRebated, burnBps, sinkBps, BPS - burnBps - sinkBps, sink ); } }