Repository navigation
feat(l1): let governance pin a validator-set sample floor at rollup cutover - #25593
Open
aminsammara wants to merge 3 commits into
Open
aminsammara wants to merge 3 commits into
aminsammara wants to merge 3 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.
GSE snapshots are keyed by timestamp, and `Checkpoints._insert` overwrites in place when the key equals the last one rather than appending. So "the set at time T" stays mutable for the whole of block T. Sampling normally reads a lagged timestamp, at least a full epoch in the past and therefore sealed -- writing to a past key reverts with `CheckpointUnorderedInsertion`. The current block's key is the only mutable one. Clamping to a floor of `block.timestamp` pointed sampling straight at it. In the cutover block, `setupEpoch` could latch a commitment and any permissionless exit later in the same block would rewrite the set that same sample time reports, leaving the stored commitment disagreeing with what every observer recomputes. Proposers build attestations off-chain against the recomputed committee, so `propose` would fail for the rest of the epoch. Measured: one exit moved 47 of 48 members, because the set size feeds `computeCommittee` and every sampled index shifts with it. Enough exits in that block take the set under `targetCommitteeSize` and reinstate the multi-epoch stall outright -- triggerable by anyone rather than by the lag. Clamping only to a floor strictly behind the current block closes it. In the pinning block the rollup still cannot sample, exactly as before this series; from the next L1 block the floor can no longer be written to. That trades twelve seconds against the ~5900 the stall costs today. Falls through to the unclamped time rather than reverting with a new error: the resulting `InsufficientValidatorSetSize` is accurate for that sample time, and it keeps the error surface and the code size unchanged. The regression test fails without the clamp guard and passes with it. The fixture now carries more validators than the committee size, so a single exit changes the committee instead of emptying the set below the target.
aminsammara
requested review from
iAmMichaelConnor,
just-mitch and
koenmtb1
as code owners
October 5, 2026 12:12
Collaborator
Flakey Tests🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry. |
just-mitch
approved these changes
Oct 8, 2026
This branch has not been deployed
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.
Replaces #25588, which sat on a branch whose name collided with the
v*classic branch-protection pattern and so could not be pushed to. Same three commits plus the fix below. The review thread on #25588 (thanks @alexghr) is still worth reading; carrying it over here.Opening for opinions rather than merge — the trade-off in "Open questions" is a real decision, and two known issues are deliberately not fixed yet.
The problem
Committee selection samples the validator set as it stood
lagInEpochsForValidatorSetepochs 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 (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.uint32packing free into the slot already holding the three lag/committeeuint32s; a zero floor never clamps. Deployed rollups, deploy scripts andconfig.version(config-derived, not bytecode-derived) are unaffected.Pinned in the generic
RegisterNewRollupVersionPayload, so every future fork gets it. 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 the floor must be strictly in the past
GSE snapshots are keyed by timestamp, and
Checkpoints._insertoverwrites in place when the key equals the last one rather than appending. So "the set at time T" stays mutable for the whole of block T. Sampling normally reads a lagged timestamp — at least a full epoch back, therefore sealed, since writing to a past key reverts withCheckpointUnorderedInsertion. The current block's key is the only mutable one.An earlier revision clamped to a floor of
block.timestampand pointed sampling straight at it:setupEpochcould latch a commitment, and any permissionless exit later in the same block would rewrite what that same sample time reports, leaving the stored commitment disagreeing with what every observer recomputes. Measured: one exit moved 47 of 48 members, because the set size feedscomputeCommitteeand every sampled index shifts with it. Enough exits in that block take the set undertargetCommitteeSizeand reinstate the full multi-epoch stall — triggerable by anyone.Clamping only to a floor strictly behind the current block closes it, at the cost of one L1 block (12s against the ~5900s the stall costs today).
Why not latch it lazily in the first
setupEpochThe cheaper option, and it does not work:
RollupCore.setupEpoch()is permissionless. Whoever called it first would choose the cutover epoch's committee from a menu of inclusion timestamps.proposecarries committee attestations gathered off-chain beforehand. You cannot collect signatures from a committee that is not determined until your own transaction lands.Open questions / known gaps
1. Dual duty at cutover. Today the successor physically cannot propose for ~3 epochs, so "the same validators serve both rollups" is theoretical. This makes it real: the successor holds a live committee while the outgoing rollup still samples one, from the same bonus attesters. Worth deciding explicitly, including how the sequencer client behaves when the canonical rollup moves mid-epoch.
2. A late pin can rewrite an already-latched committee. The setter's guard only checks GSE latest-ness, which stays true until the next upgrade, so it can be called long after cutover. Doing so moves the sample time of the epoch it lands in, whose commitment is already stored. Bounded to the remainder of that one epoch (
setupEpochonly ever latches the current epoch, andgetCommitteeCommitmentAtreturns stored values, so history is safe). Governance-only, and the generic payload pins atomically — but worth guarding, e.g. by refusing to pin once the rollup can already form a committee without a floor. Not fixed here.3. The clamped epochs are biasable.
lagInEpochsForValidatorSet > lagInEpochsForRandaoexists specifically "to avoid sequencer bias" (ValidatorSelectionLib.sol:129): the set is frozen before the seed that selects into it exists. The clamp inverts that for epochs whose randao is already checkpointed —lagRandao + 1of them, so 2 epochs / 64 slots on mainnet. WithGovernance.executeandflushEntryQueue(_toAdd)both permissionless, whoever executes picks the floor and can re-roll the draw.Bounded: sampling is hypergeometric without replacement and an attacker cannot remove honest validators, so reaching the 33-of-48 signing threshold needs ~50% of the stake (P ≈ 5.5e-23 per draw at 10% stake, against ~1500 achievable re-rolls). It is a proposer-slot and ordering advantage, not committee control, and cannot reach slashing (round is 128 slots vs a 64-slot window, quorum 65, 28-round execution delay, live vetoer). A decision, not a patch.
4. Size.
Rollupis at 170 bytes of EIP-170 margin (was 354);ValidatorOperationsExtLibat 188 (was 809). DroppinggetValidatorSetSampleFloor()recovers ~184 bytes if the margin matters more than the getter.5.
gas_report.jsonis not regenerated and will fail CI.Testing
test/governance/scenario/ValidatorSetSampleFloor.t.sol, 9 tests, two rollups sharing a GSE with every validator 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 instead of a drifting copy.forge test: 1399 passed, 0 failed, 3 skipped.forge fmt --checkclean,solhint0 errors.