Skip to content

feat: prevent gov update from bricking rollup#22656

Merged
just-mitch merged 6 commits into
nextfrom
mitch/tmnt-521-rollup-gate-contract
May 29, 2026
Merged

feat: prevent gov update from bricking rollup#22656
just-mitch merged 6 commits into
nextfrom
mitch/tmnt-521-rollup-gate-contract

Conversation

@just-mitch
Copy link
Copy Markdown
Collaborator

@just-mitch just-mitch commented Apr 20, 2026

Implements AZIP-2.

The shared theme: governance and rollup-config setters that could mute or strand the escape hatch — or strand validators or rewards — are removed, made one-shot, delay-gated, or rate-limited.

Escape hatch (one-shot): updateEscapeHatchsetEscapeHatch. Reverts on address(0) and on any second call. Once a rollup has an escape hatch, it cannot be replaced or removed; rollups that want no escape hatch simply never call the setter. EscapeHatchUpdatedEscapeHatchSet. New errors ValidatorSelection__EscapeHatchAlreadySet, ValidatorSelection__EscapeHatchCannotBeZero.

Reward distributor — per-address earmarking + canonical inheritance: Anyone can subsidize block production on a specific address (typically a rollup instance), regardless of whether it is canonical. Rollups are not privileged at the bookkeeping layer; the only place "rollup" enters is that the canonical rollup is the sole address with access to the implicit (un-earmarked) pool.

  • subsidizeAddress(recipient, amount) is permissionless and earmarks ASSET to a specific recipient in specificRecipientBalance[recipient] (tracked in totalEarmarkedBalance). Reverts on address(0).
  • ASSET sent to the contract directly forms an unearmarked pool, implicitly available to whichever rollup is canonical at the time.
  • availableTo(recipient) = (balance - totalEarmarked) + specificRecipientBalance[recipient] for the canonical rollup; just specificRecipientBalance[recipient] otherwise.
  • claim(to, amount) no longer gates on msg.sender == canonicalRollup(). Authorization is implicit through accounting: canonical callers draw the implicit pool first and fall through to their earmarked balance; any other caller can only draw specificRecipientBalance[msg.sender]. Insufficient funds revert with RewardDistributor__InsufficientAvailable. Old (non-canonical) rollups can still drain anything earmarked to them across rotations.
  • recoverFrom(from, to, amount) (was recover(asset, to, amount)) mirrors claim's accounting under owner gating. The rename is deliberate: the new shape recover(address,address,uint256) would have collided on its 4-byte selector with the old asset-recovery shape, silently re-interpreting governance calldata that referenced the previous ABI. Renaming forces a clean break.
  • recoverWrongAsset(asset, to, amount) is the owner-only path for non-ASSET tokens.
  • New errors: RewardDistributor__InsufficientAvailable, RewardDistributor__ZeroRollup, RewardDistributor__WrongRecoverMechanism.

Reward config — addresses immutable post-deployment: setRewardConfig no longer takes a full RewardConfig. It takes MutableRewardConfig, which exposes only sequencerBps and checkpointReward. The rewardDistributor and booster addresses are written exactly once in the constructor (RewardLib.initializeConfig) and immutable thereafter. Rotating either requires redeploying the rollup via Registry.addRollup. The post-deployment writer is RewardLib.updateConfig. RewardConfigUpdated event signature follows.

setProvingCostPerMana — rate-limited: floor of 2 (MIN_PROVING_COST_PER_MANA), 30-day cooldown (first post-init update waived), symmetric 3/2 multiplicative step. FeeStore gains uint64 provingCostLastUpdate. New errors FeeLib__ProvingCostBelowFloor, FeeLib__ProvingCostCooldown, FeeLib__ProvingCostStepExceeded. With 3/2 per 30 days, the value needs ~170 days to move 10× and ~340 days to move 100×.

Staking queue invariants — enforced on every write: assertValidQueueConfig lifted into StakingLib and called from both the constructor and updateStakingQueueConfig. normalFlushSizeMin > 0 and normalFlushSizeQuotient > 0 for the life of the rollup; the path that could close deposits on a running rollup is gone.

Slasher swap — 60-day timelock: setSlasher removed, replaced by queueSetSlasher (owner) → cancelSetSlasher (owner) | finalizeSetSlasher (permissionless). SLASHER_EXECUTION_DELAY = 60 days exceeds the ~38-day withdrawal window so validators who object can exit before the change lands. Queueing while a change is pending overwrites it and resets the timer. New events PendingSlasherQueued, PendingSlasherCancelled. New errors Staking__NoPendingSlasher, Staking__SlasherNotReady. New views getPendingSlasher, getSlasherExecutionDelay.

setLocalEjectionThreshold — removed: mutator gone entirely; reader stays. Threshold is fixed at deploy.

updateManaTarget is deliberately left otherwise unchanged — its worst-case outcomes no longer mute the escape hatch.

Test plan

  • New unit + integration coverage:
    • setEscapeHatchOneShot.t.sol — every transition of the one-shot guard, including zero-then-nonzero.
    • ProvingCostRateLimit.t.sol — floor, step boundaries (up and down), cooldown boundaries, and a 10-step amplification check that bounds (3/2)^10 growth.
    • setSlasher.t.sol — queue/cancel/finalize states, finalize permissionless, overwrite-pending semantics.
    • setLocalEjectionThresholdRemoval.t.sol — selector unreachable post-removal.
    • slash.t.sol — new SlashLocalEjectionTest deploys with a non-zero threshold to exercise the local-ejection path.
    • updateStakingQueueConfig.t.sol — invalid-config reverts.
    • initialize.t.sol — proving-cost floor enforced at construction.
  • Reward distributor: claim.t.sol, recover.t.sol, subsidizeAddress.t.sol (renamed from subsidizeRollup.t.sol) cover boundary, multi-recipient isolation, and canonical-rotation semantics. recover.t.sol exercises both recoverFrom (ASSET) and recoverWrongAsset (other tokens, including the named-error revert when called with ASSET). New invariant.t.sol adds a stateful fuzz handler asserting three accounting identities (balance ≥ totalEarmarked, sum of specifics == totalEarmarked, canonical availableTo identity) under random subsidize/claim/recover/donate/rotate sequences.
  • Yarn-project: rollup_cheat_codes.ts gains clearProvingCostCooldown for tests that need to bump proving cost more than once; slash_veto_demo.test.ts deploys the slasher with the correct vetoer up front instead of swapping mid-test (now that setSlasher is gone).

@just-mitch just-mitch force-pushed the mitch/tmnt-521-rollup-gate-contract branch 5 times, most recently from 504903a to a019231 Compare April 20, 2026 19:21
@just-mitch just-mitch changed the title WIP: feat: prevent gov update from bricking rollup feat: prevent gov update from bricking rollup Apr 21, 2026
@just-mitch just-mitch added the ci-full Run all master checks. label Apr 21, 2026
@just-mitch just-mitch force-pushed the mitch/tmnt-521-rollup-gate-contract branch from a019231 to a45292e Compare April 21, 2026 00:25
@just-mitch just-mitch force-pushed the mitch/tmnt-521-rollup-gate-contract branch 3 times, most recently from 6805039 to 0accbc6 Compare May 11, 2026 18:46
@just-mitch just-mitch requested a review from a team as a code owner May 26, 2026 19:25
@just-mitch just-mitch force-pushed the mitch/tmnt-521-rollup-gate-contract branch from de9421c to 5ee8561 Compare May 27, 2026 00:30
just-mitch and others added 4 commits May 26, 2026 22:02
Closes 9 audit findings plus the slasher rotation hardening discussed
on this PR:

- #1 slasher rotation now installs the outgoing slasher in a legacy
  slot with a 30-day drain window so quorum-backed rounds finish
  executing past finalize; new LegacySlasherAuthorized event +
  getLegacySlasher view.
- #6 ValidatorSelectionLib.setEscapeHatch requires
  IEscapeHatch(_escapeHatch).getRollup() == address(this).
- #7 RewardDistributor emits Subsidized and Distributed events so
  indexers can rebuild bucket history from logs alone.
- #8 RollupCore constructor enforces exitDelaySeconds <=
  SLASHER_EXECUTION_DELAY.
- #9 RewardBooster constructor rejects configs that can return zero
  shares; RewardLib.handleRewardsAndFees re-checks shares > 0 at the
  boundary.
- #10 queueSetSlasher and finalizeSetSlasher require the replacement
  Slasher's PROPOSER to be initialized.
- #12 FeeLib.initialize caps the initial provingCostPerMana at
  MAX_INITIAL_PROVING_COST_PER_MANA (1e10) with explicit error.
- #13 Outbox.MessageConsumed and RootAdded carry numCheckpointsInEpoch
  so log-only consumers recover the AZIP-14 root slot.
- #14 assertValidQueueConfig rejects maxQueueFlushSize == 0 and
  bootstrapFlushSize == 0 while bootstrap is active.
- #15 assertValidQueueConfig rejects bootstrapFlushSize >
  maxQueueFlushSize and the doc on getEntryQueueFlushSize now
  accurately describes how the cap is enforced across phases.

Each fix has focused Foundry tests covering the rejection path and
the happy boundary.
…omments

Drops MAX_INITIAL_PROVING_COST_PER_MANA from 1e10 to 1e8 to keep the deploy-time
ceiling close to realistic operating values (~2.5e7) given the expectation that
proving costs trend down, and shortens the surrounding rationale comments on
the legacy slasher slot and the FeeHeaderOverflow test.
@just-mitch
Copy link
Copy Markdown
Collaborator Author

Bypassing rules because Palla's PR was reviewed by Phil, and this specific PR was audited by Cantina.

@just-mitch just-mitch merged commit 74219f5 into next May 29, 2026
30 of 37 checks passed
@just-mitch just-mitch deleted the mitch/tmnt-521-rollup-gate-contract branch May 29, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-full Run all master checks. ci-no-squash

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants