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

375 lines
17 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 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);
}
}