// SPDX-License-Identifier: MIT pragma solidity 0.8.23; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title AereRollupSettlementV2 — bug-fixed per-rollup settlement contract on AERE * @notice Bug-fix redeploy of AereRollupSettlement. Deployed PER-ROLLUP by * AereRaaSFactoryV2. Same three roles as V1: * 1. State-root commitment ledger (optimistic with N-hour challenge) * 2. Revenue collector (sequencer settles fees in DEBT_TOKEN) * 3. Revenue splitter (rollupOwner / sinkSplit / sequencerSplit) * * ─────────────────────────── WHY V2 ─────────────────────────── * V1 (per-rollup template, deployed by AereRaaSFactory 0x8C1b0018…26Cd) * had four confirmed issues around the challenge/finalisation game: * * F6 (last-block challenge grief). A challenge placed in the LAST * in-window block could not be defended: resolveChallenge reverted * WindowClosed the moment block.timestamp >= proposedAt+WINDOW, so * only resolveChallengeExpired remained — which REJECTED the (valid) * root and refunded the challenger's bond IN FULL. Griefing a valid * root therefore cost the attacker nothing but gas. * FIX: a DEFENSE_GRACE period after window-end during which the * sequencer can still call resolveChallenge. A defeated / false * challenger's bond is SLASHED to the AereSink (not refunded). * resolveChallengeExpired only becomes callable AFTER the grace. * * F7 (rejected epoch freezes finalisation). Once an epoch was rejected, * the contiguous latestFinalisedEpoch tracker stuck forever (the * walk stops at the gap) AND the epoch could never be re-proposed * (strict epoch==latestEpoch+1 continuity). One griefing challenge * permanently froze finalisation. * FIX: proposeStateRoot may REPLACE a rejected epoch (re-propose it), * so the sequencer can heal the gap and finalisation resumes. * * isFinalised ancestor safety (MED). V1 isFinalised(epoch) was purely * LOCAL — a descendant of a rejected epoch read as final. A bridge * trusting isFinalised on a post-gap epoch could release funds against * an unfinalised ancestor chain. * FIX: isFinalised(epoch) is now ANCESTOR-AWARE: it is true iff * epoch <= latestFinalisedEpoch, and latestFinalisedEpoch only ever * advances CONTIGUOUSLY through settled, non-rejected epochs. The * purely-local notion is exposed separately as isEpochSettled(). * * settleRevenue stuck-residual (LOW). V1's sink flush was best-effort * with no recovery if the sink call failed. * FIX: flushResidualToSink() re-pushes any free DEBT_TOKEN balance * (never touching pending challenge bonds) to the sink. * * Everything else (immutability model, revenue split maths, governance- * lite sequencer/owner rotation) is behaviourally identical to V1. * * IMMUTABILITY at deploy: rollupId, sink, debtToken, challengeWindow, * DEFENSE_GRACE, sinkBps/rollupBps/sequencerBps (sum 10_000), * minChallengeBond. */ contract AereRollupSettlementV2 is ReentrancyGuard { /* ------------------------------- immutable ------------------------------- */ bytes32 public immutable ROLLUP_ID; address public immutable SINK; address public immutable DEBT_TOKEN; uint64 public immutable CHALLENGE_WINDOW; uint64 public immutable DEFENSE_GRACE; // F6: sequencer defense grace after window-end 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; /// @notice DEBT_TOKEN currently escrowed against pending challenges. The /// residual-flush recovery never touches this, so a live challenge /// bond can never be swept to the sink out from under a challenger. uint256 public activeBondEscrow; uint256 public totalRevenueSettled; /* --------------------------------- events ------------------------------- */ event StateRootProposed(uint256 indexed epoch, bytes32 root, bytes32 l2BlockHash); event StateRootReplaced(uint256 indexed epoch, bytes32 root); // F7: re-proposed a rejected epoch 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 ChallengerSlashed(uint256 indexed epoch, address indexed challenger, uint256 bond, address sink); // F6 event RevenueSettled(uint256 amount, uint256 toSink, uint256 toRollup, uint256 toSequencer); event ResidualFlushed(uint256 amount); // LOW: sink recovery event SequencerRotated(address oldSequencer, address newSequencer); event OwnerTransferred(address oldOwner, address newOwner); /* --------------------------------- errors ------------------------------- */ error NotSequencer(); error NotOwner(); error EpochOutOfOrder(); error WindowOpen(); // grace not yet expired (expired path called too early) error WindowClosed(); // defense grace already expired (sequencer resolve too late) error UnknownEpoch(); error AlreadyChallenged(); error NotChallenged(); error AlreadyFinalised(); // challenge window already closed / epoch already final error EpochRejected(); // cannot challenge an already-rejected epoch 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; uint64 defenseGrace; 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; DEFENSE_GRACE = c.defenseGrace; SINK_BPS = c.sinkBps; ROLLUP_BPS = c.rollupBps; SEQUENCER_BPS = c.sequencerBps; MIN_CHALLENGE_BOND = c.minChallengeBond; } /* ----------------------------- state roots ------------------------------ */ /// @notice Propose a fresh epoch (epoch == latestEpoch+1) OR re-propose a /// previously-REJECTED epoch to heal a finalisation gap (F7). A /// rejected epoch is replaced with a fresh commitment that restarts /// its own challenge window; latestEpoch is unchanged on replacement. function proposeStateRoot(uint256 epoch, bytes32 root, bytes32 l2BlockHash) external { if (msg.sender != sequencer) revert NotSequencer(); StateRoot storage existing = roots[epoch]; bool isNew = (epoch == latestEpoch + 1) && existing.proposedAt == 0; // F7: allow replacing an epoch that was rejected and is not yet part of // the contiguous finalised prefix. bool isReplacement = existing.proposedAt != 0 && existing.resolvedRejected && epoch > latestFinalisedEpoch; if (!isNew && !isReplacement) revert EpochOutOfOrder(); roots[epoch] = StateRoot({ root: root, l2BlockHash: l2BlockHash, proposedAt: uint64(block.timestamp), challenged: false, resolvedRejected: false, challenger: address(0), challengeBond: 0 }); if (isNew) { latestEpoch = epoch; emit StateRootProposed(epoch, root, l2BlockHash); } else { emit StateRootReplaced(epoch, root); emit StateRootProposed(epoch, root, l2BlockHash); } } /// @notice LOCAL settlement notion: this epoch's own challenge window has /// passed and it was neither challenged nor rejected. Does NOT imply /// its ancestors are settled — use isFinalised() for that. function isEpochSettled(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 ANCESTOR-AWARE finality (bridge-safe). True iff epoch is within /// the contiguous finalised prefix [1..latestFinalisedEpoch]. Because /// latestFinalisedEpoch only advances through settled, non-rejected /// epochs, a descendant of a rejected/unsettled epoch can NEVER read /// as final. Call advanceFinalisation() to extend the prefix first. function isFinalised(uint256 epoch) public view returns (bool) { if (epoch == 0) return false; return epoch <= latestFinalisedEpoch; } /// @notice Extend the contiguous finalised prefix. Permissionless. Walks from /// latestFinalisedEpoch+1 while each epoch isEpochSettled(); stops at /// the first challenged / rejected / still-in-window / re-proposed /// epoch. A rejected epoch that is later re-proposed and settles will /// let a subsequent call continue past it (F7 healing). function advanceFinalisation() external { uint256 i = latestFinalisedEpoch + 1; while (i <= latestEpoch && isEpochSettled(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 (s.resolvedRejected) revert EpochRejected(); if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) revert AlreadyFinalised(); // one challenge per (re)proposal: challenger is only reset on re-propose. if (s.challenger != address(0)) 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; activeBondEscrow += bond; emit Challenged(epoch, msg.sender, bond, reasonHash); } /// @notice Phase-1 sequencer-side resolution. Callable within the challenge /// window AND for DEFENSE_GRACE seconds after it (F6): a challenge /// landed in the last in-window block can still be defended. /// accepted=true → sequencer concedes the root is fraudulent: root /// rejected, honest challenger's bond REFUNDED. /// accepted=false → sequencer defends a valid root: challenge /// defeated, root survives, challenger's bond /// SLASHED to the AereSink (grief is now costly). 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 + DEFENSE_GRACE) revert WindowClosed(); uint256 bond = s.challengeBond; address challenger = s.challenger; s.challengeBond = 0; s.challenged = false; if (bond > 0) activeBondEscrow -= bond; if (accepted) { s.resolvedRejected = true; if (bond > 0) { if (!IERC20(DEBT_TOKEN).transfer(challenger, bond)) revert TransferFailed(); } emit ChallengeResolved(epoch, true, challenger); } else { // valid root defended: slash the defeated challenger's bond to the sink. if (bond > 0) { _pushToSink(bond); emit ChallengerSlashed(epoch, challenger, bond, SINK); } emit ChallengeResolved(epoch, false, SINK); } } /// @notice Permissionless fallback: only AFTER the defense grace has fully /// expired without the sequencer resolving. The root is rejected by /// default (sequencer failed to defend) and the challenger's bond is /// refunded — the grief protection is the grace window above, so a /// false challenger is slashed there; an absent sequencer is the one /// penalised here (its root is dropped). function resolveChallengeExpired(uint256 epoch) external nonReentrant { StateRoot storage s = roots[epoch]; if (!s.challenged) revert NotChallenged(); if (block.timestamp < uint256(s.proposedAt) + CHALLENGE_WINDOW + DEFENSE_GRACE) revert WindowOpen(); address challenger = s.challenger; uint256 bond = s.challengeBond; s.challengeBond = 0; s.challenged = false; s.resolvedRejected = true; if (bond > 0) { activeBondEscrow -= bond; 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) { _pushToSink(toSink); // best-effort; residual recoverable via flushResidualToSink } 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); } /// @notice LOW-fix recovery: re-push any FREE DEBT_TOKEN balance (a stuck /// sink-flush residual, or a slashed bond whose sink push failed) to /// the sink. Never touches DEBT_TOKEN escrowed against a pending /// challenge (activeBondEscrow). Permissionless. function flushResidualToSink() external nonReentrant { uint256 bal = IERC20(DEBT_TOKEN).balanceOf(address(this)); uint256 locked = activeBondEscrow; if (bal <= locked) revert ZeroAmount(); uint256 free = bal - locked; _pushToSink(free); emit ResidualFlushed(free); } function _pushToSink(uint256 amount) internal { IERC20(DEBT_TOKEN).approve(SINK, amount); (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", DEBT_TOKEN, amount)); if (!ok) { // clear the dangling allowance; funds stay here, recoverable later. IERC20(DEBT_TOKEN).approve(SINK, 0); } } /* ----------------------------- 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); } }