From 2a6aa7f832eda3a3a7dd3974712fd6e6bbc0c150 Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Thu, 17 Sep 2026 17:10:48 +0700 Subject: [PATCH 1/7] feat(l1): v6 rollup upgrade deploy script, payload and runbook Adds the deployment path for the v6 rollup upgrade. - DeployRollupForUpgradeV6.s.sol deploys the verifier, the rollup, the escape hatch and the governance payload. Every configuration value is a literal in _config(), branched for mainnet and Sepolia, so there are no env-var defaults and no network-defaults.json fallbacks that could change what is deployed; REGISTRY_ADDRESS is the only environment input. verify() reads each value back off the deployed contracts and asserts it matches the table. - V6UpgradePayload.sol registers the rollup with the Registry and the GSE, and moves the outgoing flush rewarder's unowed balance into a replacement bound to the new rollup. - V6UpgradeSimulation.sol 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. - V6_UPGRADE_RUNBOOK.md documents the build, inputs, pre-flight checks, deploy, proposal and post-execution verification. The escape hatch is deployed and registered before ownership moves to governance, because setEscapeHatch is onlyOwner and one-shot. Not deployable as-is: the genesis roots, initialEthPerFeeAsset and the registry reward override values are left as TODOs. --- .../deploy/DeployRollupForUpgradeV6.s.sol | 584 ++++++++++++++++++ .../script/deploy/V6UpgradeSimulation.sol | 247 ++++++++ .../script/deploy/V6_UPGRADE_RUNBOOK.md | 208 +++++++ .../src/periphery/V6UpgradePayload.sol | 207 +++++++ 4 files changed, 1246 insertions(+) create mode 100644 l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol create mode 100644 l1-contracts/script/deploy/V6UpgradeSimulation.sol create mode 100644 l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md create mode 100644 l1-contracts/src/periphery/V6UpgradePayload.sol diff --git a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol new file mode 100644 index 000000000000..01bfc6616133 --- /dev/null +++ b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol @@ -0,0 +1,584 @@ +// 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 {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. + */ + function _config() internal view 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 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 the Rollup's owner. Passing `deployer` rather than governance is what + // would let the deployer run owner-gated setup here; ownership is handed to governance + // immediately after, so anything not done before that line needs a governance call instead. + Rollup rollup = new Rollup( + current.getFeeAsset(), + current.getStakingAsset(), + current.getGSE(), + verifier, + deployer, + _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 + ); + + // `setEscapeHatch` is `onlyOwner` and one-shot: it reverts if a hatch is already registered, + // and it rejects any hatch whose `getRollup()` is not this rollup. Calling it here, while the + // deployer still owns the rollup, is what keeps it out of the governance payload. + rollup.setEscapeHatch(address(escapeHatch)); + + // From here on, only governance can change the rollup's configuration. + rollup.transferOwnership(governance); + + V6UpgradePayload payload = new V6UpgradePayload( + registry, IInstance(address(rollup)), 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)); + console.log("rewardBooster ", address(rollup.getRewardConfig().booster)); + console.log("payload ", address(payload)); + console.log("newFlushRewarder ", address(payload.NEW_FLUSH_REWARDER())); + verifyFlushRewarder(address(rollup), address(payload)); + + // 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 or misbehave. + // Requires forked state; see V6UpgradeSimulation. + new V6UpgradeSimulation().simulate(address(payload)); + console.log("version ", rollup.getVersion()); + } + + /// @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); + _verifyEscapeHatch(rollup, c); + _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"); + } + + function _verifyEscapeHatch(Rollup _rollup, Config memory _c) private view { + // A zero address here means `setEscapeHatch` never ran; since it is one-shot and owner-gated, + // that would leave the hatch permanently unsettable except by governance. + EscapeHatch hatch = EscapeHatch(address(_rollup.getEscapeHatch())); + assertTrue(address(hatch) != address(0), "escape hatch not set on the rollup"); + + 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) 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()); + 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..079cd5502411 --- /dev/null +++ b/l1-contracts/script/deploy/V6UpgradeSimulation.sol @@ -0,0 +1,247 @@ +// 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"); + + _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..606e963210dd --- /dev/null +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -0,0 +1,208 @@ +# v6 rollup upgrade runbook + +How to deploy the v6 rollup and get it made canonical. Covers `DeployRollupForUpgradeV6.s.sol`, +`V6UpgradePayload.sol` and `V6UpgradeSimulation.sol`. + +## What this does + +The deploy script deploys, in one broadcast: + +1. `HonkVerifier` — the real epoch proof verifier. +2. `Rollup` — owned by the **deployer** at construction, not governance. Deploying it also + constructs its `Inbox`, `Outbox`, `FeeJuicePortal`, `Slasher` + `SlashingProposer`, and a fresh + `RewardBooster`. +3. `EscapeHatch`, then `rollup.setEscapeHatch(...)` — done here, while the deployer still owns the + rollup. `setEscapeHatch` is `onlyOwner` and one-shot. +4. `rollup.transferOwnership(governance)` — after this line nothing owner-gated can be done + without a governance proposal. +5. `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 | `Registry.addRollup(v6)` | makes v6 the canonical rollup | +| 2 | `GSE.addRollup(v6)` | lets existing attesters follow without redepositing | +| 3 | `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 # the deployed hatch, non-zero +cast call "getVersion()(uint256)" --rpc-url $RPC # not already in the registry +``` + +## 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. + +## 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.sol b/l1-contracts/src/periphery/V6UpgradePayload.sol new file mode 100644 index 000000000000..994d758cccb7 --- /dev/null +++ b/l1-contracts/src/periphery/V6UpgradePayload.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +pragma solidity >=0.8.27; + +import {IInstance} from "@aztec/core/interfaces/IInstance.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 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 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 _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, FlushRewarder _oldFlushRewarder, bool _enforceExecutionWindow) { + REGISTRY = _registry; + ROLLUP = _rollup; + OLD_FLUSH_REWARDER = _oldFlushRewarder; + ENFORCE_EXECUTION_WINDOW = _enforceExecutionWindow; + + // 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()); + address outgoingRollup = address(_registry.getCanonicalRollup()); + 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[]((ENFORCE_EXECUTION_WINDOW ? 1 : 0) + (migrateFlushRewarder ? 3 : 2)); + + // 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. + // Placed first so nothing is mutated before the check: Governance reverts the whole execution + // on any failed action, and it 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)}); + } + + // 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 the current time is inside the permitted execution window. + /// @dev Targeted by the first action 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; + } +} From 816e08bc1cc717b53bd1f7ba02e5f60e358fffbc Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Tue, 22 Sep 2026 22:06:20 +0900 Subject: [PATCH 2/7] fix(l1): bind the v6 upgrade payload to its predecessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration is append-only with last-write-wins, and `Governance.execute` is permissionless and only checks that a proposal is `Executable`. So two accepted registrations are a hazard rather than a queue: if this payload is abandoned after the vote — a bug found in the rollup, a replacement proposed — and the replacement executes first, anyone can then execute this one and move both `Registry.getCanonicalRollup()` and GSE latest back to v6. The demotion is permanent, because neither registry re-admits a rollup it already holds, and the bonus validator set follows in the same transaction. The window is narrow and sits exactly where it hurts. With mainnet timings a proposal is executable from 40 to 47 days after creation, so two registrations overlap only if the second is created within 7 days of the first — which is precisely what "find a bug in v6, patch it quickly" produces. Fast remediation is what opens the hole. The payload now records the canonical rollup at deployment as `PREDECESSOR` and asserts it is still canonical before anything is written, so it only ever authorises the transition voters actually approved: from that rollup, to this one. A stale payload reverts the whole execution and changes nothing. Placed FIRST, ahead of the execution-window check. Both are non-mutating so the order is free, and between "this payload must never run" and "come back on Monday" the first is the one worth surfacing. Enforced as a self-targeted action rather than a require in `getActions`, matching the window check for the same reason: a stale payload must stay READABLE. Explorers, `GSEPayload.amIValid` and the deploy script's simulation all call `getActions`, and a payload that cannot describe itself once it can no longer execute is worse to diagnose, not better. The constructor already read `getCanonicalRollup()` for the flush-rewarder check; that read is now unconditional and shared, so both checks refer to the same rollup by construction. One consequence: deploying against a registry with no canonical rollup now reverts. This payload succeeds a rollup and was never usable for a first registration. The runbook carries the operational half — the pre-signalling checks that the guard is in the action list and the predecessor is still canonical, and the consequence nobody should meet by surprise: once any other registration lands this payload is dead, and registering v6 then needs a fresh deploy and a full governance cycle, not a quick swap. Mirrors the approach reviewed in aztec-packages-private#830 for `RegisterNewRollupVersionPayload`, which that PR notes does not cover this payload. Not yet tested: `test/` does not compile without `generated/HonkVerifier.sol`, so the revert path is verified by review and compiler only. A regression test follows. --- .../script/deploy/V6_UPGRADE_RUNBOOK.md | 30 +++++++++++ .../src/periphery/V6UpgradePayload.sol | 52 ++++++++++++++++--- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md index 606e963210dd..90b032fb1514 100644 --- a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -149,6 +149,10 @@ Sanity-check by hand that the rollup is inert and correctly owned: cast call "owner()(address)" --rpc-url $RPC # governance cast call "getEscapeHatch()(address)" --rpc-url $RPC # the deployed hatch, non-zero 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 @@ -166,6 +170,32 @@ delay — on the order of weeks in total), so expect the proposal to sit before 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 diff --git a/l1-contracts/src/periphery/V6UpgradePayload.sol b/l1-contracts/src/periphery/V6UpgradePayload.sol index 994d758cccb7..b9d4799c58b3 100644 --- a/l1-contracts/src/periphery/V6UpgradePayload.sol +++ b/l1-contracts/src/periphery/V6UpgradePayload.sol @@ -34,6 +34,11 @@ contract V6UpgradePayload is IPayload { /// @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 flush rewarder serving the outgoing rollup, or zero on a chain that has none. FlushRewarder public immutable OLD_FLUSH_REWARDER; @@ -50,6 +55,9 @@ contract V6UpgradePayload is IPayload { /// @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 Binds the payload to the rollup it will make canonical, and deploys the replacement * flush rewarder when there is an outgoing one to migrate. @@ -65,6 +73,13 @@ contract V6UpgradePayload is IPayload { 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 @@ -72,7 +87,6 @@ contract V6UpgradePayload is IPayload { // replaced. This rejects a rewarder for a foreign or already-retired rollup. if (address(_oldFlushRewarder) != address(0)) { address servedRollup = address(_oldFlushRewarder.ROLLUP()); - address outgoingRollup = address(_registry.getCanonicalRollup()); require( servedRollup == outgoingRollup, V6UpgradePayload__FlushRewarderRollupMismatch(servedRollup, outgoingRollup) ); @@ -94,14 +108,31 @@ contract V6UpgradePayload is IPayload { uint256 next = 0; IPayload.Action[] memory res = - new IPayload.Action[]((ENFORCE_EXECUTION_WINDOW ? 1 : 0) + (migrateFlushRewarder ? 3 : 2)); + new IPayload.Action[](1 + (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. - // Placed first so nothing is mutated before the check: Governance reverts the whole execution - // on any failed action, and it rolls back the Executed flag with it, so a rejected attempt - // leaves the proposal executable again once the window opens. + // 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)}); @@ -137,8 +168,17 @@ contract V6UpgradePayload is IPayload { 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 the first action when {ENFORCE_EXECUTION_WINDOW} is set. + /// @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)); From 5be8e4b6d197f977f9ddbe5fac3395ef5da12e58 Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Tue, 22 Sep 2026 22:15:17 +0900 Subject: [PATCH 3/7] test(l1): cover the v6 upgrade payload's actions, constructor and calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payload had no test at all. `V6UpgradeSimulation` is the only thing that ran it, and it needs a fork, so it runs once — on deploy day, against the state it is about to change. That is the wrong moment to learn that the action list is malformed. 18 tests, no fork, no RollupBuilder: a real Registry plus a stub rollup is all the payload's action list and preconditions actually need, and keeping the rollup out makes the suite cheap enough to run on every change. The predecessor guard is covered red-green. Removing the guard action from `getActions` fails seven of these; removing only its wiring while keeping the function fails the ordering test alone, which is the split that was wanted — one test owns the predicate, another owns the fact that governance is made to call it. The stale-payload case also asserts `getActions()` still RETURNS once the payload can no longer execute, since a payload that cannot describe itself is worse to diagnose, not safer. The action list is checked by decoded arguments, not just targets and lengths: the addresses inside `addRollup` and `recover` are the part that would be silently wrong. `recover`'s amount is asserted to track the rewarder's balance between two calls, pinning that it is read when the list is built rather than when the payload is deployed. THE CALENDAR IS CHECKED AGAINST THE REAL CALENDAR. The date math is hand-rolled — no library, no tabulated DST table — so a Solidity reimplementation would only reproduce whatever bug it has. The 23 vectors come from the IANA Europe/London database instead: both sides of 08:00 and 17:00 in GMT and in BST, weekends, three leap days including one on a Sunday, and the Monday after BST starts in a year where March's last Sunday is the 25th and in one where it is the 31st, which is what exercises `_lastSundayOfMonth` at both ends. The transition instants themselves are Sundays, so they are closed by the weekday rule regardless; the first observable consequence of a wrong offset is the Monday after, and that is what is asserted. The fuzz asserts nine open hours on a weekday and none at a weekend, a property independently verified against the tz database across the same six years. It constrains the window's shape, not its alignment — dropping the BST offset entirely still leaves nine hours a day and still passes — so the vectors catch that mutation and the property catches shape errors the vectors would miss. Both are kept, and the file says why. Not covered here, from the same review: the governance-lifecycle test on a RollupBuilder stack, and `DeployRollupForUpgradeV6.t.sol` for the config table. Runs under the coverage profile locally (`FOUNDRY_PROFILE=coverage`), which remaps the verifier to the mock; the default profile needs `generated/HonkVerifier.sol` from a noir-projects bootstrap, which no test here touches. --- .../test/periphery/V6UpgradePayload.t.sol | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 l1-contracts/test/periphery/V6UpgradePayload.t.sol diff --git a/l1-contracts/test/periphery/V6UpgradePayload.t.sol b/l1-contracts/test/periphery/V6UpgradePayload.t.sol new file mode 100644 index 000000000000..ca680269afbe --- /dev/null +++ b/l1-contracts/test/periphery/V6UpgradePayload.t.sol @@ -0,0 +1,395 @@ +// 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 {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); + } +} + +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 `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)); + + 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, 5, "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, 5, "wrong action count"); + + _assertGuard(a[0], payload); + _assertWindow(a[1], payload); + _assertAddRollup(a[2]); + _assertGseAddRollup(a[3]); + _assertRecover(a[4], payload); + } + + function test_ActionListShapeWindowNoRewarder() public { + V6UpgradePayload payload = _deploy({_window: true, _withRewarder: false}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 4, "wrong action count"); + + _assertGuard(a[0], payload); + _assertWindow(a[1], payload); + _assertAddRollup(a[2]); + _assertGseAddRollup(a[3]); + } + + function test_ActionListShapeNoWindowWithRewarder() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: true}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 4, "wrong action count"); + + _assertGuard(a[0], payload); + _assertAddRollup(a[1]); + _assertGseAddRollup(a[2]); + _assertRecover(a[3], payload); + } + + function test_ActionListShapeNoWindowNoRewarder() public { + V6UpgradePayload payload = _deploy({_window: false, _withRewarder: false}); + IPayload.Action[] memory a = payload.getActions(); + assertEq(a.length, 3, "wrong action count"); + + _assertGuard(a[0], payload); + _assertAddRollup(a[1]); + _assertGseAddRollup(a[2]); + } + + /// @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[3].data); + + token.mint(address(old), 500e18); + + IPayload.Action[] memory later = payload.getActions(); + (,, uint256 amountAfter) = _decodeRecover(later[3].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, 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, 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, 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 _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; + } +} From 1c0487fc20313154c594c939987ce5b9df0e6e8c Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Tue, 22 Sep 2026 22:21:50 +0900 Subject: [PATCH 4/7] test(l1): prove a stale v6 payload executes nothing at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guarding the transition is only worth anything if a failed guard leaves NOTHING behind. If a rejected execution could still land `Registry.addRollup` and stop before the rest, the guard would have converted one hazard into a worse one: v6 canonical, the GSE never told, the flush incentive stranded on a rewarder nobody will call again. It cannot. `Governance.execute` is a loop of `target.call(...)` with `require(success)` per action inside a single transaction, so a failure unwinds everything, including the actions that already succeeded and the `Executed` flag written before the loop. That is a property of the code rather than of our ordering, and the tests assert it both ways: `test_StalePayloadExecutesNothingAtAll` runs the real thing — RollupBuilder stack, signal, vote, warp to Executable — then moves canonical out from under the queued proposal and executes. It asserts the absence of EVERY later action's effect, not just the first: canonical unchanged, version count unchanged, GSE latest unchanged, old rewarder not drained, new rewarder not funded. It also asserts the proposal is still `Executable` afterwards, since the rolled-back flag is what lets a rejected attempt be retried rather than burning the proposal. Canonical is moved after the proposal is queued, pranked as the registry's owner. Both details are forced: signalling resolves proposers off whatever rollup is canonical, so a stub canonical before the round breaks the proposer machinery rather than the payload; and how canonical moved is irrelevant to a guard that only compares. `test_AFailureAfterAddRollupRollsBackTheRegistration` pins the general property with a payload built to fail in its SECOND action, after a real `addRollup` succeeded in its first. The registration is gone when the transaction ends. This one does not involve the guard at all, and deliberately so: atomicity is what makes the guard's position a readability choice rather than a safety one. Red-green: removing the guard action fails the first test with "next call did not revert as expected" — the stale payload executes clean through and takes canonical with it — and leaves the second passing, which is the split that was wanted. --- .../scenario/V6UpgradeAtomicity.t.sol | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol 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..9ed3a64dadd2 --- /dev/null +++ b/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol @@ -0,0 +1,249 @@ +// 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 {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 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. + return new V6UpgradePayload(IRegistry(address(registry)), IInstance(address(newRollup)), _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; + } +} From 592620f837b236b23dca252ce80ee498ec7d317e Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Tue, 22 Sep 2026 22:30:29 +0900 Subject: [PATCH 5/7] test(l1): deploy the v6 config table on both chains it supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing checked that `_config()` produces a rollup that can actually be deployed. The literals are reviewed by eye, and the first thing that tests them is `run()` on deploy day, against mainnet, with a broadcast in flight. The constructors have opinions — escape-hatch bounds, the reward-override ceiling, duplicate registries, field-element ranges — and every one of them was being discovered at the worst possible moment. This deploys the table against a local stack under `vm.chainId(1)` and `vm.chainId(11155111)` and calls `verify()` on the result, plus the two paths that should fail: an unsupported chain, and the real unmodified script refusing while the genesis roots are zero. WHAT THIS DOES AND DOES NOT CATCH. `verify()` reads the deployed rollup back against `_config()` — the same table — so a wrong literal propagates to both sides and verifies clean. It cannot tell you a value is wrong, only that the value is internally consistent and that the constructors accept it. Judging the numbers stays a review question, which is what the v5 comparison on each line is for. Three production changes, each forced by something a local stack cannot do: - `_config()` is `virtual`, so the harness can supply the three genesis roots. `run()` still refuses while they are zero, and a test asserts that against the unmodified script. - `deployedRollup` / `deployedPayload` are exposed, mirroring `rollupOutput()` on the sibling script. `run()` only logged them, so nothing could read back what it built. - the governance simulation moved behind a `virtual _simulate`, because it needs forked state — real voters, mainnet timings — and is the one step of `run()` a local stack cannot satisfy. It is skipped here and runs on deploy day, against a fork, unchanged. Three things the test had to discover, all now written down where the next person will hit them: the genesis roots are FIELD ELEMENTS, and a stand-in that is not reduced fails with `Rollup__FieldElementOutOfRange` for a reason unrelated to the config; the GSE thresholds are asserted against the GSE that already exists rather than set, so the local stack is raised to production values instead of weakening one of the few assertions that catches a rollup pointed at the wrong GSE; and registry ownership stays with the real Governance, since `run()` resolves `getGovernance()` and drives the payload through it. Mainnet's config names the v5 flush rewarder by address, so the test etches one bound to the outgoing rollup at that literal address rather than editing the table to point somewhere convenient. Runs under `FOUNDRY_PROFILE=coverage`, which supplies the mock verifier; the default profile needs `generated/HonkVerifier.sol` from a noir bootstrap, and no assertion here touches proof verification. --- .../deploy/DeployRollupForUpgradeV6.s.sol | 39 +++- .../script/DeployRollupForUpgradeV6.t.sol | 216 ++++++++++++++++++ 2 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol diff --git a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol index 01bfc6616133..368696f6087b 100644 --- a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol +++ b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol @@ -174,7 +174,10 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { * 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. */ - function _config() internal view returns (Config memory c) { + /// @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 @@ -188,9 +191,9 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { 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 + // 200_000e18 expectedGseEjectionThreshold: 100_000e18, // not set here; asserted against the existing GSE. v5 production: - // 100_000e18 + // 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 @@ -225,7 +228,7 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { 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 + // aztecProofSubmissionEpochs + 1 escapeHatchLagInHatches: 1, // v5 production escapeHatchProposingExitDelay: 30 days, // v5 production; also the maximum the constructor allows enforcePayloadExecutionWindow: true, @@ -266,6 +269,15 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { 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(); @@ -345,11 +357,22 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { console.log("newFlushRewarder ", address(payload.NEW_FLUSH_REWARDER())); verifyFlushRewarder(address(rollup), address(payload)); - // 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 or misbehave. - // Requires forked state; see V6UpgradeSimulation. - new V6UpgradeSimulation().simulate(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}. diff --git a/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol new file mode 100644 index 000000000000..ea3aa31d4e73 --- /dev/null +++ b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol @@ -0,0 +1,216 @@ +// 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 {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"); + } + + 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")); + } +} From 278155d7b0c0f466b6edb3286bf10515eafbe078 Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Tue, 22 Sep 2026 22:52:30 +0900 Subject: [PATCH 6/7] docs(l1): a reviewer's map for the v6 upgrade payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything needed to review this payload existed, spread across the contract's NatSpec, the runbook's "deliberately not done", two PR descriptions and a thread. Both reviewers so far arrived with questions the code does answer — but only to someone who reads the whole file first. This is the page that answers them in one screen. Deliberately NOT a restatement of the contract. It carries what a reviewer needs and the code cannot conveniently say: the action list and when each action is present, what the payload guarantees, what it leaves alone on purpose, the two `cast call`s to run before signalling, and the limits — stated here rather than discovered in review. The limits are the point. That a stale payload is permanently dead is intended but expensive during an incident. That `totalEarmarkedBalance` is deliberately NOT enforced on-chain — because `subsidizeAddress` is permissionless and a 1-wei call would otherwise block the upgrade forever — is a decision that reads as an omission unless someone writes down that it was a decision. Both belong in front of a reviewer, not in a thread. It sits next to the contract rather than in the package README, which is about building and testing l1-contracts and has nothing to say about any one contract. The runbook now points at it: the person running the upgrade and the person reviewing it want different documents, and the runbook is the operator's. --- .../script/deploy/V6_UPGRADE_RUNBOOK.md | 4 + .../src/periphery/V6UpgradePayload.md | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 l1-contracts/src/periphery/V6UpgradePayload.md diff --git a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md index 90b032fb1514..95a6e03afce0 100644 --- a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -3,6 +3,10 @@ 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: diff --git a/l1-contracts/src/periphery/V6UpgradePayload.md b/l1-contracts/src/periphery/V6UpgradePayload.md new file mode 100644 index 000000000000..95046da73d1a --- /dev/null +++ b/l1-contracts/src/periphery/V6UpgradePayload.md @@ -0,0 +1,83 @@ +# 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 | `Registry.addRollup(v6)` | always | makes v6 canonical | +| 4 | `GSE.addRollup(v6)` | always | existing attesters follow without withdrawing and redepositing | +| 5 | `oldFlushRewarder.recover(asset, newRewarder, rewardsAvailable())` | a rewarder exists | carries the entry-queue flush incentive to the replacement | + +Three to five 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. +- **Nothing is owner-gated on the deployer.** The rollup's ownership is handed to governance during + the deploy, before this payload exists. + +## 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 +``` + +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` | From d100b00568534656367eb24522851cfc362d6801 Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Thu, 24 Sep 2026 13:21:58 +0900 Subject: [PATCH 7/7] fix(l1): construct the v6 rollup owned by governance, not the deploy key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rollup constructor's owner argument is load-bearing twice. It becomes the `Ownable` owner, and it is forwarded into the Slasher as its immutable `GOVERNANCE` (`RollupCore.sol:243` and `:270`), which may execute ANY slash payload with no vote, no round and no delay (`Slasher.sol:58`). The NatSpec calls it "the address with owner privileges" and mentions only the first use. The deploy script passed the DEPLOYER there, to run the owner-only one-shot `setEscapeHatch` before handing ownership over. `transferOwnership` moves only the Ownable half. The Slasher's copy is immutable, so the finished deploy left the operational deploy key able to slash any v6 attester, for any amount, at will, for the life of the rollup — and a coordinated slash of the set halts the chain. A leaked key is enough; no malice required. Recovery would not have been quick. The vetoer can only veto a payload address after seeing it, or pause slashing three days at a time. Replacing the Slasher is `SLASHER_EXECUTION_DELAY` of 60 days plus a `LEGACY_SLASHER_DRAIN_WINDOW` of 30 during which the old one still works. Verified before fixing, by running the deploy script in a test: rollup owner came out as governance, `Slasher.GOVERNANCE()` as the deployer. The fix is the shape v5 used: construct with governance and install the hatch through the payload. The alternative — splitting owner from governance in `RollupCore` — is the better long-term answer but changes a core contract and every caller, which is not this PR's business. So the rollup is now owned by governance from construction, nothing owner-gated happens in the script, and `V6UpgradePayload` gains a `setEscapeHatch` action placed before `Registry.addRollup`, so the hatch is installed before v6 is canonical. The payload checks the hatch's `getRollup()` back-pointer in its constructor, because `setEscapeHatch` is one-shot and a hatch bound elsewhere would burn the only chance to install one. `_verifySlashing` now asserts `Slasher.GOVERNANCE() == governance`. Its absence is why this passed review: the function already read VETOER, the disable duration and every proposer immutable, so it looked thorough. The deploy-script test asserts the same thing against a real deploy. Escape-hatch verification moved out of `verify()`, which is a deploy-time check and can no longer see an installed hatch, into `verifyEscapeHatch(rollup, payload)` — mirroring `verifyFlushRewarder`. Installation itself is asserted post-execution by the simulation. Docs corrected. `V6UpgradePayload.md` claimed "Nothing is owner-gated on the deployer", which was true of ownership and false about power, and is exactly the assurance a reviewer would have relied on. Reported by a reviewer against 278155d7; every line of that report was re-verified here. --- .../deploy/DeployRollupForUpgradeV6.s.sol | 61 +++++++++----- .../script/deploy/V6UpgradeSimulation.sol | 4 + .../script/deploy/V6_UPGRADE_RUNBOOK.md | 28 ++++--- .../src/periphery/V6UpgradePayload.md | 19 +++-- .../src/periphery/V6UpgradePayload.sol | 38 ++++++++- .../scenario/V6UpgradeAtomicity.t.sol | 17 +++- .../test/periphery/V6UpgradePayload.t.sol | 82 ++++++++++++++----- .../script/DeployRollupForUpgradeV6.t.sol | 21 +++++ 8 files changed, 207 insertions(+), 63 deletions(-) diff --git a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol index 368696f6087b..009b7cf164b3 100644 --- a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol +++ b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol @@ -11,6 +11,7 @@ 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"; @@ -302,15 +303,23 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { // Always the real verifier: this script exists for upgrades of live networks. IVerifier verifier = IVerifier(address(new HonkVerifier())); - // The 5th argument is the Rollup's owner. Passing `deployer` rather than governance is what - // would let the deployer run owner-gated setup here; ownership is handed to governance - // immediately after, so anything not done before that line needs a governance call instead. + // 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, - deployer, + governance, _genesisState(c), _rollupConfigInput(c, registry.getRewardDistributor()) ); @@ -329,16 +338,14 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { c.escapeHatchProposingExitDelay ); - // `setEscapeHatch` is `onlyOwner` and one-shot: it reverts if a hatch is already registered, - // and it rejects any hatch whose `getRollup()` is not this rollup. Calling it here, while the - // deployer still owns the rollup, is what keeps it out of the governance payload. - rollup.setEscapeHatch(address(escapeHatch)); - - // From here on, only governance can change the rollup's configuration. - rollup.transferOwnership(governance); - + // 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)), FlushRewarder(c.oldFlushRewarder), c.enforcePayloadExecutionWindow + registry, + IInstance(address(rollup)), + IEscapeHatch(address(escapeHatch)), + FlushRewarder(c.oldFlushRewarder), + c.enforcePayloadExecutionWindow ); vm.stopBroadcast(); @@ -351,10 +358,11 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { console.log("outbox ", address(rollup.getOutbox())); console.log("feeJuicePortal ", address(rollup.getFeeAssetPortal())); console.log("slasher ", rollup.getSlasher()); - console.log("escapeHatch ", address(escapeHatch)); + 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)); @@ -384,8 +392,7 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { _verifyGenesisAndTime(rollup, c); _verifyStakingAndFees(rollup, c); _verifyRewards(rollup, c); - _verifySlashing(rollup, c); - _verifyEscapeHatch(rollup, c); + _verifySlashing(rollup, c, registry.getGovernance()); _verifySubContracts(rollup, registry); } @@ -414,12 +421,18 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { assertEq(newRewarder.owner(), Registry(vm.envAddress("REGISTRY_ADDRESS")).getGovernance(), "flush rewarder owner"); } - function _verifyEscapeHatch(Rollup _rollup, Config memory _c) private view { - // A zero address here means `setEscapeHatch` never ran; since it is one-shot and owner-gated, - // that would leave the hatch permanently unsettable except by governance. - EscapeHatch hatch = EscapeHatch(address(_rollup.getEscapeHatch())); - assertTrue(address(hatch) != address(0), "escape hatch not set on the rollup"); + /** + * @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"); @@ -486,7 +499,7 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { assertEq(overrides[1].sequencerReward, _c.rewardOverrideSequencerReward1, "rewardOverrideSequencerReward1"); } - function _verifySlashing(Rollup _rollup, Config memory _c) private view { + 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"); @@ -494,6 +507,10 @@ contract DeployRollupForUpgradeV6 is Script, StdAssertions { } 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"); diff --git a/l1-contracts/script/deploy/V6UpgradeSimulation.sol b/l1-contracts/script/deploy/V6UpgradeSimulation.sol index 079cd5502411..f7ebd6af1f7a 100644 --- a/l1-contracts/script/deploy/V6UpgradeSimulation.sol +++ b/l1-contracts/script/deploy/V6UpgradeSimulation.sol @@ -196,6 +196,10 @@ contract V6UpgradeSimulation is Test { // 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); } diff --git a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md index 95a6e03afce0..0caba7541603 100644 --- a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -12,23 +12,25 @@ guarantees, and deliberately leaves alone. The deploy script deploys, in one broadcast: 1. `HonkVerifier` — the real epoch proof verifier. -2. `Rollup` — owned by the **deployer** at construction, not governance. Deploying it also - constructs its `Inbox`, `Outbox`, `FeeJuicePortal`, `Slasher` + `SlashingProposer`, and a fresh - `RewardBooster`. -3. `EscapeHatch`, then `rollup.setEscapeHatch(...)` — done here, while the deployer still owns the - rollup. `setEscapeHatch` is `onlyOwner` and one-shot. -4. `rollup.transferOwnership(governance)` — after this line nothing owner-gated can be done - without a governance proposal. -5. `V6UpgradePayload` — and, on a chain with a flush rewarder, a replacement `FlushRewarder` +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 | `Registry.addRollup(v6)` | makes v6 the canonical rollup | -| 2 | `GSE.addRollup(v6)` | lets existing attesters follow without redepositing | -| 3 | `oldFlushRewarder.recover(asset, newRewarder, rewardsAvailable())` | carries the entry-queue flush incentive across (skipped if there is no old rewarder) | +| 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 @@ -151,7 +153,9 @@ 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 # the deployed hatch, non-zero +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 diff --git a/l1-contracts/src/periphery/V6UpgradePayload.md b/l1-contracts/src/periphery/V6UpgradePayload.md index 95046da73d1a..4166bd2b6713 100644 --- a/l1-contracts/src/periphery/V6UpgradePayload.md +++ b/l1-contracts/src/periphery/V6UpgradePayload.md @@ -14,11 +14,12 @@ Governance runs each as `target.call(data)` from its own address, in this order, |---|---|---|---| | 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 | `Registry.addRollup(v6)` | always | makes v6 canonical | -| 4 | `GSE.addRollup(v6)` | always | existing attesters follow without withdrawing and redepositing | -| 5 | `oldFlushRewarder.recover(asset, newRewarder, rewardsAvailable())` | a rewarder exists | carries the entry-queue flush incentive to the replacement | +| 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 | -Three to five actions depending on chain. Sepolia has neither the window nor a rewarder. +Four to six actions depending on chain. Sepolia has neither the window nor a rewarder. ## What it guarantees @@ -30,8 +31,12 @@ Three to five actions depending on chain. Sepolia has neither the window nor a r - **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. -- **Nothing is owner-gated on the deployer.** The rollup's ownership is handed to governance during - the deploy, before this payload exists. +- **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 @@ -51,6 +56,8 @@ Three to five actions depending on chain. Sepolia has neither the window nor a r 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. diff --git a/l1-contracts/src/periphery/V6UpgradePayload.sol b/l1-contracts/src/periphery/V6UpgradePayload.sol index b9d4799c58b3..3a9fc90d2e22 100644 --- a/l1-contracts/src/periphery/V6UpgradePayload.sol +++ b/l1-contracts/src/periphery/V6UpgradePayload.sol @@ -2,7 +2,9 @@ // 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"; @@ -39,6 +41,14 @@ contract V6UpgradePayload is IPayload { /// 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; @@ -58,18 +68,34 @@ contract V6UpgradePayload is IPayload { /// @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, FlushRewarder _oldFlushRewarder, bool _enforceExecutionWindow) { + 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; @@ -108,7 +134,7 @@ contract V6UpgradePayload is IPayload { uint256 next = 0; IPayload.Action[] memory res = - new IPayload.Action[](1 + (ENFORCE_EXECUTION_WINDOW ? 1 : 0) + (migrateFlushRewarder ? 3 : 2)); + 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. @@ -138,6 +164,14 @@ contract V6UpgradePayload is IPayload { 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++] = diff --git a/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol b/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol index 9ed3a64dadd2..f1b658ce2af7 100644 --- a/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol +++ b/l1-contracts/test/governance/scenario/V6UpgradeAtomicity.t.sol @@ -13,6 +13,7 @@ import {IRegistry, IHaveVersion} from "@aztec/governance/interfaces/IRegistry.so 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"; @@ -26,6 +27,19 @@ 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_; @@ -214,7 +228,8 @@ contract V6UpgradeAtomicityTest is TestBase { 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. - return new V6UpgradePayload(IRegistry(address(registry)), IInstance(address(newRollup)), _old, false); + 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. diff --git a/l1-contracts/test/periphery/V6UpgradePayload.t.sol b/l1-contracts/test/periphery/V6UpgradePayload.t.sol index ca680269afbe..a9f07a132e7f 100644 --- a/l1-contracts/test/periphery/V6UpgradePayload.t.sol +++ b/l1-contracts/test/periphery/V6UpgradePayload.t.sol @@ -8,6 +8,8 @@ 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"; @@ -35,6 +37,20 @@ contract StubRollup { } } +/// @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; @@ -48,6 +64,9 @@ contract V6UpgradePayloadTest is TestBase { /// @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; @@ -61,6 +80,7 @@ contract V6UpgradePayloadTest is TestBase { incoming = IInstance(address(new StubRollup(gseAddr))); registry.addRollup(_haveVersion(outgoing)); + hatch = IEscapeHatch(address(new StubEscapeHatch(address(incoming)))); payloadForWindow = _deploy({_window: true, _withRewarder: false}); } @@ -105,7 +125,7 @@ contract V6UpgradePayloadTest is TestBase { registry.addRollup(_haveVersion(other)); IPayload.Action[] memory actions = payload.getActions(); - assertEq(actions.length, 5, "stale payload no longer describes itself"); + assertEq(actions.length, 6, "stale payload no longer describes itself"); assertEq(bytes4(actions[0].data), payload.assertPredecessorIsCanonical.selector, "guard action lost"); } @@ -132,45 +152,49 @@ contract V6UpgradePayloadTest is TestBase { function test_ActionListShapeWindowAndRewarder() public { V6UpgradePayload payload = _deploy({_window: true, _withRewarder: true}); IPayload.Action[] memory a = payload.getActions(); - assertEq(a.length, 5, "wrong action count"); + assertEq(a.length, 6, "wrong action count"); _assertGuard(a[0], payload); _assertWindow(a[1], payload); - _assertAddRollup(a[2]); - _assertGseAddRollup(a[3]); - _assertRecover(a[4], 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, 4, "wrong action count"); + assertEq(a.length, 5, "wrong action count"); _assertGuard(a[0], payload); _assertWindow(a[1], payload); - _assertAddRollup(a[2]); - _assertGseAddRollup(a[3]); + _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, 4, "wrong action count"); + assertEq(a.length, 5, "wrong action count"); _assertGuard(a[0], payload); - _assertAddRollup(a[1]); - _assertGseAddRollup(a[2]); - _assertRecover(a[3], 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, 3, "wrong action count"); + assertEq(a.length, 4, "wrong action count"); _assertGuard(a[0], payload); - _assertAddRollup(a[1]); - _assertGseAddRollup(a[2]); + _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 @@ -180,12 +204,12 @@ contract V6UpgradePayloadTest is TestBase { FlushRewarder old = payload.OLD_FLUSH_REWARDER(); IPayload.Action[] memory before = payload.getActions(); - (,, uint256 amountBefore) = _decodeRecover(before[3].data); + (,, uint256 amountBefore) = _decodeRecover(before[4].data); token.mint(address(old), 500e18); IPayload.Action[] memory later = payload.getActions(); - (,, uint256 amountAfter) = _decodeRecover(later[3].data); + (,, 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"); @@ -204,13 +228,25 @@ contract V6UpgradePayloadTest is TestBase { V6UpgradePayload.V6UpgradePayload__FlushRewarderRollupMismatch.selector, address(incoming), address(outgoing) ) ); - new V6UpgradePayload(IRegistry(address(registry)), incoming, foreign, false); + 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, FlushRewarder(address(0)), false); + 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 { @@ -341,7 +377,7 @@ contract V6UpgradePayloadTest is TestBase { if (_withRewarder) { token.mint(address(old), 1000e18); } - return new V6UpgradePayload(IRegistry(address(registry)), incoming, old, _window); + return new V6UpgradePayload(IRegistry(address(registry)), incoming, hatch, old, _window); } function _haveVersion(IInstance _rollup) internal pure returns (IHaveVersion) { @@ -359,6 +395,12 @@ contract V6UpgradePayloadTest is TestBase { 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"); diff --git a/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol index ea3aa31d4e73..5273b6d48e16 100644 --- a/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol +++ b/l1-contracts/test/script/DeployRollupForUpgradeV6.t.sol @@ -11,6 +11,7 @@ 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"; @@ -121,6 +122,26 @@ contract DeployRollupForUpgradeV6Test is Test { 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();