aere-contracts/contracts/AereCoinbaseSplitterV2.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

280 lines
11 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)
*
* All three are shares of the VALIDATOR COINBASE REWARD. None of them
* is a share of transaction fees and none is a base-fee burn.
*
* The 3-way default raises the protocol-directed share of a validator
* reward from 37.5% to 52.5% (burn bucket plus the sink bucket). Of
* that 52.5%, the portion actually destroyed is 45.75%, because
* AereSink's immutable internal 15/40/45 split sends 45% of its input
* to staker yield rather than to a burn. 45.75% is the honest number
* for token destruction; 52.5% counts the whole sink slice as
* protocol-directed. Both are CONDITIONAL RATES on a validator reward.
*
* MEASURED: validator coinbase revenue on chain 2800 is currently ZERO,
* so every rate above currently applies to zero. AereFeeBurnVault holds
* approximately 0.137 AERE against a 2.8 billion fixed supply
* (eth_getBalance at block 10,571,949). AERE is NOT deflationary today.
*
* @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
);
}
}