diff --git a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol new file mode 100644 index 000000000000..009b7cf164b3 --- /dev/null +++ b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol @@ -0,0 +1,624 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +// solhint-disable comprehensive-interface +pragma solidity >=0.8.27; + +import {Script} from "forge-std/Script.sol"; +import {StdAssertions} from "forge-std/StdAssertions.sol"; +import {console} from "forge-std/console.sol"; + +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; + +import {Rollup} from "@aztec/core/Rollup.sol"; +import {EscapeHatch} from "@aztec/core/EscapeHatch.sol"; +import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; +import {GenesisState, RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; +import {EthValue, EthPerFeeAssetE12} from "@aztec/core/libraries/rollup/FeeLib.sol"; +import { + RewardConfig, + Bps, + RegistryRewardOverride, + MAX_REGISTRY_REWARD_OVERRIDES +} from "@aztec/core/libraries/rollup/RewardLib.sol"; +import {IBoosterCore, RewardBoostConfig} from "@aztec/core/reward-boost/RewardBooster.sol"; +import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; +import {Inbox, INBOX_BUCKET_RING_SIZE} from "@aztec/core/messagebridge/Inbox.sol"; +import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; +import {Slasher} from "@aztec/core/slashing/Slasher.sol"; +import {SlashingProposer} from "@aztec/core/slashing/SlashingProposer.sol"; +import {Timestamp} from "@aztec/shared/libraries/TimeMath.sol"; + +import {GSE} from "@aztec/governance/GSE.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; + +import {HonkVerifier} from "@generated/HonkVerifier.sol"; + +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {V6UpgradePayload} from "@aztec/periphery/V6UpgradePayload.sol"; + +import {V6UpgradeSimulation} from "./V6UpgradeSimulation.sol"; + +/** + * @title DeployRollupForUpgradeV6 + * @author Aztec Labs + * @notice Deploys the v6 rollup and the governance payload that makes it canonical. + * + * @dev Usage: + * REGISTRY_ADDRESS=0x... forge script script/deploy/DeployRollupForUpgradeV6.s.sol \ + * --rpc-url $L1_RPC_URL --private-key $KEY --broadcast + * Re-check an already deployed rollup without deploying anything: + * REGISTRY_ADDRESS=0x... forge script ... --sig 'verify(address)' + * + * `REGISTRY_ADDRESS` is the only environment input. Every configuration value is a literal in + * {_config} below, so reviewing this file is sufficient to review the deployment: there are no + * env-var defaults and no `network-defaults.json` fallbacks that could change what is + * deployed. {verify} reads each value back off the deployed contracts and asserts it matches + * the table, so a typo in the table or a drift in how the Rollup consumes it fails loudly. + * + * Deploy order matters: the rollup is constructed owned by the deployer, the EscapeHatch is + * deployed and registered while that is still true, and only then is ownership handed to + * governance. Anything owner-gated that is not done before that line can only be done by a + * governance proposal afterwards. + * + * Not covered here, deliberately: the protocol fee margin and recipient keep their + * constructor values (0 and a placeholder address). Setting them is a governance call, so it + * belongs in {V6UpgradePayload}. + */ +contract DeployRollupForUpgradeV6 is Script, StdAssertions { + /// @notice Thrown when run against a chain this script has no configuration for. + error DeployRollupForUpgradeV6__UnsupportedChain(uint256 chainId); + + uint256 internal constant MAINNET_CHAIN_ID = 1; + uint256 internal constant SEPOLIA_CHAIN_ID = 11_155_111; + + /// @notice Every value that determines what the v6 rollup is. One field per configurable knob. + struct Config { + // Genesis state of the protocol circuits. Produced by the v6 noir-projects build, not chosen. + // Left zero on purpose: {run} refuses to deploy until they are filled in, because the Rollup + // accepts zeros silently and a wrong genesis is only detectable after the fact. + bytes32 vkTreeRoot; + bytes32 protocolContractsHash; + bytes32 genesisArchiveRoot; + // Time. `ethereumSlotDuration` is the L1 slot length and is used to derive the inbox + // censorship cutoff; the rest define the Aztec slot/epoch clock. + uint256 ethereumSlotDuration; + uint256 aztecSlotDuration; + uint256 aztecEpochDuration; + uint256 aztecProofSubmissionEpochs; + // Committee selection. + uint256 targetCommitteeSize; + uint256 lagInEpochsForValidatorSet; + uint256 lagInEpochsForRandao; + // Staking. `localEjectionThreshold` is in staking-asset units and is the only one of these + // three the rollup sets. The other two are immutables of the existing GSE that this rollup + // inherits by being constructed against it: they are recorded here so {verify} fails if the + // GSE behind `REGISTRY_ADDRESS` is not the one these numbers were chosen against. Nothing in + // the contracts enforces a relationship between the three, so this file is the only place the + // choice of `localEjectionThreshold` can be checked against the thresholds it sits between. + uint256 localEjectionThreshold; + uint256 expectedGseActivationThreshold; + uint256 expectedGseEjectionThreshold; + uint256 exitDelaySeconds; + // Entry queue. Bootstrap values apply until the validator set reaches + // `bootstrapValidatorSetSize`; afterwards each flush admits + // max(normalFlushSizeMin, setSize / normalFlushSizeQuotient), capped by `maxFlushSize`. + uint256 entryQueueBootstrapValidatorSetSize; + uint256 entryQueueBootstrapFlushSize; + uint256 entryQueueNormalFlushSizeMin; + uint256 entryQueueNormalFlushSizeQuotient; + uint256 entryQueueMaxFlushSize; + // Fees. `initialEthPerFeeAsset` is an E12 fixed-point ETH-per-fee-asset price and is therefore + // a point-in-time market value that must be refreshed before the deploy. + uint256 manaTarget; + uint256 provingCostPerMana; + uint256 initialEthPerFeeAsset; + // Rewards. `sequencerBps` is the sequencer's share of each checkpoint reward in basis points; + // the remainder goes to provers. A fresh RewardBooster is deployed with `rewardBoost`. + uint16 sequencerBps; + uint96 checkpointReward; + RewardBoostConfig rewardBoost; + // Per-registry sequencer reward overrides. Validators whose GSE withdrawer resolves through + // `staker.getATP().getRegistry()` to one of these registries earn the override instead of the + // default sequencer share, so these are reductions, not bonuses: the Rollup's constructor + // rejects any `sequencerReward` above `checkpointReward * sequencerBps` (450e18 at the values + // above), rejects a non-zero reward on a zero registry, and rejects duplicate registries. + // A zero registry means "slot unused", so all-zero disables overrides entirely -- unlike the + // genesis roots, zero here is a valid configuration and is NOT guarded in {run}. + address rewardOverrideRegistry0; + uint96 rewardOverrideSequencerReward0; + address rewardOverrideRegistry1; + uint96 rewardOverrideSequencerReward1; + // Slashing. `slashingRoundSizeInEpochs` is multiplied by `aztecEpochDuration` to get the round + // size in slots, which is what the SlashingProposer actually stores. + bool slasherEnabled; + uint256 slashingRoundSizeInEpochs; + uint256 slashingQuorum; + uint256 slashingLifetimeInRounds; + uint256 slashingExecutionDelayInRounds; + uint256 slashingOffsetInRounds; + uint256 slashingDisableDuration; + address slashingVetoer; + uint256 slashAmountSmall; + uint256 slashAmountMedium; + uint256 slashAmountLarge; + // Escape hatch. The bond is denominated in the staking asset. The EscapeHatch constructor + // rejects a configuration that breaks any of these, so they are constraints, not preferences: + // `escapeHatchActiveDuration` >= `aztecProofSubmissionEpochs` + 1, `escapeHatchFrequency` > + // both `EscapeHatch.LAG_IN_EPOCHS_FOR_SET_SIZE` (a constant 2) and `escapeHatchActiveDuration`, + // `escapeHatchLagInHatches` >= 1, withdrawal tax and failed-hatch punishment <= bond size, and + // `escapeHatchProposingExitDelay` <= 30 days. + uint96 escapeHatchBondSize; + uint96 escapeHatchWithdrawalTax; + uint96 escapeHatchFailedHatchPunishment; + uint256 escapeHatchFrequency; + uint256 escapeHatchActiveDuration; + uint256 escapeHatchLagInHatches; + uint256 escapeHatchProposingExitDelay; + // Restricts governance execution of the payload to UK office hours (Mon-Fri, 08:00-17:00 + // London, DST-aware). Enforced as the payload's first action, so a rejected attempt reverts + // the whole execution and leaves the proposal executable again when the window next opens. + bool enforcePayloadExecutionWindow; + // The flush rewarder serving the rollup being replaced, or zero on a chain that has none. + // A FlushRewarder is immutably bound to one rollup, so v6 needs its own; the payload deploys + // the replacement and moves the outgoing one's unowed balance across. + address oldFlushRewarder; + } + + /** + * @notice The configuration this script deploys. This is the table to review. + * @dev The literal below is the mainnet configuration; Sepolia is expressed as a short list of + * overrides underneath it, so the only thing a reviewer has to check for Sepolia is that + * list. A chain with neither branch reverts rather than silently deploying mainnet values. + * Values track v5 production unless a comment says otherwise; each line records the v5 + * value so any intentional v6 divergence is visible as a divergence. + */ + /// @dev `virtual` so a test can supply the three genesis roots and exercise the rest of this + /// table for real. Nothing else about it is overridable, and nothing in the deploy path + /// overrides it: `run()` still refuses to deploy while the roots are zero. + function _config() internal view virtual returns (Config memory c) { + c = Config({ + vkTreeRoot: bytes32(0), // TODO: from the v6 protocol circuits build + protocolContractsHash: bytes32(0), // TODO: from the v6 protocol circuits build + genesisArchiveRoot: bytes32(0), // TODO: from the v6 protocol circuits build; + ethereumSlotDuration: 12, // L1 slot time; no v5 equivalent, the v5 inbox took an explicit lag instead + aztecSlotDuration: 72, + aztecEpochDuration: 32, + aztecProofSubmissionEpochs: 1, + targetCommitteeSize: 48, + lagInEpochsForValidatorSet: 2, + lagInEpochsForRandao: 1, + localEjectionThreshold: 190_000e18, // v5 production: 190_000e18 (mainnet), 199_000e18 (sepolia) + expectedGseActivationThreshold: 200_000e18, // not set here; asserted against the existing GSE. v5 production: + // 200_000e18 + expectedGseEjectionThreshold: 100_000e18, // not set here; asserted against the existing GSE. v5 production: + // 100_000e18 + exitDelaySeconds: 345_600, // 4 days. v5 production: 345_600 (mainnet), 172_800 (sepolia) + entryQueueBootstrapValidatorSetSize: 500, // v5 production: 500. + entryQueueBootstrapFlushSize: 4, // v5 production: 4 + entryQueueNormalFlushSizeMin: 1, // v5 production: 1 + entryQueueNormalFlushSizeQuotient: 400, // v5 production: 400 + entryQueueMaxFlushSize: 4, // v5 production: 4 + manaTarget: 75_000_000, // v5 production: 75_000_000 + provingCostPerMana: 12_500_000, // v5 production: 12_500_000 (set by AZIP-16) + initialEthPerFeeAsset: 10_000_000, // v5 production: 9_512_195, priced 2026-06-11. TODO: refresh before v6 deploy + sequencerBps: 9000, // 90% sequencer / 10% prover. v5 production: 7000 + checkpointReward: 500e18, // same as v5 + rewardBoost: RewardBoostConfig({ + increment: 101_400, maxScore: 367_500, a: 250_000, minimum: 10_000, k: 1_000_000 + }), // AZIP-5; same as v5 + rewardOverrideRegistry0: address(0), // TODO: ATP registry (auction) -- address lives in ignition-contracts + rewardOverrideSequencerReward0: 0, // TODO: must be <= 450e18 + rewardOverrideRegistry1: address(0), // TODO: ATP registry (genesis sale) + rewardOverrideSequencerReward1: 0, // TODO: must be <= 450e18 + slasherEnabled: true, + slashingRoundSizeInEpochs: 4, + slashingQuorum: 65, // of the 128-slot round (4 epochs x 32 slots) + slashingLifetimeInRounds: 34, // v5 production: 34 (mainnet), 5 (sepolia) + slashingExecutionDelayInRounds: 28, // v5 production: 28 (mainnet), 2 (sepolia) + slashingOffsetInRounds: 2, + slashingDisableDuration: 259_200, // 3 days. v5 production: 259_200 (mainnet), 432_000 (sepolia) + slashingVetoer: 0xBbB4aF368d02827945748b28CD4b2D42e4A37480, // v5 production (mainnet) + slashAmountSmall: 2000e18, // v5 production: 2000e18 (mainnet), 100_000e18 (sepolia) + slashAmountMedium: 5000e18, // v5 production: 5000e18 (mainnet), 250_000e18 (sepolia) + slashAmountLarge: 5000e18, // v5 production: 5000e18 (mainnet), 250_000e18 (sepolia) + escapeHatchBondSize: 332_000_000e18, // v5 production + escapeHatchWithdrawalTax: 1_660_000e18, // v5 production + escapeHatchFailedHatchPunishment: 9_600_000e18, // v5 production + escapeHatchFrequency: 112, // epochs between hatches. v5 production + escapeHatchActiveDuration: 2, // epochs. v5 production; also the minimum allowed here, being + // aztecProofSubmissionEpochs + 1 + escapeHatchLagInHatches: 1, // v5 production + escapeHatchProposingExitDelay: 30 days, // v5 production; also the maximum the constructor allows + enforcePayloadExecutionWindow: true, + oldFlushRewarder: 0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e // bound to the v5 rollup + }); + + if (block.chainid == MAINNET_CHAIN_ID) { + return c; + } + + if (block.chainid == SEPOLIA_CHAIN_ID) { + // Every field Sepolia diverges on, and nothing else. Values are v5's Sepolia production + // values; + c.localEjectionThreshold = 199_000e18; + c.exitDelaySeconds = 172_800; // 2 days + c.slashingLifetimeInRounds = 5; + c.slashingExecutionDelayInRounds = 2; + c.slashingDisableDuration = 432_000; // 5 days + c.slashingVetoer = 0xdfe19Da6a717b7088621d8bBB66be59F2d78e924; + c.slashAmountSmall = 100_000e18; + c.slashAmountMedium = 250_000e18; + c.slashAmountLarge = 250_000e18; + // Testnet upgrades are executed on demand, so the office-hours restriction is mainnet only. + c.enforcePayloadExecutionWindow = false; + // Sepolia has no flush rewarder to migrate, so the payload skips that action entirely. + // Leaving the mainnet address here would make the payload constructor revert, since it + // reads the outgoing rewarder's asset and rate. + c.oldFlushRewarder = address(0); + // Sepolia's own ATP registries and reward amounts. The ceiling is the same 450e18 as + // mainnet, because Sepolia does not override `checkpointReward` or `sequencerBps`. + c.rewardOverrideRegistry0 = address(0); // TODO: Sepolia ATP registry, or leave zero if none exists + c.rewardOverrideSequencerReward0 = 0; // TODO: must be <= 450e18 + c.rewardOverrideRegistry1 = address(0); // TODO: Sepolia ATP registry, or leave zero if none exists + c.rewardOverrideSequencerReward1 = 0; // TODO: must be <= 450e18 + return c; + } + + revert DeployRollupForUpgradeV6__UnsupportedChain(block.chainid); + } + + /// @notice The rollup {run} deployed. Zero until it has run. + /// @dev Mirrors `rollupOutput()` on DeployRollupForUpgrade: the addresses are logged for an + /// operator, and exposed here for anything that has to read them back -- a test, or tooling + /// that follows the deploy. + Rollup public deployedRollup; + + /// @notice The payload {run} deployed. Zero until it has run. + V6UpgradePayload public deployedPayload; + + /// @notice Deploys the verifier, the rollup, and the governance payload, then verifies the result. + function run() public { + Config memory c = _config(); + + // The three genesis roots are the only values the Rollup accepts silently when wrong, so they + // are gated here rather than trusted. + require(c.vkTreeRoot != bytes32(0), "vkTreeRoot not set"); + require(c.protocolContractsHash != bytes32(0), "protocolContractsHash not set"); + require(c.genesisArchiveRoot != bytes32(0), "genesisArchiveRoot not set"); + + Registry registry = Registry(vm.envAddress("REGISTRY_ADDRESS")); + // Everything the new rollup must share with the old one is read off the chain rather than + // configured, so the new rollup cannot be pointed at the wrong asset or GSE by a typo. + IInstance current = IInstance(address(registry.getCanonicalRollup())); + address governance = registry.getGovernance(); + + // The account `--private-key` / `--sender` resolves to. Every contract below is deployed by it. + address deployer = msg.sender; + + vm.startBroadcast(deployer); + + // Always the real verifier: this script exists for upgrades of live networks. + IVerifier verifier = IVerifier(address(new HonkVerifier())); + + // The 5th argument is GOVERNANCE, and it is load-bearing twice: it becomes the Rollup's + // `Ownable` owner AND the Slasher's immutable `GOVERNANCE`, which may execute any slash + // payload with no vote, no round and no delay (Slasher.sol:58). + // + // So it MUST be governance, not the deployer. `transferOwnership` moves only the Ownable + // half; the Slasher's copy is immutable and would leave the deploy key able to slash every + // validator for the life of the rollup. Recovery would take 60 days to queue a replacement + // Slasher plus a 30-day window in which the old one still works. + // + // The cost is that no owner-gated setup can happen here. `setEscapeHatch` therefore moved + // into the payload, where governance performs it. + Rollup rollup = new Rollup( + current.getFeeAsset(), + current.getStakingAsset(), + current.getGSE(), + verifier, + governance, + _genesisState(c), + _rollupConfigInput(c, registry.getRewardDistributor()) + ); + + // Deployed after the rollup because its constructor reads `getProofSubmissionEpochs()` off it + // to check `escapeHatchActiveDuration`, and because it must be built for this exact rollup. + EscapeHatch escapeHatch = new EscapeHatch( + address(rollup), + address(current.getStakingAsset()), + c.escapeHatchBondSize, + c.escapeHatchWithdrawalTax, + c.escapeHatchFailedHatchPunishment, + c.escapeHatchFrequency, + c.escapeHatchActiveDuration, + c.escapeHatchLagInHatches, + c.escapeHatchProposingExitDelay + ); + + // The rollup has been owned by governance since construction, so nothing owner-gated happens + // here and there is no ownership to transfer. The hatch is installed by the payload. + V6UpgradePayload payload = new V6UpgradePayload( + registry, + IInstance(address(rollup)), + IEscapeHatch(address(escapeHatch)), + FlushRewarder(c.oldFlushRewarder), + c.enforcePayloadExecutionWindow + ); + + vm.stopBroadcast(); + + verify(address(rollup)); + + console.log("rollup ", address(rollup)); + console.log("verifier ", address(verifier)); + console.log("inbox ", address(rollup.getInbox())); + console.log("outbox ", address(rollup.getOutbox())); + console.log("feeJuicePortal ", address(rollup.getFeeAssetPortal())); + console.log("slasher ", rollup.getSlasher()); + console.log("escapeHatch ", address(escapeHatch), "(installed by the payload)"); + console.log("rewardBooster ", address(rollup.getRewardConfig().booster)); + console.log("payload ", address(payload)); + console.log("newFlushRewarder ", address(payload.NEW_FLUSH_REWARDER())); + verifyEscapeHatch(address(rollup), address(payload)); + verifyFlushRewarder(address(rollup), address(payload)); + + _simulate(address(payload)); + console.log("version ", rollup.getVersion()); + + deployedRollup = rollup; + deployedPayload = payload; + } + + /** + * @notice Runs the payload through the real governance lifecycle against a state snapshot and + * reverts it, so a deploy cannot succeed while producing a payload that would fail. + * @dev Requires FORKED state -- it needs a governance with real voters and mainnet timings; see + * V6UpgradeSimulation. `virtual` so a test exercising the config table against a local stack + * can skip it, which is the only thing in `run()` that a local stack cannot satisfy. + */ + function _simulate(address _payload) internal virtual { + new V6UpgradeSimulation().simulate(_payload); + } + + /// @notice Asserts every configurable value of an already-deployed rollup against {_config}. + function verify(address _rollup) public view { + Config memory c = _config(); + Rollup rollup = Rollup(_rollup); + Registry registry = Registry(vm.envAddress("REGISTRY_ADDRESS")); + + _verifyGenesisAndTime(rollup, c); + _verifyStakingAndFees(rollup, c); + _verifyRewards(rollup, c); + _verifySlashing(rollup, c, registry.getGovernance()); + _verifySubContracts(rollup, registry); + } + + /// @notice Asserts the payload's replacement flush rewarder is wired to this rollup. + /// @dev Separate from {verify} because it needs the payload address, which {verify} is not given. + function verifyFlushRewarder(address _rollup, address _payload) public view { + Config memory c = _config(); + FlushRewarder newRewarder = V6UpgradePayload(_payload).NEW_FLUSH_REWARDER(); + + assertEq( + V6UpgradePayload(_payload).ENFORCE_EXECUTION_WINDOW(), + c.enforcePayloadExecutionWindow, + "payload execution window flag" + ); + + if (c.oldFlushRewarder == address(0)) { + assertEq(address(newRewarder), address(0), "flush rewarder deployed on a chain with none to migrate"); + return; + } + + FlushRewarder oldRewarder = FlushRewarder(c.oldFlushRewarder); + assertEq(address(newRewarder.ROLLUP()), _rollup, "new flush rewarder bound to another rollup"); + assertEq(address(newRewarder.REWARD_ASSET()), address(oldRewarder.REWARD_ASSET()), "flush reward asset"); + assertEq(newRewarder.rewardPerInsertion(), oldRewarder.rewardPerInsertion(), "rewardPerInsertion"); + // Governance must own it, or the reward rate can never be changed again. + assertEq(newRewarder.owner(), Registry(vm.envAddress("REGISTRY_ADDRESS")).getGovernance(), "flush rewarder owner"); + } + + /** + * @notice Asserts the payload's escape hatch is built for this rollup. + * @dev Separate from {verify} for the same reason as {verifyFlushRewarder}: the hatch is + * installed BY the payload, so the rollup does not point at it until governance executes. + * Installation itself is asserted post-execution by V6UpgradeSimulation. + */ + function verifyEscapeHatch(address _rollup, address _payload) public view { + _verifyEscapeHatch(EscapeHatch(V6UpgradePayload(_payload).ESCAPE_HATCH()), Rollup(_rollup), _config()); + } + + function _verifyEscapeHatch(EscapeHatch hatch, Rollup _rollup, Config memory _c) private view { + assertTrue(address(hatch) != address(0), "payload has no escape hatch"); + assertEq(hatch.getRollup(), address(_rollup), "escape hatch points at another rollup"); + assertEq(hatch.getBondToken(), address(_rollup.getStakingAsset()), "escape hatch bond token"); + assertEq(hatch.getBondSize(), _c.escapeHatchBondSize, "escapeHatchBondSize"); + assertEq(hatch.getWithdrawalTax(), _c.escapeHatchWithdrawalTax, "escapeHatchWithdrawalTax"); + assertEq(hatch.getFailedHatchPunishment(), _c.escapeHatchFailedHatchPunishment, "escapeHatchFailedHatchPunishment"); + assertEq(hatch.getFrequency(), _c.escapeHatchFrequency, "escapeHatchFrequency"); + assertEq(hatch.getActiveDuration(), _c.escapeHatchActiveDuration, "escapeHatchActiveDuration"); + assertEq(hatch.getLagInHatches(), _c.escapeHatchLagInHatches, "escapeHatchLagInHatches"); + assertEq(hatch.getProposingExitDelay(), _c.escapeHatchProposingExitDelay, "escapeHatchProposingExitDelay"); + } + + function _verifyGenesisAndTime(Rollup _rollup, Config memory _c) private view { + assertEq(_rollup.getVkTreeRoot(), _c.vkTreeRoot, "vkTreeRoot mismatch"); + assertEq(_rollup.getProtocolContractsHash(), _c.protocolContractsHash, "protocolContractsHash"); + // The genesis archive root is not exposed by a named getter; it is the archive of checkpoint 0. + assertEq(_rollup.archiveAt(0), _c.genesisArchiveRoot, "genesisArchiveRoot mismatch"); + assertEq(_rollup.getSlotDuration(), _c.aztecSlotDuration, "aztecSlotDuration mismatch"); + assertEq(_rollup.getEpochDuration(), _c.aztecEpochDuration, "aztecEpochDuration mismatch"); + assertEq(_rollup.getProofSubmissionEpochs(), _c.aztecProofSubmissionEpochs, "aztecProofSubmissionEpochs mismatch"); + assertEq(_rollup.getTargetCommitteeSize(), _c.targetCommitteeSize, "targetCommitteeSize mismatch"); + assertEq( + _rollup.getLagInEpochsForValidatorSet(), _c.lagInEpochsForValidatorSet, "lagInEpochsForValidatorSet mismatch" + ); + assertEq(_rollup.getLagInEpochsForRandao(), _c.lagInEpochsForRandao, "lagInEpochsForRandao"); + } + + function _verifyStakingAndFees(Rollup _rollup, Config memory _c) private view { + assertEq(_rollup.getLocalEjectionThreshold(), _c.localEjectionThreshold, "localEjectionThreshold mismatch"); + // These two read straight off the GSE's immutables, so they are readable as soon as the rollup + // is constructed and do not depend on the GSE or Registry having registered it. + assertEq(_rollup.getActivationThreshold(), _c.expectedGseActivationThreshold, "gse activationThreshold mismatch"); + assertEq(_rollup.getEjectionThreshold(), _c.expectedGseEjectionThreshold, "gse ejectionThreshold mismatch"); + assertEq(Timestamp.unwrap(_rollup.getExitDelay()), _c.exitDelaySeconds, "exitDelaySeconds mismatch"); + assertEq(_rollup.getManaTarget(), _c.manaTarget, "manaTarget mismatch"); + assertEq( + EthValue.unwrap(_rollup.getProvingCostPerManaInEth()), _c.provingCostPerMana, "provingCostPerMana mismatch" + ); + assertEq(EthPerFeeAssetE12.unwrap(_rollup.getEthPerFeeAsset()), _c.initialEthPerFeeAsset, "initialEthPerFeeAsset"); + + // The protocol fee margin and recipient are not deploy-time config; these assertions record + // that the rollup starts with the constructor's values and still needs a governance call. + assertEq(_rollup.getProtocolFeeMargin(), 0, "protocol fee margin should start at 0"); + assertEq( + _rollup.getProtocolFeeRecipient(), + address(bytes20("CUAUHXICALLI")), + "protocol fee recipient should still be the constructor placeholder" + ); + } + + function _verifyRewards(Rollup _rollup, Config memory _c) private view { + RewardConfig memory rc = _rollup.getRewardConfig(); + assertEq(Bps.unwrap(rc.sequencerBps), _c.sequencerBps, "sequencerBps"); + assertEq(rc.checkpointReward, _c.checkpointReward, "checkpointReward"); + // A booster address of zero in the config makes the Rollup deploy a fresh one, so a non-zero + // address here confirms that happened. + assertTrue(address(rc.booster) != address(0), "reward booster not deployed"); + + // The overrides live in `internal immutable`s, so `getRegistryRewardOverrides` reassembling + // them is the only way to read them back; there is no storage slot to inspect. + RegistryRewardOverride[MAX_REGISTRY_REWARD_OVERRIDES] memory overrides = _rollup.getRegistryRewardOverrides(); + assertEq(overrides[0].registry, _c.rewardOverrideRegistry0, "rewardOverrideRegistry0"); + assertEq(overrides[0].sequencerReward, _c.rewardOverrideSequencerReward0, "rewardOverrideSequencerReward0"); + assertEq(overrides[1].registry, _c.rewardOverrideRegistry1, "rewardOverrideRegistry1"); + assertEq(overrides[1].sequencerReward, _c.rewardOverrideSequencerReward1, "rewardOverrideSequencerReward1"); + } + + function _verifySlashing(Rollup _rollup, Config memory _c, address _governance) private view { + // With slashing disabled the Rollup deploys no slasher at all, so there is nothing else to read. + if (!_c.slasherEnabled) { + assertEq(_rollup.getSlasher(), address(0), "slasher deployed despite slasherEnabled = false"); + return; + } + + Slasher slasher = Slasher(_rollup.getSlasher()); + // The one that matters most and is easiest to get wrong: GOVERNANCE is immutable and is taken + // from the Rollup constructor's owner argument, so a rollup constructed by the deployer hands + // the deploy key unilateral, permanent slashing power that no later transfer can revoke. + assertEq(slasher.GOVERNANCE(), _governance, "slasher GOVERNANCE is not governance"); + assertEq(slasher.VETOER(), _c.slashingVetoer, "slashingVetoer"); + assertEq(slasher.SLASHING_DISABLE_DURATION(), _c.slashingDisableDuration, "slashingDisableDuration"); + + SlashingProposer proposer = SlashingProposer(slasher.PROPOSER()); + assertEq(proposer.INSTANCE(), address(_rollup), "slashing proposer points at another rollup"); + assertEq(proposer.QUORUM(), _c.slashingQuorum, "slashingQuorum"); + assertEq(proposer.ROUND_SIZE_IN_EPOCHS(), _c.slashingRoundSizeInEpochs, "slashingRoundSizeInEpochs"); + assertEq(proposer.ROUND_SIZE(), _c.slashingRoundSizeInEpochs * _c.aztecEpochDuration, "slashingRoundSize"); + assertEq(proposer.LIFETIME_IN_ROUNDS(), _c.slashingLifetimeInRounds, "slashingLifetimeInRounds"); + assertEq(proposer.EXECUTION_DELAY_IN_ROUNDS(), _c.slashingExecutionDelayInRounds, "slashingExecutionDelayInRounds"); + assertEq(proposer.SLASH_OFFSET_IN_ROUNDS(), _c.slashingOffsetInRounds, "slashingOffsetInRounds"); + assertEq(proposer.SLASH_AMOUNT_SMALL(), _c.slashAmountSmall, "slashAmountSmall"); + assertEq(proposer.SLASH_AMOUNT_MEDIUM(), _c.slashAmountMedium, "slashAmountMedium"); + assertEq(proposer.SLASH_AMOUNT_LARGE(), _c.slashAmountLarge, "slashAmountLarge"); + } + + function _verifySubContracts(Rollup _rollup, Registry _registry) private view { + IInstance current = IInstance(address(_registry.getCanonicalRollup())); + uint256 version = _rollup.getVersion(); + + // Inherited from the chain, not configured: assert they match the rollup being replaced. + assertEq(address(_rollup.getFeeAsset()), address(current.getFeeAsset()), "feeAsset"); + assertEq(address(_rollup.getStakingAsset()), address(current.getStakingAsset()), "stakingAsset"); + assertEq(address(_rollup.getGSE()), address(current.getGSE()), "gse"); + assertEq(address(_rollup.getRewardDistributor()), address(_registry.getRewardDistributor()), "rewardDistributor"); + + // The Inbox and Outbox are deployed by the Rollup's constructor and are only correct if they + // point back at it and carry its version, which is what scopes L1<>L2 messages to this rollup. + Inbox inbox = Inbox(address(_rollup.getInbox())); + assertEq(inbox.ROLLUP(), address(_rollup), "inbox rollup"); + assertEq(inbox.VERSION(), version, "inbox version"); + assertEq(inbox.BUCKET_RING_SIZE(), INBOX_BUCKET_RING_SIZE, "inbox bucket ring size"); + assertEq(inbox.FEE_ASSET_PORTAL(), address(_rollup.getFeeAssetPortal()), "fee juice portal"); + + Outbox outbox = Outbox(address(_rollup.getOutbox())); + assertEq(address(outbox.ROLLUP()), address(_rollup), "outbox rollup"); + assertEq(outbox.VERSION(), version, "outbox version"); + + // The deploy is only safe to hand to governance if governance actually controls it. + assertEq(_rollup.owner(), _registry.getGovernance(), "rollup owner should be governance"); + } + + function _genesisState(Config memory _c) private pure returns (GenesisState memory) { + return GenesisState({ + vkTreeRoot: _c.vkTreeRoot, + protocolContractsHash: _c.protocolContractsHash, + genesisArchiveRoot: _c.genesisArchiveRoot + }); + } + + function _rollupConfigInput(Config memory _c, IRewardDistributor _rewardDistributor) + private + pure + returns (RollupConfigInput memory config) + { + config.ethereumSlotDuration = _c.ethereumSlotDuration; + config.aztecSlotDuration = _c.aztecSlotDuration; + config.aztecEpochDuration = _c.aztecEpochDuration; + config.aztecProofSubmissionEpochs = _c.aztecProofSubmissionEpochs; + config.targetCommitteeSize = _c.targetCommitteeSize; + config.lagInEpochsForValidatorSet = _c.lagInEpochsForValidatorSet; + config.lagInEpochsForRandao = _c.lagInEpochsForRandao; + config.localEjectionThreshold = _c.localEjectionThreshold; + config.exitDelaySeconds = _c.exitDelaySeconds; + config.manaTarget = _c.manaTarget; + config.provingCostPerMana = EthValue.wrap(_c.provingCostPerMana); + config.initialEthPerFeeAsset = EthPerFeeAssetE12.wrap(_c.initialEthPerFeeAsset); + + config.slasherEnabled = _c.slasherEnabled; + config.slashingVetoer = _c.slashingVetoer; + config.slashingDisableDuration = _c.slashingDisableDuration; + config.slashingQuorum = _c.slashingQuorum; + // The Rollup stores the round size in slots; epochs are the reviewable unit. + config.slashingRoundSize = _c.slashingRoundSizeInEpochs * _c.aztecEpochDuration; + config.slashingLifetimeInRounds = _c.slashingLifetimeInRounds; + config.slashingExecutionDelayInRounds = _c.slashingExecutionDelayInRounds; + config.slashingOffsetInRounds = _c.slashingOffsetInRounds; + config.slashAmounts = [_c.slashAmountSmall, _c.slashAmountMedium, _c.slashAmountLarge]; + + config.stakingQueueConfig = StakingQueueConfig({ + bootstrapValidatorSetSize: _c.entryQueueBootstrapValidatorSetSize, + bootstrapFlushSize: _c.entryQueueBootstrapFlushSize, + normalFlushSizeMin: _c.entryQueueNormalFlushSizeMin, + normalFlushSizeQuotient: _c.entryQueueNormalFlushSizeQuotient, + maxQueueFlushSize: _c.entryQueueMaxFlushSize + }); + + config.rewardConfig = RewardConfig({ + rewardDistributor: _rewardDistributor, + sequencerBps: Bps.wrap(_c.sequencerBps), + // Zero makes the Rollup's constructor deploy a fresh RewardBooster from `rewardBoostConfig`. + booster: IBoosterCore(address(0)), + checkpointReward: _c.checkpointReward + }); + config.rewardBoostConfig = _c.rewardBoost; + + config.registryRewardOverrides[0] = RegistryRewardOverride({ + registry: _c.rewardOverrideRegistry0, sequencerReward: _c.rewardOverrideSequencerReward0 + }); + config.registryRewardOverrides[1] = RegistryRewardOverride({ + registry: _c.rewardOverrideRegistry1, sequencerReward: _c.rewardOverrideSequencerReward1 + }); + + // The version identifies this rollup in the Registry and scopes its Inbox/Outbox messages. It + // is derived from the configuration so that two rollups with different configuration cannot + // collide. `config.version` is still zero at this point (memory structs start zeroed and + // nothing above assigns it), which is what `RollupConfiguration._computeConfigVersion` also + // hashes over, so the two agree. + config.version = uint32(bytes4(keccak256(abi.encode(config, _genesisState(_c))))); + } +} diff --git a/l1-contracts/script/deploy/V6UpgradeSimulation.sol b/l1-contracts/script/deploy/V6UpgradeSimulation.sol new file mode 100644 index 000000000000..f7ebd6af1f7a --- /dev/null +++ b/l1-contracts/script/deploy/V6UpgradeSimulation.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +// solhint-disable comprehensive-interface +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {console} from "forge-std/console.sol"; + +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; + +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Timestamp} from "@aztec/shared/libraries/TimeMath.sol"; + +import {Governance} from "@aztec/governance/Governance.sol"; +import {GSEPayload} from "@aztec/governance/GSEPayload.sol"; +import {IGSE} from "@aztec/governance/GSE.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {IRewardDistributor} from "@aztec/governance/interfaces/IRewardDistributor.sol"; +import {Proposal, ProposalState} from "@aztec/governance/interfaces/IGovernance.sol"; + +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {V6UpgradePayload} from "@aztec/periphery/V6UpgradePayload.sol"; + +/** + * @title V6UpgradeSimulation + * @author Aztec Labs + * @notice Executes {V6UpgradePayload} through the real governance lifecycle against a state + * snapshot, asserts the resulting state, and reverts the snapshot so nothing persists. + * + * @dev Kept out of DeployRollupForUpgradeV6 on purpose: that file is a configuration table meant + * to be reviewed line by line, and this is cheatcode machinery. The deploy script calls + * {simulate} in one line after it has stopped broadcasting. + * + * Requires forked state (a real registry, GSE and governance), so it runs under `--fork-url` + * or against a live RPC in simulation mode, not against a bare anvil. + * + * What this does and does not prove: it proves the payload's actions execute correctly and + * leave the chain in the expected state. It does NOT predict whether the proposal would + * pass -- a simulation-only voter is given a majority of the voting power so execution is + * reached regardless of how power is really distributed. + */ +contract V6UpgradeSimulation is Test { + error V6UpgradeSimulation__SnapshotRevertFailed(uint256 snapshotId); + + /// @notice Chain state read before execution, so the assertions afterwards compare against what + /// was actually there rather than against hardcoded amounts. + struct Snapshot { + address canonicalRollup; + uint256 versions; + uint256 bonusAttesters; + uint256 rewardDistributorBalance; + uint256 rewardDistributorAvailableToNew; + uint256 flushFundsToMove; + uint256 oldFlushBalance; + uint256 newFlushBalance; + } + + /// @notice Runs the whole lifecycle and reverts the snapshot afterwards. + /// @dev Also the entry point for `forge script ... --sig 'simulate(address)' `. + /// @param _payload The deployed {V6UpgradePayload} to execute + function simulate(address _payload) public { + V6UpgradePayload payload = V6UpgradePayload(_payload); + IRegistry registry = payload.REGISTRY(); + Governance governance = Governance(registry.getGovernance()); + IGSE gse = IGSE(address(payload.ROLLUP().getGSE())); + + Snapshot memory before = _capture(payload, registry, gse); + + uint256 snapshotId = vm.snapshotState(); + + _executeThroughGovernance(payload, governance, gse, before.canonicalRollup); + _assertPostState(payload, registry, gse, before); + + if (!vm.revertToState(snapshotId)) { + revert V6UpgradeSimulation__SnapshotRevertFailed(snapshotId); + } + console.log(unicode"[simulate] payload executes and leaves the expected state ✓"); + } + + function _capture(V6UpgradePayload _payload, IRegistry _registry, IGSE _gse) + internal + view + returns (Snapshot memory s) + { + s.canonicalRollup = address(_registry.getCanonicalRollup()); + s.versions = _registry.numberOfVersions(); + s.bonusAttesters = _gse.getAttesterCountAtTime(_gse.getBonusInstanceAddress(), Timestamp.wrap(block.timestamp)); + + IRewardDistributor distributor = _registry.getRewardDistributor(); + IERC20 rewardAsset = IERC20(Rollup(address(_payload.ROLLUP())).getFeeAsset()); + s.rewardDistributorBalance = rewardAsset.balanceOf(address(distributor)); + s.rewardDistributorAvailableToNew = distributor.availableTo(address(_payload.ROLLUP())); + + FlushRewarder oldFlush = _payload.OLD_FLUSH_REWARDER(); + if (address(oldFlush) != address(0)) { + IERC20 flushAsset = oldFlush.REWARD_ASSET(); + s.flushFundsToMove = oldFlush.rewardsAvailable(); + s.oldFlushBalance = flushAsset.balanceOf(address(oldFlush)); + s.newFlushBalance = flushAsset.balanceOf(address(_payload.NEW_FLUSH_REWARDER())); + } + } + + /// @dev Mirrors what GovernanceProposer does: the payload is wrapped in a GSEPayload before + /// being proposed, so the simulation exercises the same shape governance will see. + function _executeThroughGovernance( + V6UpgradePayload _payload, + Governance _governance, + IGSE _gse, + address _oldCanonical + ) internal { + GSEPayload wrapped = new GSEPayload(IPayload(address(_payload)), _gse, _payload.REGISTRY()); + + // A simulation-only voter holding twice the total power guarantees the proposal reaches + // execution however real power is distributed. Everything here is inside the snapshot. + address simVoter = address(uint160(uint256(keccak256("V6UpgradeSimulation.simVoter")))); + uint256 simPower = _governance.totalPowerAt(Timestamp.wrap(block.timestamp - 1)) * 2; + IERC20 govAsset = _governance.ASSET(); + deal(address(govAsset), simVoter, simPower); + + vm.startPrank(simVoter); + govAsset.approve(address(_governance), simPower); + _governance.deposit(simVoter, simPower); + vm.stopPrank(); + + vm.prank(_governance.governanceProposer()); + uint256 proposalId = _governance.propose(IPayload(address(wrapped))); + + Proposal memory proposal = _governance.getProposal(proposalId); + Timestamp pendingThrough = + Timestamp.wrap(Timestamp.unwrap(proposal.creation) + Timestamp.unwrap(proposal.config.votingDelay)); + Timestamp queuedThrough = Timestamp.wrap( + Timestamp.unwrap(pendingThrough) + Timestamp.unwrap(proposal.config.votingDuration) + + Timestamp.unwrap(proposal.config.executionDelay) + ); + + vm.warp(Timestamp.unwrap(pendingThrough) + 1); + assertEq(uint256(_governance.getProposalState(proposalId)), uint256(ProposalState.Active), "proposal not active"); + + // The outgoing rollup votes with whatever power it and the bonus instance hold, so the + // simulation exercises the real voting path and not only the synthetic voter. + uint256 rollupPower = _gse.getVotingPowerAt(_oldCanonical, pendingThrough); + if (rollupPower > 0) { + vm.prank(_oldCanonical); + _gse.vote(proposalId, rollupPower, true); + } + uint256 bonusPower = _gse.getVotingPowerAt(_gse.getBonusInstanceAddress(), pendingThrough); + if (bonusPower > 0) { + vm.prank(_oldCanonical); + _gse.voteWithBonus(proposalId, bonusPower, true); + } + vm.prank(simVoter); + _governance.vote(proposalId, simPower, true); + + vm.warp(Timestamp.unwrap(queuedThrough) + 1); + + // Where execution is restricted to office hours, step to the next open slot. The proposal + // survives the wait because the longest gap (Friday 17:00 to Monday 08:00, ~63h) is well + // inside the grace period, but assert it rather than assume. + if (_payload.ENFORCE_EXECUTION_WINDOW()) { + for (uint256 i = 0; i < 7 days / 1 hours && !_payload.isWithinExecutionWindow(block.timestamp); i++) { + vm.warp(block.timestamp + 1 hours); + } + assertTrue(_payload.isWithinExecutionWindow(block.timestamp), "no execution window found within a week"); + } + + assertEq( + uint256(_governance.getProposalState(proposalId)), uint256(ProposalState.Executable), "proposal not executable" + ); + + _governance.execute(proposalId); + assertEq( + uint256(_governance.getProposalState(proposalId)), uint256(ProposalState.Executed), "proposal not executed" + ); + } + + function _assertPostState(V6UpgradePayload _payload, IRegistry _registry, IGSE _gse, Snapshot memory _before) + internal + view + { + address newRollup = address(_payload.ROLLUP()); + + assertEq(address(_registry.getCanonicalRollup()), newRollup, "new rollup is not canonical"); + assertEq(_registry.numberOfVersions(), _before.versions + 1, "version count did not grow by one"); + + // The bonus-instance attesters follow the canonical rollup by delegation rather than by + // re-depositing, so the new rollup should see at least everything the bonus bucket held. + assertGe( + _gse.getAttesterCountAtTime(newRollup, Timestamp.wrap(block.timestamp)), + _before.bonusAttesters, + "new rollup did not inherit the bonus instance attesters" + ); + + // Ownership must survive the upgrade: governance owns the rollup, or none of the owner-gated + // configuration (fee margin, fee recipient, queue config) can ever be changed again. + assertEq(Rollup(newRollup).owner(), _registry.getGovernance(), "rollup owner is not governance"); + + // The hatch is installed by the payload, not the deploy, so this is the only place the + // installation is proved before the real execution. + assertEq(address(Rollup(newRollup).getEscapeHatch()), _payload.ESCAPE_HATCH(), "escape hatch was not installed"); + + _assertRewardDistributor(_payload, _registry, _before); + _assertFlushRewarder(_payload, _before); + } + + /// @dev The distributor resolves the canonical rollup live, so no action moves its funds: the + /// implicit pool simply becomes claimable by the new rollup. Asserted against the captured + /// balances rather than any fixed amount. + function _assertRewardDistributor(V6UpgradePayload _payload, IRegistry _registry, Snapshot memory _before) + internal + view + { + address newRollup = address(_payload.ROLLUP()); + IRewardDistributor distributor = _registry.getRewardDistributor(); + IERC20 rewardAsset = IERC20(Rollup(newRollup).getFeeAsset()); + + assertEq(distributor.canonicalRollup(), newRollup, "distributor does not follow the new rollup"); + assertEq( + rewardAsset.balanceOf(address(distributor)), + _before.rewardDistributorBalance, + "distributor balance moved during the upgrade" + ); + assertGe( + distributor.availableTo(newRollup), + _before.rewardDistributorAvailableToNew, + "new rollup cannot claim what it could before" + ); + } + + /// @dev The replacement rewarder should gain exactly what was movable, and the outgoing one + /// should keep the remainder it owes to unclaimed flushers. + function _assertFlushRewarder(V6UpgradePayload _payload, Snapshot memory _before) internal view { + FlushRewarder oldFlush = _payload.OLD_FLUSH_REWARDER(); + if (address(oldFlush) == address(0)) { + return; + } + + IERC20 flushAsset = oldFlush.REWARD_ASSET(); + assertEq( + flushAsset.balanceOf(address(_payload.NEW_FLUSH_REWARDER())), + _before.newFlushBalance + _before.flushFundsToMove, + "new flush rewarder did not receive the migrated funds" + ); + assertEq( + flushAsset.balanceOf(address(oldFlush)), + _before.oldFlushBalance - _before.flushFundsToMove, + "old flush rewarder did not retain exactly its unclaimed remainder" + ); + } +} diff --git a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md new file mode 100644 index 000000000000..0caba7541603 --- /dev/null +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -0,0 +1,246 @@ +# v6 rollup upgrade runbook + +How to deploy the v6 rollup and get it made canonical. Covers `DeployRollupForUpgradeV6.s.sol`, +`V6UpgradePayload.sol` and `V6UpgradeSimulation.sol`. + +Reviewing the payload rather than running the upgrade? Start at +[`src/periphery/V6UpgradePayload.md`](../../src/periphery/V6UpgradePayload.md) — what it intends, +guarantees, and deliberately leaves alone. + +## What this does + +The deploy script deploys, in one broadcast: + +1. `HonkVerifier` — the real epoch proof verifier. +2. `Rollup` — owned by **governance** from construction. Deploying it also constructs its `Inbox`, + `Outbox`, `FeeJuicePortal`, `Slasher` + `SlashingProposer`, and a fresh `RewardBooster`. + **The owner argument is not only the owner:** it also becomes the Slasher's immutable + `GOVERNANCE`, which can execute any slash payload with no vote and no delay. Constructing with + the deploy key would hand that power to the key permanently — `transferOwnership` moves only the + `Ownable` half. This is why no owner-gated setup happens in the script. +3. `EscapeHatch` — built here, but **installed by the payload**, since `setEscapeHatch` is + `onlyOwner` and the owner is governance. +4. `V6UpgradePayload` — and, on a chain with a flush rewarder, a replacement `FlushRewarder` + deployed inside the payload's constructor. + +Nothing is canonical yet. The payload is what governance executes later; it performs: + +| # | Action | Why | +|---|---|---| +| 1 | `v6.setEscapeHatch(hatch)` | installs the hatch; `onlyOwner`, so only governance can | +| 2 | `Registry.addRollup(v6)` | makes v6 the canonical rollup | +| 3 | `GSE.addRollup(v6)` | lets existing attesters follow without redepositing | +| 4 | `oldFlushRewarder.recover(asset, newRewarder, rewardsAvailable())` | carries the entry-queue flush incentive across (skipped if there is no old rewarder) | + +## 1. Build + +The script needs two generated artifacts that are not in git: + +- `src/core/libraries/ConstantsGen.sol` — from `./scripts/remake-constants.sh` +- `generated/HonkVerifier.sol` — copied by `l1-contracts/bootstrap.sh build_verifier` from + `noir-projects/fnd/noir-protocol-circuits/target/keys/rollup_root_verifier.sol`, so + **noir-projects must have been bootstrapped first** + +From the repo root: + +```bash +make l1-contracts # or: cd l1-contracts && ./bootstrap.sh +``` + +This also fetches the pinned `solc-0.8.30` that `foundry.toml` points at. Then confirm: + +```bash +cd l1-contracts && forge build --skip test +``` + +> Do **not** hand-write a stub `generated/HonkVerifier.sol`. A stub compiles and deploys a verifier +> that accepts every proof. + +## 2. Fill in the inputs + +All configuration is the `_config()` literal in `DeployRollupForUpgradeV6.s.sol`. Before a real +deploy these must be set: + +| Field | Status | Notes | +|---|---|---| +| `vkTreeRoot` | **TODO — zero** | from the v6 protocol circuits build | +| `protocolContractsHash` | **TODO — zero** | same | +| `genesisArchiveRoot` | **TODO — zero** | same; must be below the BN254 scalar field modulus | +| `initialEthPerFeeAsset` | **TODO — stale** | E12 ETH-per-fee-asset price; refresh at deploy time | +| `oldFlushRewarder` | set (mainnet) | `0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e` | + +`run()` refuses to proceed while any of the three genesis roots is zero. The other fields have no +such guard — `initialEthPerFeeAsset` will deploy silently at whatever value is in the table. + +The three genesis values are produced by the protocol circuits / node build, not by anything in +`l1-contracts`. Get them from the same source the v6 release uses; do not carry v5's forward. + +## 3. Pre-flight checks + +Read the chain and confirm the assumptions the config is built on. Mainnet registry: +`0x35b22e09Ee0390539439E24f06Da43D83f90e298`. + +```bash +export RPC= +REG=0x35b22e09Ee0390539439E24f06Da43D83f90e298 +ROLLUP=$(cast call $REG "getCanonicalRollup()(address)" --rpc-url $RPC) +GSE=$(cast call $ROLLUP "getGSE()(address)" --rpc-url $RPC) +RD=$(cast call $REG "getRewardDistributor()(address)" --rpc-url $RPC) +BONUS=$(cast call $GSE "BONUS_INSTANCE_ADDRESS()(address)" --rpc-url $RPC) + +cast call $GSE "ACTIVATION_THRESHOLD()(uint256)" --rpc-url $RPC # expect 200_000e18 +cast call $GSE "EJECTION_THRESHOLD()(uint256)" --rpc-url $RPC # expect 100_000e18 +cast call $ROLLUP "getActiveAttesterCount()(uint256)" --rpc-url $RPC +cast call $ROLLUP "getIsBootstrapped()(bool)" --rpc-url $RPC + +# must be 0, or funds earmarked to the outgoing rollup strand when it stops being canonical +cast call $RD "totalEarmarkedBalance()(uint256)" --rpc-url $RPC +cast call $RD "specificRecipientBalance(address)(uint256)" $ROLLUP --rpc-url $RPC + +# how much the payload will actually move (balance minus unclaimed debt) +cast call 0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e "rewardsAvailable()(uint256)" --rpc-url $RPC +``` + +Baseline measured 2026-09-15 (block 25980374), for comparison rather than as expected constants: +attesters 3187, all in the bonus instance; `isBootstrapped` true; distributor holds ~98.98M with +`totalEarmarkedBalance` 0; flush rewarder holds 390,000e18 of which 378,900e18 is movable. + +`totalEarmarkedBalance` is the one to re-check immediately before the proposal executes — +`subsidizeAddress` is permissionless, so anyone can earmark funds to the outgoing rollup after +this check and strand them. + +## 4. Dry run + +Run without `--broadcast` first. This executes the whole script including `verify`, +`verifyFlushRewarder` and the governance simulation, against forked state, changing nothing: + +```bash +cd l1-contracts +REGISTRY_ADDRESS=0x35b22e09Ee0390539439E24f06Da43D83f90e298 \ +forge script script/deploy/DeployRollupForUpgradeV6.s.sol:DeployRollupForUpgradeV6 \ + --rpc-url $RPC -vvv +``` + +`V6UpgradeSimulation` wraps the payload in a `GSEPayload` exactly as `GovernanceProposer` does, +funds a simulation-only voter with a majority of power, votes, warps past the delays, executes, +asserts the post-state, then reverts the snapshot. It proves the **actions execute correctly**; it +does not predict whether a real proposal would pass. + +A green dry run means the config is self-consistent and the payload works. It does not check the +genesis roots are the right ones — nothing on chain can. + +## 5. Deploy + +```bash +REGISTRY_ADDRESS=0x35b22e09Ee0390539439E24f06Da43D83f90e298 \ +forge script script/deploy/DeployRollupForUpgradeV6.s.sol:DeployRollupForUpgradeV6 \ + --rpc-url $RPC --private-key $KEY --broadcast --verify -vvv +``` + +Record the logged addresses: `rollup`, `verifier`, `inbox`, `outbox`, `feeJuicePortal`, `slasher`, +`rewardBooster`, `escapeHatch`, `payload`, `newFlushRewarder`, `version`. + +Re-run the checks against the deployed contracts at any time: + +```bash +forge script ... --sig 'verify(address)' +forge script ... --sig 'verifyFlushRewarder(address,address)' +forge script script/deploy/V6UpgradeSimulation.sol:V6UpgradeSimulation \ + --sig 'simulate(address)' --rpc-url $RPC +``` + +Sanity-check by hand that the rollup is inert and correctly owned: + +```bash +cast call "owner()(address)" --rpc-url $RPC # governance +cast call "getEscapeHatch()(address)" --rpc-url $RPC # ZERO until the payload executes +cast call "ESCAPE_HATCH()(address)" --rpc-url $RPC # the hatch it will install +cast call "owner()(address)" --rpc-url $RPC # governance, from construction +cast call "getVersion()(uint256)" --rpc-url $RPC # not already in the registry + +# the payload is bound to the rollup it succeeds; must equal the OUTGOING rollup, not the new one +cast call "PREDECESSOR()(address)" --rpc-url $RPC +cast call $REG "getCanonicalRollup()(address)" --rpc-url $RPC +``` + +## 6. Governance proposal + +Propose the **payload address**, not the rollup. `GovernanceProposer` wraps it in a `GSEPayload` +automatically. After proposing, read the exact timings off the proposal rather than assuming: + +```bash +cast call "getProposal(uint256)" --rpc-url $RPC +``` + +On mainnet the configured delays are long (voting delay, then voting duration, then execution +delay — on the order of weeks in total), so expect the proposal to sit before it is executable. + +Before execution, re-run the `totalEarmarkedBalance` and `rewardsAvailable` checks from step 3 — +both can move while the proposal is pending, and `rewardsAvailable` is read at execution time. + +### Before signalling or voting, check the payload is still live + +The payload only authorises a transition **from** the rollup that was canonical when it was +deployed, and it refuses to execute once that is no longer true. Both halves are readable on-chain, +and both should be checked before anything is committed to: + +```bash +# (a) the guard is actually in the action list — expect assertPredecessorIsCanonical first +cast call "getActions()((address,bytes)[])" --rpc-url $RPC + +# (b) the rollup it is bound to is still canonical — these two must be equal +cast call "PREDECESSOR()(address)" --rpc-url $RPC +cast call $REG "getCanonicalRollup()(address)" --rpc-url $RPC +``` + +A registration payload without that guard is the hazard the guard exists to remove: registration is +append-only with last-write-wins and `execute` is permissionless, so an accepted-but-abandoned +payload can be executed by anyone later and demote whatever replaced it — permanently, since +neither the registry nor the GSE re-admits a rollup it already holds. + +**The consequence, which is intended but worth stating.** Once *any* other registration executes, +this payload is dead: it can never execute, even if its proposal was accepted and is still inside +its grace period. Registering v6 then requires deploying a fresh payload — which picks up the new +canonical rollup as its `PREDECESSOR` — and taking it through the full governance cycle again. So a +patched replacement for a bad v6 is not a quick swap; budget the whole cycle for it. + +## 7. After execution + +```bash +cast call $REG "getCanonicalRollup()(address)" --rpc-url $RPC # the new rollup +cast call $RD "canonicalRollup()(address)" --rpc-url $RPC # follows automatically +cast call "getActiveAttesterCount()(uint256)" --rpc-url $RPC # inherits the bonus bucket +cast call $ROLLUP "getActiveAttesterCount()(uint256)" --rpc-url $RPC # outgoing rollup, expect 0 +cast call "rewardsAvailable()(uint256)" --rpc-url $RPC +``` + +The outgoing rollup dropping to **exactly 0** is expected, not a fault: every mainnet attester is +registered against the bonus instance (`moveWithLatestRollup = true`), and the bonus instance is +only visible to whichever rollup is currently canonical. + +## Deliberately not done + +- **Protocol fee recipient and margin.** The rollup launches with `protocolFeeMarginBps = 0` and + `protocolFeeRecipient` set to a placeholder address. Both are `onlyOwner`, so they now require a + governance proposal. **Set the recipient before, or in the same payload as, any non-zero + margin** — otherwise the protocol fee tranche is transferred to an unrecoverable address on + every claim. The first margin increase is exempt from the 30-day cooldown but capped at + 5000 bps by the ×3/2 step on the fee multiplier. +- **Reward distributor migration.** Not needed: the distributor resolves the canonical rollup live + off the registry, so its implicit pool follows v6 the moment action 1 executes. This holds only + while `totalEarmarkedBalance` is 0. +- **Registry address pinning.** `REGISTRY_ADDRESS` is an unvalidated env input. Everything else — + fee asset, staking asset, GSE, governance, reward distributor — is derived from it, so a wrong + registry silently changes all of them together. Double-check it on the command line. + +## Do not renounce ownership of the outgoing rollup + +Retired rollups have had ownership renounced in the past. Two things break if that is done here: + +- the outgoing `FlushRewarder` keeps its unclaimed `debt` and must stay callable for + `claimRewards()`; +- `updateStakingQueueConfig` is the only way to recover a rollup whose entry queue is wedged, and + it is `onlyOwner`. + +Mainnet's outgoing rollup is already `isBootstrapped = true`, so its queue still flushes at +1 validator/epoch from zero and it remains restartable. Renouncing ownership removes the fallback. diff --git a/l1-contracts/src/periphery/V6UpgradePayload.md b/l1-contracts/src/periphery/V6UpgradePayload.md new file mode 100644 index 000000000000..4166bd2b6713 --- /dev/null +++ b/l1-contracts/src/periphery/V6UpgradePayload.md @@ -0,0 +1,90 @@ +# V6UpgradePayload — what governance is being asked to approve + +A single-use payload that makes the v6 rollup canonical, and nothing else. Deployed by +`script/deploy/DeployRollupForUpgradeV6.s.sol`; executed once, by governance, weeks later. + +This is the reviewer's map. The contract's NatSpec carries the detail — this says what the payload +intends, what it guarantees, and what it deliberately leaves alone. + +## The actions, in order + +Governance runs each as `target.call(data)` from its own address, in this order, in one transaction. + +| # | Action | Present when | Why | +|---|---|---|---| +| 1 | `this.assertPredecessorIsCanonical()` | always | this payload only authorises a transition **from** the rollup that was canonical when it was deployed | +| 2 | `this.assertWithinExecutionWindow()` | `ENFORCE_EXECUTION_WINDOW` | mainnet only: UK weekdays 08:00–17:00 London, so an upgrade is not executed unattended | +| 3 | `v6.setEscapeHatch(ESCAPE_HATCH)` | always | installs the escape hatch, before v6 is canonical | +| 4 | `Registry.addRollup(v6)` | always | makes v6 canonical | +| 5 | `GSE.addRollup(v6)` | always | existing attesters follow without withdrawing and redepositing | +| 6 | `oldFlushRewarder.recover(asset, newRewarder, rewardsAvailable())` | a rewarder exists | carries the entry-queue flush incentive to the replacement | + +Four to six actions depending on chain. Sepolia has neither the window nor a rewarder. + +## What it guarantees + +- **Only the approved transition.** `PREDECESSOR` is read from the registry in the constructor and + compared at execution. If any other registration lands first, this payload can never execute. +- **All or nothing.** `Governance.execute` requires success per action inside one transaction, so a + failed check leaves *nothing* behind — not a registration, not a moved GSE, not a moved balance. + This holds regardless of where in the list the failure happens. +- **It stays readable when it is dead.** Both checks are actions rather than reverts inside + `getActions()`, so explorers, `GSEPayload.amIValid` and the deploy simulation can still read what + the payload would do after it can no longer do it. +- **The deploy key holds no power over v6, at any point.** The rollup is constructed owned by + governance rather than transferred afterwards. That matters beyond ownership: the same + constructor argument becomes the Slasher's immutable `GOVERNANCE`, which can slash any attester + with no vote and no delay, and `transferOwnership` cannot move it. Constructing with governance + is what keeps it out of the deploy key's hands — at the cost of the escape hatch having to be + installed here, by governance, rather than during the deploy. + +## What it deliberately does not do + +- **No reward-distributor migration.** The distributor resolves the canonical rollup live off the + registry, so its implicit pool follows v6 the moment action 3 executes. +- **No protocol fee margin or recipient.** The rollup launches with margin `0` and a placeholder + recipient. Both are `onlyOwner` and now need their own proposal. **Set the recipient before, or + in the same payload as, any non-zero margin**, or the fee tranche goes to an unrecoverable + address on every claim. +- **It does not retire the outgoing rollup.** Its ownership must NOT be renounced: the old + `FlushRewarder` keeps unclaimed debt and must stay callable, and `updateStakingQueueConfig` is the + only way to recover a wedged entry queue. + +## What to check before signalling or voting + +```bash +cast call "PREDECESSOR()(address)" # must equal the CURRENT canonical rollup +cast call $REG "getCanonicalRollup()(address)" +cast call "getActions()((address,bytes)[])" # guard must be present, and first +cast call "owner()(address)" # governance, from construction +cast call "GOVERNANCE()(address)" # governance -- NOT the deploy key +``` + +A registration payload without that guard is the hazard the guard exists to remove. + +## Known limits, stated rather than discovered + +- **Once any other registration executes, this payload is dead.** Intended — voters re-approve + "v6 succeeds X" explicitly rather than letting execution order decide — but it means a patched + replacement needs a fresh deploy and a full governance cycle, not a quick swap. +- **`totalEarmarkedBalance` is not enforced on-chain.** A non-zero earmark at execution shrinks the + implicit pool v6 inherits. It is recoverable by governance via `recoverFrom`, and it is *not* + enforced here on purpose: `subsidizeAddress` is permissionless, so a 1-wei call from anyone would + otherwise block the upgrade indefinitely. Check it before execution; treat it as an accounting + surprise, not a lost-funds event. +- **The execution window hardcodes the UK DST rule.** Derived from the rule rather than tabulated, + so it has no expiry — but it would be wrong if the rule itself changed. +- **`REGISTRY_ADDRESS` is an unvalidated deploy input.** Everything else is derived from it, so a + wrong registry changes all of them together and silently. + +## Where things are + +| | | +|---|---| +| Contract | `src/periphery/V6UpgradePayload.sol` | +| Unit tests — actions, constructor, calendar | `test/periphery/V6UpgradePayload.t.sol` | +| Atomicity through real governance | `test/governance/scenario/V6UpgradeAtomicity.t.sol` | +| Deploy script and its config table | `script/deploy/DeployRollupForUpgradeV6.s.sol` | +| Config table deployed on both chains | `test/script/DeployRollupForUpgradeV6.t.sol` | +| Fork simulation of the full lifecycle | `script/deploy/V6UpgradeSimulation.sol` | +| How to run the upgrade | `script/deploy/V6_UPGRADE_RUNBOOK.md` | diff --git a/l1-contracts/src/periphery/V6UpgradePayload.sol b/l1-contracts/src/periphery/V6UpgradePayload.sol new file mode 100644 index 000000000000..3a9fc90d2e22 --- /dev/null +++ b/l1-contracts/src/periphery/V6UpgradePayload.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; +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"; +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; + +/** + * @title V6UpgradePayload + * @author Aztec Labs + * @notice The payload governance executes to make the v6 rollup canonical. + * @dev Governance executes each action as `target.call(data)` from its own address, in order, in a + * single transaction that reverts as a whole if any action fails. Every target below is + * `onlyOwner` and governance owns all of them, which is what makes this payload the only way + * to perform these steps. + * + * Extend `getActions` to bundle further governance-gated calls (for example + * `setProtocolFeeRecipient` / `setProtocolFeeMargin`, which cannot be set any other way once + * the deploy script has handed rollup ownership to governance). + */ +contract V6UpgradePayload is IPayload { + /// @notice London-local hour the window opens, inclusive. + uint256 public constant WINDOW_OPEN_HOUR = 8; + + /// @notice London-local hour the window closes, exclusive. + uint256 public constant WINDOW_CLOSE_HOUR = 17; + + /// @notice The registry that resolves the canonical rollup for the protocol. + IRegistry public immutable REGISTRY; + + /// @notice The rollup being made canonical. + IInstance public immutable ROLLUP; + + /// @notice The canonical rollup at deployment -- the one {ROLLUP} is built to succeed. + /// @dev Public so it can be checked before signalling: a payload whose PREDECESSOR is no longer + /// canonical can never execute, and that is readable on-chain before any vote is committed. + address public immutable PREDECESSOR; + + /// @notice The escape hatch this payload installs on {ROLLUP}. + /// @dev Installed HERE rather than during the deploy. `setEscapeHatch` is `onlyOwner`, and the + /// rollup is constructed owned by governance -- because that same constructor argument also + /// becomes the Slasher's immutable GOVERNANCE, which can slash any attester with no vote. + /// Constructing with the deployer to do owner-gated setup would hand that power to the + /// deploy key permanently, and no later `transferOwnership` could take it back. + address public immutable ESCAPE_HATCH; + + /// @notice The flush rewarder serving the outgoing rollup, or zero on a chain that has none. + FlushRewarder public immutable OLD_FLUSH_REWARDER; + + /// @notice The replacement flush rewarder, deployed by this constructor and bound to {ROLLUP}. + /// Zero when {OLD_FLUSH_REWARDER} is zero. + FlushRewarder public immutable NEW_FLUSH_REWARDER; + + /// @notice Whether execution is restricted to UK office hours. Set per chain by the deploy script. + bool public immutable ENFORCE_EXECUTION_WINDOW; + + /// @notice Thrown when the supplied flush rewarder serves a rollup other than the outgoing one. + error V6UpgradePayload__FlushRewarderRollupMismatch(address served, address outgoing); + + /// @notice Thrown when governance executes outside the permitted window. + error V6UpgradePayload__OutsideExecutionWindow(uint256 timestamp); + + /// @notice Thrown when the rollup this payload was built to succeed is no longer canonical. + error V6UpgradePayload__PredecessorNotCanonical(address expected, address actual); + + /// @notice Thrown when the supplied escape hatch was built for a different rollup. + error V6UpgradePayload__EscapeHatchRollupMismatch(address served, address expected); + + /** + * @notice Binds the payload to the rollup it will make canonical, and deploys the replacement + * flush rewarder when there is an outgoing one to migrate. + * @param _registry The registry to register the rollup in + * @param _rollup The newly deployed rollup + * @param _escapeHatch The escape hatch built for {_rollup}, installed by this payload + * @param _oldFlushRewarder The flush rewarder serving the outgoing rollup, or zero to skip the + * flush-incentive migration entirely + * @param _enforceExecutionWindow Whether to restrict execution to UK office hours + */ + constructor( + IRegistry _registry, + IInstance _rollup, + IEscapeHatch _escapeHatch, + FlushRewarder _oldFlushRewarder, + bool _enforceExecutionWindow + ) { + REGISTRY = _registry; + ROLLUP = _rollup; + + // The hatch names the rollup it was built for, and `setEscapeHatch` is one-shot, so a hatch + // bound elsewhere would burn the only chance to install one. + address hatchRollup = _escapeHatch.getRollup(); + require(hatchRollup == address(_rollup), V6UpgradePayload__EscapeHatchRollupMismatch(hatchRollup, address(_rollup))); + ESCAPE_HATCH = address(_escapeHatch); + OLD_FLUSH_REWARDER = _oldFlushRewarder; + ENFORCE_EXECUTION_WINDOW = _enforceExecutionWindow; + + // Read ONCE and bound as an immutable, so the rewarder check below and the execution-time + // guard are talking about the same rollup by construction. Unconditional, so this reverts + // with `Registry__NoRollupsRegistered` against an empty registry -- this payload succeeds a + // rollup and cannot be used for a first registration. + address outgoingRollup = address(_registry.getCanonicalRollup()); + PREDECESSOR = outgoingRollup; + + // Nothing on the rollup points back at its flush rewarder -- a FlushRewarder is a + // permissionless wrapper around the permissionless `flushEntryQueue`, so a rollup may have + // any number of them and knows about none. The address therefore has to be supplied, but it + // can be checked: the rewarder names the rollup it serves, which must be the one being + // replaced. This rejects a rewarder for a foreign or already-retired rollup. + if (address(_oldFlushRewarder) != address(0)) { + address servedRollup = address(_oldFlushRewarder.ROLLUP()); + require( + servedRollup == outgoingRollup, V6UpgradePayload__FlushRewarderRollupMismatch(servedRollup, outgoingRollup) + ); + } + + // A FlushRewarder's `ROLLUP` is immutable, so the incentive cannot follow the upgrade: the + // replacement has to be a new contract. Asset and rate are mirrored off the outgoing one so + // the incentive carries over unchanged, and governance owns it so it stays adjustable. + NEW_FLUSH_REWARDER = address(_oldFlushRewarder) == address(0) + ? FlushRewarder(address(0)) + : new FlushRewarder( + _registry.getGovernance(), _rollup, _oldFlushRewarder.REWARD_ASSET(), _oldFlushRewarder.rewardPerInsertion() + ); + } + + /// @inheritdoc IPayload + function getActions() external view override(IPayload) returns (IPayload.Action[] memory) { + bool migrateFlushRewarder = address(OLD_FLUSH_REWARDER) != address(0); + uint256 next = 0; + + IPayload.Action[] memory res = + new IPayload.Action[](2 + (ENFORCE_EXECUTION_WINDOW ? 1 : 0) + (migrateFlushRewarder ? 3 : 2)); + + // FIRST, before the window and before anything is written: this payload only authorises a + // transition FROM the rollup that was canonical when it was deployed. + // + // Registration is append-only with last-write-wins, and `execute` is permissionless, so two + // accepted registrations are a hazard: if this one is abandoned and a replacement executes + // first, anyone could later execute this one and demote the replacement -- permanently, since + // neither registry re-admits a rollup it already holds. Binding the predecessor makes a stale + // payload inert instead: it reverts, the proposal stays Executable until it expires, and + // nothing moves. + // + // Ahead of the window check deliberately. Both are non-mutating so the order is free, and of + // the two failures "this payload must never run" is the one worth surfacing over "come back + // on Monday". + res[next++] = + Action({target: address(this), data: abi.encodeWithSelector(this.assertPredecessorIsCanonical.selector)}); + + // The window is enforced as its own action rather than inside `getActions` so that reading the + // proposal stays possible at any time -- explorers, `GSEPayload.amIValid` and the deploy + // script's simulation all call `getActions`, and a revert here would break them out of hours. + // The same is true of the guard above, which is why it is an action too: a stale payload must + // still be READABLE, so that what it would do stays inspectable after it can no longer do it. + // Governance reverts the whole execution on any failed action and rolls back the Executed flag + // with it, so a rejected attempt leaves the proposal executable again once the window opens. + if (ENFORCE_EXECUTION_WINDOW) { + res[next++] = + Action({target: address(this), data: abi.encodeWithSelector(this.assertWithinExecutionWindow.selector)}); + } + + // Installs the escape hatch, BEFORE the rollup becomes canonical. `setEscapeHatch` is + // `onlyOwner` and one-shot, and governance has owned this rollup since construction -- see + // {ESCAPE_HATCH} for why it is not owned by the deployer even briefly. + res[next++] = Action({ + target: address(ROLLUP), + data: abi.encodeWithSelector(IValidatorSelectionCore.setEscapeHatch.selector, ESCAPE_HATCH) + }); + + // Registers the rollup under its own version, making it the canonical rollup. Reverts if that + // version is already registered. + res[next++] = + Action({target: address(REGISTRY), data: abi.encodeWithSelector(IRegistry.addRollup.selector, address(ROLLUP))}); + + // Lets the GSE recognise the rollup as a valid instance, so existing attesters follow the + // upgrade without withdrawing and redepositing their stake. + res[next++] = Action({ + target: address(ROLLUP.getGSE()), data: abi.encodeWithSelector(IGSECore.addRollup.selector, address(ROLLUP)) + }); + + if (migrateFlushRewarder) { + // Moves the outgoing rewarder's unowed balance to the replacement. `recover` caps the + // reward asset at `rewardsAvailable()` (balance minus rewards already accrued to flushers + // who have not claimed), so the outgoing rewarder deliberately keeps that remainder and + // must stay callable for `claimRewards`. Read at execution time, not at deploy time. + res[next++] = Action({ + target: address(OLD_FLUSH_REWARDER), + data: abi.encodeWithSelector( + FlushRewarder.recover.selector, + address(NEW_FLUSH_REWARDER.REWARD_ASSET()), + address(NEW_FLUSH_REWARDER), + OLD_FLUSH_REWARDER.rewardsAvailable() + ) + }); + } + + return res; + } + + /// @notice Reverts unless {PREDECESSOR} is still the canonical rollup. + /// @dev Targeted by the first action. Voters can also call it directly before signalling to + /// confirm the payload is still live. + // solhint-disable-next-line comprehensive-interface + function assertPredecessorIsCanonical() external view { + address canonical = address(REGISTRY.getCanonicalRollup()); + require(canonical == PREDECESSOR, V6UpgradePayload__PredecessorNotCanonical(PREDECESSOR, canonical)); + } + + /// @notice Reverts unless the current time is inside the permitted execution window. + /// @dev Targeted by an action, after the predecessor guard, when {ENFORCE_EXECUTION_WINDOW} is set. + // solhint-disable-next-line comprehensive-interface + function assertWithinExecutionWindow() external view { + require(isWithinExecutionWindow(block.timestamp), V6UpgradePayload__OutsideExecutionWindow(block.timestamp)); + } + + /// @inheritdoc IPayload + function getURI() external pure override(IPayload) returns (string memory) { + return "V6UpgradePayload"; + } + + /// @notice Whether `_timestamp` falls on a UK weekday between 08:00 (inclusive) and 17:00 + /// (exclusive) London time. + /// @dev Public so the window can be queried before proposing or executing. + // solhint-disable-next-line comprehensive-interface + function isWithinExecutionWindow(uint256 _timestamp) public pure returns (bool) { + uint256 london = _timestamp + (_isBritishSummerTime(_timestamp) ? 1 hours : 0); + + // 1970-01-01 was a Thursday, so adding 4 puts Sunday at 0 and Saturday at 6. + uint256 weekday = (london / 1 days + 4) % 7; + if (weekday == 0 || weekday == 6) { + return false; + } + + uint256 hourOfDay = (london % 1 days) / 1 hours; + return hourOfDay >= WINDOW_OPEN_HOUR && hourOfDay < WINDOW_CLOSE_HOUR; + } + + /// @dev British Summer Time runs from 01:00 UTC on the last Sunday of March to 01:00 UTC on the + /// last Sunday of October. Derived rather than tabulated so the payload has no expiry date. + function _isBritishSummerTime(uint256 _timestamp) private pure returns (bool) { + uint256 year = _yearFromDays(_timestamp / 1 days); + uint256 start = _lastSundayOfMonth(year, 3) * 1 days + 1 hours; + uint256 end = _lastSundayOfMonth(year, 10) * 1 days + 1 hours; + return _timestamp >= start && _timestamp < end; + } + + /// @dev Days since the epoch of the last Sunday in `_month` of `_year`. Only used for March and + /// October, which both have 31 days, so the month length needs no lookup. + function _lastSundayOfMonth(uint256 _year, uint256 _month) private pure returns (uint256) { + uint256 lastDay = _daysFromCivil(_year, _month, 31); + return lastDay - ((lastDay + 4) % 7); + } + + /// @dev Days since 1970-01-01 for a proleptic Gregorian date. Howard Hinnant's `days_from_civil`, + /// valid for every date this contract can see. + function _daysFromCivil(uint256 _year, uint256 _month, uint256 _day) private pure returns (uint256) { + uint256 y = _year - (_month <= 2 ? 1 : 0); + uint256 era = y / 400; + uint256 yoe = y - era * 400; + uint256 doy = (153 * (_month > 2 ? _month - 3 : _month + 9) + 2) / 5 + _day - 1; + uint256 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146_097 + doe - 719_468; + } + + /// @dev Calendar year containing `_days` days since 1970-01-01. The inverse of {_daysFromCivil}, + /// keeping only the year. + function _yearFromDays(uint256 _days) private pure returns (uint256) { + uint256 z = _days + 719_468; + uint256 era = z / 146_097; + uint256 doe = z - era * 146_097; + uint256 yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + uint256 doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + uint256 mp = (5 * doy + 2) / 153; + return (mp < 10 ? mp + 3 : mp - 9) <= 2 ? yoe + era * 400 + 1 : yoe + era * 400; + } +} diff --git a/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol b/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol new file mode 100644 index 000000000000..f1b658ce2af7 --- /dev/null +++ b/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable +pragma solidity >=0.8.27; + +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Governance} from "@aztec/governance/Governance.sol"; +import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol"; +import {GSEPayload} from "@aztec/governance/GSEPayload.sol"; +import {IGSE, GSE} from "@aztec/governance/GSE.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {IRegistry, IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {Proposal, ProposalState} from "@aztec/governance/interfaces/IGovernance.sol"; +import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {V6UpgradePayload} from "@aztec/periphery/V6UpgradePayload.sol"; +import {Timestamp, Slot} from "@aztec/core/libraries/TimeLib.sol"; +import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; +import {BN254Lib} from "@aztec/shared/libraries/BN254Lib.sol"; +import {MultiAdder, CheatDepositArgs} from "@aztec/mock/MultiAdder.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {TestBase} from "@test/base/Base.sol"; +import {TestConstants} from "../../harnesses/TestConstants.sol"; +import {RollupBuilder} from "../../builder/RollupBuilder.sol"; +import {UncompressedProposalWrapper} from "@test/governance/helpers/UncompressedProposalTestLib.sol"; + +/// @dev The payload only reads `getRollup()` off the hatch it installs. +contract StubEscapeHatch { + address internal immutable ROLLUP_; + + constructor(address _rollup) { + ROLLUP_ = _rollup; + } + + function getRollup() external view returns (address) { + return ROLLUP_; + } +} + +/// @dev Stands in for a rollup being registered: a version to key on, a GSE to point at. +contract StandInRollup { + IGSE public immutable GSE_; + + constructor(IGSE _gse) { + GSE_ = _gse; + } + + function getVersion() external view returns (uint256) { + return uint256(keccak256(abi.encodePacked(bytes("aztec_rollup"), block.chainid, address(this)))); + } + + function getGSE() external view returns (GSE) { + return GSE(address(GSE_)); + } +} + +/** + * @dev Two actions where the FIRST succeeds and the SECOND reverts, so the test can ask what + * `Governance.execute` leaves behind. Shaped like the real payload -- `Registry.addRollup` + * then something else -- because that is the ordering whose rollback actually matters. + */ +contract RegisterThenFailPayload is IPayload { + IRegistry public immutable REGISTRY; + address public immutable ROLLUP; + + error DeliberateFailure(); + + constructor(IRegistry _registry, address _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, ROLLUP)}); + res[1] = Action({target: address(this), data: abi.encodeWithSelector(this.alwaysReverts.selector)}); + return res; + } + + function alwaysReverts() external pure { + revert DeliberateFailure(); + } + + function getURI() external pure override(IPayload) returns (string memory) { + return "RegisterThenFailPayload"; + } +} + +contract V6UpgradeAtomicityTest is TestBase { + UncompressedProposalWrapper internal upw = new UncompressedProposalWrapper(); + + TestERC20 internal token; + Registry internal registry; + Governance internal governance; + GovernanceProposer internal governanceProposer; + Rollup internal rollup; + IGSE internal gse; + + address internal constant EMPEROR = address(uint160(bytes20("EMPEROR"))); + uint256 internal constant VALIDATOR_COUNT = 4; + uint256 internal constant REWARD_PER_INSERTION = 1000e18; + uint256 internal constant REWARDER_BALANCE = 390_000e18; + + function setUp() external { + StakingQueueConfig memory stakingQueueConfig = TestConstants.getStakingQueueConfig(); + stakingQueueConfig.normalFlushSizeMin = VALIDATOR_COUNT * 2; + + vm.warp(100_000); + RollupBuilder builder = new RollupBuilder(address(this)) + .setGovProposerN(7) + .setGovProposerM(10) + .setStakingQueueConfig(stakingQueueConfig) + .setTargetCommitteeSize(0); + builder.deploy(); + + rollup = builder.getConfig().rollup; + registry = builder.getConfig().registry; + token = builder.getConfig().testERC20; + governance = builder.getConfig().governance; + governanceProposer = GovernanceProposer(governance.governanceProposer()); + gse = IGSE(address(rollup.getGSE())); + + CheatDepositArgs[] memory initialValidators = new CheatDepositArgs[](VALIDATOR_COUNT); + for (uint256 i = 1; i <= VALIDATOR_COUNT; i++) { + address validator = vm.addr(uint256(keccak256(abi.encode("validator", i)))); + initialValidators[i - 1] = CheatDepositArgs({ + attester: validator, + withdrawer: validator, + publicKeyInG1: BN254Lib.g1Zero(), + publicKeyInG2: BN254Lib.g2Zero(), + proofOfPossession: BN254Lib.g1Zero() + }); + } + MultiAdder multiAdder = new MultiAdder(address(rollup), address(this)); + uint256 activationThreshold = rollup.getActivationThreshold(); + vm.prank(token.owner()); + token.mint(address(multiAdder), activationThreshold * VALIDATOR_COUNT); + multiAdder.addValidators(initialValidators); + } + + /** + * @dev THE QUESTION THIS FILE EXISTS FOR: when the predecessor guard fails, is anything left + * behind? Every later action's effect is asserted absent, not just the first one. + */ + function test_StalePayloadExecutesNothingAtAll() public { + FlushRewarder oldRewarder = _deployRewarder(); + V6UpgradePayload payload = _deployV6Payload(oldRewarder); + address newRollup = address(payload.ROLLUP()); + + registry.transferOwnership(address(governance)); + uint256 proposalId = _proposeAndQueue(IPayload(address(payload))); + + // Another registration lands while this proposal sat in its delay -- the abandoned-payload + // scenario. Pranked as governance, the registry's owner, rather than run through a second + // proposal round: how canonical moved is irrelevant to the guard, only that it moved. It has + // to happen AFTER the proposal is queued, because signalling resolves proposers off whatever + // rollup is canonical at the time. + StandInRollup other = new StandInRollup(gse); + vm.prank(address(governance)); + registry.addRollup(IHaveVersion(address(other))); + + // Snapshot everything the payload would touch if it ran at all. + address canonicalBefore = address(registry.getCanonicalRollup()); + address gseLatestBefore = gse.getLatestRollup(); + uint256 versionsBefore = registry.numberOfVersions(); + uint256 oldRewarderBefore = token.balanceOf(address(oldRewarder)); + uint256 newRewarderBefore = token.balanceOf(address(payload.NEW_FLUSH_REWARDER())); + + // The failing action is the payload's own self-targeted guard, so that is the target + // `Governance` names when it refuses the whole execution. + vm.expectRevert(abi.encodeWithSelector(Errors.Governance__CallFailed.selector, address(payload))); + governance.execute(proposalId); + + // Action 1 (Registry.addRollup) did not run. + assertEq(address(registry.getCanonicalRollup()), canonicalBefore, "canonical rollup moved"); + assertEq(registry.numberOfVersions(), versionsBefore, "a version was registered"); + assertNotEq(address(registry.getCanonicalRollup()), newRollup, "v6 became canonical"); + // Action 2 (GSE.addRollup) did not run. + assertEq(gse.getLatestRollup(), gseLatestBefore, "GSE latest moved"); + // Action 3 (FlushRewarder.recover) did not run. + assertEq(token.balanceOf(address(oldRewarder)), oldRewarderBefore, "old rewarder was drained"); + assertEq(token.balanceOf(address(payload.NEW_FLUSH_REWARDER())), newRewarderBefore, "new rewarder was funded"); + + // And the proposal was not consumed: the Executed flag is rolled back with everything else, so + // a rejected attempt leaves it executable until it expires on its own. + assertTrue(governance.getProposalState(proposalId) == ProposalState.Executable, "proposal was consumed"); + } + + /** + * @dev The general property, independent of where the guard sits: a failure in a LATER action + * rolls back an earlier one that already succeeded. `Governance.execute` requires success + * per action inside one transaction, so a partial execution is not representable. + */ + function test_AFailureAfterAddRollupRollsBackTheRegistration() public { + StandInRollup target = new StandInRollup(gse); + RegisterThenFailPayload payload = new RegisterThenFailPayload(IRegistry(address(registry)), address(target)); + registry.transferOwnership(address(governance)); + + uint256 proposalId = _proposeAndQueue(IPayload(address(payload))); + + address canonicalBefore = address(registry.getCanonicalRollup()); + uint256 versionsBefore = registry.numberOfVersions(); + + vm.expectRevert(abi.encodeWithSelector(Errors.Governance__CallFailed.selector, address(payload))); + governance.execute(proposalId); + + assertEq(address(registry.getCanonicalRollup()), canonicalBefore, "addRollup survived a later failure"); + assertEq(registry.numberOfVersions(), versionsBefore, "a version survived a later failure"); + assertNotEq(address(registry.getCanonicalRollup()), address(target), "target became canonical"); + } + + // ----------------------------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------------------------- + + function _deployRewarder() internal returns (FlushRewarder) { + FlushRewarder rewarder = + new FlushRewarder(address(governance), IInstance(address(rollup)), IERC20(address(token)), REWARD_PER_INSERTION); + vm.prank(token.owner()); + token.mint(address(rewarder), REWARDER_BALANCE); + return rewarder; + } + + function _deployV6Payload(FlushRewarder _old) internal returns (V6UpgradePayload) { + StandInRollup newRollup = new StandInRollup(gse); + // Window off: this file is about atomicity, and the clock would only add a second reason to + // revert. The window itself is covered in test/periphery/V6UpgradePayload.t.sol. + IEscapeHatch hatch = IEscapeHatch(address(new StubEscapeHatch(address(newRollup)))); + return new V6UpgradePayload(IRegistry(address(registry)), IInstance(address(newRollup)), hatch, _old, false); + } + + /// @dev Signal → submit → vote → warp, leaving the proposal Executable. + function _proposeAndQueue(IPayload _payload) internal returns (uint256) { + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(Slot.wrap(1)))); + for (uint256 i = 0; i < 10; i++) { + vm.prank(rollup.getCurrentProposer()); + governanceProposer.signal(_payload); + vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(rollup.getCurrentSlot() + Slot.wrap(1)))); + } + governanceProposer.submitRoundWinner(0); + + Proposal memory proposal = governance.getProposal(0); + assertEq(address(GSEPayload(address(proposal.payload)).getOriginalPayload()), address(_payload)); + + vm.prank(token.owner()); + token.mint(EMPEROR, 10_000 ether); + vm.startPrank(EMPEROR); + token.approve(address(governance), 10_000 ether); + governance.deposit(EMPEROR, 10_000 ether); + vm.stopPrank(); + + vm.warp(Timestamp.unwrap(upw.pendingThrough(proposal)) + 1); + vm.prank(EMPEROR); + governance.vote(0, 10_000 ether, true); + + vm.warp(Timestamp.unwrap(upw.activeThrough(proposal)) + 1); + vm.warp(Timestamp.unwrap(upw.queuedThrough(proposal)) + 1); + assertTrue(governance.getProposalState(0) == ProposalState.Executable, "proposal is not executable"); + return 0; + } +} diff --git a/l1-contracts/test/periphery/V6UpgradePayload.t.sol b/l1-contracts/test/periphery/V6UpgradePayload.t.sol new file mode 100644 index 000000000000..a9f07a132e7f --- /dev/null +++ b/l1-contracts/test/periphery/V6UpgradePayload.t.sol @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: UNLICENSED +// solhint-disable +pragma solidity >=0.8.27; + +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {GSE, IGSECore} from "@aztec/governance/GSE.sol"; +import {IPayload} from "@aztec/governance/interfaces/IPayload.sol"; +import {IHaveVersion, IRegistry} from "@aztec/governance/interfaces/IRegistry.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {Errors} from "@aztec/governance/libraries/Errors.sol"; +import {IEscapeHatch} from "@aztec/core/interfaces/IEscapeHatch.sol"; +import {IValidatorSelectionCore} from "@aztec/core/interfaces/IValidatorSelection.sol"; +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {V6UpgradePayload} from "@aztec/periphery/V6UpgradePayload.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; +import {TestBase} from "@test/base/Base.sol"; + +/** + * @dev Enough of a rollup for the payload: a version the registry can key on, and a GSE address the + * action list can target. Deliberately NOT a real Rollup -- these tests are about the payload's + * action list and its preconditions, and a real rollup would only add deploy cost and noise. + */ +contract StubRollup { + address internal immutable GSE_ADDR; + + constructor(address _gse) { + GSE_ADDR = _gse; + } + + function getVersion() external view returns (uint256) { + return uint256(keccak256(abi.encodePacked(bytes("aztec_rollup"), block.chainid, address(this)))); + } + + function getGSE() external view returns (GSE) { + return GSE(GSE_ADDR); + } +} + +/// @dev The payload only reads `getRollup()` off the hatch, so a real EscapeHatch -- whose +/// constructor interrogates the rollup -- would add nothing but cost here. +contract StubEscapeHatch { + address internal immutable ROLLUP_; + + constructor(address _rollup) { + ROLLUP_ = _rollup; + } + + function getRollup() external view returns (address) { + return ROLLUP_; + } +} + +contract V6UpgradePayloadTest is TestBase { + TestERC20 internal token; + Registry internal registry; + + IInstance internal outgoing; // canonical when the payload is deployed + IInstance internal incoming; // the rollup the payload makes canonical + address internal gseAddr = address(0x65E); + + uint256 internal constant REWARD_PER_INSERTION = 1000e18; + + /// @dev Day index of 2026-11-01, the month the v6 upgrade is prepared in. + uint256 internal constant FIRST_DAY = 20_758; + + /// @dev The hatch the payload installs, bound to the incoming rollup. + IEscapeHatch internal hatch; + + /// @dev `isWithinExecutionWindow` is pure; this instance is only a host to call it on. + V6UpgradePayload internal payloadForWindow; + + function setUp() public { + token = new TestERC20("test", "TEST", address(this)); + // Owner is the test contract, standing in for governance: it lets the test register rollups + // directly, which is what moves the canonical pointer the guard reads. + registry = new Registry(address(this), IERC20(address(token))); + + outgoing = IInstance(address(new StubRollup(gseAddr))); + incoming = IInstance(address(new StubRollup(gseAddr))); + registry.addRollup(_haveVersion(outgoing)); + + hatch = IEscapeHatch(address(new StubEscapeHatch(address(incoming)))); + payloadForWindow = _deploy({_window: true, _withRewarder: false}); + } + + // --------------------------------------------------------------------------------------------- + // Predecessor guard + // --------------------------------------------------------------------------------------------- + + function test_PredecessorIsTheCanonicalRollupAtDeploy() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + assertEq(payload.PREDECESSOR(), address(outgoing), "predecessor is not the outgoing rollup"); + assertEq(payload.PREDECESSOR(), address(registry.getCanonicalRollup()), "predecessor is not canonical"); + } + + function test_GuardPassesWhilePredecessorIsStillCanonical() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + // Nothing has moved since deployment, so this must not revert. Called rather than asserted: + // the guard's whole contract is "reverts, or does nothing". + payload.assertPredecessorIsCanonical(); + } + + function test_GuardRevertsOnceAnotherRollupIsRegistered() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + + // Someone else's registration lands first -- the abandoned-payload scenario. + IInstance other = IInstance(address(new StubRollup(gseAddr))); + registry.addRollup(_haveVersion(other)); + + vm.expectRevert( + abi.encodeWithSelector( + V6UpgradePayload.V6UpgradePayload__PredecessorNotCanonical.selector, address(outgoing), address(other) + ) + ); + payload.assertPredecessorIsCanonical(); + } + + /// @dev The whole point of the guard being an action rather than a revert inside `getActions`: + /// a payload that can no longer execute must still be able to say what it would have done. + function test_GetActionsStaysReadableWhenStale() public { + V6UpgradePayload payload = _deploy({_window: true, _withRewarder: true}); + + IInstance other = IInstance(address(new StubRollup(gseAddr))); + registry.addRollup(_haveVersion(other)); + + IPayload.Action[] memory actions = payload.getActions(); + assertEq(actions.length, 6, "stale payload no longer describes itself"); + assertEq(bytes4(actions[0].data), payload.assertPredecessorIsCanonical.selector, "guard action lost"); + } + + function test_GuardActionIsFirstInEveryConfiguration() public { + bool[2] memory windows = [true, false]; + bool[2] memory rewarders = [true, false]; + + for (uint256 i = 0; i < windows.length; i++) { + for (uint256 j = 0; j < rewarders.length; j++) { + V6UpgradePayload payload = _deploy({_window: windows[i], _withRewarder: rewarders[j]}); + IPayload.Action[] memory actions = payload.getActions(); + assertEq(actions[0].target, address(payload), "guard is not self-targeted"); + assertEq( + bytes4(actions[0].data), payload.assertPredecessorIsCanonical.selector, "guard is not the first action" + ); + } + } + } + + // --------------------------------------------------------------------------------------------- + // Action list: shape, order and arguments + // --------------------------------------------------------------------------------------------- + + function test_ActionListShapeWindowAndRewarder() public { + V6UpgradePayload payload = _deploy({_window: true, _withRewarder: true}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 6, "wrong action count"); + + _assertGuard(a[0], payload); + _assertWindow(a[1], payload); + _assertSetEscapeHatch(a[2], payload); + _assertAddRollup(a[3]); + _assertGseAddRollup(a[4]); + _assertRecover(a[5], payload); + } + + function test_ActionListShapeWindowNoRewarder() public { + V6UpgradePayload payload = _deploy({_window: true, _withRewarder: false}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 5, "wrong action count"); + + _assertGuard(a[0], payload); + _assertWindow(a[1], payload); + _assertSetEscapeHatch(a[2], payload); + _assertAddRollup(a[3]); + _assertGseAddRollup(a[4]); + } + + function test_ActionListShapeNoWindowWithRewarder() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: true}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 5, "wrong action count"); + + _assertGuard(a[0], payload); + _assertSetEscapeHatch(a[1], payload); + _assertAddRollup(a[2]); + _assertGseAddRollup(a[3]); + _assertRecover(a[4], payload); + } + + function test_ActionListShapeNoWindowNoRewarder() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 4, "wrong action count"); + + _assertGuard(a[0], payload); + _assertSetEscapeHatch(a[1], payload); + _assertAddRollup(a[2]); + _assertGseAddRollup(a[3]); + } + + /// @dev `rewardsAvailable()` is read when the action list is built, not when the payload is + /// deployed, so a balance that moves during the governance delay moves the recovered amount. + function test_RecoverAmountIsReadAtCallTime() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: true}); + FlushRewarder old = payload.OLD_FLUSH_REWARDER(); + + IPayload.Action[] memory before = payload.getActions(); + (,, uint256 amountBefore) = _decodeRecover(before[4].data); + + token.mint(address(old), 500e18); + + IPayload.Action[] memory later = payload.getActions(); + (,, uint256 amountAfter) = _decodeRecover(later[4].data); + + assertEq(amountAfter, amountBefore + 500e18, "recover amount did not follow the balance"); + assertEq(amountAfter, old.rewardsAvailable(), "recover amount is not rewardsAvailable at call time"); + } + + // --------------------------------------------------------------------------------------------- + // Constructor + // --------------------------------------------------------------------------------------------- + + function test_ConstructorRevertsWhenRewarderServesAnotherRollup() public { + // A rewarder bound to the INCOMING rollup, not the outgoing one it claims to replace. + FlushRewarder foreign = new FlushRewarder(address(this), incoming, IERC20(address(token)), REWARD_PER_INSERTION); + + vm.expectRevert( + abi.encodeWithSelector( + V6UpgradePayload.V6UpgradePayload__FlushRewarderRollupMismatch.selector, address(incoming), address(outgoing) + ) + ); + new V6UpgradePayload(IRegistry(address(registry)), incoming, hatch, foreign, false); + } + + function test_ConstructorRevertsAgainstAnEmptyRegistry() public { + Registry empty = new Registry(address(this), IERC20(address(token))); + vm.expectRevert(abi.encodeWithSelector(Errors.Registry__NoRollupsRegistered.selector)); + new V6UpgradePayload(IRegistry(address(empty)), incoming, hatch, FlushRewarder(address(0)), false); + } + + function test_ConstructorRevertsWhenTheHatchServesAnotherRollup() public { + // `setEscapeHatch` is one-shot, so a hatch bound elsewhere would burn the only chance to + // install one on this rollup. + IEscapeHatch foreign = IEscapeHatch(address(new StubEscapeHatch(address(outgoing)))); + vm.expectRevert( + abi.encodeWithSelector( + V6UpgradePayload.V6UpgradePayload__EscapeHatchRollupMismatch.selector, address(outgoing), address(incoming) + ) + ); + new V6UpgradePayload(IRegistry(address(registry)), incoming, foreign, FlushRewarder(address(0)), false); + } + + function test_NewRewarderIsZeroWhenThereIsNoOldOne() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + assertEq(address(payload.NEW_FLUSH_REWARDER()), address(0), "deployed a rewarder with nothing to migrate"); + } + + function test_NewRewarderMirrorsTheOldOneAndFollowsTheNewRollup() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: true}); + FlushRewarder old = payload.OLD_FLUSH_REWARDER(); + FlushRewarder fresh = payload.NEW_FLUSH_REWARDER(); + + assertEq(address(fresh.REWARD_ASSET()), address(old.REWARD_ASSET()), "reward asset not mirrored"); + assertEq(fresh.rewardPerInsertion(), old.rewardPerInsertion(), "reward rate not mirrored"); + assertEq(address(fresh.ROLLUP()), address(incoming), "replacement is not bound to the new rollup"); + assertEq(fresh.owner(), registry.getGovernance(), "replacement is not owned by governance"); + } + + // --------------------------------------------------------------------------------------------- + // Execution window + // + // The date math is hand-rolled -- no library, no tabulated DST table -- so it is checked against + // the real IANA Europe/London calendar rather than against a reimplementation of itself. Every + // vector below was generated from the tz database; a reimplementation in Solidity would only + // reproduce whatever bug the contract has. + // --------------------------------------------------------------------------------------------- + + function test_WindowMatchesTheRealLondonCalendar() public view { + // Boundaries in GMT: 08:00 opens, 17:00 closes. + _vec(1_799_654_340, false); // 2027-01-11 07:59 GMT Mon + _vec(1_799_654_400, true); // 2027-01-11 08:00 GMT Mon + _vec(1_800_032_340, true); // 2027-01-15 16:59 GMT Fri + _vec(1_800_032_400, false); // 2027-01-15 17:00 GMT Fri + + // The same boundaries in BST, which are an hour earlier in UTC. + _vec(1_815_375_540, false); // 2027-07-12 07:59 BST Mon + _vec(1_815_375_600, true); // 2027-07-12 08:00 BST Mon + _vec(1_815_753_540, true); // 2027-07-16 16:59 BST Fri + _vec(1_815_753_600, false); // 2027-07-16 17:00 BST Fri + + // Weekends, whatever the hour. + _vec(1_800_100_800, false); // 2027-01-16 12:00 GMT Sat + _vec(1_800_187_200, false); // 2027-01-17 12:00 GMT Sun + + // Leap days, including one that lands on a weekend. + _vec(1_835_420_400, false); // 2028-02-29 07:00 GMT Tue + _vec(1_835_438_400, true); // 2028-02-29 12:00 GMT Tue + _vec(1_961_668_800, false); // 2032-02-29 12:00 GMT Sun + _vec(2_087_899_200, true); // 2036-02-29 12:00 GMT Fri + + // `_lastSundayOfMonth` at both extremes: the Monday after BST starts in a year where March's + // last Sunday is the 25th, and in one where it is the 31st. The transition instant itself is + // always a Sunday, so it is closed by the weekday rule either way -- the first observable + // consequence of getting the offset wrong is on the Monday that follows. + _vec(1_869_202_740, false); // 2029-03-26 07:59 BST Mon (BST began 25 Mar) + _vec(1_869_202_800, true); // 2029-03-26 08:00 BST Mon + _vec(1_901_257_140, false); // 2030-04-01 07:59 BST Mon (BST began 31 Mar) + _vec(1_901_257_200, true); // 2030-04-01 08:00 BST Mon + _vec(1_887_955_140, false); // 2029-10-29 07:59 GMT Mon (BST ended 28 Oct) + _vec(1_887_955_200, true); // 2029-10-29 08:00 GMT Mon + + // The cliff date itself, for the upgrade this payload exists for. + _vec(1_794_571_200, true); // 2026-11-13 12:00 GMT Fri + _vec(1_794_589_200, false); // 2026-11-13 17:00 GMT Fri + _vec(1_794_657_600, false); // 2026-11-14 12:00 GMT Sat + } + + /// @dev A property the calendar must satisfy for every day in the payload's plausible life: + /// nine open hours on a weekday, none at a weekend. Verified independently against the tz + /// database across the same range, including every DST transition in it. + /// + /// It constrains the SHAPE of the window, not its alignment: dropping the BST offset + /// entirely still leaves nine open hours a day, just an hour out, and this test still + /// passes. That mutation is caught by the vectors above, which is why both exist. + function testFuzz_NineOpenHoursOnWeekdaysNoneAtWeekends(uint256 _day) public view { + uint256 day = bound(_day, FIRST_DAY, FIRST_DAY + 6 * 365); + + uint256 open = 0; + for (uint256 h = 0; h < 24; h++) { + if (payloadForWindow.isWithinExecutionWindow(day * 1 days + h * 1 hours)) { + open++; + } + } + + uint256 weekday = (day + 4) % 7; + bool isWeekend = weekday == 0 || weekday == 6; + assertEq(open, isWeekend ? 0 : 9, "wrong number of open hours in the day"); + } + + function test_AssertWithinExecutionWindowFollowsTheWindow() public { + vm.warp(1_799_654_340); // Mon 07:59 GMT, one minute early + vm.expectRevert( + abi.encodeWithSelector(V6UpgradePayload.V6UpgradePayload__OutsideExecutionWindow.selector, block.timestamp) + ); + payloadForWindow.assertWithinExecutionWindow(); + + vm.warp(1_799_654_400); // Mon 08:00 GMT + payloadForWindow.assertWithinExecutionWindow(); + } + + /// @dev The window is enforced only where the deploy script asks for it, but the predicate is + /// always readable -- so a chain without the window still answers the same question. + function test_WindowNotEnforcedStillAnswersTheQuestion() public { + V6UpgradePayload noWindow = _deploy({_window: false, _withRewarder: false}); + assertFalse(noWindow.ENFORCE_EXECUTION_WINDOW(), "window should be off"); + assertTrue(noWindow.isWithinExecutionWindow(1_799_654_400), "predicate should still work"); + + IPayload.Action[] memory actions = noWindow.getActions(); + for (uint256 i = 0; i < actions.length; i++) { + assertTrue( + bytes4(actions[i].data) != noWindow.assertWithinExecutionWindow.selector, "window action should be absent" + ); + } + } + + function _vec(uint256 _timestamp, bool _expected) internal view { + assertEq(payloadForWindow.isWithinExecutionWindow(_timestamp), _expected, vm.toString(_timestamp)); + } + + // --------------------------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------------------------- + + function _deploy(bool _window, bool _withRewarder) internal returns (V6UpgradePayload) { + FlushRewarder old = _withRewarder + ? new FlushRewarder(address(this), outgoing, IERC20(address(token)), REWARD_PER_INSERTION) + : FlushRewarder(address(0)); + if (_withRewarder) { + token.mint(address(old), 1000e18); + } + return new V6UpgradePayload(IRegistry(address(registry)), incoming, hatch, old, _window); + } + + function _haveVersion(IInstance _rollup) internal pure returns (IHaveVersion) { + return IHaveVersion(address(_rollup)); + } + + function _assertGuard(IPayload.Action memory _a, V6UpgradePayload _payload) internal view { + assertEq(_a.target, address(_payload), "guard target"); + assertEq(bytes4(_a.data), _payload.assertPredecessorIsCanonical.selector, "guard selector"); + assertEq(_a.data.length, 4, "guard takes no arguments"); + } + + function _assertWindow(IPayload.Action memory _a, V6UpgradePayload _payload) internal view { + assertEq(_a.target, address(_payload), "window target"); + assertEq(bytes4(_a.data), _payload.assertWithinExecutionWindow.selector, "window selector"); + } + + function _assertSetEscapeHatch(IPayload.Action memory _a, V6UpgradePayload _payload) internal view { + assertEq(_a.target, address(incoming), "setEscapeHatch must target the NEW rollup"); + assertEq(bytes4(_a.data), IValidatorSelectionCore.setEscapeHatch.selector, "setEscapeHatch selector"); + assertEq(abi.decode(_slice4(_a.data), (address)), _payload.ESCAPE_HATCH(), "setEscapeHatch argument"); + } + + function _assertAddRollup(IPayload.Action memory _a) internal view { + assertEq(_a.target, address(registry), "addRollup target"); + assertEq(bytes4(_a.data), IRegistry.addRollup.selector, "addRollup selector"); + assertEq(abi.decode(_slice4(_a.data), (address)), address(incoming), "addRollup argument"); + } + + function _assertGseAddRollup(IPayload.Action memory _a) internal view { + assertEq(_a.target, gseAddr, "gse addRollup target"); + assertEq(bytes4(_a.data), IGSECore.addRollup.selector, "gse addRollup selector"); + assertEq(abi.decode(_slice4(_a.data), (address)), address(incoming), "gse addRollup argument"); + } + + function _assertRecover(IPayload.Action memory _a, V6UpgradePayload _payload) internal view { + assertEq(_a.target, address(_payload.OLD_FLUSH_REWARDER()), "recover target"); + assertEq(bytes4(_a.data), FlushRewarder.recover.selector, "recover selector"); + (address asset, address to, uint256 amount) = _decodeRecover(_a.data); + assertEq(asset, address(_payload.NEW_FLUSH_REWARDER().REWARD_ASSET()), "recover asset"); + assertEq(to, address(_payload.NEW_FLUSH_REWARDER()), "recover recipient"); + assertEq(amount, _payload.OLD_FLUSH_REWARDER().rewardsAvailable(), "recover amount"); + } + + function _decodeRecover(bytes memory _data) internal pure returns (address, address, uint256) { + return abi.decode(_slice4(_data), (address, address, uint256)); + } + + /// @dev Everything after the 4-byte selector, so arguments can be decoded rather than eyeballed. + function _slice4(bytes memory _data) internal pure returns (bytes memory) { + bytes memory out = new bytes(_data.length - 4); + for (uint256 i = 0; i < out.length; i++) { + out[i] = _data[i + 4]; + } + return out; + } +} diff --git a/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol new file mode 100644 index 000000000000..5273b6d48e16 --- /dev/null +++ b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +import {DeployAztecL1Contracts, DeployAztecL1ContractsOutput} from "../../script/deploy/DeployAztecL1Contracts.s.sol"; +import {DeployRollupForUpgradeV6} from "../../script/deploy/DeployRollupForUpgradeV6.s.sol"; +import {IInstance} from "@aztec/core/interfaces/IInstance.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {FlushRewarder} from "@aztec/periphery/FlushRewarder.sol"; +import {Slasher} from "@aztec/core/slashing/Slasher.sol"; +import {V6UpgradePayload} from "@aztec/periphery/V6UpgradePayload.sol"; +import {IERC20} from "@oz/token/ERC20/IERC20.sol"; + +/** + * @dev The deploy script with the three genesis roots filled in, and nothing else changed. + * + * `run()` refuses to deploy while they are zero, which is correct for the real thing and is + * exactly what stops the rest of the table from ever being exercised before deploy day. The + * roots are opaque commitments from the circuits build; every other literal is a protocol + * parameter the constructors have opinions about, and those opinions are what this tests. + */ +contract V6ConfigHarness is DeployRollupForUpgradeV6 { + /// @dev BN254 Fr. The roots are field elements, not arbitrary bytes32 -- the Rollup rejects + /// anything at or above this with `Rollup__FieldElementOutOfRange`, so a stand-in has to be + /// reduced into the field or it fails for a reason that has nothing to do with the config. + uint256 internal constant FR = + 21_888_242_871_839_275_222_246_405_745_257_275_088_548_364_400_416_034_343_698_204_186_575_808_495_617; + + /// @dev The governance simulation needs forked state -- real voters, mainnet timings -- and is + /// the one step of `run()` a local stack cannot satisfy. Skipped here so the config table + /// itself can be exercised; the simulation is what runs on deploy day, against a fork. + function _simulate(address) internal override {} + + function _config() internal view override returns (Config memory c) { + c = super._config(); + c.vkTreeRoot = bytes32(uint256(keccak256("vkTreeRoot")) % FR); + c.protocolContractsHash = bytes32(uint256(keccak256("protocolContractsHash")) % FR); + c.genesisArchiveRoot = bytes32(uint256(keccak256("genesisArchiveRoot")) % FR); + } +} + +/** + * @title DeployRollupForUpgradeV6Test + * @notice Deploys the v6 config table against a local stack, on both chains it supports. + * @dev Nothing here checks the VALUES are the ones we want -- that is a review question, and the + * comments in `_config()` carry the v5 comparison for it. What this checks is that the table + * is internally consistent and deployable: that the constructors accept it, and that `verify()` + * reads every value back off the deployed contracts. Those are the failures that would + * otherwise surface on deploy day, against mainnet, with a broadcast in flight. + */ +contract DeployRollupForUpgradeV6Test is Test { + using stdJson for string; + + uint256 internal constant MAINNET_CHAIN_ID = 1; + uint256 internal constant SEPOLIA_CHAIN_ID = 11_155_111; + + /// @dev Where the v5 flush rewarder lives on mainnet, per the config table. + address internal constant MAINNET_OLD_FLUSH_REWARDER = 0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e; + + Registry internal registry; + Rollup internal outgoing; + + modifier skipWhenCoverage() { + if (vm.envOr("FORGE_COVERAGE", false)) { + vm.skip(true); + } + _; + } + + function setUp() public skipWhenCoverage { + _loadNetworkDefaults(); + + DeployAztecL1Contracts fullDeploy = new DeployAztecL1Contracts(); + fullDeploy.run(); + + DeployAztecL1ContractsOutput memory out = fullDeploy.output(); + registry = out.registry; + outgoing = out.rollup.rollup; + + vm.setEnv("REGISTRY_ADDRESS", vm.toString(address(registry))); + + // Registry ownership stays with the real Governance. The script never writes to the registry -- + // it reads the outgoing rollup off it and hands registration to the payload -- and `run()` + // finishes by driving that payload through the real governance lifecycle, which needs + // `getGovernance()` to actually be a Governance contract. + } + + function test_MainnetConfigDeploysAndVerifies() public { + vm.chainId(MAINNET_CHAIN_ID); + _placeOutgoingFlushRewarder(); + + V6ConfigHarness harness = new V6ConfigHarness(); + harness.run(); + + Rollup deployed = harness.deployedRollup(); + assertNotEq(address(deployed), address(outgoing), "deployed the outgoing rollup"); + + // `run()` already calls these, but calling them again from the test is what pins that they are + // callable stand-alone -- which is how the runbook tells an operator to re-check a deploy. + harness.verify(address(deployed)); + harness.verifyFlushRewarder(address(deployed), address(harness.deployedPayload())); + } + + function test_SepoliaConfigDeploysAndVerifies() public { + vm.chainId(SEPOLIA_CHAIN_ID); + // No rewarder to place: Sepolia's override sets `oldFlushRewarder` to zero, and the payload + // skips the migration entirely. + + V6ConfigHarness harness = new V6ConfigHarness(); + harness.run(); + + Rollup deployed = harness.deployedRollup(); + harness.verify(address(deployed)); + + V6UpgradePayload payload = harness.deployedPayload(); + assertEq(address(payload.OLD_FLUSH_REWARDER()), address(0), "sepolia should have no old rewarder"); + assertEq(address(payload.NEW_FLUSH_REWARDER()), address(0), "sepolia should deploy no new rewarder"); + assertFalse(payload.ENFORCE_EXECUTION_WINDOW(), "sepolia should not enforce the window"); + } + + /// @dev The Rollup constructor's `_governance` becomes BOTH the Ownable owner and the Slasher's + /// immutable GOVERNANCE, which may execute any slash payload with no vote. Ownership is + /// handed over after construction; the Slasher's copy cannot be. + function test_SlasherGovernanceIsGovernanceNotTheDeployer() public { + vm.chainId(MAINNET_CHAIN_ID); + _placeOutgoingFlushRewarder(); + + V6ConfigHarness harness = new V6ConfigHarness(); + harness.run(); + Rollup deployed = harness.deployedRollup(); + + address slasherGov = Slasher(deployed.getSlasher()).GOVERNANCE(); + emit log_named_address("slasher GOVERNANCE", slasherGov); + emit log_named_address("rollup owner ", deployed.owner()); + emit log_named_address("registry governance", registry.getGovernance()); + emit log_named_address("deployer (this) ", address(harness)); + + assertEq(slasherGov, registry.getGovernance(), "slasher GOVERNANCE is not governance"); + } + + function test_UnsupportedChainReverts() public { + vm.chainId(31_337); + V6ConfigHarness harness = new V6ConfigHarness(); + vm.expectRevert( + abi.encodeWithSelector(DeployRollupForUpgradeV6.DeployRollupForUpgradeV6__UnsupportedChain.selector, 31_337) + ); + harness.run(); + } + + /// @dev The real script must refuse to deploy while the genesis roots are unset, which is the + /// only reason this file needs a harness at all. Asserted against the UNMODIFIED script. + function test_RealScriptRefusesWhileGenesisRootsAreZero() public { + vm.chainId(MAINNET_CHAIN_ID); + DeployRollupForUpgradeV6 real = new DeployRollupForUpgradeV6(); + vm.expectRevert(bytes("vkTreeRoot not set")); + real.run(); + } + + // ----------------------------------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------------------------------- + + /** + * @dev Put a flush rewarder bound to the outgoing rollup at the mainnet address the config names. + * Etched rather than deployed: the address is a literal in the table, and the point is to + * exercise the table as written rather than a version of it with the address swapped out. + * Immutables live in the runtime code so ROLLUP and REWARD_ASSET survive the etch; storage + * does not, so `rewardPerInsertion` reads zero, which the payload simply mirrors. + */ + function _placeOutgoingFlushRewarder() internal { + FlushRewarder template = new FlushRewarder( + registry.getGovernance(), IInstance(address(outgoing)), IERC20(address(outgoing.getStakingAsset())), 0 + ); + vm.etch(MAINNET_OLD_FLUSH_REWARDER, address(template).code); + assertEq( + address(FlushRewarder(MAINNET_OLD_FLUSH_REWARDER).ROLLUP()), address(outgoing), "etched rewarder is not bound" + ); + } + + function _loadNetworkDefaults() internal { + string memory json = vm.readFile(string.concat(vm.projectRoot(), "/scripts/network-defaults.json")); + + // The GSE thresholds are the one part of the v6 table that is NOT set by the deploy -- they are + // asserted against the GSE that already exists, because the new rollup inherits it. The local + // stack's defaults are 100e18/50e18, so they are raised to production values here; otherwise + // the test would have to weaken that assertion, which is one of the few in the script that + // catches a rollup pointed at the wrong GSE. + vm.setEnv("AZTEC_ACTIVATION_THRESHOLD", vm.toString(uint256(200_000e18))); + vm.setEnv("AZTEC_EJECTION_THRESHOLD", vm.toString(uint256(100_000e18))); + + vm.setEnv("ETHEREUM_SLOT_DURATION", vm.toString(json.readUint(".ETHEREUM_SLOT_DURATION"))); + vm.setEnv("AZTEC_SLOT_DURATION", vm.toString(json.readUint(".AZTEC_SLOT_DURATION"))); + vm.setEnv("AZTEC_EPOCH_DURATION", vm.toString(json.readUint(".AZTEC_EPOCH_DURATION"))); + vm.setEnv("AZTEC_PROOF_SUBMISSION_EPOCHS", vm.toString(json.readUint(".AZTEC_PROOF_SUBMISSION_EPOCHS"))); + vm.setEnv("AZTEC_TARGET_COMMITTEE_SIZE", vm.toString(json.readUint(".AZTEC_TARGET_COMMITTEE_SIZE"))); + vm.setEnv( + "AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET", vm.toString(json.readUint(".AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET")) + ); + vm.setEnv("AZTEC_LAG_IN_EPOCHS_FOR_RANDAO", vm.toString(json.readUint(".AZTEC_LAG_IN_EPOCHS_FOR_RANDAO"))); + vm.setEnv("AZTEC_LOCAL_EJECTION_THRESHOLD", json.readString(".AZTEC_LOCAL_EJECTION_THRESHOLD")); + vm.setEnv("AZTEC_EXIT_DELAY_SECONDS", vm.toString(json.readUint(".AZTEC_EXIT_DELAY_SECONDS"))); + vm.setEnv( + "AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE", + vm.toString(json.readUint(".AZTEC_ENTRY_QUEUE_BOOTSTRAP_VALIDATOR_SET_SIZE")) + ); + vm.setEnv( + "AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE", vm.toString(json.readUint(".AZTEC_ENTRY_QUEUE_BOOTSTRAP_FLUSH_SIZE")) + ); + vm.setEnv("AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN", vm.toString(json.readUint(".AZTEC_ENTRY_QUEUE_FLUSH_SIZE_MIN"))); + vm.setEnv( + "AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT", vm.toString(json.readUint(".AZTEC_ENTRY_QUEUE_FLUSH_SIZE_QUOTIENT")) + ); + vm.setEnv("AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE", vm.toString(json.readUint(".AZTEC_ENTRY_QUEUE_MAX_FLUSH_SIZE"))); + vm.setEnv("AZTEC_MANA_TARGET", vm.toString(json.readUint(".AZTEC_MANA_TARGET"))); + vm.setEnv("AZTEC_PROVING_COST_PER_MANA", vm.toString(json.readUint(".AZTEC_PROVING_COST_PER_MANA"))); + vm.setEnv("AZTEC_INITIAL_ETH_PER_FEE_ASSET", vm.toString(json.readUint(".AZTEC_INITIAL_ETH_PER_FEE_ASSET"))); + vm.setEnv("AZTEC_REGISTRY_REWARD_OVERRIDE_0", json.readString(".AZTEC_REGISTRY_REWARD_OVERRIDE_0")); + vm.setEnv("AZTEC_REGISTRY_REWARD_OVERRIDE_1", json.readString(".AZTEC_REGISTRY_REWARD_OVERRIDE_1")); + vm.setEnv("AZTEC_SLASHER_ENABLED", vm.toString(json.readBool(".AZTEC_SLASHER_ENABLED"))); + vm.setEnv("AZTEC_SLASHING_ROUND_SIZE_IN_EPOCHS", vm.toString(json.readUint(".AZTEC_SLASHING_ROUND_SIZE_IN_EPOCHS"))); + vm.setEnv("AZTEC_SLASHING_OFFSET_IN_ROUNDS", vm.toString(json.readUint(".AZTEC_SLASHING_OFFSET_IN_ROUNDS"))); + vm.setEnv("AZTEC_SLASHING_LIFETIME_IN_ROUNDS", vm.toString(json.readUint(".AZTEC_SLASHING_LIFETIME_IN_ROUNDS"))); + vm.setEnv( + "AZTEC_SLASHING_EXECUTION_DELAY_IN_ROUNDS", + vm.toString(json.readUint(".AZTEC_SLASHING_EXECUTION_DELAY_IN_ROUNDS")) + ); + vm.setEnv("AZTEC_SLASHING_DISABLE_DURATION", vm.toString(json.readUint(".AZTEC_SLASHING_DISABLE_DURATION"))); + vm.setEnv("AZTEC_SLASHING_VETOER", json.readString(".AZTEC_SLASHING_VETOER")); + vm.setEnv("AZTEC_SLASH_AMOUNT_SMALL", json.readString(".AZTEC_SLASH_AMOUNT_SMALL")); + vm.setEnv("AZTEC_SLASH_AMOUNT_MEDIUM", json.readString(".AZTEC_SLASH_AMOUNT_MEDIUM")); + vm.setEnv("AZTEC_SLASH_AMOUNT_LARGE", json.readString(".AZTEC_SLASH_AMOUNT_LARGE")); + } +}