diff --git a/l1-contracts/script/InternalGov.s.sol b/l1-contracts/script/InternalGov.s.sol index 233226c77e5..cbb18fff1fd 100644 --- a/l1-contracts/script/InternalGov.s.sol +++ b/l1-contracts/script/InternalGov.s.sol @@ -12,7 +12,7 @@ import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; import {IValidatorSelection} from "@aztec/core/interfaces/IValidatorSelection.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; -import {RegisterNewRollupVersionPayload} from "../test/governance/scenario/RegisterNewRollupVersionPayload.sol"; +import {RegisterNewRollupVersionPayload} from "@aztec/periphery/RegisterNewRollupVersionPayload.sol"; import {Ownable} from "@oz/access/Ownable.sol"; import {Fakerollup} from "../test/governance/governance-proposer/mocks/Fakerollup.sol"; import {StakingAssetHandler} from "../src/mock/StakingAssetHandler.sol"; diff --git a/l1-contracts/script/StakingAssetHandler.s.sol b/l1-contracts/script/StakingAssetHandler.s.sol index ab9aa6f6aab..465812a4a8d 100644 --- a/l1-contracts/script/StakingAssetHandler.s.sol +++ b/l1-contracts/script/StakingAssetHandler.s.sol @@ -11,7 +11,7 @@ import {IRollup} from "@aztec/core/interfaces/IRollup.sol"; import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; import {IValidatorSelection} from "@aztec/core/interfaces/IValidatorSelection.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; -import {RegisterNewRollupVersionPayload} from "../test/governance/scenario/RegisterNewRollupVersionPayload.sol"; +import {RegisterNewRollupVersionPayload} from "@aztec/periphery/RegisterNewRollupVersionPayload.sol"; import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol"; import {Ownable} from "@oz/access/Ownable.sol"; import {Fakerollup} from "../test/governance/governance-proposer/mocks/Fakerollup.sol"; diff --git a/l1-contracts/src/core/Rollup.sol b/l1-contracts/src/core/Rollup.sol index 62f344221f3..2ef3925190a 100644 --- a/l1-contracts/src/core/Rollup.sol +++ b/l1-contracts/src/core/Rollup.sol @@ -450,6 +450,10 @@ contract Rollup is IStaking, IValidatorSelection, IRollup, RollupCore { return ValidatorOperationsExtLib.getLagInEpochsForValidatorSet(); } + function getValidatorSetSampleFloor() external view override(IValidatorSelection) returns (uint256) { + return ValidatorOperationsExtLib.getValidatorSetSampleFloor(); + } + function getLagInEpochsForRandao() external view override(IValidatorSelection) returns (uint256) { return ValidatorOperationsExtLib.getLagInEpochsForRandao(); } diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index 56527e4d2ff..410f889c480 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -405,6 +405,16 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali ValidatorOperationsExtLib.setEscapeHatch(_escapeHatch); } + /** + * @notice Pins the earliest timestamp that validator-set sampling may read from + * @dev Called by governance in the transaction that makes this rollup canonical, after + * `GSE.addRollup`. One-shot, and rejected while the GSE's latest rollup is not this one. + * See {ValidatorSelectionLib.setValidatorSetSampleFloor} for why the floor is needed. + */ + function setValidatorSetSampleFloor() external override(IValidatorSelectionCore) onlyOwner { + ValidatorOperationsExtLib.setValidatorSetSampleFloor(); + } + /** * @notice Claims accumulated rewards for a sequencer (checkpoint proposer) * @dev Transfers all accumulated rewards to the recipient. diff --git a/l1-contracts/src/core/interfaces/IValidatorSelection.sol b/l1-contracts/src/core/interfaces/IValidatorSelection.sol index 8607c43b026..b9a4519c9a5 100644 --- a/l1-contracts/src/core/interfaces/IValidatorSelection.sol +++ b/l1-contracts/src/core/interfaces/IValidatorSelection.sol @@ -12,20 +12,25 @@ struct ValidatorSelectionStorage { mapping(Epoch => bytes32 committeeCommitment) committeeCommitments; // Checkpointed map of epoch -> randao value Checkpoints.Trace224 randaos; - // The following 3 uint32s pack into a single slot (12 bytes) + // The following 4 uint32s pack into a single slot (16 bytes) uint32 targetCommitteeSize; uint32 lagInEpochsForValidatorSet; uint32 lagInEpochsForRandao; + // Lower bound on the validator-set sample timestamp. Zero until governance pins it, and zero + // leaves sampling untouched. See ValidatorSelectionLib.setValidatorSetSampleFloor. + uint32 validatorSetSampleFloor; // Checkpointed escape hatch addresses (key = timestamp, value = address as uint160) Checkpoints.Trace160 escapeHatchCheckpoints; } interface IValidatorSelectionCore { event EscapeHatchSet(address escapeHatch); + event ValidatorSetSampleFloorSet(uint32 floor); function setupEpoch() external; function checkpointRandao() external; function setEscapeHatch(address _escapeHatch) external; + function setValidatorSetSampleFloor() external; } interface IValidatorSelection is IValidatorSelectionCore, IEmperor { @@ -48,6 +53,7 @@ interface IValidatorSelection is IValidatorSelectionCore, IEmperor { function getSampleSeedAt(Timestamp _ts) external view returns (uint256); function getSamplingSizeAt(Timestamp _ts) external view returns (uint256); function getLagInEpochsForValidatorSet() external view returns (uint256); + function getValidatorSetSampleFloor() external view returns (uint256); function getLagInEpochsForRandao() external view returns (uint256); function getCurrentSampleSeed() external view returns (uint256); diff --git a/l1-contracts/src/core/libraries/Errors.sol b/l1-contracts/src/core/libraries/Errors.sol index b3d0b82f55b..5431eaf205d 100644 --- a/l1-contracts/src/core/libraries/Errors.sol +++ b/l1-contracts/src/core/libraries/Errors.sol @@ -150,6 +150,8 @@ library Errors { error ValidatorSelection__EscapeHatchAlreadySet(); error ValidatorSelection__EscapeHatchCannotBeZero(); error ValidatorSelection__EscapeHatchRollupMismatch(address expected, address actual); + error ValidatorSelection__SampleFloorAlreadySet(); + error ValidatorSelection__NotLatestRollupInGSE(address expected, address actual); // Staking error Staking__AlreadyQueued(address _attester); diff --git a/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol b/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol index 737b764855f..038bb9b6efe 100644 --- a/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ValidatorOperationsExtLib.sol @@ -101,6 +101,11 @@ library ValidatorOperationsExtLib { emit IValidatorSelectionCore.EscapeHatchSet(_escapeHatch); } + function setValidatorSetSampleFloor() external { + ValidatorSelectionLib.setValidatorSetSampleFloor(); + emit IValidatorSelectionCore.ValidatorSetSampleFloorSet(uint32(ValidatorSelectionLib.getValidatorSetSampleFloor())); + } + function invalidateBadAttestation( uint256 _checkpointNumber, CommitteeAttestations memory _attestations, @@ -150,6 +155,10 @@ library ValidatorOperationsExtLib { return ValidatorSelectionLib.getLagInEpochsForValidatorSet(); } + function getValidatorSetSampleFloor() external view returns (uint256) { + return ValidatorSelectionLib.getValidatorSetSampleFloor(); + } + function getLagInEpochsForRandao() external view returns (uint256) { return ValidatorSelectionLib.getLagInEpochsForRandao(); } diff --git a/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol b/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol index c57f4319d73..522844c898a 100644 --- a/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol +++ b/l1-contracts/src/core/libraries/rollup/ValidatorSelectionLib.sol @@ -178,6 +178,38 @@ library ValidatorSelectionLib { store.escapeHatchCheckpoints.push(nextEpochTs, uint160(_escapeHatch)); } + /** + * @notice Pins the earliest timestamp that validator-set sampling may read from + * @dev Committee selection reads the validator set as it stood `lagInEpochsForValidatorSet` epochs + * before the epoch starts. The GSE credits the shared "bonus" validators to whichever rollup + * was its latest at the queried timestamp, and that attribution is historical: it is not + * backdated when a new rollup is added. So for the first `lag + 1` epochs after an upgrade the + * lagged timestamp still resolves to the previous rollup, this rollup's own bucket is empty, + * and sampling reverts with `InsufficientValidatorSetSize` even though every validator has + * already been inherited. + * + * Pinning a floor here lets those epochs sample at the moment the inheritance took effect + * instead. The floor is in the past, so the committee for any given epoch is still fixed and + * agreed by every observer. It is also strictly more conservative than the lag it replaces -- + * the sample predates the epoch being sampled, so no one can stake after the upgrade and be + * selected. + * + * Governance calls this in the transaction that makes the rollup canonical, after + * `GSE.addRollup`. Ordering it earlier pins a timestamp that still resolves to the previous + * rollup, which is why that case is rejected rather than silently stored. + * @custom:reverts Errors.ValidatorSelection__SampleFloorAlreadySet if a floor is already pinned + * @custom:reverts Errors.ValidatorSelection__NotLatestRollupInGSE if the GSE's latest is not us + */ + function setValidatorSetSampleFloor() internal { + ValidatorSelectionStorage storage store = getStorage(); + require(store.validatorSetSampleFloor == 0, Errors.ValidatorSelection__SampleFloorAlreadySet()); + + address latest = StakingLib.getStorage().gse.getLatestRollup(); + require(latest == address(this), Errors.ValidatorSelection__NotLatestRollupInGSE(address(this), latest)); + + store.validatorSetSampleFloor = block.timestamp.toUint32(); + } + /** * @notice Performs epoch setup by sampling the committee and setting future seeds * @dev This function handles the epoch transition by: @@ -597,6 +629,10 @@ library ValidatorSelectionLib { return getStorage().lagInEpochsForValidatorSet; } + function getValidatorSetSampleFloor() internal view returns (uint256) { + return getStorage().validatorSetSampleFloor; + } + function getLagInEpochsForRandao() internal view returns (uint256) { return getStorage().lagInEpochsForRandao; } @@ -709,13 +745,21 @@ library ValidatorSelectionLib { } function stableEpochToValidatorSetSampleTime(Epoch _epoch) private view returns (uint32) { - uint32 sub = getStorage().lagInEpochsForValidatorSet * TimeLib.getEpochDurationInSeconds().toUint32(); + ValidatorSelectionStorage storage store = getStorage(); + uint32 sub = store.lagInEpochsForValidatorSet * TimeLib.getEpochDurationInSeconds().toUint32(); uint32 ts = Timestamp.unwrap(_epoch.toTimestamp()).toUint32() - sub; + // Stability is judged on the unclamped time, so an epoch is stable exactly when it was before. require( ts <= block.timestamp, Errors.ValidatorSelection__EpochNotStable(uint256(Epoch.unwrap(_epoch)), uint32(block.timestamp)) ); - return ts; + + // Only clamp to a floor that is already sealed. GSE snapshots are keyed by timestamp and an + // equal-key checkpoint write overwrites in place, so the set "at" the current block is still + // mutable: anyone could exit after the committee was latched and change what the same sample + // time reports. A floor one block back can no longer be written to. + uint32 floor = store.validatorSetSampleFloor; + return (ts < floor && floor < block.timestamp) ? floor : ts; } /** diff --git a/l1-contracts/src/periphery/RegisterNewRollupVersionPayload.sol b/l1-contracts/src/periphery/RegisterNewRollupVersionPayload.sol index 96264266587..5d376bf4772 100644 --- a/l1-contracts/src/periphery/RegisterNewRollupVersionPayload.sol +++ b/l1-contracts/src/periphery/RegisterNewRollupVersionPayload.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.27; import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {IValidatorSelectionCore} from "@aztec/core/interfaces/IValidatorSelection.sol"; import {IGSECore} from "@aztec/governance/GSE.sol"; import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; @@ -34,7 +35,7 @@ contract RegisterNewRollupVersionPayload is IPayload { * @return The array of actions to execute */ function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { - IPayload.Action[] memory res = new IPayload.Action[](2); + IPayload.Action[] memory res = new IPayload.Action[](3); res[0] = Action({target: address(REGISTRY), data: abi.encodeWithSelector(IRegistry.addRollup.selector, address(ROLLUP))}); @@ -43,6 +44,14 @@ contract RegisterNewRollupVersionPayload is IPayload { target: address(ROLLUP.getGSE()), data: abi.encodeWithSelector(IGSECore.addRollup.selector, address(ROLLUP)) }); + // Pins the validator-set sample floor to this transaction's timestamp, so the rollup can form a + // committee from its inherited validators immediately instead of stalling for `lag + 1` epochs. + // MUST stay after `GSE.addRollup`: until that action runs the GSE still reports the previous + // rollup as its latest, and the setter rejects a floor pinned at such a timestamp. + res[2] = Action({ + target: address(ROLLUP), data: abi.encodeWithSelector(IValidatorSelectionCore.setValidatorSetSampleFloor.selector) + }); + return res; } diff --git a/l1-contracts/test/governance/scenario/AddRollup.t.sol b/l1-contracts/test/governance/scenario/AddRollup.t.sol index 181235762e8..9a30cc8950d 100644 --- a/l1-contracts/test/governance/scenario/AddRollup.t.sol +++ b/l1-contracts/test/governance/scenario/AddRollup.t.sol @@ -25,7 +25,7 @@ import {RollupBuilder} from "../../builder/RollupBuilder.sol"; import {IGSE, GSE} from "@aztec/governance/GSE.sol"; import {GSEPayload} from "@aztec/governance/GSEPayload.sol"; import {FakeRollup} from "../governance/TestPayloads.sol"; -import {RegisterNewRollupVersionPayload} from "./RegisterNewRollupVersionPayload.sol"; +import {RegisterNewRollupVersionPayload} from "@aztec/periphery/RegisterNewRollupVersionPayload.sol"; import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; import {BN254Lib, G1Point, G2Point} from "@aztec/shared/libraries/BN254Lib.sol"; @@ -45,6 +45,8 @@ contract BadRollup { function getGSE() external view returns (GSE) { return GSE(address(gse)); } + + function setValidatorSetSampleFloor() external {} } contract AddRollupTest is TestBase { diff --git a/l1-contracts/test/governance/scenario/RegisterNewRollupVersionPayload.sol b/l1-contracts/test/governance/scenario/RegisterNewRollupVersionPayload.sol deleted file mode 100644 index 8e97954dd27..00000000000 --- a/l1-contracts/test/governance/scenario/RegisterNewRollupVersionPayload.sol +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.27; - -import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; -import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; -import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; -import {IGSECore} from "@aztec/governance/GSE.sol"; - -/** - * @title RegisterNewRollupVersionPayload - * @author Aztec Labs - * @notice A payload that registers a new rollup version. - */ -contract RegisterNewRollupVersionPayload is IPayload { - IRegistry public immutable REGISTRY; - IInstance public immutable ROLLUP; - - constructor(IRegistry _registry, IInstance _rollup) { - REGISTRY = _registry; - ROLLUP = _rollup; - } - - function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { - IPayload.Action[] memory res = new IPayload.Action[](2); - - res[0] = - Action({target: address(REGISTRY), data: abi.encodeWithSelector(IRegistry.addRollup.selector, address(ROLLUP))}); - - res[1] = Action({ - target: address(ROLLUP.getGSE()), data: abi.encodeWithSelector(IGSECore.addRollup.selector, address(ROLLUP)) - }); - - return res; - } - - function getURI() external pure override(IPayload) returns (string memory) { - return "RegisterNewRollupVersionPayload"; - } -} diff --git a/l1-contracts/test/governance/scenario/ValidatorSetSampleFloor.t.sol b/l1-contracts/test/governance/scenario/ValidatorSetSampleFloor.t.sol new file mode 100644 index 00000000000..4ebb0df22fd --- /dev/null +++ b/l1-contracts/test/governance/scenario/ValidatorSetSampleFloor.t.sol @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable +pragma solidity >=0.8.27; + +import {TestBase} from "@test/base/Base.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {GSE} from "@aztec/governance/GSE.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; +import {IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol"; +import {Errors} from "@aztec/core/libraries/Errors.sol"; +import {Epoch, Timestamp} from "@aztec/core/libraries/TimeLib.sol"; +import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; +import {MultiAdder, CheatDepositArgs} from "@aztec/mock/MultiAdder.sol"; +import {BN254Lib} from "@aztec/shared/libraries/BN254Lib.sol"; +import {Ownable} from "@oz/access/Ownable.sol"; +import {RollupBuilder} from "../../builder/RollupBuilder.sol"; +import {TestConstants} from "../../harnesses/TestConstants.sol"; + +/** + * Every validator here deposits into the GSE's shared "bonus" bucket, which is how mainnet is + * staked. That bucket is credited to whichever rollup the GSE considers latest *at the timestamp + * being queried*, and the attribution is never backdated. So a rollup that has just been made + * canonical owns the whole validator set for a query at `now`, yet sees an empty set at the lagged + * timestamp its committee sampling actually reads from. + */ +contract ValidatorSetSampleFloorTest is TestBase { + uint256 internal constant VALIDATOR_COUNT = 60; + + Registry internal registry; + GSE internal gse; + TestERC20 internal token; + Rollup internal oldRollup; + Rollup internal newRollup; + + uint256 internal targetCommitteeSize; + uint256 internal lag; + uint256 internal epochSeconds; + + function setUp() external { + vm.warp(1_000_000); + + StakingQueueConfig memory queueConfig = TestConstants.getStakingQueueConfig(); + queueConfig.normalFlushSizeMin = VALIDATOR_COUNT; + + RollupBuilder builder = new RollupBuilder(address(this)).setStakingQueueConfig(queueConfig) + .setValidators(_validators("validator", VALIDATOR_COUNT)); + builder.deploy(); + + oldRollup = Rollup(address(builder.getConfig().rollup)); + registry = builder.getConfig().registry; + gse = builder.getConfig().gse; + token = builder.getConfig().testERC20; + + // The version is a hash of the config, so the successor needs a config that differs somewhere or + // the Registry rejects it as already registered. `setMakeGovernance(false)` keeps the successor + // from standing up a second Governance and repointing the GSE at it, which would orphan the + // stake; `setUpdateOwnerships` then leaves this contract owning the rollup and the GSE, standing + // in for governance. + RollupConfigInput memory successorConfig = TestConstants.getRollupConfigInput(); + successorConfig.version = successorConfig.version + 1; + + RollupBuilder successorBuilder = new RollupBuilder(address(this)).setRegistry(registry).setGSE(gse) + .setTestERC20(token).setRollupConfigInput(successorConfig).setStakingQueueConfig(queueConfig) + .setMakeCanonical(false).setMakeGovernance(false); + successorBuilder.deploy(); + newRollup = Rollup(address(successorBuilder.getConfig().rollup)); + + targetCommitteeSize = oldRollup.getTargetCommitteeSize(); + lag = oldRollup.getLagInEpochsForValidatorSet(); + epochSeconds = oldRollup.getEpochDuration() * oldRollup.getSlotDuration(); + + assertGt(targetCommitteeSize, 0, "fixture needs a committee"); + assertGt(lag, 0, "fixture needs a lag"); + + // Let enough history accumulate that the outgoing rollup samples normally, and land partway + // into an epoch rather than on a boundary. On a boundary the sample time of epoch + // `cutover + lag` lands exactly on the cutover and already resolves to the successor, so the + // stall is one epoch shorter; a governance execution is not going to be that well aligned. + vm.warp(block.timestamp + (lag + 1) * epochSeconds + epochSeconds / 3); + + assertEq(gse.supplyOf(address(oldRollup)), 0, "validators must sit in the bonus bucket"); + assertEq( + oldRollup.getEpochCommittee(oldRollup.getCurrentEpoch()).length, + targetCommitteeSize, + "outgoing rollup should have a committee" + ); + } + + function _validators(string memory _salt, uint256 _count) internal pure returns (CheatDepositArgs[] memory) { + CheatDepositArgs[] memory out = new CheatDepositArgs[](_count); + for (uint256 i = 0; i < _count; i++) { + address validator = vm.addr(uint256(keccak256(abi.encode(_salt, i)))); + out[i] = CheatDepositArgs({ + attester: validator, + withdrawer: validator, + publicKeyInG1: BN254Lib.g1Zero(), + publicKeyInG2: BN254Lib.g2Zero(), + proofOfPossession: BN254Lib.g1Zero() + }); + } + return out; + } + + /// The floor only takes effect once the block that pinned it is sealed, so every test that wants + /// to sample against it has to leave that block first. + function _pinFloor() internal { + newRollup.setValidatorSetSampleFloor(); + vm.warp(block.timestamp + 12); + } + + /// Registers the successor exactly as an upgrade payload does, and nothing else. + function _cutover() internal { + registry.addRollup(IHaveVersion(address(newRollup))); + gse.addRollup(address(newRollup)); + } + + function test_WhenNoFloorIsPinned_TheSuccessorCannotFormACommittee() external { + _cutover(); + + assertEq( + gse.getAttesterCountAtTime(address(newRollup), Timestamp.wrap(block.timestamp)), + VALIDATOR_COUNT, + "the successor has inherited every validator as of now" + ); + + for (uint256 k = 0; k <= lag; k++) { + Epoch epoch = newRollup.getCurrentEpoch(); + vm.expectRevert( + abi.encodeWithSelector(Errors.ValidatorSelection__InsufficientValidatorSetSize.selector, 0, targetCommitteeSize) + ); + newRollup.getEpochCommittee(epoch); + vm.warp(block.timestamp + epochSeconds); + } + + // The stall ends on its own once the lagged sample time passes the cutover. + assertEq(newRollup.getEpochCommittee(newRollup.getCurrentEpoch()).length, targetCommitteeSize); + } + + function test_WhenAFloorIsPinned_TheSuccessorFormsACommitteeFromTheNextBlock() external { + _cutover(); + _pinFloor(); + + for (uint256 k = 0; k <= lag; k++) { + assertEq( + newRollup.getEpochCommittee(newRollup.getCurrentEpoch()).length, + targetCommitteeSize, + "successor should have a committee from the cutover epoch onwards" + ); + vm.warp(block.timestamp + epochSeconds); + } + } + + /// The clamped epochs read one validator set but mix a different randao, so their committees must + /// not collapse onto each other. + function test_ClampedEpochsDoNotShareACommittee() external { + _cutover(); + _pinFloor(); + + bytes32 first = keccak256(abi.encode(newRollup.getEpochCommittee(newRollup.getCurrentEpoch()))); + vm.warp(block.timestamp + epochSeconds); + bytes32 second = keccak256(abi.encode(newRollup.getEpochCommittee(newRollup.getCurrentEpoch()))); + + assertNotEq(first, second, "clamped epochs should still sample distinct committees"); + } + + /// Once the lagged sample time overtakes the floor the clamp has to stop mattering, so validators + /// who joined after the floor must show up exactly as they would have without it. + function test_TheClampBecomesInertOnceTheLagClears() external { + _cutover(); + _pinFloor(); + assertEq(newRollup.getSamplingSizeAt(Timestamp.wrap(block.timestamp)), VALIDATOR_COUNT); + + CheatDepositArgs[] memory extra = _validators("late-validator", 4); + MultiAdder adder = new MultiAdder(address(newRollup), address(this)); + vm.prank(token.owner()); + token.addMinter(address(this)); + token.mint(address(adder), gse.ACTIVATION_THRESHOLD() * extra.length); + adder.addValidators(extra); + + vm.warp(block.timestamp + (lag + 2) * epochSeconds); + + assertEq( + newRollup.getSamplingSizeAt(Timestamp.wrap(block.timestamp)), + VALIDATOR_COUNT + extra.length, + "sampling must track the validator set again once the floor is behind the lagged time" + ); + } + + /// A floor may only be sampled from once the block that pinned it is sealed. + /// + /// GSE snapshots are keyed by timestamp, and an equal-key checkpoint write overwrites in place + /// rather than appending, so "the set at time T" stays mutable for the whole of block T. Sampling + /// normally reads a lagged timestamp, which is always sealed. A floor at `block.timestamp` is not: + /// a committee latched against it could be silently rewritten by any exit later in the same block, + /// leaving the stored commitment disagreeing with what every observer recomputes. + function test_TheFloorCannotBeSampledInTheBlockThatPinnedIt() external { + _cutover(); + newRollup.setValidatorSetSampleFloor(); + + // Still unclamped in this block, so nothing can be latched against a set that is still open. + vm.expectRevert( + abi.encodeWithSelector(Errors.ValidatorSelection__InsufficientValidatorSetSize.selector, 0, targetCommitteeSize) + ); + newRollup.setupEpoch(); + + vm.warp(block.timestamp + 12); + Epoch epoch = newRollup.getCurrentEpoch(); + newRollup.setupEpoch(); + (bytes32 latched,) = newRollup.getEpochCommitteeCommitment(epoch); + assertEq(latched, keccak256(abi.encode(newRollup.getEpochCommittee(epoch))), "latched and consistent"); + + // The floor is behind us now, so an exit cannot reach back and rewrite the set it reports. + address exiting = vm.addr(uint256(keccak256(abi.encode("validator", uint256(0))))); + vm.prank(exiting); + newRollup.initiateWithdraw(exiting, exiting); + + assertEq( + latched, + keccak256(abi.encode(newRollup.getEpochCommittee(epoch))), + "a sealed floor must not move once a committee is latched against it" + ); + } + + function test_WhenOrderedBeforeTheGseAction_ItReverts() external { + registry.addRollup(IHaveVersion(address(newRollup))); + + vm.expectRevert( + abi.encodeWithSelector( + Errors.ValidatorSelection__NotLatestRollupInGSE.selector, address(newRollup), address(oldRollup) + ) + ); + newRollup.setValidatorSetSampleFloor(); + } + + function test_ItIsOneShot() external { + _cutover(); + newRollup.setValidatorSetSampleFloor(); + + vm.expectRevert(abi.encodeWithSelector(Errors.ValidatorSelection__SampleFloorAlreadySet.selector)); + newRollup.setValidatorSetSampleFloor(); + } + + function test_ItIsOwnerOnly() external { + _cutover(); + + address stranger = address(uint160(bytes20("stranger"))); + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + newRollup.setValidatorSetSampleFloor(); + } + + function test_TheFloorIsReadableAfterwards() external { + assertEq(newRollup.getValidatorSetSampleFloor(), 0, "unset by default"); + _cutover(); + newRollup.setValidatorSetSampleFloor(); + assertEq(newRollup.getValidatorSetSampleFloor(), block.timestamp, "floor is the cutover timestamp"); + } +}