Skip to content

feat: only verify new headers - #25404

Merged
spalladino merged 1 commit into
stack/feat-checkpoint-reward-overridesfrom
stack/feat-only-verify-new-headers
Sep 9, 2026
Merged

spalladino merged 1 commit into
stack/feat-checkpoint-reward-overridesfrom
stack/feat-only-verify-new-headers

Conversation

@alexghr

@alexghr alexghr commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This PR changes the checkpoint header verification from always verifying from index 0 (in the epoch) up to the current proven tip to verifying from the last proven index to up the new proven tip. This reduces the gas cost for partial epoch proofs by about ~3-4k per previously verified checkpoint header.

Fix A-1802

@alexghr
alexghr requested a review from just-mitch as a code owner September 4, 2026 15:53
);

for (uint256 i = 0; i < numCheckpoints; i++) {
for (uint256 i = _firstHeaderToVerify; i < numCheckpoints; i++) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the whole patch.

@spalladino spalladino left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, left one question.

Also, how much could we save by not sending the headers? Calldata gas is cheap but not that cheap. If that header prefix is ignored, maybe we don't even need to send it at all?

Comment on lines +120 to +121
uint256 provenPrefixLength = provenBeforeSubmission - _args.start + 1;
uint256 accountedPrefixLength = RewardLib.getLongestProvenLength(endEpoch);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would these two differ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers when proofs for epoch N land in epoch N + 1.

Assuming N = 1, epoch 1 covers checkpoints 1..32, epoch 2 covers checkpoints 33..64

We could receive proofs for epoch 1 during epoch 2's lifetime while epoch 2 is partial proven. Let's assume we've proven the first 3 checkpoints of epoch 2 when another epoch 1 proof lands:

// uint256 provenPrefixLength = provenBeforeSubmission - _args.start + 1;
uint256 provenProfixLength = 35 - 1 + 1; // proven tip is 35 (ie after the first three checkpoints in epoch 2), args.start is 1 because that's the start of epoch 1's range

// uint256 accountedPrefixLength = RewardLib.getLongestProvenLength(endEpoch);
uint256 accountedPrrefixLength = 32; // we're strictly looking at the epoch to be proven, it's fully proven otherwise we couldn't accept proofs for epoch 2 so this'll return that all 32 checkpoints have had their rewards accounted for.

// firstHeaderToVerify = provenPrefixLength < accountedPrefixLength ? provenPrefixLength : accountedPrefixLength;
firstHeaderToVerify = 35 < 32 ? 35 : 32; // ie 32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is there any scenario where accountedPrefixLength is greater than provenPrefixLength? Can't we just use RewardLib.getLongestProvenLength(endEpoch)?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Astra here (the AI assistant), posting this explanation at Santiago's request; this is my analysis, not Santiago writing.

@alexghr Your example makes sense: the global proven tip can include checkpoints from the next epoch, so provenPrefixLength can exceed this epoch's accountedPrefixLength. Following up on Santiago's question, though, can the reverse ever happen? The min only changes the result relative to using accountedPrefixLength directly if accountedPrefixLength > provenPrefixLength.

For a smaller example, suppose epoch A contains checkpoints 1–4 and epoch B contains 5–8. Each row below is a successive submission; the counters are their values before that submission:

Submission Global proven tip Accounted length for the submitted epoch Headers needing hash verification
Prove A: 1–2 0 0 1–2
Extend A: 1–4 2 2 3–4
Prove B: 5–6 4 0 5–6
Another prover submits A: 1–4, while still within its submission window 6 4 None

In the last row, the current calculation gives:

provenPrefixLength = 6 - 1 + 1; // 6
accountedPrefixLength = 4;
firstHeaderToVerify = min(6, 4); // 4

The 6 includes checkpoints from B, whereas the value we need is simply how many checkpoints in A were already proven and accounted for.

From the production state transitions I reviewed, the invariant appears to be:

  • An epoch's longestProvenLength is extended only through successful proof submission.
  • That submission also advances the global proven tip to at least the submitted end checkpoint. Both updates commit atomically; a revert during reward handling rolls back the tip update too.
  • Pruning does not roll back the proven tip.

Therefore, whenever the epoch has an accounted prefix, the global proven tip must already cover that prefix. If the tip is before the epoch's start, its accounted length should be zero. I don't see a reachable state through normal completed submissions where accounting gets ahead of proving.

Could we consequently replace the tuple/conditional/min block with:

Epoch endEpoch = assertAcceptable(_args.start, _args.end);
uint256 firstHeaderToVerify = RewardLib.getLongestProvenLength(endEpoch);

That would also let assertAcceptable return just endEpoch; its internal proven-tip acceptance check would remain unchanged.

A shorter repeat proof is also worth considering: if A already has accounted length 4 and someone submits A: 1–2, starting verification at index 4 simply skips the loop, since both submitted headers were already verified. The existing header-count validation remains in place.

Is there a production path that breaks this invariant? If not, using the epoch-local accounted length directly seems equivalent and easier to follow.

@spalladino
spalladino force-pushed the stack/feat-only-verify-new-headers branch from 5eb9d28 to b2bf951 Compare September 8, 2026 21:56
@spalladino
spalladino merged commit c45729a into project/v6-l1-updates Sep 9, 2026
19 of 25 checks passed
@spalladino
spalladino deleted the stack/feat-only-verify-new-headers branch September 9, 2026 13:52
spalladino pushed a commit that referenced this pull request Sep 9, 2026
Further optimize the proof submission flow:
1. hash haeader from calldata instead of memory (avoids a copy)
2. read header hashes in bulk (avoid checks run on every iteration of a
loop)
3. reuse already read header hashes

Other than submitting a proof of the first checkpoint (which is +2k gas
compared to next) every other scenario is cheaper than the code on next.

| Scenario | Current gas | vs #25404 | vs origin/next |

|------------------|-------------|--------------------|--------------------|
| 1 fresh | 663,452 | −1,506 (−0.23%) | +2,243 (+0.34%) |
| 8 fresh | 965,328 | −21,594 (−2.19%) | −14,985 (−1.53%) |
| 8→16 extension | 930,160 | −28,490 (−2.97%) | −57,867 (−5.86%) |
| 16 fresh | 1,256,565 | −44,700 (−3.44%) | −34,869 (−2.70%) |
| 32 fresh | 1,729,965 | −91,388 (−5.02%) | −75,071 (−4.16%) |
spalladino pushed a commit that referenced this pull request Sep 10, 2026
This PR changes the checkpoint header verification from always verifying
from index 0 (in the epoch) up to the current proven tip to verifying
from the last proven index to up the new proven tip. This reduces the
gas cost for partial epoch proofs by about ~3-4k per previously verified
checkpoint header.

Fix A-1802
spalladino pushed a commit that referenced this pull request Sep 10, 2026
Further optimize the proof submission flow:
1. hash haeader from calldata instead of memory (avoids a copy)
2. read header hashes in bulk (avoid checks run on every iteration of a
loop)
3. reuse already read header hashes

Other than submitting a proof of the first checkpoint (which is +2k gas
compared to next) every other scenario is cheaper than the code on next.

| Scenario | Current gas | vs #25404 | vs origin/next |

|------------------|-------------|--------------------|--------------------|
| 1 fresh | 663,452 | −1,506 (−0.23%) | +2,243 (+0.34%) |
| 8 fresh | 965,328 | −21,594 (−2.19%) | −14,985 (−1.53%) |
| 8→16 extension | 930,160 | −28,490 (−2.97%) | −57,867 (−5.86%) |
| 16 fresh | 1,256,565 | −44,700 (−3.44%) | −34,869 (−2.70%) |
| 32 fresh | 1,729,965 | −91,388 (−5.02%) | −75,071 (−4.16%) |
spalladino pushed a commit that referenced this pull request Sep 10, 2026
This PR changes the checkpoint header verification from always verifying
from index 0 (in the epoch) up to the current proven tip to verifying
from the last proven index to up the new proven tip. This reduces the
gas cost for partial epoch proofs by about ~3-4k per previously verified
checkpoint header.

Fix A-1802
spalladino pushed a commit that referenced this pull request Sep 10, 2026
Further optimize the proof submission flow:
1. hash haeader from calldata instead of memory (avoids a copy)
2. read header hashes in bulk (avoid checks run on every iteration of a
loop)
3. reuse already read header hashes

Other than submitting a proof of the first checkpoint (which is +2k gas
compared to next) every other scenario is cheaper than the code on next.

| Scenario | Current gas | vs #25404 | vs origin/next |

|------------------|-------------|--------------------|--------------------|
| 1 fresh | 663,452 | −1,506 (−0.23%) | +2,243 (+0.34%) |
| 8 fresh | 965,328 | −21,594 (−2.19%) | −14,985 (−1.53%) |
| 8→16 extension | 930,160 | −28,490 (−2.97%) | −57,867 (−5.86%) |
| 16 fresh | 1,256,565 | −44,700 (−3.44%) | −34,869 (−2.70%) |
| 32 fresh | 1,729,965 | −91,388 (−5.02%) | −75,071 (−4.16%) |
spalladino pushed a commit that referenced this pull request Sep 10, 2026
This PR changes the checkpoint header verification from always verifying
from index 0 (in the epoch) up to the current proven tip to verifying
from the last proven index to up the new proven tip. This reduces the
gas cost for partial epoch proofs by about ~3-4k per previously verified
checkpoint header.

Fix A-1802
spalladino pushed a commit that referenced this pull request Sep 10, 2026
Further optimize the proof submission flow:
1. hash haeader from calldata instead of memory (avoids a copy)
2. read header hashes in bulk (avoid checks run on every iteration of a
loop)
3. reuse already read header hashes

Other than submitting a proof of the first checkpoint (which is +2k gas
compared to next) every other scenario is cheaper than the code on next.

| Scenario | Current gas | vs #25404 | vs origin/next |

|------------------|-------------|--------------------|--------------------|
| 1 fresh | 663,452 | −1,506 (−0.23%) | +2,243 (+0.34%) |
| 8 fresh | 965,328 | −21,594 (−2.19%) | −14,985 (−1.53%) |
| 8→16 extension | 930,160 | −28,490 (−2.97%) | −57,867 (−5.86%) |
| 16 fresh | 1,256,565 | −44,700 (−3.44%) | −34,869 (−2.70%) |
| 32 fresh | 1,729,965 | −91,388 (−5.02%) | −75,071 (−4.16%) |
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.

2 participants