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.
283 lines
12 KiB
Solidity
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);
|
|
}
|
|
}
|