aere-contracts/contracts/raas/AereRollupSettlementV2.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

415 lines
20 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.
*
* ───────────────── WHAT THIS CONTRACT STILL DOES NOT DO ─────────────────
* RESTORED 2026-08-01. V1 carried this warning twice, in its header and
* above resolveChallenge, and V2 shipped without it. Removing an honest
* warning while fixing bugs is a regression in its own right, so it is
* put back, and stated more plainly than V1 stated it:
*
* THERE IS NO FRAUD-PROOF VERIFIER HERE. Nothing in this contract
* checks whether a proposed state root is correct. The challenge game
* is Phase 1: a challenger posts a bond, and the outcome is decided by
* `resolveChallenge`, which is callable ONLY by the sequencer — that
* is, by the party whose own root is being challenged. A challenger
* who is right can still be ruled against by the accused, and in that
* case (accepted=false) the challenger's bond is SLASHED. The
* `resolveChallengeExpired` path only lets a challenge that the
* sequencer simply ignored past the grace period reject the root.
*
* So the security of a rollup settled through this contract rests on
* the sequencer's honesty plus the reputational and off-chain cost of
* misbehaving. It does NOT rest on a proof. Do not describe a rollup
* deployed by AereRaaSFactoryV2 as trust-minimised on the strength of
* this challenge window.
*
* Phase 2 replaces sequencer-side resolution with a real on-chain
* fraud-proof verifier. Until that ships, the sentence above is the
* accurate description of this contract.
*
* NOTE ON BYTECODE IDENTITY: this restoration adds comments only. Solidity
* comments emit no instructions, so the executable runtime bytecode is
* unchanged; the embedded metadata hash does move, so a fresh compile of
* this file is expected to be an executable-identical, metadata-differing
* ("partial") match against templates deployed before 2026-08-01.
*
* 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).
///
/// HONEST LIMIT, restored 2026-08-01 (V1 stated this, V2 had dropped it):
/// this function verifies NOTHING about the state root. The judge of a
/// challenge is `sequencer`, which is the party being challenged. A
/// correct challenge can be defeated by the accused, and the honest
/// challenger then loses the bond. Phase 2 replaces this with an
/// on-chain fraud-proof verifier; until then the challenge window is a
/// bonded dispute with a self-interested arbiter, not a proof.
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);
}
}