Skip to content

feat(L1): fork-gate AggregateVerifier proposal intervals for Denim - #431

Open
0xth4nh wants to merge 3 commits into
mainfrom
feat/aggregate-verifier-denim-intervals
Open

feat(L1): fork-gate AggregateVerifier proposal intervals for Denim#431
0xth4nh wants to merge 3 commits into
mainfrom
feat/aggregate-verifier-denim-intervals

Conversation

@0xth4nh

@0xth4nh 0xth4nh commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What changed?

AggregateVerifier now carries both sets of proposal intervals and selects between them per game, instead of a single immutable pair.

  • The two pairs are named for the block cadence they are calibrated to, not for the fork: SLOW_BLOCK_INTERVAL / SLOW_INTERMEDIATE_BLOCK_INTERVAL (600/30 at 2s) and FAST_BLOCK_INTERVAL / FAST_INTERMEDIATE_BLOCK_INTERVAL (6,000/300 at 200ms). Both spans are the same 20 minutes, which is the point.
  • New IntervalConfig constructor struct: slowBlockInterval, slowIntermediateBlockInterval, fastBlockInterval, fastIntermediateBlockInterval (replaces the two loose uint256 params).
  • New _firstFastBlock(), extracted from _l2Timestamp() so the fork boundary is derived in exactly one place from the existing ProtocolVersions schedule (FAST_BLOCK_UPGRADE_INDEX = 13). _l2Timestamp() behaviour is unchanged. _legacyL2Timestamp() becomes _slowL2Timestamp().
  • Public view intervalsForStartingBlock(uint256) drives the two fork-sensitive call sites — the UnexpectedBlockNumber check in initializeWithInitData, and the sequence numbers in _getStartingIntermediateRootAndL2SequenceNumbers — and lets the proposer and challenger resolve a game's intervals without a clone. initializeWithInitData resolves _denimActivationBlock() once and threads it into both its interval selection and _l2Timestamp, so it reads PROTOCOL_VERSIONS.getSchedule() once rather than twice.
  • New error MismatchedIntermediateRootCount. The constructor requires both pairs to yield the same intermediate root count.
  • Semver 0.1.00.2.0; ABI and semver-lock snapshots regenerated.
  • Deploy plumbing: the config keys become multiproofSlowBlockInterval / multiproofSlowIntermediateBlockInterval / multiproofFastBlockInterval / multiproofFastIntermediateBlockInterval across DeployConfig, SystemDeploy, and deploy-config/local.json; the two dev deploy virtuals collapse into one _intervalConfig(). Interval validation lives only in the constructor — SystemDeploy does not restate it, because a partial copy there would read as full validation while skipping the ratio check.
  • SeedGames.s.sol reads 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.sh has 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.

⚠️ Breaking: BLOCK_INTERVAL() and INTERMEDIATE_BLOCK_INTERVAL() are gone from the 0.2.0 ABI

They are SLOW_BLOCK_INTERVAL() and SLOW_INTERMEDIATE_BLOCK_INTERVAL() now. This is deliberate rather than incidental: a consumer that calls BLOCK_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 to intervalsForStartingBlock() — 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:

  1. 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 + blockInterval for 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 while scheduleId is 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.

  2. The activation block is re-derived live rather than pinned into storage. This is safe in both directions:

    • A game that selected the fast-block intervals starts at or past the activation, so the activation is in the past, and ProtocolVersions._assertNotFrozen rejects every mutation of a passed activation — from setTimestamp and delayTimestamp alike. That selection can never be revoked.
    • A game that selected the slow-block intervals cannot be pulled across the boundary either, including by the owner moving the activation earlier rather than delaying it. initializeWithInitData rejects a game whose ending L2 timestamp L1 has not yet reached, so every initialized game satisfies startingTimestamp < endingTimestamp <= block.timestamp, while any new activation must clear block.timestamp + MIN_NOTICE.

    It also keeps the diff free of a new storage slot — AggregateVerifier is 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 CWIA extraData offset are byte-identical across the fork. The constructor enforces this rather than assuming it — otherwise parsing extraData would need the root count, which would need the interval, which would need the starting block, which lives in extraData.

How to test?

cd contracts
just deps                # or: forge install
forge build
forge test               # 1232 passed, 0 failed, 1 skipped

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:

cd scripts/go-ffi && go build -o go-ffi . && cd -

Targeted run for this change:

forge test --match-path 'test/L1/proofs/AggregateVerifier.t.sol' -vv

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 Asserts
test_intervalsForStartingBlock_selectsOnFirstFastBlock_succeeds Activation at L2 block 50: block 49 → (100, 10); blocks 50 and 51 → (1000, 100).
test_intervalsForStartingBlock_speedupUnscheduled_succeeds No Denim entry in the schedule → pre-Denim intervals for every starting block.
test_initialize_fastIntervals_succeeds Activation at genesis: a game ending at block 100 reverts UnexpectedBlockNumber(1000, 100); a game ending at 1000 initializes.
test_initialize_straddlingGame_usesSlowInterval_succeeds Activation at block 50, inside the first game's [0, 100) range: ending at 1000 reverts UnexpectedBlockNumber(100, 1000); ending at 100 initializes.
test_challenge_fastIntermediateInterval_succeeds Activation at genesis: challenging intermediate root 0 of a fast-block game hands the ZK verifier a journal covering [0, 100), not [0, 10).
test_challenge_straddlingGameUsesSlowIntermediateInterval_succeeds Activation at block 50: the straddling game's sub-ranges stay slow-block sized — root 0 covers [0, 10).
test_constructor_mismatchedIntermediateRootCount_reverts (100, 10, 1000, 200)MismatchedIntermediateRootCount(10, 5).
test_constructor_invalidFastBlockIntervals_reverts fastIntermediateBlockInterval = 0InvalidBlockInterval(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 exact IVerifier.verify call, since the mock verifier accepts any payload; both fail if the opposite interval is substituted.

Snapshot and lint checks (just recipes, expanded so they run without just):

forge fmt --check $(git diff --name-only origin/main -- '*.sol')
forge build --force --skip "/**/test/**" --skip "/**/scripts/**"
go run ./scripts/autogen/generate-snapshots .
go run scripts/autogen/generate-semver-lock/main.go
git diff --exit-code snapshots/     # clean

Two repo checks fail on main as well as here, neither involving AggregateVerifier: go run ./scripts/checks/interfaces (IERC721, IDisputeGameFactory, FeeDisburser, BalanceTracker) and go 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/base crates/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/base crates/proof/challenge/src/scanner.rs resolve_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 lists GameScanner as already correct; it is not, and the doc needs correcting.
  • Proposer must pick the interval for the game it is about to create.

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.

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>
@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

0xth4nh and others added 2 commits September 3, 2026 15:36
…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>
/// 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;

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.

We need to change this to 12 now to target Cobalt

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;
}

@0x00101010 0x00101010 Sep 4, 2026

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.

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);
}

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