Skip to content

perf(l1): hold the rollup config in immutables instead of storage - #25314

Open
spalladino wants to merge 5 commits into
project/fast-inboxfrom
spl/rollup-config-immutable
Open

perf(l1): hold the rollup config in immutables instead of storage#25314
spalladino wants to merge 5 commits into
project/fast-inboxfrom
spl/rollup-config-immutable

Conversation

@spalladino

@spalladino spalladino commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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.

Context

Every field of RollupConfigvkTreeRoot, 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.

Approach

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.

Gas

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 SSTOREs.

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 compatibility

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. #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.

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
spalladino force-pushed the spl/rollup-config-immutable branch from 08920ae to 807803a Compare August 28, 2026 18:51
@spalladino
spalladino changed the base branch from spl/a-1390-inbox-ring-overwrite-protection to project/fast-inbox August 28, 2026 18:51
@spalladino
spalladino changed the base branch from project/fast-inbox to next August 29, 2026 11:17
@spalladino
spalladino changed the base branch from next to project/fast-inbox August 29, 2026 11:18
@AztecBot

Copy link
Copy Markdown
Collaborator

Flakey Tests

🤖 says: This CI run detected 1 tests that failed, but were tolerated due to a .test_patterns.yml entry.

\033FLAKED\033 (8;;http://ci.aztec-labs.com/c90cbaa08cce1b9c�c90cbaa08cce1b9c8;;�): yarn-project/kv-store/scripts/run_test.sh src/sqlite-opfs/internal/ordered-binary-browser.test.ts (1s) (code: 0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants