feat(L1): fork-gate AggregateVerifier proposal intervals for Denim - #431
Open
0xth4nh wants to merge 3 commits into
Open
feat(L1): fork-gate AggregateVerifier proposal intervals for Denim#4310xth4nh wants to merge 3 commits into
0xth4nh wants to merge 3 commits into
Conversation
Denim shortens the L2 block time from 2s to 200ms, which multiplies the proposal intervals by 10 (600 -> 6000 blocks, 30 -> 300 intermediate) for the same 20-minute range. Carry both value sets on one implementation and select between them per game so no contract swap is needed at the fork. Intervals are selected on the game's *starting* block relative to the Denim activation block, which is re-derived from the existing ProtocolVersions schedule. Selecting on the start block keeps the game chain contiguous and produces exactly one straddling game. Both interval pairs must yield the same intermediate root count, which the constructor enforces, so the CWIA extraData layout and INITIALIZE_CALLDATA_SIZE are unchanged across the fork. Co-Authored-By: Claude <noreply@anthropic.com>
Collaborator
🟡 Heimdall Review Status
|
…vals live Review follow-ups on the fork-gated proposal intervals. - Test `challenge` under both sides of the activation. `_intervals` drives two fork-sensitive call sites and only the `initializeWithInitData` one was covered. The other feeds the journal the prover signs, so a stale interval there makes a valid challenge unconstructable instead of reverting. - Merge `_intervals` into `intervalsForStartingBlock` and resolve `_denimActivationBlock()` once in `initializeWithInitData`, threading it into both the interval selection and `_l2Timestamp` instead of reading `PROTOCOL_VERSIONS.getSchedule()` twice. - Document the deliberate start-block vs ending-block asymmetry between the interval selection and the `scheduleId` pin, and extend the `_denimActivationBlock` safety argument to cover the owner moving the activation earlier, not just delaying it. - Drop the `SystemDeploy` Denim interval requires. The constructor already reverts on zero, non-divisible, and mismatched-ratio pairs; restating a subset read as full validation while skipping the ratio check. - `SeedGames.s.sol` reads the block interval and intermediate root count off the deployed implementation rather than hardcoding 600/30, which would have seeded unopenable games on a devnet with Denim active. `generate-roots.sh` cannot see the chain, so its intervals are env-overridable. - `OptimismPortal2.t.sol` moves off `BLOCK_INTERVAL()`. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
The two proposal interval pairs differ because they are calibrated for different L2 block cadences: 600 blocks at 2s and 6,000 at 200ms are both a 20-minute range. Name them for that, rather than for the fork that happens to introduce the second one. This also fixes an asymmetry. The pre-Denim pair was unprefixed and the post-Denim pair prefixed, implying "default plus special case", when the fast pair is the permanent steady state and the slow one is the legacy. BLOCK_INTERVAL -> SLOW_BLOCK_INTERVAL INTERMEDIATE_BLOCK_INTERVAL -> SLOW_INTERMEDIATE_BLOCK_INTERVAL DENIM_BLOCK_INTERVAL -> FAST_BLOCK_INTERVAL DENIM_INTERMEDIATE_BLOCK_INTERVAL -> FAST_INTERMEDIATE_BLOCK_INTERVAL DENIM_BLOCKS_PER_SECOND -> FAST_BLOCKS_PER_SECOND DENIM_UPGRADE_INDEX -> FAST_BLOCK_UPGRADE_INDEX _denimActivationBlock() -> _firstFastBlock() _legacyL2Timestamp() -> _slowL2Timestamp() `intervalsForStartingBlock` and `L2_BLOCK_TIME` are unchanged. IntervalConfig fields and the multiproof* deploy-config keys move with the immutables. `BLOCK_INTERVAL()` and `INTERMEDIATE_BLOCK_INTERVAL()` therefore leave the 0.2.0 ABI. That is deliberate: a consumer calling them post-Denim gets a plausible-but-wrong number today, and a missing method is a better failure mode than a wrong answer. It forces the migration to `intervalsForStartingBlock()` that the offchain follow-ups already require. In-flight 0.1.0 clones keep exposing the old names. Denim is now named in exactly one place, on FAST_BLOCK_UPGRADE_INDEX, which is the only spot the contract is pinned to a specific hardfork. A later cadence change is a new index and a new interval pair, not new machinery. Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
0x00101010
reviewed
Sep 4, 2026
| /// which drops the L2 block time from 2s to 200ms. Everything downstream is expressed as | ||
| /// slow-vs-fast blocks, so a later cadence change is a new index and new interval pair | ||
| /// rather than new machinery. | ||
| uint256 private constant FAST_BLOCK_UPGRADE_INDEX = 13; |
Contributor
There was a problem hiding this comment.
We need to change this to 12 now to target Cobalt
0x00101010
reviewed
Sep 4, 2026
Comment on lines
+1228
to
+1233
| uint256 blocksUntilFast; | ||
| if (fastActivationTimestamp > L2_GENESIS_TIMESTAMP) { | ||
| blocksUntilFast = FixedPointMathLib.divUp(fastActivationTimestamp - L2_GENESIS_TIMESTAMP, L2_BLOCK_TIME); | ||
| } | ||
| return L2_GENESIS_BLOCK_NUMBER + blocksUntilFast; | ||
| } |
Contributor
There was a problem hiding this comment.
nit, prefer it like this:
Suggested change
| uint256 blocksUntilFast; | |
| if (fastActivationTimestamp > L2_GENESIS_TIMESTAMP) { | |
| blocksUntilFast = FixedPointMathLib.divUp(fastActivationTimestamp - L2_GENESIS_TIMESTAMP, L2_BLOCK_TIME); | |
| } | |
| return L2_GENESIS_BLOCK_NUMBER + blocksUntilFast; | |
| } | |
| if (fastActivationTimestamp <= L2_GENESIS_TIMESTAMP) { | |
| return L2_GENESIS_BLOCK_NUMBER; | |
| } | |
| return L2_GENESIS_BLOCK_NUMBER + FixedPointMathLib.divUp(fastActivationTimestamp - L2_GENESIS_TIMESTAMP, L2_BLOCK_TIME); | |
| } |
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed?
AggregateVerifiernow carries both sets of proposal intervals and selects between them per game, instead of a single immutable pair.SLOW_BLOCK_INTERVAL/SLOW_INTERMEDIATE_BLOCK_INTERVAL(600/30 at 2s) andFAST_BLOCK_INTERVAL/FAST_INTERMEDIATE_BLOCK_INTERVAL(6,000/300 at 200ms). Both spans are the same 20 minutes, which is the point.IntervalConfigconstructor struct:slowBlockInterval,slowIntermediateBlockInterval,fastBlockInterval,fastIntermediateBlockInterval(replaces the two looseuint256params)._firstFastBlock(), extracted from_l2Timestamp()so the fork boundary is derived in exactly one place from the existingProtocolVersionsschedule (FAST_BLOCK_UPGRADE_INDEX = 13)._l2Timestamp()behaviour is unchanged._legacyL2Timestamp()becomes_slowL2Timestamp().intervalsForStartingBlock(uint256)drives the two fork-sensitive call sites — theUnexpectedBlockNumbercheck ininitializeWithInitData, and the sequence numbers in_getStartingIntermediateRootAndL2SequenceNumbers— and lets the proposer and challenger resolve a game's intervals without a clone.initializeWithInitDataresolves_denimActivationBlock()once and threads it into both its interval selection and_l2Timestamp, so it readsPROTOCOL_VERSIONS.getSchedule()once rather than twice.MismatchedIntermediateRootCount. The constructor requires both pairs to yield the same intermediate root count.0.1.0→0.2.0; ABI and semver-lock snapshots regenerated.multiproofSlowBlockInterval/multiproofSlowIntermediateBlockInterval/multiproofFastBlockInterval/multiproofFastIntermediateBlockIntervalacrossDeployConfig,SystemDeploy, anddeploy-config/local.json; the two dev deploy virtuals collapse into one_intervalConfig(). Interval validation lives only in the constructor —SystemDeploydoes not restate it, because a partial copy there would read as full validation while skipping the ratio check.SeedGames.s.solreads the block interval and intermediate root count off the deployed implementation instead of hardcoding 600/30, which would otherwise seed unopenable games on a devnet with Denim active.generate-roots.shhas no L1 RPC to read from, so its two intervals became env-overridable with the pre-Denim values as defaults; seeding aborts on a mismatched roots file rather than creating bad games.BLOCK_INTERVAL()andINTERMEDIATE_BLOCK_INTERVAL()are gone from the 0.2.0 ABIThey are
SLOW_BLOCK_INTERVAL()andSLOW_INTERMEDIATE_BLOCK_INTERVAL()now. This is deliberate rather than incidental: a consumer that callsBLOCK_INTERVAL()on a post-Denim game today gets a plausible-but-wrong number, and a missing method is a better failure mode than a wrong answer. It turns the migration tointervalsForStartingBlock()— which the offchain follow-ups below already require — into a compile error instead of a silent miscalculation.In-flight 0.1.0 clones keep exposing the old names, so tooling needs both paths during the transition regardless.
Denim is now named in exactly one place in the contract, on
FAST_BLOCK_UPGRADE_INDEX, which is the only spot pinned to a specific hardfork. A later cadence change is a new index and a new interval pair, not new machinery.Why?
Denim drops the L2 block time from 2s to 200ms. To keep the proposal range at 20 minutes, the block interval goes 600 → 6,000 and the intermediate interval 30 → 300. Those are immutable today, so the fork would need a contract swap timed to the activation.
Carrying both sets on one implementation means we deploy and upgrade before Denim and nothing has to happen during the fork. This is section 1 of the Denim proof config switchover doc.
Two design points, both reviewed and now documented in-code:
Selection is on the game's starting block, not its ending block. Selecting on the end block would break the chain invariant
end == parent.end + blockIntervalfor the game spanning the activation. Selecting on the start block keeps the chain contiguous and produces exactly one straddling game — the one whose range contains the activation block — proven under the slow-block 600-interval. That is correct because provers apply fork rules per block by timestamp, not per game. This was the doc's open decision; it is resolved as acceptable.Note the deliberate asymmetry this creates inside
initializeWithInitData: intervals are selected from the starting block whilescheduleIdis pinned from the ending block. That is what makes the straddling game work — it spans the slow-block interval its start selects, while pinning the post-Denim schedule its end falls under, so the prover knows Denim is active for the blocks past the boundary. There is now a comment saying so.The activation block is re-derived live rather than pinned into storage. This is safe in both directions:
ProtocolVersions._assertNotFrozenrejects every mutation of a passed activation — fromsetTimestampanddelayTimestampalike. That selection can never be revoked.initializeWithInitDatarejects a game whose ending L2 timestamp L1 has not yet reached, so every initialized game satisfiesstartingTimestamp < endingTimestamp <= block.timestamp, while any new activation must clearblock.timestamp + MIN_NOTICE.It also keeps the diff free of a new storage slot —
AggregateVerifieris at 20,346 / 24,576 bytes (82.8%) after this change, up from 20,059.The intermediate root count is 20 on both sides (600/30 = 6000/300), so
intermediateOutputRootsCount(),INITIALIZE_CALLDATA_SIZE, and every CWIAextraDataoffset are byte-identical across the fork. The constructor enforces this rather than assuming it — otherwise parsingextraDatawould need the root count, which would need the interval, which would need the starting block, which lives inextraData.How to test?
The FFI-backed suites (
test/libraries/trie/*,test/libraries/Hashing.t.sol,test/L1/OptimismPortal2.t.sol) need the Go helper built first, otherwise they fail on a missing binary unrelated to this change:Targeted run for this change:
New tests, all in
test/L1/proofs/AggregateVerifier.t.sol(test fixtures use 100/10 slow and 1000/100 fast, same 10:1 ratio):test_intervalsForStartingBlock_selectsOnFirstFastBlock_succeedstest_intervalsForStartingBlock_speedupUnscheduled_succeedstest_initialize_fastIntervals_succeedsUnexpectedBlockNumber(1000, 100); a game ending at 1000 initializes.test_initialize_straddlingGame_usesSlowInterval_succeeds[0, 100)range: ending at 1000 revertsUnexpectedBlockNumber(100, 1000); ending at 100 initializes.test_challenge_fastIntermediateInterval_succeeds[0, 100), not[0, 10).test_challenge_straddlingGameUsesSlowIntermediateInterval_succeeds[0, 10).test_constructor_mismatchedIntermediateRootCount_reverts(100, 10, 1000, 200)→MismatchedIntermediateRootCount(10, 5).test_constructor_invalidFastBlockIntervals_revertsfastIntermediateBlockInterval = 0→InvalidBlockInterval(1000, 0).The two
test_challenge_*cases cover_getStartingIntermediateRootAndL2SequenceNumbers, the second fork-sensitive call site and the one whose output goes into the journal the prover signs — a stale interval there makes a valid challenge unconstructable rather than reverting loudly. They assert by matching the exactIVerifier.verifycall, since the mock verifier accepts any payload; both fail if the opposite interval is substituted.Snapshot and lint checks (
justrecipes, expanded so they run withoutjust):Two repo checks fail on
mainas well as here, neither involvingAggregateVerifier:go run ./scripts/checks/interfaces(IERC721,IDisputeGameFactory,FeeDisburser,BalanceTracker) andgo run ./scripts/checks/test-validation.Follow-ups (not in this PR)
The zero-arg interval views can no longer answer without game context, and the old names are gone. Offchain consumers must move to
intervalsForStartingBlock():base/basecrates/proof/contracts/src/aggregate_verifier.rs— the generated bindings. These will fail to compile against the new ABI, which is the intended forcing function.base/basecrates/proof/challenge/src/scanner.rsresolve_intermediate_block_interval()— caches the interval per implementation address, so once one implementation serves both sides it returns a single value for all games. The doc listsGameScanneras already correct; it is not, and the doc needs correcting.Per the doc's sequencing, the proposer and challenger changes must be deployed before this implementation is swapped in, even though this PR can merge first.