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