Skip to content

feat(l1): let governance pin a validator-set sample floor at rollup cutover - #25588

Closed
aminsammara wants to merge 2 commits into
nextfrom
validator-set-sample-floor
Closed

aminsammara wants to merge 2 commits into
nextfrom
validator-set-sample-floor

Conversation

@aminsammara

Copy link
Copy Markdown
Contributor

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 lagInEpochsForValidatorSet epochs 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. setupEpoch reverts with InsufficientValidatorSetSize(0, targetCommitteeSize) for the cutover epoch and the next lag epochs — and propose calls setupEpoch, 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.

uint32 floor = store.validatorSetSampleFloor;
return ts < floor ? floor : ts;
  • Unset by default. The floor is a uint32 that packs free into the slot already holding the three lag/committee uint32s, and a zero floor never clamps. Deployed rollups, deploy scripts and config.version (config-derived, not bytecode-derived) are all unaffected.
  • Inert after the window. Once the lagged sample time overtakes the floor the clamp stops mattering, permanently.
  • More conservative than the lag it stands in for. The sample still predates the epoch being sampled, so nobody can stake after the upgrade and be selected.
  • Clamped epochs do not share a committee. They read one validator set but still mix a per-epoch randao.

Pinned in the generic RegisterNewRollupVersionPayload, so every future fork gets it without anyone re-remembering. The action must follow GSE.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 setupEpoch

That was the obvious cheaper option and it does not work:

  1. 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.
  2. propose carries 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

  • The randao side. stableEpochToRandaoSampleTime reads the rollup-local randaos trace, empty before cutover, so early epochs seed from upperLookup misses. Untouched and not made worse, but worth looking at in the same pass.
  • Wiring the action into the bespoke V6 payload on 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:

  • reproduces the stall with no floor pinned, including that it ends by itself
  • shows it gone from the cutover epoch with a floor pinned
  • clamped epochs sample distinct committees
  • the clamp goes inert once the lag clears: validators who join after the floor show up normally
  • the setter rejects being ordered before GSE.addRollup, is one-shot, and is owner-only

AddRollup.t.sol now runs the production payload through the full governance lifecycle — signal, vote, queue, Governance.execute — instead of a drifting copy.

forge test across test/governance/** (315), test/validator-selection/** (41), test/staking/** (128) and test/escape-hatch/** (113) passes; forge fmt --check and solhint are clean.

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.
Comment thread l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol
Comment thread l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol
@aminsammara

Copy link
Copy Markdown
Contributor Author

Superseded by #25593 — same commits plus the fix for the same-block mutable-floor issue.

The branch this sits on (validator-set-sample-floor) matches the v* classic branch-protection pattern, so it cannot be pushed to and renaming it needs repo admin. Moved to rollup-sample-floor instead.

@alexghr your comments carried over — the uint32 point is right (unsigned, so 2106 rather than 2038, and GSE.addRollup has the same horizon in already-deployed code), and the stability comment wording is still on my list. Re-requesting review on #25593.

@aminsammara aminsammara closed this Oct 5, 2026
@aminsammara

Copy link
Copy Markdown
Contributor Author

Closed in favor of #25593

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants