Repository navigation
feat(l1): let governance pin a validator-set sample floor at rollup cutover - #25588
Closed
aminsammara wants to merge 2 commits into
Closed
aminsammara wants to merge 2 commits into
aminsammara wants to merge 2 commits into
Conversation
Committee selection samples the validator set as it stood
`lagInEpochsForValidatorSet` epochs before an epoch starts. The GSE credits
the shared bonus bucket to whichever rollup was its latest *at the queried
timestamp*, and never backdates that attribution when a new rollup is added.
So a rollup that governance has just made canonical owns every inherited
validator for a query at `now`, but sees an empty set at the lagged timestamp
sampling actually reads. `setupEpoch` reverts with
`InsufficientValidatorSetSize(0, targetCommitteeSize)` for the cutover epoch
and the next `lag` epochs, which is also every ordinary `propose` in them.
On mainnet values (lag 2, 2304s epochs, 3059 attesters all in the bonus
bucket) that is a bounded but automatic ~1h40m stall at every upgrade.
`setValidatorSetSampleFloor` lets governance pin the timestamp at which the
inheritance took effect, and sampling clamps to it. The floor is unset (zero)
by default and a zero floor never clamps, so deployed rollups and deploy
scripts are unaffected.
The floor is pinned in the same transaction that makes the rollup canonical,
rather than latched lazily on the first `setupEpoch`:
- `setupEpoch` is permissionless, so whoever called it first would choose
the cutover epoch's committee from a menu of inclusion timestamps.
- `propose` carries committee attestations gathered off-chain beforehand,
which is impossible for a committee that is not determined until the
transaction lands.
Both are avoided by establishing the floor atomically with the event that
creates the need for it. The setter is one-shot and refuses to pin a floor at
which the GSE does not already report this rollup as latest, so a payload
that orders it before `GSE.addRollup` reverts rather than storing a floor
that resolves to the previous rollup.
The clamp is strictly more conservative than the lag it stands in for: the
sample still predates the epoch being sampled, so no one can stake after the
upgrade and be selected. Clamped epochs share a validator set but still mix
a per-epoch randao, so their committees differ.
Every rollup upgrade runs the same two governance actions, `Registry.addRollup` and `GSE.addRollup`, so the committee stall they cause is best fixed once here rather than re-remembered in each bespoke upgrade payload. The new action has to follow `GSE.addRollup`: until that runs, the GSE still reports the previous rollup as its latest and the setter refuses the floor. All three actions execute in one transaction, so the pinned floor is exactly the cutover timestamp wherever it sits in the list. `test/governance/scenario/RegisterNewRollupVersionPayload.sol` was a verbatim copy of the production payload, and the two would now drift. Dropped in favour of the real one, which puts the new action through the full governance lifecycle in `AddRollup.t.sol` -- signalling, voting, queueing and `Governance.execute` -- instead of leaving it covered only by direct calls. `InternalGov.s.sol` and `StakingAssetHandler.s.sol` imported the test copy and now take the production one too.
aminsammara
requested review from
iAmMichaelConnor,
just-mitch and
koenmtb1
as code owners
October 5, 2026 06:55
alexghr
reviewed
Oct 5, 2026
Contributor
Author
|
Superseded by #25593 — same commits plus the fix for the same-block mutable-floor issue. The branch this sits on ( @alexghr your comments carried over — the uint32 point is right (unsigned, so 2106 rather than 2038, and |
Contributor
Author
|
Closed in favor of #25593 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opening this for opinions rather than merge — the trade-off in the last section is a real decision, not a detail.
The problem
Committee selection samples the validator set as it stood
lagInEpochsForValidatorSetepochs before an epoch starts (ValidatorSelectionLib.stableEpochToValidatorSetSampleTime). The GSE credits the shared bonus bucket to whichever rollup was its latest at the queried timestamp, and never backdates that attribution when a new rollup is added (GSE.sol:809,GSE.sol:839).So a rollup governance has just made canonical owns every inherited validator for a query at
now, but sees an empty set at the lagged timestamp sampling actually reads.setupEpochreverts withInsufficientValidatorSetSize(0, targetCommitteeSize)for the cutover epoch and the nextlagepochs — andproposecallssetupEpoch, so that is every ordinary proposal in them.No attacker needed; an honest upgrade triggers it every time. On mainnet values (lag 2, 2304s epochs, 3059 attesters all in the bonus bucket) that is a bounded, self-healing stall of up to ~1h55m at every upgrade, on the rollup everything has just been pointed at.
The change
setValidatorSetSampleFloor()lets governance pin the timestamp at which the inheritance took effect; sampling clamps to it.uint32that packs free into the slot already holding the three lag/committeeuint32s, and a zero floor never clamps. Deployed rollups, deploy scripts andconfig.version(config-derived, not bytecode-derived) are all unaffected.Pinned in the generic
RegisterNewRollupVersionPayload, so every future fork gets it without anyone re-remembering. The action must followGSE.addRollup— until that runs the GSE still reports the previous rollup as latest and the setter refuses — and all three actions share one transaction, so the floor is exactly the cutover timestamp.Why not latch it lazily in the first
setupEpochThat was the obvious cheaper option and it does not work:
RollupCore.setupEpoch()is permissionless. Whoever called it first would pick the cutover epoch's committee from a menu of inclusion timestamps — a grinding surface in consensus-critical selection, which is a worse problem than the liveness stall it fixes.proposecarries committee attestations gathered off-chain beforehand. You cannot collect signatures from a committee that is not determined until your own transaction lands.Pinning it in the cutover transaction means no window exists where the rollup is canonical but the floor is unknown.
The setter is one-shot, and refuses a floor at which the GSE does not already report this rollup as latest — so a payload that orders it wrong reverts rather than storing a useless value. One-shot is only safe because of that guard: the single reachable value is a timestamp at which this rollup was already latest. It also degrades gracefully — pinned late, the floor is still ≥ cutover and still recovers whatever stall epochs remain.
The trade-off worth arguing about
Today the successor physically cannot propose for ~3 epochs, so "the same validators serve both rollups" is theoretical. This change makes it real. The successor gets a committee at the cutover epoch while the outgoing rollup still samples its own full committee for the same epochs, from the same bonus attesters.
Worth deciding explicitly whether that dual duty is acceptable and how the sequencer client behaves when the canonical rollup moves mid-epoch while it still holds obligations on the old one. The alternative — making bonus inheritance historical in the GSE — has the same overlap plus it cannot ship, since the GSE is deployed and not being replaced.
Not covered here
stableEpochToRandaoSampleTimereads the rollup-localrandaostrace, empty before cutover, so early epochs seed fromupperLookupmisses. Untouched and not made worse, but worth looking at in the same pass.payload-v6(feat(l1): v6 rollup upgrade deploy script, payload and runbook #25496), which is a few lines once the shape here is agreed.Testing
test/governance/scenario/ValidatorSetSampleFloor.t.sol, 8 tests, two rollups sharing a GSE with all 48 validators in the bonus bucket and a mid-epoch cutover:GSE.addRollup, is one-shot, and is owner-onlyAddRollup.t.solnow runs the production payload through the full governance lifecycle — signal, vote, queue,Governance.execute— instead of a drifting copy.forge testacrosstest/governance/**(315),test/validator-selection/**(41),test/staking/**(128) andtest/escape-hatch/**(113) passes;forge fmt --checkandsolhintare clean.