Skip to content

fix(l1): freeze the escape hatch candidate set before its seed is knowable - #25510

Open
spalladino wants to merge 1 commit into
nextfrom
spl/a-1663-escape-hatch-seed-timing
Open

spalladino wants to merge 1 commit into
nextfrom
spl/a-1663-escape-hatch-seed-timing

Conversation

@spalladino

Copy link
Copy Markdown
Contributor

EscapeHatch snapshots the candidate set an epoch before the entropy that draws its designated proposer, so the set is meant to be closed by the time anyone can know who wins. It was not.

Context

selectCandidates asked for the seed with ROLLUP.getSampleSeedAt(seedTs), which is not a raw randao getter: Rollup converts the timestamp back to an epoch and ValidatorSelectionLib.getSampleSeed subtracts the rollup's own lagInEpochsForRandao again. With F = firstEpoch(H - LAG_IN_HATCHES) the entropy actually came from start(F - 1 - lagInEpochsForRandao), at or before the freeze at start(F - 2) for every lag we ship. scripts/network-defaults.json carries a lag of 2, which leaves a full epoch in which an attacker can read the seed, compute the fewest addresses that move the draw into their own range, and join that many times — buying the sole permitted proposer slot for a hatch window rather than winning it. The constructor check that claimed to prevent this compared two compile-time constants, so it could never fail.

Approach

  • ValidatorSelectionLib.getCheckpointedRandaoAt reads the randao checkpointed at a timestamp with no lag of its own, and returns the key it found alongside the value so a caller that depends on when the entropy was revealed can tell an exact hit from an upperLookup fall-back. Plumbed through ValidatorOperationsExtLib, Rollup and IValidatorSelection.
  • EscapeHatch draws against that and refuses to designate a proposer unless the checkpoint sits exactly on the seed epoch, so entropy predating the freeze can never select. Its timing no longer depends on the rollup's validator-selection lag at all.
  • Snapshot reads move to one second before the freeze. Checkpoints.upperLookup is inclusive of its key, so joins made in the freeze block were landing in the frozen set even though selectCandidates uses a strict comparison. This also makes initiateExit's existing comparison against the next freeze exactly right rather than one second conservative.
  • The dead constructor require is gone; the invariant it claimed is now covered by a test that varies the rollup's lagInEpochsForRandao and asserts the entropy stays unreadable until after the freeze.

A hatch whose seed epoch passed with no randao checkpointed stays closed for that cycle — the same outcome as a hatch with no candidates. checkpointRandao is permissionless, so any party can record it, but it cannot be created retroactively. Drawing against the older checkpoint upperLookup would otherwise return means drawing against entropy that predates the freeze, which is the bug itself.

The escape-hatch suite is at the same 8 pre-existing Rollup__InvalidArchive fixture failures as next, with 6 new tests covering the seed timing and 4 covering the new getter against upperLookup semantics.

API changes

IValidatorSelection gains getCheckpointedRandaoAt(Timestamp) returns (bool exists, uint32 keyTs, uint224 randao). IEscapeHatch gains getSeed(Hatch), which reverts with EscapeHatch__EntropyNotReady until the seed epoch's randao is checkpointed, and a HatchPreparationSkipped event for the case where it never was.

Fixes A-1663

…wable

EscapeHatch snapshots the candidate set an epoch before the entropy that
draws its designated proposer, so the set is meant to be closed by the time
anyone can know who wins. It was not.

selectCandidates asked for the seed with ROLLUP.getSampleSeedAt(seedTs),
which is not a raw randao getter: Rollup converts the timestamp back to an
epoch and ValidatorSelectionLib.getSampleSeed subtracts the rollup's own
lagInEpochsForRandao again. The entropy actually came from
start(F - 1 - lagInEpochsForRandao), at or before the freeze at start(F - 2)
for every lag we ship. With the shipped lag of 2 that leaves a full epoch in
which an attacker can read the seed, compute the fewest addresses that move
the draw into their own range, and join that many times -- buying the sole
permitted proposer slot for a hatch window rather than winning it.

Add ValidatorSelectionLib.getCheckpointedRandaoAt, which reads the randao
checkpointed at a timestamp with no lag of its own and returns the key it
found alongside the value, and draw against that instead. EscapeHatch now
refuses to designate a proposer unless the checkpoint sits exactly on the
seed epoch, so entropy predating the freeze can never select. Its timing no
longer depends on the rollup's validator-selection lag at all.

Two things fall out of the same boundary. Snapshot reads move to one second
before the freeze, because Checkpoints.upperLookup is inclusive of its key
and was admitting joins made in the freeze block into the frozen set; that
also makes initiateExit's strict comparison against the next freeze exactly
right rather than one second conservative. And the constructor check that
claimed to enforce all this compared two compile-time constants, so it could
never fail; the invariant is now covered by tests that vary the rollup's lag.

A hatch whose seed epoch passed with no randao checkpointed stays closed for
that cycle, which is the same outcome as a hatch with no candidates. Drawing
against the older checkpoint upperLookup would otherwise fall back to means
drawing against entropy that predates the freeze, which is the bug itself.

Fixes A-1663.
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.

1 participant