From b2bf951b45ea7b2cda671a90c8c6ac0136be93f5 Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Fri, 4 Sep 2026 15:50:06 +0000 Subject: [PATCH] feat: only verify new headers --- .../partial_epoch_proof_gas_report.json | 72 +++++++++--------- .../partial_epoch_proof_gas_report.md | 18 ++--- .../core/libraries/rollup/EpochProofLib.sol | 48 ++++++++---- .../src/core/libraries/rollup/RewardLib.sol | 4 + l1-contracts/test/Rollup.t.sol | 73 +++++++++++++++++++ 5 files changed, 156 insertions(+), 59 deletions(-) diff --git a/l1-contracts/partial_epoch_proof_gas_report.json b/l1-contracts/partial_epoch_proof_gas_report.json index cf8cb6d47dbe..d941f9396147 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.json +++ b/l1-contracts/partial_epoch_proof_gas_report.json @@ -8,66 +8,66 @@ "functions": { "gasReportSubmit16Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1298507, - "mean": 1298507, - "median": 1298507, - "max": 1298507 + "min": 1298643, + "mean": 1298643, + "median": 1298643, + "max": 1298643 }, "gasReportSubmit16CheckpointsWithTwoOverrides((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1463518, - "mean": 1463518, - "median": 1463518, - "max": 1463518 + "min": 1463654, + "mean": 1463654, + "median": 1463654, + "max": 1463654 }, "gasReportSubmit1Checkpoint((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 659719, - "mean": 659719, - "median": 659719, - "max": 659719 + "min": 659855, + "mean": 659855, + "median": 659855, + "max": 659855 }, "gasReportSubmit1CheckpointWithTwoOverrides((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 686472, - "mean": 686472, - "median": 686472, - "max": 686472 + "min": 686608, + "mean": 686608, + "median": 686608, + "max": 686608 }, "gasReportSubmit32Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1836386, - "mean": 1836386, - "median": 1836386, - "max": 1836386 + "min": 1836522, + "mean": 1836522, + "median": 1836522, + "max": 1836522 }, "gasReportSubmit32CheckpointsWithTwoOverrides((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 2094127, - "mean": 2094127, - "median": 2094127, - "max": 2094127 + "min": 2094263, + "mean": 2094263, + "median": 2094263, + "max": 2094263 }, "gasReportSubmit8Checkpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 983949, - "mean": 983949, - "median": 983949, - "max": 983949 + "min": 984085, + "mean": 984085, + "median": 984085, + "max": 984085 }, "gasReportSubmit8CheckpointsWithTwoOverrides((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1080528, - "mean": 1080528, - "median": 1080528, - "max": 1080528 + "min": 1080664, + "mean": 1080664, + "median": 1080664, + "max": 1080664 }, "gasReportSubmit8MoreCheckpoints((uint256,uint256,(bytes32,bytes32,bytes32,bytes32,bytes32,address),(bytes32,bytes32,bytes32,bytes32,bytes32,uint256,uint256,address,bytes32,(uint128,uint128),uint256,uint256)[],(bytes,bytes),bytes,bytes))": { "calls": 1, - "min": 1010042, - "mean": 1010042, - "median": 1010042, - "max": 1010042 + "min": 974194, + "mean": 974194, + "median": 974194, + "max": 974194 } } } diff --git a/l1-contracts/partial_epoch_proof_gas_report.md b/l1-contracts/partial_epoch_proof_gas_report.md index 6ebdce8d7820..503248a5900d 100644 --- a/l1-contracts/partial_epoch_proof_gas_report.md +++ b/l1-contracts/partial_epoch_proof_gas_report.md @@ -2,14 +2,14 @@ | Proof submission | Gas | |---|---:| -| 1 Checkpoint | 659,719 | -| 1 Checkpoint With Two Overrides | 686,472 | -| 8 Checkpoints | 983,949 | -| 8 Checkpoints With Two Overrides | 1,080,528 | -| 8 More Checkpoints | 1,010,042 | -| 16 Checkpoints | 1,298,507 | -| 16 Checkpoints With Two Overrides | 1,463,518 | -| 32 Checkpoints | 1,836,386 | -| 32 Checkpoints With Two Overrides | 2,094,127 | +| 1 Checkpoint | 659,855 | +| 1 Checkpoint With Two Overrides | 686,608 | +| 8 Checkpoints | 984,085 | +| 8 Checkpoints With Two Overrides | 1,080,664 | +| 8 More Checkpoints | 974,194 | +| 16 Checkpoints | 1,298,643 | +| 16 Checkpoints With Two Overrides | 1,463,654 | +| 32 Checkpoints | 1,836,522 | +| 32 Checkpoints With Two Overrides | 2,094,263 | _Uses the mock epoch proof verifier._ diff --git a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol index 61b321891247..b96092c342f8 100644 --- a/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol +++ b/l1-contracts/src/core/libraries/rollup/EpochProofLib.sol @@ -122,11 +122,17 @@ library EpochProofLib { STFLib.prune(); } - (Epoch endEpoch, Epoch currentEpoch) = assertAcceptable(_args.start, _args.end); + (Epoch endEpoch, Epoch currentEpoch, uint256 provenBeforeSubmission) = assertAcceptable(_args.start, _args.end); + uint256 firstHeaderToVerify; + if (provenBeforeSubmission >= _args.start) { + uint256 provenPrefixLength = provenBeforeSubmission - _args.start + 1; + uint256 accountedPrefixLength = RewardLib.getLongestProvenLength(endEpoch); + firstHeaderToVerify = provenPrefixLength < accountedPrefixLength ? provenPrefixLength : accountedPrefixLength; + } - // Rehash the supplied headers against storage once, here: the public-input assembly below reads the fee - // recipient/value out of them and relies on this call having run. - verifyHeaders(_args.start, _args.end, _args.headers); + // The skipped calldata prefix is untrusted, but rewards have already consumed it and proof verification binds its + // fee data to the canonical checkpoint headers. We only verify new headers since the last proof + verifyHeaders(_args.start, _args.end, _args.headers, firstHeaderToVerify); // Verify attestations for the last checkpoint in the epoch // -> This serves as training wheels for the public part of the system (proving systems used in public and AVM) @@ -178,8 +184,8 @@ library EpochProofLib { * * @dev The fee recipient/value public inputs are sourced from the supplied headers, so this entry point rehashes * them against storage before assembling: an off-chain caller must not walk away with public inputs built from - * unverified fee fields and only discover the mismatch when the on-chain proof reverts. The submit path verifies - * the headers up front and assembles via computeEpochProofPublicInputs to avoid rehashing them twice. + * unverified fee fields and only discover the mismatch when the on-chain proof reverts. The submit path separately + * validates headers that have not already been proven and accounted for. * * @param _start - The start of the epoch (inclusive) * @param _end - The end of the epoch (inclusive) @@ -196,7 +202,7 @@ library EpochProofLib { bytes calldata _blobPublicInputs, RollupConfig memory _config ) internal view returns (bytes32[] memory) { - verifyHeaders(_start, _end, _headers); + verifyHeaders(_start, _end, _headers, 0); return computeEpochProofPublicInputs(_start, _end, _args, _headers, _blobPublicInputs, _config); } @@ -416,19 +422,25 @@ library EpochProofLib { } /** - * @notice Rehashes each provided checkpoint header and requires it to match the stored header hash + * @notice Rehashes a suffix of the provided checkpoint headers and requires it to match the stored header hashes * * @param _start The first checkpoint number in the epoch (inclusive) * @param _end The last checkpoint number in the epoch (inclusive) * @param _headers The proposed headers for each checkpoint in [_start, _end] + * @param _firstHeaderToVerify The index of the first header that has not already been proven and accounted for */ - function verifyHeaders(uint256 _start, uint256 _end, ProposedHeader[] calldata _headers) private view { + function verifyHeaders( + uint256 _start, + uint256 _end, + ProposedHeader[] calldata _headers, + uint256 _firstHeaderToVerify + ) private view { uint256 numCheckpoints = _end - _start + 1; require( _headers.length == numCheckpoints, Errors.Rollup__InvalidCheckpointHeaderCount(numCheckpoints, _headers.length) ); - for (uint256 i = 0; i < numCheckpoints; i++) { + for (uint256 i = _firstHeaderToVerify; i < numCheckpoints; i++) { bytes32 expectedHeaderHash = STFLib.getHeaderHash(_start + i); bytes32 providedHeaderHash = ProposedHeaderLib.hash(_headers[i]); require( @@ -460,8 +472,13 @@ library EpochProofLib { * @param _end The last checkpoint number in the epoch (inclusive) * @return endEpoch The epoch number that the proof covers * @return currentEpoch The epoch at the time the proof is submitted + * @return provenBeforeSubmission The proven checkpoint number observed while checking the submission */ - function assertAcceptable(uint256 _start, uint256 _end) private view returns (Epoch endEpoch, Epoch currentEpoch) { + function assertAcceptable(uint256 _start, uint256 _end) + private + view + returns (Epoch endEpoch, Epoch currentEpoch, uint256 provenBeforeSubmission) + { RollupStore storage rollupStore = STFLib.getStorage(); Epoch startEpoch = STFLib.getEpochForCheckpoint(_start); @@ -486,7 +503,8 @@ library EpochProofLib { bool isStartOfEpoch = _start == 1 || parentEpoch <= startEpoch - Epoch.wrap(1); require(isStartOfEpoch, Errors.Rollup__StartIsNotFirstCheckpointOfEpoch()); - bool isStartBuildingOnProven = _start - 1 <= rollupStore.tips.getProven(); + provenBeforeSubmission = rollupStore.tips.getProven(); + bool isStartBuildingOnProven = _start - 1 <= provenBeforeSubmission; require(isStartBuildingOnProven, Errors.Rollup__StartIsNotBuildingOnProven()); bool claimedNumCheckpointsInEpoch = _end - _start + 1 <= Constants.MAX_CHECKPOINTS_PER_EPOCH; @@ -494,6 +512,8 @@ library EpochProofLib { claimedNumCheckpointsInEpoch, Errors.Rollup__TooManyCheckpointsInEpoch(Constants.MAX_CHECKPOINTS_PER_EPOCH, _end - _start) ); + + return (endEpoch, currentEpoch, provenBeforeSubmission); } /** @@ -534,8 +554,8 @@ library EpochProofLib { * 2. Assembling the public inputs for the root rollup circuit * 3. Verifying the validity proof against the assembled public inputs using the configured verifier * - * @dev Assumes the caller has already verified the supplied checkpoint headers against storage, so assembly skips - * rehashing them. + * @dev Assumes the caller has completed the submit path's required header checks, so assembly does not rehash + * headers. * * @dev Errors Thrown: * - Rollup__InvalidBlobProof: Batched blob proof verification failed diff --git a/l1-contracts/src/core/libraries/rollup/RewardLib.sol b/l1-contracts/src/core/libraries/rollup/RewardLib.sol index f74627533474..101bebeed97b 100644 --- a/l1-contracts/src/core/libraries/rollup/RewardLib.sol +++ b/l1-contracts/src/core/libraries/rollup/RewardLib.sol @@ -327,6 +327,10 @@ library RewardLib { return getStorage().epochRewards[_epoch].rewards; } + function getLongestProvenLength(Epoch _epoch) internal view returns (uint256) { + return getStorage().epochRewards[_epoch].longestProvenLength; + } + function getHasSubmitted(Epoch _epoch, uint256 _length, address _prover) internal view returns (bool) { return getStorage().epochRewards[_epoch].subEpoch[_length].shares[_prover] > 0; } diff --git a/l1-contracts/test/Rollup.t.sol b/l1-contracts/test/Rollup.t.sol index b5667b07dadb..2ffc20309797 100644 --- a/l1-contracts/test/Rollup.t.sol +++ b/l1-contracts/test/Rollup.t.sol @@ -864,6 +864,79 @@ contract RollupTest is RollupBase { assertEq(outbox.getRootData(Epoch.wrap(0), 2), outHash2, "Root at K=2 should be outHash2"); } + function testLongerEpochProofAllowsModifiedPreviouslyProvenHeader() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + _proposeCheckpoint("mixed_checkpoint_2", 2); + + DecoderBase.Data memory checkpoint1Data = load("mixed_checkpoint_1").checkpoint; + DecoderBase.Data memory checkpoint2Data = load("mixed_checkpoint_2").checkpoint; + CheckpointLog memory checkpoint = rollup.getCheckpoint(0); + + _submitEpochProof( + 1, + 1, + checkpoint.archive, + checkpoint1Data.archive, + checkpoint1Data.batchedBlobInputs, + checkpoint1Data.header.outHash + ); + + address modifiedCoinbase = makeAddr("modifiedCoinbase"); + + // even though his header was tampered with the next _submitEpochProof call will succeed (with a MockVerifier): + // the correct header for slot 1 was correct when the previous proof was sent + // the fact that it is now bogus does no matter because its rewards will not be processed again + // with a RealVerifier the proof will fail because the header's hash is sent as a public input + proposedHeaders[1].coinbase = modifiedCoinbase; + + _submitEpochProof( + 1, + 2, + checkpoint.archive, + checkpoint2Data.archive, + checkpoint2Data.batchedBlobInputs, + checkpoint2Data.header.outHash + ); + + assertEq(rollup.getProvenCheckpointNumber(), 2); + assertEq(rollup.getSequencerRewards(modifiedCoinbase), 0); + } + + function testLongerEpochProofRejectsModifiedNewHeader() public setUpFor("mixed_checkpoint_1") { + _proposeCheckpoint("mixed_checkpoint_1", 1); + _proposeCheckpoint("mixed_checkpoint_2", 2); + + DecoderBase.Data memory checkpoint1Data = load("mixed_checkpoint_1").checkpoint; + DecoderBase.Data memory checkpoint2Data = load("mixed_checkpoint_2").checkpoint; + CheckpointLog memory checkpoint = rollup.getCheckpoint(0); + + _submitEpochProof( + 1, + 1, + checkpoint.archive, + checkpoint1Data.archive, + checkpoint1Data.batchedBlobInputs, + checkpoint1Data.header.outHash + ); + + bytes32 expectedHeaderHash = ProposedHeaderLib.hash(proposedHeaders[2]); + // send bogus header + proposedHeaders[2].accumulatedFees += 1; + bytes32 providedHeaderHash = ProposedHeaderLib.hash(proposedHeaders[2]); + + vm.expectRevert( + abi.encodeWithSelector(Errors.Rollup__InvalidCheckpointHeader.selector, expectedHeaderHash, providedHeaderHash) + ); + _submitEpochProof( + 1, + 2, + checkpoint.archive, + checkpoint2Data.archive, + checkpoint2Data.batchedBlobInputs, + checkpoint2Data.header.outHash + ); + } + // getEpochProofPublicInputs is the view that the prover-publisher calls off-chain to validate its inputs before // submitting. Because the fee recipient/value public inputs are taken from the supplied headers, the header check // must run here too - not only on the submit path - so a mismatch is caught before publishing rather than reverting