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

283 lines
12 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title AereRollupSettlement — per-rollup settlement contract on AERE
* @notice One deployment per third-party rollup registered through
* AereRaaSFactory. Acts as:
* 1. State-root commitment ledger (optimistic with N-hour challenge)
* 2. Revenue collector (sequencer collects fees on the rollup,
* periodically settles to this contract in DEBT_TOKEN)
* 3. Revenue splitter (rollupOwner / sinkSplit / sequencerSplit)
*
* IMMUTABILITY at deploy:
* - rollupId
* - rollupOwner address (settles revenue here)
* - sequencer address (proposes state roots)
* - DEBT_TOKEN (revenue settlement currency — typically WAERE or USDC.e)
* - SINK address
* - challengeWindow
* - sinkBps + rollupBps + sequencerBps (must sum to 10_000)
*
* GOVERNANCE-LITE:
* - sequencer is rotatable by rollupOwner via setSequencer (one operational change permitted)
* - rollupOwner is rotatable via transferOwner
* - NO param changes (splits, window) post-deploy.
*
* STATE ROOT FLOW:
* - sequencer calls proposeStateRoot(epoch, root, l2BlockHash)
* - root enters pending state; finalises automatically once
* challengeWindow expires (no explicit finaliseAll needed)
* - challenger calls challenge(epoch) with a bond (in DEBT_TOKEN) +
* a reason hash; sets challenged=true. Resolution is off-chain
* via the AERE Foundation's signed attestation (Phase 1) — see
* accept/rejectChallenge. Phase 2 will replace with a real
* fraud-proof verifier.
*
* REVENUE FLOW:
* - rollupOwner calls settleRevenue(amount) — pulls DEBT_TOKEN from
* rollupOwner, splits sinkBps to AereSink + sequencerBps to
* sequencer + rollupBps to rollupOwner (yes, the same wallet —
* this is the operating margin the rollup keeps).
*/
contract AereRollupSettlement is ReentrancyGuard {
/* ------------------------------- immutable ------------------------------- */
bytes32 public immutable ROLLUP_ID;
address public immutable SINK;
address public immutable DEBT_TOKEN;
uint64 public immutable CHALLENGE_WINDOW;
uint16 public immutable SINK_BPS;
uint16 public immutable ROLLUP_BPS;
uint16 public immutable SEQUENCER_BPS;
uint256 public immutable MIN_CHALLENGE_BOND;
/* --------------------------------- state -------------------------------- */
address public rollupOwner;
address public sequencer;
struct StateRoot {
bytes32 root;
bytes32 l2BlockHash;
uint64 proposedAt;
bool challenged;
bool resolvedRejected;
address challenger;
uint256 challengeBond;
}
/// @notice epoch → state root commitment
mapping(uint256 => StateRoot) public roots;
uint256 public latestEpoch;
uint256 public latestFinalisedEpoch;
uint256 public totalRevenueSettled;
/* --------------------------------- events ------------------------------- */
event StateRootProposed(uint256 indexed epoch, bytes32 root, bytes32 l2BlockHash);
event StateRootFinalised(uint256 indexed epoch, bytes32 root);
event Challenged(uint256 indexed epoch, address indexed challenger, uint256 bond, bytes32 reasonHash);
event ChallengeResolved(uint256 indexed epoch, bool accepted, address indexed bondRecipient);
event RevenueSettled(uint256 amount, uint256 toSink, uint256 toRollup, uint256 toSequencer);
event SequencerRotated(address oldSequencer, address newSequencer);
event OwnerTransferred(address oldOwner, address newOwner);
/* --------------------------------- errors ------------------------------- */
error NotSequencer();
error NotOwner();
error EpochOutOfOrder();
error WindowOpen(); // challenge window not yet closed (used in expired path)
error WindowClosed(); // challenge window already closed (used in normal resolve)
error UnknownEpoch();
error AlreadyChallenged();
error NotChallenged();
error AlreadyFinalised();
error ZeroAddress();
error ZeroAmount();
error BondTooSmall();
error TransferFailed();
error InvalidSplits();
/* ----------------------------- constructor ------------------------------ */
struct Config {
bytes32 rollupId;
address rollupOwner;
address sequencer;
address sink;
address debtToken;
uint64 challengeWindow;
uint16 sinkBps;
uint16 rollupBps;
uint16 sequencerBps;
uint256 minChallengeBond;
}
constructor(Config memory c) {
if (c.rollupOwner == address(0) || c.sequencer == address(0)) revert ZeroAddress();
if (c.sink == address(0) || c.debtToken == address(0)) revert ZeroAddress();
if (uint256(c.sinkBps) + uint256(c.rollupBps) + uint256(c.sequencerBps) != 10_000) revert InvalidSplits();
ROLLUP_ID = c.rollupId;
rollupOwner = c.rollupOwner;
sequencer = c.sequencer;
SINK = c.sink;
DEBT_TOKEN = c.debtToken;
CHALLENGE_WINDOW = c.challengeWindow;
SINK_BPS = c.sinkBps;
ROLLUP_BPS = c.rollupBps;
SEQUENCER_BPS = c.sequencerBps;
MIN_CHALLENGE_BOND = c.minChallengeBond;
}
/* ----------------------------- state roots ------------------------------ */
function proposeStateRoot(uint256 epoch, bytes32 root, bytes32 l2BlockHash) external {
if (msg.sender != sequencer) revert NotSequencer();
if (epoch != latestEpoch + 1) revert EpochOutOfOrder();
roots[epoch] = StateRoot({
root: root,
l2BlockHash: l2BlockHash,
proposedAt: uint64(block.timestamp),
challenged: false,
resolvedRejected: false,
challenger: address(0),
challengeBond: 0
});
latestEpoch = epoch;
emit StateRootProposed(epoch, root, l2BlockHash);
}
/// @notice True once: the challenge window has passed AND the root was
/// not rejected. Pure view — no state change needed for downstream
/// consumers (e.g. bridge withdrawal verifiers) to read this.
function isFinalised(uint256 epoch) public view returns (bool) {
StateRoot memory s = roots[epoch];
if (s.proposedAt == 0) return false;
if (s.resolvedRejected) return false;
if (s.challenged) return false;
return block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW;
}
/// @notice Bookkeeping advance to compress reads. Anyone can call.
function advanceFinalisation() external {
uint256 i = latestFinalisedEpoch + 1;
while (i <= latestEpoch && isFinalised(i)) {
emit StateRootFinalised(i, roots[i].root);
unchecked { i++; }
}
unchecked { latestFinalisedEpoch = i - 1; }
}
/* ----------------------------- challenges ------------------------------- */
function challenge(uint256 epoch, uint256 bond, bytes32 reasonHash) external nonReentrant {
StateRoot storage s = roots[epoch];
if (s.proposedAt == 0) revert UnknownEpoch();
if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) revert AlreadyFinalised();
if (s.challenged) revert AlreadyChallenged();
if (bond < MIN_CHALLENGE_BOND) revert BondTooSmall();
if (!IERC20(DEBT_TOKEN).transferFrom(msg.sender, address(this), bond)) revert TransferFailed();
s.challenged = true;
s.challenger = msg.sender;
s.challengeBond = bond;
emit Challenged(epoch, msg.sender, bond, reasonHash);
}
/// @notice Phase 1 sequencer-side challenge resolution.
/// @dev MUST be called STRICTLY within the challenge window.
/// If the sequencer fails to resolve before window-end, the
/// challenge stays open and `resolveChallengeExpired()` lets
/// anyone return the bond to the challenger AND flag the root
/// as rejected. This removes the "wait-out-the-window then
/// pocket the bond + finalise fraudulent root" exploit path.
/// Phase 2 replaces this with an on-chain fraud-proof verifier.
function resolveChallenge(uint256 epoch, bool accepted) external nonReentrant {
if (msg.sender != sequencer) revert NotSequencer();
StateRoot storage s = roots[epoch];
if (!s.challenged) revert NotChallenged();
if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) revert WindowClosed();
address recipient = accepted ? s.challenger : sequencer;
uint256 bond = s.challengeBond;
s.challengeBond = 0;
s.challenged = false; // resolution closes the challenge state either way
if (accepted) {
s.resolvedRejected = true; // fraudulent root permanently rejected
}
if (bond > 0) {
if (!IERC20(DEBT_TOKEN).transfer(recipient, bond)) revert TransferFailed();
}
emit ChallengeResolved(epoch, accepted, recipient);
}
/// @notice Permissionless: if the sequencer didn't resolve a challenge
/// in time, the challenger gets their bond back AND the root
/// is automatically marked rejected. The sequencer is punished
/// for inattention by losing the chance to defend.
function resolveChallengeExpired(uint256 epoch) external nonReentrant {
StateRoot storage s = roots[epoch];
if (!s.challenged) revert NotChallenged();
if (block.timestamp < uint256(s.proposedAt) + CHALLENGE_WINDOW) revert WindowOpen();
address challenger = s.challenger;
uint256 bond = s.challengeBond;
s.challengeBond = 0;
s.challenged = false;
s.resolvedRejected = true; // sequencer didn't defend → reject the root by default
if (bond > 0) {
if (!IERC20(DEBT_TOKEN).transfer(challenger, bond)) revert TransferFailed();
}
emit ChallengeResolved(epoch, true, challenger);
}
/* ----------------------------- revenue ---------------------------------- */
function settleRevenue(uint256 amount) external nonReentrant {
if (msg.sender != rollupOwner) revert NotOwner();
if (amount == 0) revert ZeroAmount();
if (!IERC20(DEBT_TOKEN).transferFrom(msg.sender, address(this), amount)) revert TransferFailed();
uint256 toSink = (amount * SINK_BPS) / 10_000;
uint256 toSeq = (amount * SEQUENCER_BPS) / 10_000;
uint256 toRol = amount - toSink - toSeq; // remainder (handles rounding)
if (toSink > 0) {
IERC20(DEBT_TOKEN).approve(SINK, toSink);
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", DEBT_TOKEN, toSink));
ok; // tolerant; residual stays here flushable later
}
if (toSeq > 0) {
if (!IERC20(DEBT_TOKEN).transfer(sequencer, toSeq)) revert TransferFailed();
}
if (toRol > 0) {
if (!IERC20(DEBT_TOKEN).transfer(rollupOwner, toRol)) revert TransferFailed();
}
totalRevenueSettled += amount;
emit RevenueSettled(amount, toSink, toRol, toSeq);
}
/* ----------------------------- governance-lite -------------------------- */
function setSequencer(address newSeq) external {
if (msg.sender != rollupOwner) revert NotOwner();
if (newSeq == address(0)) revert ZeroAddress();
address old = sequencer;
sequencer = newSeq;
emit SequencerRotated(old, newSeq);
}
function transferOwner(address newOwner) external {
if (msg.sender != rollupOwner) revert NotOwner();
if (newOwner == address(0)) revert ZeroAddress();
address old = rollupOwner;
rollupOwner = newOwner;
emit OwnerTransferred(old, newOwner);
}
}