Skip to content

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

Open
aminsammara wants to merge 3 commits into
nextfrom
rollup-sample-floor
Open

aminsammara wants to merge 3 commits into
nextfrom
rollup-sample-floor

Conversation

@aminsammara

Copy link
Copy Markdown
Contributor

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 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 (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 < block.timestamp) ? floor : ts;
  • Unset by default. A uint32 packing free into the slot already holding the three lag/committee uint32s; a zero floor never clamps. Deployed rollups, deploy scripts and config.version (config-derived, not bytecode-derived) are unaffected.
  • Inert after the window. Once the lagged sample time overtakes the floor the clamp stops mattering, permanently.
  • Only ever a sealed floor. See below.
  • 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. 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 the floor must be strictly in the past

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 back, therefore sealed, since writing to a past key reverts with CheckpointUnorderedInsertion. The current block's key is the only mutable one.

An earlier revision clamped to a floor of block.timestamp and pointed sampling straight at it: setupEpoch could 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 feeds computeCommittee and every sampled index shifts with it. Enough exits in that block take the set under targetCommitteeSize and 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 setupEpoch

The cheaper option, and it does not work:

  1. RollupCore.setupEpoch() is permissionless. Whoever called it first would choose the cutover epoch's committee from a menu of inclusion timestamps.
  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.

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 (setupEpoch only ever latches the current epoch, and getCommitteeCommitmentAt returns 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 > lagInEpochsForRandao exists 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 + 1 of them, so 2 epochs / 64 slots on mainnet. With Governance.execute and flushEntryQueue(_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. Rollup is at 170 bytes of EIP-170 margin (was 354); ValidatorOperationsExtLib at 188 (was 809). Dropping getValidatorSetSampleFloor() recovers ~184 bytes if the margin matters more than the getter.

5. gas_report.json is 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:

  • reproduces the stall with no floor pinned, including that it ends by itself
  • shows it gone from the cutover epoch with a floor pinned
  • the floor cannot be sampled in the block that pinned it (fails without the guard, passes with it)
  • clamped epochs sample distinct committees
  • the clamp goes inert once the lag clears
  • 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 instead of a drifting copy.

forge test: 1399 passed, 0 failed, 3 skipped. forge fmt --check clean, solhint 0 errors.

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.
@AztecBot

AztecBot commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/ebe57676e8da9ed8�ebe57676e8da9ed88;;�): cd labs && export root= ci3= &&  yarn-project/end-to-end/scripts/run_test.sh simple src/multi-node/slashing/sentinel_status_slash.parallel.test.ts "slashes the proposer with INACTIVITY when checkpoint validation records invalid" (76s) (code: 0) group:e2e-p2p-epoch-flakes

This branch has not been deployed

No deployments
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.

3 participants