From efeb2d44a9322d86b436f925af77e1eeecb09770 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 8 Oct 2026 14:41:31 -0300 Subject: [PATCH] docs(l1): fix stale v6 upgrade runbook and comments - Deploy script header described the old deployer-owned-then-transferred flow; the rollup is owned by governance from construction. - State which values verify() does not read back. - Runbook: drop the incorrect 'totalEarmarkedBalance must be 0' precondition (earmarks stay claimable and subsidizeAddress cannot shrink the implicit pool); check availableTo(v5) covers the reservation instead. - Runbook: list the six library deployments and the measured gas, add a verifier bytecode check, fix action numbers, fill in placeholder commands. --- .../deploy/DeployRollupForUpgradeV6.s.sol | 14 +++-- .../script/deploy/V6_UPGRADE_RUNBOOK.md | 57 +++++++++++++------ .../src/periphery/V6UpgradePayload.md | 14 +++-- .../src/periphery/V6UpgradePayload.sol | 4 +- 4 files changed, 59 insertions(+), 30 deletions(-) diff --git a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol index c5a1e7093f2..912f1df137a 100644 --- a/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol +++ b/l1-contracts/script/deploy/DeployRollupForUpgradeV6.s.sol @@ -50,13 +50,15 @@ import {V6UpgradeSimulation} from "./V6UpgradeSimulation.sol"; * `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. + * deployed. {verify} reads values back off the deployed contracts and asserts they match the + * table, so a typo in the table or a drift in how the Rollup consumes it fails loudly. It does + * not read back the entry-queue config, the reward-boost parameters, `ethereumSlotDuration` + * or the verifier's bytecode and `VK_HASH`; check those against the table and the pinned + * ./HonkVerifier.sol by other means. * - * 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. + * The rollup is constructed owned by governance, so the deployer never holds owner rights + * over it and nothing owner-gated happens in this script. The EscapeHatch is deployed here + * but installed by the payload, and anything else owner-gated needs a governance proposal. * * 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 diff --git a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md index c8cbccedd62..3c2252e05f0 100644 --- a/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md +++ b/l1-contracts/script/deploy/V6_UPGRADE_RUNBOOK.md @@ -9,8 +9,12 @@ guarantees, and deliberately leaves alone. ## What this does -The deploy script deploys, in one broadcast: +The deploy script deploys, in one broadcast of ten transactions: +0. Six external libraries the `Rollup` links against (`AttesterExitExtLib`, + `ValidatorOperationsExtLib`, `SlasherDeploymentExtLib`, `RollupOperationsExtLib`, `RewardExtLib`, + `EpochProofExtLib`), via the CREATE2 deployer. They go out first and account for about 33M of the + roughly 58M gas the broadcast uses. 1. `HonkVerifier` — the real epoch proof verifier, from the pinned `script/deploy/HonkVerifier.sol` (see [Build](#1-build)), not from this tree's `generated/`. 2. `Rollup` — owned by **governance** from construction. Deploying it also constructs its `Inbox`, @@ -85,7 +89,9 @@ forge script script/deploy/DeployRollupForUpgradeV6.s.sol --sig 'run()' \ - Gas: forge sets each transaction's gas limit from its local simulation (`evm_version = 'prague'`), which matches pre-Glamsterdam pricing, so the default limits are correct on mainnet before the fork. Measured pre-fork on Sepolia: verifier 3.75M gas (limit 4.88M), rollup 11.98M (limit - 15.58M). Do **not** add `--gas-estimate-multiplier` before Glamsterdam: the rollup's limit would + 15.58M). A mainnet dry run (2026-10-08) estimated 58.4M gas across all ten transactions, each + limit under the cap, the largest being the rollup at 15.58M; budget roughly 0.25-0.4 ETH at + 4-6 gwei. Do **not** add `--gas-estimate-multiplier` before Glamsterdam: the rollup's limit would exceed the EIP-7825 per-transaction cap of 16,777,216 and the node would reject it after the verifier had already been sent. The rollup's default limit sits about 1.2M under that cap. - After Glamsterdam, the default limits are far too low: on post-fork Sepolia the verifier creation @@ -203,6 +209,7 @@ 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) +TOKEN=$(cast call $ROLLUP "getFeeAsset()(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 @@ -212,21 +219,30 @@ cast call $GSE "owner()(address)" --rpc-url $RPC # governance, 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 +# the reservation's headroom: the implicit pool plus what is already earmarked to the outgoing +# rollup must cover the earmark amount (1,800,000e18), or action 3 reverts +cast call $TOKEN "balanceOf(address)(uint256)" $RD --rpc-url $RPC cast call $RD "totalEarmarkedBalance()(uint256)" --rpc-url $RPC cast call $RD "specificRecipientBalance(address)(uint256)" $ROLLUP --rpc-url $RPC +cast call $RD "availableTo(address)(uint256)" $ROLLUP --rpc-url $RPC # >= 1,800,000e18 # how much the payload will actually move (balance minus unclaimed debt) cast call 0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e "rewardsAvailable()(uint256)" --rpc-url $RPC +cast call 0x5B98cA4dcE7b59CCf241D12f81d3d2eCF14e410e "owner()(address)" --rpc-url $RPC # governance, or action 9 reverts ``` 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. +Re-measured 2026-10-08 (block 26149128): attesters 3090; distributor holds 85.76M with +`totalEarmarkedBalance` 0; 363,900e18 movable from the flush rewarder. -`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. +`totalEarmarkedBalance` does **not** need to be 0. Earmarks are keyed by address and stay +claimable by their recipient whether or not it is canonical, and `subsidizeAddress` (which anyone +can call) raises the distributor's balance and `totalEarmarkedBalance` by the same amount, so it +cannot shrink the implicit pool. A donation earmarked to the outgoing rollup before execution +simply adds to the reservation. What matters is `availableTo($ROLLUP)` covering the earmark +amount, checked above. ## 4. Dry run @@ -284,12 +300,18 @@ forge script script/deploy/V6UpgradeSimulation.sol:V6UpgradeSimulation \ Sanity-check by hand that the rollup is inert and correctly owned: ```bash -cast call "owner()(address)" --rpc-url $RPC # governance cast call "getEscapeHatch()(address)" --rpc-url $RPC # ZERO until the payload executes cast call "ESCAPE_HATCH()(address)" --rpc-url $RPC # the hatch it will install cast call "owner()(address)" --rpc-url $RPC # governance, from construction cast call "getVersion()(uint256)" --rpc-url $RPC # not already in the registry +# the rollup uses the pinned verifier. `verify` does not check this, and the verifier is immutable, +# so a wrong one cannot be fixed after the payload executes. The two hashes must be equal +# (HonkVerifier has no immutables, so its runtime code is exactly the compiled artifact). +cast call "getEpochProofVerifier()(address)" --rpc-url $RPC # == the logged verifier +cast code --rpc-url $RPC | cast keccak +forge inspect script/deploy/HonkVerifier.sol:HonkVerifier deployedBytecode | cast keccak + # 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 @@ -310,15 +332,15 @@ 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. So -is the earmark amount's headroom: `subsidizeAddress` is permissionless, so anyone can raise -`totalEarmarkedBalance` and shrink the implicit pool the reservation draws from. +Before execution, re-run the reservation-headroom and `rewardsAvailable` checks from step 3. Both +can move while the proposal is pending: the canonical rollup's reward claims draw the implicit pool +down, and `rewardsAvailable` is read at execution time. A non-zero or growing +`totalEarmarkedBalance` on its own is not a reason to hold the execution; see step 3. ### Executing on mainnet: office hours only The mainnet payload will only execute **Monday to Friday, 08:00–17:00 London**, DST included — -08:00–17:00 UTC in winter, 07:00–16:00 UTC in summer. Outside that the first action reverts and +08:00–17:00 UTC in winter, 07:00–16:00 UTC in summer. Outside that the window check (action 2) reverts and the whole execution rolls back, including the `Executed` flag, so the proposal stays executable and can simply be retried when the window next opens. Nothing is consumed by a rejected attempt. @@ -374,10 +396,10 @@ cast call "rewardsAvailable()(uint256)" --rpc-url $RPC cast call $TOKEN "balanceOf(address)(uint256)" $RD --rpc-url $RPC # same as before cast call $RD "totalEarmarkedBalance()(uint256)" --rpc-url $RPC # up by the amount cast call $RD "specificRecipientBalance(address)(uint256)" $ROLLUP --rpc-url $RPC # up by the amount -cast call "..." # payload holds none of the asset +cast call $TOKEN "balanceOf(address)(uint256)" --rpc-url $RPC # 0 -# the retune, read off the OUTGOING rollup -cast call $ROLLUP "getRewardConfig()" --rpc-url $RPC +# the retune, read off the OUTGOING rollup: (distributor, 7000, booster, 50e18) +cast call $ROLLUP "getRewardConfig()((address,uint32,address,uint96))" --rpc-url $RPC # the proof-of-possession gas cap, shared by every rollup on the GSE cast call $GSE "proofOfPossessionGasLimit()(uint64)" --rpc-url $RPC # 300000 @@ -399,8 +421,9 @@ only visible to whichever rollup is currently canonical. 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. + off the registry, so its implicit pool follows v6 the moment action 7 (`Registry.addRollup`) + executes. Balances earmarked to other addresses, including the reservation for the outgoing + rollup, stay with their recipients and are not part of what v6 inherits. - **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. diff --git a/l1-contracts/src/periphery/V6UpgradePayload.md b/l1-contracts/src/periphery/V6UpgradePayload.md index 2d830a5a974..3977bcb25e4 100644 --- a/l1-contracts/src/periphery/V6UpgradePayload.md +++ b/l1-contracts/src/periphery/V6UpgradePayload.md @@ -78,11 +78,15 @@ A registration payload without that guard is the hazard the guard exists to remo - **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. +- **Existing earmarks are left alone.** v6 inherits the implicit pool, `balance - + totalEarmarkedBalance`, and whatever is earmarked to other addresses at execution stays + earmarked to them. Earmarks are keyed by address, so a recipient that stops being canonical can + still claim its own, and governance can recover any of them via `recoverFrom`. The payload's own + reservation adds `EARMARK_AMOUNT` to v5's earmark, so `totalEarmarkedBalance` is non-zero after + execution by design. `subsidizeAddress` is permissionless but cannot shrink the implicit pool: it + raises the balance and the earmarked total by the same amount. The only thing that can make the + reservation revert is the implicit pool plus v5's existing earmark falling below + `EARMARK_AMOUNT` by execution. - **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. - **The proof-of-possession gas cap is checked against the GSE only at deployment.** The constructor diff --git a/l1-contracts/src/periphery/V6UpgradePayload.sol b/l1-contracts/src/periphery/V6UpgradePayload.sol index a1a0f9fd477..f0a6e69b447 100644 --- a/l1-contracts/src/periphery/V6UpgradePayload.sol +++ b/l1-contracts/src/periphery/V6UpgradePayload.sol @@ -24,8 +24,8 @@ import {IERC20} from "@oz/token/ERC20/IERC20.sol"; * 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). + * `setProtocolFeeRecipient` / `setProtocolFeeMargin`, which cannot be set any other way because + * the rollup is owned by governance from construction). */ contract V6UpgradePayload is IPayload { /// @notice London-local hour the window opens, inclusive.