perf(l1): hold the rollup config in immutables instead of storage - #25314
Merged
Merged
Conversation
spalladino
force-pushed
the
spl/rollup-config-immutable
branch
from
August 25, 2026 19:56
e23f0c0 to
5dd1645
Compare
spalladino
force-pushed
the
spl/rollup-config-immutable
branch
from
August 25, 2026 21:55
b792779 to
08920ae
Compare
This was referenced Aug 25, 2026
Every field of `RollupConfig` is written exactly once, in the Rollup's constructor, and has no setter. Move all eight into immutables and drop `config` from `RollupStore`. Libraries cannot read a contract's immutables -- and the ExtLibs are external, so they cannot either -- so the values are assembled into a memory `RollupConfig` and threaded down: the full struct into the epoch proof path, the Inbox into propose (bundled with `checkBlob` into a `ProposeConfig` to stay under the stack limit), and the fee asset into the reward claims. `config` was the last member of `RollupStore`, so `tips`, `archives` and `tempCheckpointLogs` keep their slots and the cheatcode offsets that depend on them are unaffected. The TS client read `vkTreeRoot` and `protocolContractsHash` out of raw storage; it now uses the contract getters. Gas: propose -1,893, submitEpochRootProof -10,807 (avg, no-validators benchmark).
`Rollup.validateHeaderWithAttestations` decoded seven parameters, built a `ValidateHeaderArgs` (which embeds a full `ProposedHeader`) in memory, resolved the mana min fee through two delegatecall hops, then re-encoded four arguments for the ExtLib call. All of that lived in the Rollup's own runtime code. Forward the parameters straight through and assemble the struct in `RollupOperationsExtLib` instead. Frees 630 bytes of Rollup runtime bytecode, bringing the EIP-170 margin back to 725 from the 96 the immutables change left it at. The mana min fee is computed identically: `RewardExtLib.summedMinFee` and `.getManaMinFeeComponentsAt` are themselves one-line forwarders to `FeeLib.summedMinFee` and `ProposeLib.getManaMinFeeComponentsAt`.
Mirrors the benchmark refresh. The config getters drop their cold SLOAD (getInbox 2,543 -> 878, getVersion 1,448 -> 852, getOutbox 2,521 -> 856), propose falls 267,116 -> 265,187 and submitEpochRootProof 386,659 -> 376,201, matching the gas_benchmark deltas. A handful of unrelated views move by 22-44 gas as the Rollup's selector dispatch shifts. Call counts on some entries drop by 27 because the three tests that only fail under FORGE_GAS_REPORT (testExtraBlobs, testRevertInvalidCoinbase, testRevertInvalidTimestamp, all failing identically on the base commit) abort at a slightly different point. Per-call gas for those entries is unchanged.
solhint's no-unused-import is an error in .solhint.json, and two imports went stale: `ValidateHeaderArgs` in Rollup.sol, once assembling that struct moved into RollupOperationsExtLib, and `RollupStore` in RollupCore.sol, once `config` left the struct and RollupCore stopped touching storage directly. RollupCore had been re-exporting `RollupStore` to its importers, so Rollup.sol and RollupWithPreheating.sol now take it straight from IRollup.sol instead.
spalladino
force-pushed
the
spl/rollup-config-immutable
branch
from
August 28, 2026 18:51
08920ae to
807803a
Compare
spalladino
changed the base branch from
spl/a-1390-inbox-ring-overwrite-protection
to
project/fast-inbox
August 28, 2026 18:51
Collaborator
Flakey Tests🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry. |
LeilaWang
approved these changes
Sep 1, 2026
spalladino
added a commit
that referenced
this pull request
Sep 4, 2026
…o 4096 (#25305) Stacked on #25314. Opening an Inbox bucket whose ring slot still holds an entry the proven chain has not consumed now reverts, and the bucket ring grows from 1024 to 4096. ## Context The Inbox stores rolling-hash buckets in a ring keyed `bucketSeq % BUCKET_RING_SIZE`, so opening bucket `n` overwrote bucket `n − RING_SIZE` unconditionally. If L2 stops consuming for long enough (outage, or a spam burst forcing rollover buckets), unconsumed buckets get destroyed — and worse: every retained bucket ends up past the 1024-message checkpoint cap while every under-cap bucket has fallen out of the `getBucket` window, so no bucket is proposable and the pending chain deadlocks permanently (pinned by `InboxRingDeadlock.t.sol`). ## Approach - **Revert-on-overwrite anchored to proven consumption.** `_absorbIntoBucket` refuses to open bucket `n ≥ RING_SIZE` unless `provenConsumedBucketSeq ≥ n − RING_SIZE`, reverting `Inbox__WouldOverwriteUnconsumedBucket(evicted)`. Sends halt instead of destroying messages, and resume automatically once proving catches up. Anchoring to the *proven* tip is prune-immune (it never rewinds) and fail-closed (the record can only lag). No governance escape hatch — an override would recreate the deadlock. - **`Inbox.markProvenConsumed(uint64)`**: ROLLUP-only, monotonic, never ahead of `currentBucketSeq`; packed in the same slot as `currentBucketSeq` so the check reads warm. Called from the single proven-tip-advancing branch of `submitEpochRootProof`, through the `RollupConfig` that #25314 threads down as a memory struct. - **`TempCheckpointLog.inboxConsumedBucket` re-added** (it was dropped as write-only before this consumer existed). Propose stores the validated `bucketHint`; the proven-tip advance reads it back. It packs into the existing `slotNumber`/`inboxMsgTotal` word (offsets 0/4/12, struct still 8 words), so no extra storage. The TS state-override encoder follows. - **Ring size 1024 → 4096.** The size is an economics knob, not a safety property: a forced rollover bucket costs ~2.2M gas, so exhausting headroom takes ~62 continuously-owned full 36M-gas L1 blocks (~12.5 min, ~2.2B gas) at 1024 vs ~250 blocks (~50 min, ~9B gas) at 4096, and the honest proving-lag floor (~385 buckets) gets ~10× margin. Ring size bounds retention only: +192 KiB of eventual state, no per-send or deploy gas. `MIN_BUCKET_RING_SIZE` stays 512. - **Observability**: `getRingHeadroom()` (bucket openings left before sends halt) and `getProvenConsumedBucketSeq()`. Gas: bucket-opening `sendL2Message` +41 pre-wrap, +393 on a wrapping open (steady state); absorb-into-open-bucket unchanged. `submitEpochRootProof` pays the Inbox write (a cold call plus the Inbox's own SLOAD/SSTORE — the Inbox address itself is an immutable after #25314) only when the epoch consumed messages — equal start/end rolling hashes skip it — so the message-less benchmark scenario reads 980,225 → 980,266, while the message-consuming `gas_report` fixtures pay it in full (mean 366,242 → 376,201). `propose` 197,433 → 197,740 (the temp-log read on proof is warm; the consumed-bucket field shares the slot-number word). ## How to review Source: `Inbox.sol` (check + `markProvenConsumed` + headroom), `EpochProofLib.sol` (call site), `CheckpointLog.sol` / `ProposeLib.sol` / `STFLib.sol` (temp-log field). Tests: `InboxOverwriteProtection.t.sol` (wrap/boundary/resume/authority/headroom/batch atomicity/fuzz from the ring wall) and `rollup/InboxRingDeadlock.t.sol` (unprotected ring deadlocks, protected one keeps a proposable cursor); `Rollup.t.sol` covers proven-tip advance, pending-only propose not unlocking, and prune/re-propose/prove. Full forge suite 912/0/3. Unrelated, pre-existing: `./bootstrap.sh gas_report` exits non-zero because three `RollupTest` cases fail only under `FORGE_GAS_REPORT=true`; identical on the base. Fixes A-1390 Fixes A-1757
spalladino
added a commit
that referenced
this pull request
Sep 10, 2026
…5314) This is the bottom of the Fast Inbox stack: it targets `project/fast-inbox` directly and is independently mergeable — nothing in it depends on the inbox work above it. Every field of `RollupConfig` — `vkTreeRoot`, `protocolContractsHash`, `version`, `feeAsset`, `feeAssetPortal`, `epochProofVerifier`, `inbox`, `outbox` — is written exactly once in the Rollup's constructor and has no setter anywhere in `src/`. They were nonetheless kept in storage, so every read paid a cold `SLOAD`. `propose` paid one, `submitEpochRootProof` paid six. Move all eight into immutables and drop `config` from `RollupStore`. Libraries cannot read a contract's immutables, and the `*ExtLib` libraries here are `external` (delegatecalled), so they cannot either. The values are assembled into a memory `RollupConfig` by `RollupCore._getRollupConfig()` and threaded down as parameters: the full struct into the epoch-proof path, the `IInbox` into propose, and the fee asset into the reward claims. Propose needed its `IInbox` bundled with the existing `checkBlob` flag into a `ProposeConfig` struct — a seventh scalar parameter pushed `ProposeLib.propose` over the stack limit, and a memory struct costs one slot instead of two. `config` was the last member of `RollupStore`, so `tips`, `archives` and `tempCheckpointLogs` keep slots 0–2 and every raw-slot consumer of `keccak256("aztec.stf.storage")` is unaffected — all of them use offsets ≤ +2. The two that used `+3`/`+4` were `RollupContract.getVkTreeRoot` and `getProtocolContractsHash` in the TS client, which now call the contract getters that `IRollup` has exposed since #22563. The second commit is bytecode budget, not gas. Each immutable read inlines a 32-byte push, which grew `Rollup`'s runtime code to within 148 bytes of the EIP-170 limit. `Rollup.validateHeaderWithAttestations` was decoding seven parameters, building a `ValidateHeaderArgs` (which embeds a full `ProposedHeader`) in memory, resolving the mana min fee through two delegatecall hops, and re-encoding four arguments — all in the Rollup's own runtime code. Forwarding the parameters straight through and assembling the struct in `RollupOperationsExtLib` frees 629 bytes. The fee value is unchanged: `RewardExtLib.summedMinFee` and `.getManaMinFeeComponentsAt` are one-line forwarders to the `FeeLib` / `ProposeLib` functions the ExtLib now calls directly. | Benchmark | Before | After | Δ | |---|---|---|---| | `propose` (no validators) | 199,366 | 197,433 | **−1,933** | | `submitEpochRootProof` (no validators) | 991,032 | 980,225 | **−10,807** | | `propose` (100 validators) | 327,774 | 325,847 | **−1,927** | | `submitEpochRootProof` (100 validators) | 1,572,081 | 1,561,291 | **−10,790** | | `aggregate3` (100 validators) | 376,665 | 374,738 | **−1,927** | The config getters lose their cold `SLOAD` outright — `getInbox` 2,543 → 878, `getVersion` 1,447 → 852, `getOutbox` 2,521 → 856. A handful of unrelated views move by 22–44 gas as the Rollup's selector dispatch shifts. Deployment also drops eight `SSTORE`s. `Rollup` runtime bytecode ends at 23,799 against the 24,576 limit — 777 bytes of margin, against 886 before this change. Note on the regenerated `gas_report.json`: call counts on a few entries drop by 36 because the three tests that fail only under `FORGE_GAS_REPORT` (`testExtraBlobs`, `testRevertInvalidCoinbase`, `testRevertInvalidTimestamp` — all failing identically on the base commit) abort at a slightly different point. Per-call gas for those entries is unchanged. Node binaries already in the field read `vkTreeRoot` and `protocolContractsHash` from raw slots `+3`/`+4`. Against a rollup deployed from this branch those slots are zero, so such a node's `waitForCompatibleRollup` reports a VK mismatch and sits in standby. it work against a rollup deployed from either version; it does nothing for binaries already released, so this needs sequencing against any node rollout.
spalladino
added a commit
that referenced
this pull request
Sep 10, 2026
…5314) This is the bottom of the Fast Inbox stack: it targets `project/fast-inbox` directly and is independently mergeable — nothing in it depends on the inbox work above it. Every field of `RollupConfig` — `vkTreeRoot`, `protocolContractsHash`, `version`, `feeAsset`, `feeAssetPortal`, `epochProofVerifier`, `inbox`, `outbox` — is written exactly once in the Rollup's constructor and has no setter anywhere in `src/`. They were nonetheless kept in storage, so every read paid a cold `SLOAD`. `propose` paid one, `submitEpochRootProof` paid six. Move all eight into immutables and drop `config` from `RollupStore`. Libraries cannot read a contract's immutables, and the `*ExtLib` libraries here are `external` (delegatecalled), so they cannot either. The values are assembled into a memory `RollupConfig` by `RollupCore._getRollupConfig()` and threaded down as parameters: the full struct into the epoch-proof path, the `IInbox` into propose, and the fee asset into the reward claims. Propose needed its `IInbox` bundled with the existing `checkBlob` flag into a `ProposeConfig` struct — a seventh scalar parameter pushed `ProposeLib.propose` over the stack limit, and a memory struct costs one slot instead of two. `config` was the last member of `RollupStore`, so `tips`, `archives` and `tempCheckpointLogs` keep slots 0–2 and every raw-slot consumer of `keccak256("aztec.stf.storage")` is unaffected — all of them use offsets ≤ +2. The two that used `+3`/`+4` were `RollupContract.getVkTreeRoot` and `getProtocolContractsHash` in the TS client, which now call the contract getters that `IRollup` has exposed since #22563. The second commit is bytecode budget, not gas. Each immutable read inlines a 32-byte push, which grew `Rollup`'s runtime code to within 148 bytes of the EIP-170 limit. `Rollup.validateHeaderWithAttestations` was decoding seven parameters, building a `ValidateHeaderArgs` (which embeds a full `ProposedHeader`) in memory, resolving the mana min fee through two delegatecall hops, and re-encoding four arguments — all in the Rollup's own runtime code. Forwarding the parameters straight through and assembling the struct in `RollupOperationsExtLib` frees 629 bytes. The fee value is unchanged: `RewardExtLib.summedMinFee` and `.getManaMinFeeComponentsAt` are one-line forwarders to the `FeeLib` / `ProposeLib` functions the ExtLib now calls directly. | Benchmark | Before | After | Δ | |---|---|---|---| | `propose` (no validators) | 199,366 | 197,433 | **−1,933** | | `submitEpochRootProof` (no validators) | 991,032 | 980,225 | **−10,807** | | `propose` (100 validators) | 327,774 | 325,847 | **−1,927** | | `submitEpochRootProof` (100 validators) | 1,572,081 | 1,561,291 | **−10,790** | | `aggregate3` (100 validators) | 376,665 | 374,738 | **−1,927** | The config getters lose their cold `SLOAD` outright — `getInbox` 2,543 → 878, `getVersion` 1,447 → 852, `getOutbox` 2,521 → 856. A handful of unrelated views move by 22–44 gas as the Rollup's selector dispatch shifts. Deployment also drops eight `SSTORE`s. `Rollup` runtime bytecode ends at 23,799 against the 24,576 limit — 777 bytes of margin, against 886 before this change. Note on the regenerated `gas_report.json`: call counts on a few entries drop by 36 because the three tests that fail only under `FORGE_GAS_REPORT` (`testExtraBlobs`, `testRevertInvalidCoinbase`, `testRevertInvalidTimestamp` — all failing identically on the base commit) abort at a slightly different point. Per-call gas for those entries is unchanged. Node binaries already in the field read `vkTreeRoot` and `protocolContractsHash` from raw slots `+3`/`+4`. Against a rollup deployed from this branch those slots are zero, so such a node's `waitForCompatibleRollup` reports a VK mismatch and sits in standby. it work against a rollup deployed from either version; it does nothing for binaries already released, so this needs sequencing against any node rollout.
spalladino
added a commit
that referenced
this pull request
Sep 10, 2026
ludamad
pushed a commit
to r3sako/aztec-packages
that referenced
this pull request
Sep 17, 2026
Rebuilds the v6 L1 integration branch on the latest `next` using cherry-picked commits. This includes AZIPs [23](AztecProtocol/governance#58), [24](AztecProtocol/governance#64), [25](AztecProtocol/governance#65) (all approved in last ACD), plus gas optimizations and refactors to work around the Rollup contract size limit. ## Included PRs - [AztecProtocol#25260](AztecProtocol#25260) — feat: introduce a protocol fee margin (AZIP-23) - [AztecProtocol#25370](AztecProtocol#25370) — chore: update activity score to only track full epoch proofs (AZIP-25) - [AztecProtocol#25386](AztecProtocol#25386) — refactor(l1): reduce full epoch proof gas overhead - [AztecProtocol#25389](AztecProtocol#25389) — feat(l1): track which prover first proved each checkpoint (AZIP-24) - [AztecProtocol#25314](AztecProtocol#25314) — perf(l1): hold the rollup config in immutables instead of storage - [AztecProtocol#25404](AztecProtocol#25404) — feat: only verify new headers - [AztecProtocol#25406](AztecProtocol#25406) — feat: optimize proof submission - [AztecProtocol#25419](AztecProtocol#25419) — feat: submitProof takes only new headers ## Gas The reports were regenerated after removing reward overrides. The `next` comparison is unchanged because no L1 contract files changed on `next` since this branch point. ### Epoch benchmark **No validators** | Function | `next` | This branch | Delta | |---|---:|---:|---:| | `propose` avg | 199,366 | 198,220 | -1,146 (-0.6%) | | `submitEpochRootProof` avg | 991,020 | 925,398 | -65,622 (-6.6%) | | `submitEpochRootProof` max | 1,029,513 | 966,444 | -63,069 (-6.1%) | | `submitEpochRootProof` calldata bytes | 14,148 | 14,212 | +64 (+0.5%) | | `setupEpoch` avg | 32,042 | 32,020 | -22 (-0.1%) | | Avg gas/second | 3,643.1 | 3,570.2 | -72.9 (-2.0%) | **100 validators** | Function | `next` | This branch | Delta | |---|---:|---:|---:| | `propose` avg | 327,769 | 326,630 | -1,139 (-0.3%) | | `submitEpochRootProof` avg | 1,572,054 | 1,505,290 | -66,764 (-4.2%) | | `submitEpochRootProof` max | 1,669,957 | 1,605,780 | -64,177 (-3.8%) | | `submitEpochRootProof` calldata bytes | 16,644 | 16,708 | +64 (+0.4%) | | `aggregate3` avg | 376,655 | 375,623 | -1,032 (-0.3%) | | `setupEpoch` avg | 46,504 | 46,482 | -22 (0.0%) | | Avg gas/second | 5,937.2 | 5,863.4 | -73.8 (-1.2%) | ### Partial epoch proof benchmark | Proof submission | `next` | This branch | Delta | |---|---:|---:|---:| | 1 checkpoint | 661,245 | 654,868 | -6,377 (-1.0%) | | 8 checkpoints | 980,349 | 956,875 | -23,474 (-2.4%) | | 8 more checkpoints | 988,039 | 910,405 | -77,634 (-7.9%) | | 16 checkpoints | 1,291,446 | 1,246,224 | -45,222 (-3.5%) | | 32 checkpoints | 1,805,072 | 1,732,651 | -72,421 (-4.0%) | The partial proof benchmark uses the mock epoch proof verifier; real ZK verification and top-level transaction calldata gas are excluded. ### Tracked function gas report The fixed `RollupTest` report now records a 41,936-byte preheated deployment, a 393,723-gas median for `submitEpochRootProof()`, and unchanged 283,135-gas median for `propose()`. The owner-only setters pay one extra delegatecall after moving fee and reward admin paths into `RewardExtLib`; hot proposal paths still call `FeeLib` directly. ## Contract size Without the admin-path extraction, the reward-free integration stack still produces a 24,744-byte `Rollup`, 168 bytes over EIP-170. Moving those paths into `RewardExtLib` brings runtime bytecode to **22,670 bytes**, 1,906 under the 24,576-byte limit. CI checks this directly under both the default and production Foundry profiles via `scripts/check_contract_sizes.sh`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the bottom of the Fast Inbox stack: it targets
project/fast-inboxdirectly and is independently mergeable — nothing in it depends on the inbox work above it.Context
Every field of
RollupConfig—vkTreeRoot,protocolContractsHash,version,feeAsset,feeAssetPortal,epochProofVerifier,inbox,outbox— is written exactly once in the Rollup's constructor and has no setter anywhere insrc/. They were nonetheless kept in storage, so every read paid a coldSLOAD.proposepaid one,submitEpochRootProofpaid six.Approach
Move all eight into immutables and drop
configfromRollupStore.Libraries cannot read a contract's immutables, and the
*ExtLiblibraries here areexternal(delegatecalled), so they cannot either. The values are assembled into a memoryRollupConfigbyRollupCore._getRollupConfig()and threaded down as parameters: the full struct into the epoch-proof path, theIInboxinto propose, and the fee asset into the reward claims. Propose needed itsIInboxbundled with the existingcheckBlobflag into aProposeConfigstruct — a seventh scalar parameter pushedProposeLib.proposeover the stack limit, and a memory struct costs one slot instead of two.configwas the last member ofRollupStore, sotips,archivesandtempCheckpointLogskeep slots 0–2 and every raw-slot consumer ofkeccak256("aztec.stf.storage")is unaffected — all of them use offsets ≤ +2. The two that used+3/+4wereRollupContract.getVkTreeRootandgetProtocolContractsHashin the TS client, which now call the contract getters thatIRolluphas exposed since #22563.The second commit is bytecode budget, not gas. Each immutable read inlines a 32-byte push, which grew
Rollup's runtime code to within 148 bytes of the EIP-170 limit.Rollup.validateHeaderWithAttestationswas decoding seven parameters, building aValidateHeaderArgs(which embeds a fullProposedHeader) in memory, resolving the mana min fee through two delegatecall hops, and re-encoding four arguments — all in the Rollup's own runtime code. Forwarding the parameters straight through and assembling the struct inRollupOperationsExtLibfrees 629 bytes. The fee value is unchanged:RewardExtLib.summedMinFeeand.getManaMinFeeComponentsAtare one-line forwarders to theFeeLib/ProposeLibfunctions the ExtLib now calls directly.Gas
propose(no validators)submitEpochRootProof(no validators)propose(100 validators)submitEpochRootProof(100 validators)aggregate3(100 validators)The config getters lose their cold
SLOADoutright —getInbox2,543 → 878,getVersion1,447 → 852,getOutbox2,521 → 856. A handful of unrelated views move by 22–44 gas as the Rollup's selector dispatch shifts. Deployment also drops eightSSTOREs.Rollupruntime bytecode ends at 23,799 against the 24,576 limit — 777 bytes of margin, against 886 before this change.Note on the regenerated
gas_report.json: call counts on a few entries drop by 36 because the three tests that fail only underFORGE_GAS_REPORT(testExtraBlobs,testRevertInvalidCoinbase,testRevertInvalidTimestamp— all failing identically on the base commit) abort at a slightly different point. Per-call gas for those entries is unchanged.Node compatibility
Node binaries already in the field read
vkTreeRootandprotocolContractsHashfrom raw slots+3/+4. Against a rollup deployed from this branch those slots are zero, so such a node'swaitForCompatibleRollupreports a VK mismatch and sits in standby. #25313 lands the getter-based read on the v5 line so binaries cut from it work against a rollup deployed from either version; it does nothing for binaries already released, so this needs sequencing against any node rollout.