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