feat: only verify new headers - #25404
spalladino merged 1 commit into
Conversation
| ); | ||
|
|
||
| for (uint256 i = 0; i < numCheckpoints; i++) { | ||
| for (uint256 i = _firstHeaderToVerify; i < numCheckpoints; i++) { |
There was a problem hiding this comment.
this is the whole patch.
spalladino
left a comment
There was a problem hiding this comment.
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?
| uint256 provenPrefixLength = provenBeforeSubmission - _args.start + 1; | ||
| uint256 accountedPrefixLength = RewardLib.getLongestProvenLength(endEpoch); |
There was a problem hiding this comment.
When would these two differ?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
But is there any scenario where accountedPrefixLength is greater than provenPrefixLength? Can't we just use RewardLib.getLongestProvenLength(endEpoch)?
There was a problem hiding this comment.
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); // 4The 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
longestProvenLengthis 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.
5eb9d28 to
b2bf951
Compare
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%) |
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
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%) |
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
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%) |
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
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%) |
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 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