diff --git a/interfaces/L1/IProtocolVersions.sol b/interfaces/L1/IProtocolVersions.sol index 60f51f47b..a3ac2bfe3 100644 --- a/interfaces/L1/IProtocolVersions.sol +++ b/interfaces/L1/IProtocolVersions.sol @@ -30,7 +30,12 @@ interface IProtocolVersions is IProxyAdminOwnedBase, ISemver, IReinitializableBa error ProtocolVersions_NotInitialized(); error ProtocolVersions_InsufficientNotice(uint64 timestamp); - function initialize(address _incidentResponder, uint64[] calldata _initialSchedule) external; + function initialize( + address _incidentResponder, + uint64[] calldata _initialSchedule, + uint256 _initialMinimumProtocolVersion + ) + external; function registerUpgrade(uint64 timestamp, uint256 minProtocolVersion) external returns (uint256); function setMinimumProtocolVersion(uint256 protocolVersion) external; function setTimestamp(uint256 id, uint64 timestamp) external; diff --git a/scripts/deploy/DeployConfig.s.sol b/scripts/deploy/DeployConfig.s.sol index 99cff3959..975749864 100644 --- a/scripts/deploy/DeployConfig.s.sol +++ b/scripts/deploy/DeployConfig.s.sol @@ -58,6 +58,7 @@ contract DeployConfig is Script { uint256 public operatorFeeVaultMinimumWithdrawalAmount; uint256 public operatorFeeVaultWithdrawalNetwork; uint256 public proofMaturityDelaySeconds; + uint256 public protocolVersionsInitialMinimumVersion; uint256 public respectedGameType; uint256 public sequencerFeeVaultMinimumWithdrawalAmount; uint256 public sequencerFeeVaultWithdrawalNetwork; @@ -121,6 +122,7 @@ contract DeployConfig is Script { operatorFeeVaultMinimumWithdrawalAmount = _json.readUint("$.operatorFeeVaultMinimumWithdrawalAmount"); operatorFeeVaultWithdrawalNetwork = _json.readUint("$.operatorFeeVaultWithdrawalNetwork"); proofMaturityDelaySeconds = _json.readUintOr("$.proofMaturityDelaySeconds", 0); + _readProtocolVersionsInitialMinimumVersion(_json); respectedGameType = _json.readUintOr("$.respectedGameType", 0); sequencerFeeVaultMinimumWithdrawalAmount = _json.readUint("$.sequencerFeeVaultMinimumWithdrawalAmount"); sequencerFeeVaultWithdrawalNetwork = _json.readUint("$.sequencerFeeVaultWithdrawalNetwork"); @@ -128,6 +130,12 @@ contract DeployConfig is Script { _readProtocolVersionsInitialSchedule(_json); } + function _readProtocolVersionsInitialMinimumVersion(string memory _json) internal { + uint256 minimumVersion = _json.readUintOr("$.protocolVersionsInitialMinimumVersion", 0); + require(minimumVersion <= type(uint128).max, "DeployConfig: initial minimum protocol version exceeds uint128"); + protocolVersionsInitialMinimumVersion = minimumVersion; + } + /// @dev Read separately so a rerun of `read` replaces the previous schedule rather than appending /// to it, and so an out-of-range timestamp fails loudly instead of silently truncating. function _readProtocolVersionsInitialSchedule(string memory _json) internal { diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index 82ee8a6d7..b196fc42f 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -308,11 +308,16 @@ contract SystemDeploy is Script { }), saltMixer: "salt mixer", gasLimit: uint64(cfg.l2GenesisBlockGasLimit()), - initialUpgradeSchedule: cfg.protocolVersionsInitialSchedule() + initialUpgradeSchedule: cfg.protocolVersionsInitialSchedule(), + initialMinimumProtocolVersion: cfg.protocolVersionsInitialMinimumVersion() }); } function deploy(DeployInput memory _input) public returns (DeployOutput memory output_) { + // Validate before any broadcast because a later revert cannot roll back transactions already sent by the + // script. + _assertValidOPChainInput(_input.opChainInput); + output_.superchain = _deployOrLoadSuperchain(_input); if (_implementationsEmpty(_input.implementations)) { output_.impls = _deployImplementations(_input.implementationsInput); @@ -483,7 +488,6 @@ contract SystemDeploy is Script { internal returns (Types.DeployOutput memory output_, Types.Implementations memory impls_) { - _assertValidOPChainInput(_input); impls_ = _impls; output_.opChainProxyAdmin = IProxyAdmin( @@ -645,7 +649,8 @@ contract SystemDeploy is Script { address(_output.protocolVersionsProxy), _impls.protocolVersionsImpl, abi.encodeCall( - IProtocolVersions.initialize, (_input.roles.incidentResponder, _input.initialUpgradeSchedule) + IProtocolVersions.initialize, + (_input.roles.incidentResponder, _input.initialUpgradeSchedule, _input.initialMinimumProtocolVersion) ) ); } @@ -1093,6 +1098,9 @@ contract SystemDeploy is Script { if (_input.roles.systemConfigOwner == address(0)) revert InvalidRoleAddress("systemConfigOwner"); if (_input.roles.batcher == address(0)) revert InvalidRoleAddress("batcher"); if (_input.roles.unsafeBlockSigner == address(0)) revert InvalidRoleAddress("unsafeBlockSigner"); + DeployUtils.assertValidProtocolVersionsInitialState( + _input.initialUpgradeSchedule, _input.initialMinimumProtocolVersion + ); if (Hash.unwrap(_input.startingAnchorRoot.root) == bytes32(0)) { revert InvalidStartingAnchorRoot(); } diff --git a/scripts/libraries/DeployUtils.sol b/scripts/libraries/DeployUtils.sol index 9d3dded7d..66eaa6a85 100644 --- a/scripts/libraries/DeployUtils.sol +++ b/scripts/libraries/DeployUtils.sol @@ -4,6 +4,9 @@ pragma solidity ^0.8.0; // Scripts import { Vm } from "lib/forge-std/src/Vm.sol"; +// Interfaces +import { IProtocolVersions } from "interfaces/L1/IProtocolVersions.sol"; + // Libraries import { LibString } from "lib/solady/src/utils/LibString.sol"; import { Bytes } from "src/libraries/Bytes.sol"; @@ -12,6 +15,10 @@ library DeployUtils { Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); bytes32 internal constant DEFAULT_SALT = keccak256("op-stack-contract-impls-salt-v0"); + /// @dev Must match ProtocolVersions.MIN_NOTICE. + uint64 internal constant PROTOCOL_VERSIONS_MIN_NOTICE = 1 hours; + /// @dev Reserves a full notice window for sequential deployment transactions to be mined before initialization. + uint64 internal constant PROTOCOL_VERSIONS_DEPLOYMENT_NOTICE_BUFFER = PROTOCOL_VERSIONS_MIN_NOTICE; function create1(string memory _name, bytes memory _args) internal returns (address payable addr_) { bytes memory bytecode = abi.encodePacked(vm.getCode(_name), _args); @@ -91,6 +98,42 @@ library DeployUtils { assertUniqueAddresses(_addrs); } + /// @notice Validates imported ProtocolVersions state before a deployment script broadcasts any transactions. + function assertValidProtocolVersionsInitialState( + uint64[] memory _schedule, + uint256 _minimumProtocolVersion + ) + internal + view + { + if (_minimumProtocolVersion > type(uint128).max) { + revert IProtocolVersions.ProtocolVersions_InvalidProtocolVersion(); + } + + uint64 currentTimestamp = uint64(block.timestamp); + uint64 minimumFutureTimestamp = + currentTimestamp + PROTOCOL_VERSIONS_MIN_NOTICE + PROTOCOL_VERSIONS_DEPLOYMENT_NOTICE_BUFFER; + uint256 previousId; + uint64 previousTimestamp; + for (uint256 id = 0; id < _schedule.length; id++) { + uint64 timestamp = _schedule[id]; + if (timestamp != 0 && _minimumProtocolVersion == 0) { + revert IProtocolVersions.ProtocolVersions_InvalidProtocolVersion(); + } + if (timestamp > currentTimestamp && timestamp < minimumFutureTimestamp) { + revert IProtocolVersions.ProtocolVersions_InsufficientNotice(timestamp); + } + if (timestamp == 0) continue; + if (previousTimestamp != 0 && timestamp < previousTimestamp) { + revert IProtocolVersions.ProtocolVersions_TimestampNotAfterPrevious( + id, previousId, previousTimestamp, timestamp + ); + } + previousId = id; + previousTimestamp = timestamp; + } + } + /// @notice Etches a contract, labels it, and allows cheatcodes for it. /// @param _etchTo Address of the contract to etch. /// @param _cname The contract name (also used to label the contract). MUST be the name of both the file and the diff --git a/scripts/libraries/Types.sol b/scripts/libraries/Types.sol index 007ecdfea..1f6f398b2 100644 --- a/scripts/libraries/Types.sol +++ b/scripts/libraries/Types.sol @@ -36,6 +36,8 @@ library Types { /// upgrade in the node's fork order, zero for unscheduled ones. Seeds /// `ProtocolVersions` at initialization, which is the only way to enter /// activations that are already in the past. + /// @custom:field initialMinimumProtocolVersion Packed semver required by non-zero timestamps in the initial + /// schedule. struct DeployInput { Roles roles; uint32 basefeeScalar; @@ -45,6 +47,7 @@ library Types { string saltMixer; uint64 gasLimit; uint64[] initialUpgradeSchedule; + uint256 initialMinimumProtocolVersion; } /// @notice The full set of outputs from deploying a new OP Stack chain. diff --git a/scripts/multiproof/DeployDevBase.s.sol b/scripts/multiproof/DeployDevBase.s.sol index d88180df8..5e2f89208 100644 --- a/scripts/multiproof/DeployDevBase.s.sol +++ b/scripts/multiproof/DeployDevBase.s.sol @@ -110,7 +110,10 @@ abstract contract DeployDevBase is Script { Proxy protocolVersionsProxy = new Proxy(msg.sender); protocolVersionsProxy.upgradeToAndCall( address(new ProtocolVersions()), - abi.encodeCall(IProtocolVersions.initialize, (address(0), cfg.protocolVersionsInitialSchedule())) + abi.encodeCall( + IProtocolVersions.initialize, + (address(0), cfg.protocolVersionsInitialSchedule(), cfg.protocolVersionsInitialMinimumVersion()) + ) ); protocolVersionsProxy.changeAdmin(address(proxyAdmin)); @@ -167,6 +170,9 @@ abstract contract DeployDevBase is Script { function _preflight() internal virtual { require(cfg.l2BlockTime() != 0, "l2BlockTime must be set in config"); require(cfg.l2GenesisTimestamp() != 0, "l2GenesisTimestamp must be set in config"); + DeployUtils.assertValidProtocolVersionsInitialState( + cfg.protocolVersionsInitialSchedule(), cfg.protocolVersionsInitialMinimumVersion() + ); } function _serializeExtra(string memory key) internal virtual { } diff --git a/scripts/multiproof/README.md b/scripts/multiproof/README.md index ee8ee7084..708bffe84 100644 --- a/scripts/multiproof/README.md +++ b/scripts/multiproof/README.md @@ -48,6 +48,7 @@ Other relevant fields: | `multiproofGenesisOutputRoot` | Initial anchor output root | | `multiproofGenesisBlockNumber` | Initial anchor L2 block number | | `protocolVersionsInitialSchedule` | Hardfork activation timestamps in the node's fork order, `0` for unscheduled forks. Omit for a chain with no history; past activations cannot be added after deployment | +| `protocolVersionsInitialMinimumVersion` | Packed semver required by every non-zero initial schedule timestamp. Must be non-zero when the initial schedule contains an activation | ### Step 2: Deploy contracts diff --git a/snapshots/abi/ProtocolVersions.json b/snapshots/abi/ProtocolVersions.json index b797d24cb..1d263c24b 100644 --- a/snapshots/abi/ProtocolVersions.json +++ b/snapshots/abi/ProtocolVersions.json @@ -117,6 +117,11 @@ "internalType": "uint64[]", "name": "_initialSchedule", "type": "uint64[]" + }, + { + "internalType": "uint256", + "name": "_initialMinimumProtocolVersion", + "type": "uint256" } ], "name": "initialize", diff --git a/snapshots/semver-lock.json b/snapshots/semver-lock.json index 8d1aa7b2c..5538256ed 100644 --- a/snapshots/semver-lock.json +++ b/snapshots/semver-lock.json @@ -16,8 +16,8 @@ "sourceCodeHash": "0x811596e7486cab9ceeeb61405b9ae510d93fcbbdfa11949201a367506c53194a" }, "src/L1/ProtocolVersions.sol:ProtocolVersions": { - "initCodeHash": "0x07398627bcea4571951b4f6fe9d0b28505cb5d896f4eb13cde870a52dfffff3e", - "sourceCodeHash": "0xac3cde345f37efbf3c3dcc481d2a5c64dfbc971f2bb1492272acdb61892684eb" + "initCodeHash": "0x58f6c7cca0659a3ea85823b67eb1b331fcfd5173007896be8a4ee8af99a4fc5a", + "sourceCodeHash": "0x0f55edce2f83f4153ebe42a50ff0509bb567182acf3dc6913ab470cc0e823a37" }, "src/L1/SuperchainConfig.sol:SuperchainConfig": { "initCodeHash": "0x9b1f3555b499709485d51d5d9665002c0eb1e5eb893be1fb978a30749e894858", diff --git a/src/L1/ProtocolVersions.sol b/src/L1/ProtocolVersions.sol index 868721328..88c27b176 100644 --- a/src/L1/ProtocolVersions.sol +++ b/src/L1/ProtocolVersions.sol @@ -92,7 +92,7 @@ contract ProtocolVersions is ProxyAdminOwnedBase, Initializable, Reinitializable /// @notice Thrown when an upgrade id is not registered. error ProtocolVersions_UnknownUpgrade(uint256 id); - /// @notice Thrown when a protocol version is zero. + /// @notice Thrown when a protocol version is zero where required or exceeds 128 bits. error ProtocolVersions_InvalidProtocolVersion(); /// @notice Thrown when modifying a timestamp whose activation has already passed. error ProtocolVersions_ActivationAlreadyPassed(uint256 id, uint64 activationTimestamp); @@ -123,28 +123,36 @@ contract ProtocolVersions is ProxyAdminOwnedBase, Initializable, Reinitializable } /// @notice Initializes the registry by seeding the hash chain, importing any preexisting upgrade - /// schedule, and appointing the initial incidentResponder. Callable only by the - /// ProxyAdmin or its owner. + /// schedule and its minimum protocol version, and appointing the initial incidentResponder. + /// Callable only by the ProxyAdmin or its owner. /// /// @dev `_initialSchedule` may import historical activations without MIN_NOTICE so a chain that /// already has a hardfork history can be represented faithfully at deployment, while it is /// still impossible for any proof game to have pinned a commitment from this registry. /// Future activations must provide MIN_NOTICE, matching every post-initialization write path. + /// @dev Any non-zero imported timestamp requires a non-zero packed protocol version so nodes can + /// validate the schedule immediately after deployment. /// /// @param _incidentResponder Initial incidentResponder allowed to delay activations, or address(0) to leave unset. /// @param _initialSchedule Activation timestamps for already-known upgrades, ordered by ascending /// upgrade id, using 0 for an upgrade that is registered but unscheduled. /// Future timestamps must be at least MIN_NOTICE from block.timestamp. /// Pass an empty array for a chain with no upgrade history. + /// @param _initialMinimumProtocolVersion Packed semver required by an imported activation, or 0 when + /// the initial schedule has no non-zero timestamps. function initialize( address _incidentResponder, - uint64[] calldata _initialSchedule + uint64[] calldata _initialSchedule, + uint256 _initialMinimumProtocolVersion ) external reinitializer(initVersion()) { // Initialization transactions must come from the ProxyAdmin or its owner. _assertOnlyProxyAdminOrProxyAdminOwner(); + if (_initialMinimumProtocolVersion > type(uint128).max) { + revert ProtocolVersions_InvalidProtocolVersion(); + } // Seed the hash chain at index 0. Keeping the seed as the first array element lets // `scheduleId` and `_refreshScheduleId` avoid an empty-registry special case, and makes a @@ -153,6 +161,9 @@ contract ProtocolVersions is ProxyAdminOwnedBase, Initializable, Reinitializable for (uint256 id = 0; id < _initialSchedule.length; id++) { uint64 timestamp = _initialSchedule[id]; + if (timestamp != 0 && _initialMinimumProtocolVersion == 0) { + revert ProtocolVersions_InvalidProtocolVersion(); + } if (timestamp > uint64(block.timestamp) && timestamp < uint64(block.timestamp) + MIN_NOTICE) { revert ProtocolVersions_InsufficientNotice(timestamp); } @@ -168,6 +179,10 @@ contract ProtocolVersions is ProxyAdminOwnedBase, Initializable, Reinitializable // pass. With an empty import this just re-emits the seed as the current commitment. _refreshScheduleId(0); + if (_initialMinimumProtocolVersion != 0) { + _writeMinimumProtocolVersion(_initialMinimumProtocolVersion); + } + incidentResponder = _incidentResponder; emit IncidentResponderUpdated(address(0), _incidentResponder); } diff --git a/test/L1/ProtocolVersions.t.sol b/test/L1/ProtocolVersions.t.sol index db2eb4e87..82e8523b4 100644 --- a/test/L1/ProtocolVersions.t.sol +++ b/test/L1/ProtocolVersions.t.sol @@ -70,6 +70,7 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { // responder from config and seeds the hash chain (scheduleId == the bytes32(0) seed). assertEq(protocolVersions.proxyAdminOwner(), proxyAdminOwner); assertEq(protocolVersions.incidentResponder(), deploy.cfg().superchainConfigIncidentResponder()); + assertEq(protocolVersions.minimumProtocolVersion(), deploy.cfg().protocolVersionsInitialMinimumVersion()); assertEq(protocolVersions.scheduleId(), bytes32(0)); } @@ -80,7 +81,7 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { vm.expectEmit(true, true, false, false, address(uninitialized)); emit IncidentResponderUpdated(address(0), _incidentResponder); vm.prank(EIP1967Helper.getAdmin(address(uninitialized))); - uninitialized.initialize(_incidentResponder, new uint64[](0)); + uninitialized.initialize(_incidentResponder, new uint64[](0), 0); assertEq(uninitialized.incidentResponder(), _incidentResponder); } @@ -90,7 +91,7 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { IProtocolVersions uninitialized = _deployUninitializedProxy(); vm.expectRevert(IProxyAdminOwnedBase.ProxyAdminOwnedBase_NotProxyAdminOrProxyAdminOwner.selector); vm.prank(_nonOwner); - uninitialized.initialize(_incidentResponder, new uint64[](0)); + uninitialized.initialize(_incidentResponder, new uint64[](0), 0); } /// @notice Tests that the initializer imports a preexisting schedule, building the same hash @@ -104,13 +105,14 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { IProtocolVersions imported = _deployUninitializedProxy(); vm.prank(EIP1967Helper.getAdmin(address(imported))); - imported.initialize(_incidentResponder, schedule); + imported.initialize(_incidentResponder, schedule, 42); uint64[] memory stored = imported.getSchedule(); assertEq(stored.length, schedule.length); assertEq(stored[0], schedule[0]); assertEq(stored[1], schedule[1]); assertEq(stored[2], schedule[2]); + assertEq(imported.minimumProtocolVersion(), 42); bytes32 link0 = keccak256(abi.encode(bytes32(0), uint256(0), uint64(10))); bytes32 link1 = keccak256(abi.encode(link0, uint256(1), uint64(0))); @@ -130,7 +132,7 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { abi.encodeWithSelector(IProtocolVersions.ProtocolVersions_InsufficientNotice.selector, activation) ); vm.prank(EIP1967Helper.getAdmin(address(imported))); - imported.initialize(_incidentResponder, schedule); + imported.initialize(_incidentResponder, schedule, 1); } /// @notice Tests that the initializer uses the same inclusive MIN_NOTICE floor as later write paths. @@ -141,7 +143,7 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { IProtocolVersions imported = _deployUninitializedProxy(); vm.prank(EIP1967Helper.getAdmin(address(imported))); - imported.initialize(_incidentResponder, schedule); + imported.initialize(_incidentResponder, schedule, 1); assertEq(imported.getSchedule()[0], activation); } @@ -164,21 +166,52 @@ contract ProtocolVersions_Initialize_Test is ProtocolVersions_TestInit { ) ); vm.prank(EIP1967Helper.getAdmin(address(imported))); - imported.initialize(address(0), schedule); + imported.initialize(address(0), schedule, 42); + } + + /// @notice Tests that an imported activation cannot omit the minimum protocol version required by nodes. + function test_initialize_importWithoutMinimumProtocolVersion_reverts() external { + uint64[] memory schedule = new uint64[](1); + schedule[0] = 1; + + IProtocolVersions imported = _deployUninitializedProxy(); + vm.expectRevert(IProtocolVersions.ProtocolVersions_InvalidProtocolVersion.selector); + vm.prank(EIP1967Helper.getAdmin(address(imported))); + imported.initialize(address(0), schedule, 0); + } + + /// @notice Tests that zero-only imports may leave the minimum protocol version unset. + function test_initialize_zeroOnlyScheduleWithoutMinimumProtocolVersion_succeeds() external { + uint64[] memory schedule = new uint64[](1); + + IProtocolVersions imported = _deployUninitializedProxy(); + vm.prank(EIP1967Helper.getAdmin(address(imported))); + imported.initialize(address(0), schedule, 0); + + assertEq(imported.getSchedule().length, 1); + assertEq(imported.minimumProtocolVersion(), 0); + } + + /// @notice Tests that the initial minimum protocol version must fit in the node's 128-bit packed semver layout. + function test_initialize_minimumProtocolVersionTooLarge_reverts() external { + IProtocolVersions imported = _deployUninitializedProxy(); + vm.expectRevert(IProtocolVersions.ProtocolVersions_InvalidProtocolVersion.selector); + vm.prank(EIP1967Helper.getAdmin(address(imported))); + imported.initialize(address(0), new uint64[](0), uint256(type(uint128).max) + 1); } /// @notice Tests that the contract cannot be initialized twice. function test_initialize_alreadyInitialized_reverts() external { vm.expectRevert("Initializable: contract is already initialized"); vm.prank(EIP1967Helper.getAdmin(address(protocolVersions))); - protocolVersions.initialize(address(0), new uint64[](0)); + protocolVersions.initialize(address(0), new uint64[](0), 0); } /// @notice Tests that the implementation itself cannot be initialized (initializers disabled). function test_initialize_implementationDisabled_reverts() external { IProtocolVersions impl = IProtocolVersions(EIP1967Helper.getImplementation(address(protocolVersions))); vm.expectRevert("Initializable: contract is already initialized"); - impl.initialize(address(0), new uint64[](0)); + impl.initialize(address(0), new uint64[](0), 0); } } @@ -1125,7 +1158,7 @@ contract ProtocolVersions_ActivatedScheduleId_Test is ProtocolVersions_TestInit IProtocolVersions imported = _deployUninitializedProxy(); vm.prank(EIP1967Helper.getAdmin(address(imported))); - imported.initialize(address(0), schedule); + imported.initialize(address(0), schedule, 1); return imported; } } diff --git a/test/L1/proofs/BaseTest.t.sol b/test/L1/proofs/BaseTest.t.sol index d41add534..123af8425 100644 --- a/test/L1/proofs/BaseTest.t.sol +++ b/test/L1/proofs/BaseTest.t.sol @@ -112,7 +112,7 @@ contract BaseTest is Test { ); factory.initialize(address(this)); delayedWETH.initialize(systemConfig); - protocolVersions.initialize(address(0), new uint64[](0)); + protocolVersions.initialize(address(0), new uint64[](0), 0); } /// @dev Rebuilds the schedule registry around a preset schedule and rebinds the verifier to it. @@ -121,7 +121,7 @@ contract BaseTest is Test { /// Must be called before any game is created, since games pin the registry they see. function _importProtocolVersionsSchedule(uint64[] memory schedule) internal { protocolVersions = ProtocolVersions(_deployProxy(address(new ProtocolVersions()))); - protocolVersions.initialize(address(0), schedule); + protocolVersions.initialize(address(0), schedule, 1); _deployAndSetAggregateVerifier(); } diff --git a/test/deploy/DeployConfig.t.sol b/test/deploy/DeployConfig.t.sol index e1a65b2c6..fdbdd4201 100644 --- a/test/deploy/DeployConfig.t.sol +++ b/test/deploy/DeployConfig.t.sol @@ -6,6 +6,10 @@ import { Test } from "lib/forge-std/src/Test.sol"; import { DeployConfig } from "scripts/deploy/DeployConfig.s.sol"; contract DeployConfigHarness is DeployConfig { + function readMinimumVersion(string memory _json) public { + _readProtocolVersionsInitialMinimumVersion(_json); + } + function readSchedule(string memory _json) public { _readProtocolVersionsInitialSchedule(_json); } @@ -53,11 +57,29 @@ contract DeployConfig_Test is Test { cfg.readSchedule('{"protocolVersionsInitialSchedule":[18446744073709551616]}'); } - /// @notice The shipped configs describe chains without a recorded history, so they must keep - /// producing an empty registry. - function test_read_localConfig_leavesScheduleEmpty_succeeds() public { + function test_readMinimumVersion_parsesValue_succeeds() public { + cfg.readMinimumVersion('{"protocolVersionsInitialMinimumVersion":42}'); + + assertEq(cfg.protocolVersionsInitialMinimumVersion(), 42); + } + + function test_readMinimumVersion_omitted_defaultsToZero_succeeds() public { + cfg.readMinimumVersion('{"l1ChainId":1}'); + + assertEq(cfg.protocolVersionsInitialMinimumVersion(), 0); + } + + function test_readMinimumVersion_aboveUint128_reverts() public { + vm.expectRevert("DeployConfig: initial minimum protocol version exceeds uint128"); + cfg.readMinimumVersion('{"protocolVersionsInitialMinimumVersion":340282366920938463463374607431768211456}'); + } + + /// @notice The shipped configs describe chains without a recorded history, so they must keep the initial registry + /// state empty. + function test_read_localConfig_leavesProtocolVersionsStateEmpty_succeeds() public { cfg.read("deploy-config/local.json"); assertEq(cfg.protocolVersionsInitialSchedule().length, 0); + assertEq(cfg.protocolVersionsInitialMinimumVersion(), 0); } } diff --git a/test/deploy/SystemDeploy.t.sol b/test/deploy/SystemDeploy.t.sol index a77b154e9..832813952 100644 --- a/test/deploy/SystemDeploy.t.sol +++ b/test/deploy/SystemDeploy.t.sol @@ -154,10 +154,10 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { assertValidStandardSystem(_expected(output, input)); } - /// @notice A chain that already has a hardfork history has to seed the registry at deploy time. - /// `registerUpgrade` cannot enter activations that are already in the past, and - /// `initialize` runs once, so an empty import here would be permanent. - function test_deploy_seedsProtocolVersionsWithInitialSchedule_succeeds() public { + /// @notice A chain that already has a hardfork history has to seed the registry and minimum protocol version at + /// deploy time. `registerUpgrade` cannot enter activations that are already in the past, and `initialize` runs + /// once, so an empty import here would be permanent. + function test_deploy_seedsProtocolVersionsWithInitialState_succeeds() public { vm.warp(1_800_000_000); uint64[] memory schedule = new uint64[](4); @@ -168,6 +168,7 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { SystemDeploy.DeployInput memory input = _defaultDeployInput(); input.opChainInput.initialUpgradeSchedule = schedule; + input.opChainInput.initialMinimumProtocolVersion = 42; SystemDeploy.DeployOutput memory output = systemDeploy.deploy(input); @@ -176,6 +177,7 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { for (uint256 i = 0; i < schedule.length; i++) { assertEq(imported[i], schedule[i], "schedule entry"); } + assertEq(output.opChain.protocolVersionsProxy.minimumProtocolVersion(), 42, "minimum protocol version"); assertEq( address(AggregateVerifier(address(output.opChain.aggregateVerifier)).PROTOCOL_VERSIONS()), @@ -184,6 +186,85 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { ); } + /// @notice Pins ProtocolVersions input validation before superchain deployment can broadcast. + function test_deploy_initialScheduleWithoutMinimumProtocolVersion_reverts() public { + uint64[] memory schedule = new uint64[](1); + schedule[0] = 1; + + SystemDeploy.DeployInput memory input = _defaultDeployInput(); + input.opChainInput.initialUpgradeSchedule = schedule; + input.superchainInput.superchainProxyAdminOwner = address(0); + + vm.expectRevert(IProtocolVersions.ProtocolVersions_InvalidProtocolVersion.selector); + systemDeploy.deploy(input); + } + + /// @notice Pins the packed-version bound check before superchain deployment can broadcast. + function test_deploy_initialMinimumProtocolVersionTooLarge_reverts() public { + SystemDeploy.DeployInput memory input = _defaultDeployInput(); + input.opChainInput.initialMinimumProtocolVersion = uint256(type(uint128).max) + 1; + input.superchainInput.superchainProxyAdminOwner = address(0); + + vm.expectRevert(IProtocolVersions.ProtocolVersions_InvalidProtocolVersion.selector); + systemDeploy.deploy(input); + } + + /// @notice Pins imported schedule ordering validation before superchain deployment can broadcast. + function test_deploy_unorderedInitialSchedule_reverts() public { + vm.warp(31); + uint64[] memory schedule = new uint64[](3); + schedule[0] = 30; + schedule[2] = 10; + + SystemDeploy.DeployInput memory input = _defaultDeployInput(); + input.opChainInput.initialUpgradeSchedule = schedule; + input.opChainInput.initialMinimumProtocolVersion = 42; + input.superchainInput.superchainProxyAdminOwner = address(0); + + vm.expectRevert( + abi.encodeWithSelector( + IProtocolVersions.ProtocolVersions_TimestampNotAfterPrevious.selector, + uint256(2), + uint256(0), + uint64(30), + uint64(10) + ) + ); + systemDeploy.deploy(input); + } + + /// @notice Pins a deployment buffer beyond the initializer's minimum notice before any broadcast. + function test_deploy_initialFutureActivationWithoutDeploymentBuffer_reverts() public { + uint64 activation = uint64(block.timestamp) + 1 hours; + uint64[] memory schedule = new uint64[](1); + schedule[0] = activation; + + SystemDeploy.DeployInput memory input = _defaultDeployInput(); + input.opChainInput.initialUpgradeSchedule = schedule; + input.opChainInput.initialMinimumProtocolVersion = 42; + input.superchainInput.superchainProxyAdminOwner = address(0); + + vm.expectRevert( + abi.encodeWithSelector(IProtocolVersions.ProtocolVersions_InsufficientNotice.selector, activation) + ); + systemDeploy.deploy(input); + } + + /// @notice Pins the inclusive future activation boundary after the deployment notice buffer. + function test_deploy_initialFutureActivationAtDeploymentBuffer_succeeds() public { + uint64 activation = uint64(block.timestamp) + 2 hours; + uint64[] memory schedule = new uint64[](1); + schedule[0] = activation; + + SystemDeploy.DeployInput memory input = _defaultDeployInput(); + input.opChainInput.initialUpgradeSchedule = schedule; + input.opChainInput.initialMinimumProtocolVersion = 42; + + SystemDeploy.DeployOutput memory output = systemDeploy.deploy(input); + + assertEq(output.opChain.protocolVersionsProxy.getSchedule()[0], activation); + } + function test_deploy_multiproofDisabled_allowsUnsetL2BlockTime_succeeds() public { SystemDeploy.DeployInput memory input = _defaultDeployInput(); input.implementationsInput.multiproofConfigHash = bytes32(0); @@ -401,7 +482,8 @@ contract SystemDeploy_Test is Test, SystemDeployAssertions { startingAnchorRoot: Proposal({ root: Hash.wrap(bytes32(uint256(1))), l2SequenceNumber: 0 }), saltMixer: "system-deploy-test", gasLimit: 60_000_000, - initialUpgradeSchedule: new uint64[](0) + initialUpgradeSchedule: new uint64[](0), + initialMinimumProtocolVersion: 0 }); } diff --git a/test/vendor/Initializable.t.sol b/test/vendor/Initializable.t.sol index ea61bbff3..828e20807 100644 --- a/test/vendor/Initializable.t.sol +++ b/test/vendor/Initializable.t.sol @@ -213,7 +213,7 @@ contract Initializer_Test is CommonTest { // ProtocolVersions is deployed by the standard deployment script but is absent on older // forked chains, so only track it when the proxy is present. if (address(protocolVersions) != address(0)) { - initCalldata = abi.encodeCall(protocolVersions.initialize, (address(0), new uint64[](0))); + initCalldata = abi.encodeCall(protocolVersions.initialize, (address(0), new uint64[](0), 0)); contracts.push( InitializeableContract({ name: "ProtocolVersionsImpl",