aere-contracts/contracts/AereCoinbaseSplitterV2.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

269 lines
10 KiB
Solidity

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