Skip to content

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

Merged
spalladino merged 5 commits into
project/fast-inboxfrom
spl/rollup-config-immutable
Sep 4, 2026
Merged

spalladino merged 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)

@spalladino
spalladino merged commit e85dd65 into project/fast-inbox Sep 4, 2026
19 checks passed
@spalladino
spalladino deleted the spl/rollup-config-immutable branch September 4, 2026 12:44
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.
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`.
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.

3 participants